Add support for draft mode (#48669)
Draft Mode is very similar to Preview Mode but doesn't include any additional data. This PR implements support for Draft Mode in `pages` and a future PR will implement support in `app`. fix NEXT-992
This commit is contained in:
parent
2e99645b6c
commit
743a59dfab
12 changed files with 430 additions and 7 deletions
|
@ -60,6 +60,23 @@ export function tryGetPreviewData(
|
|||
const previewModeId = cookies.get(COOKIE_NAME_PRERENDER_BYPASS)?.value
|
||||
const tokenPreviewData = cookies.get(COOKIE_NAME_PRERENDER_DATA)?.value
|
||||
|
||||
// Case: preview mode cookie set but data cookie is not set
|
||||
if (
|
||||
previewModeId &&
|
||||
!tokenPreviewData &&
|
||||
previewModeId === options.previewModeId
|
||||
) {
|
||||
// This is "Draft Mode" which doesn't use
|
||||
// previewData, so we return an empty object
|
||||
// for backwards compat with "Preview Mode".
|
||||
const data = {}
|
||||
Object.defineProperty(req, SYMBOL_PREVIEW_DATA, {
|
||||
value: data,
|
||||
enumerable: false,
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
// Case: neither cookie is set.
|
||||
if (!previewModeId && !tokenPreviewData) {
|
||||
return false
|
||||
|
@ -262,8 +279,42 @@ function sendJson(res: NextApiResponse, jsonBody: any): void {
|
|||
res.send(JSON.stringify(jsonBody))
|
||||
}
|
||||
|
||||
function isNotValidData(str: string): boolean {
|
||||
return typeof str !== 'string' || str.length < 16
|
||||
function isValidData(str: any): str is string {
|
||||
return typeof str === 'string' && str.length >= 16
|
||||
}
|
||||
|
||||
function setDraftMode<T>(
|
||||
res: NextApiResponse<T>,
|
||||
options: {
|
||||
enable: boolean
|
||||
previewModeId?: string
|
||||
}
|
||||
): NextApiResponse<T> {
|
||||
if (!isValidData(options.previewModeId)) {
|
||||
throw new Error('invariant: invalid previewModeId')
|
||||
}
|
||||
const expires = options.enable ? undefined : new Date(0)
|
||||
// To delete a cookie, set `expires` to a date in the past:
|
||||
// https://tools.ietf.org/html/rfc6265#section-4.1.1
|
||||
// `Max-Age: 0` is not valid, thus ignored, and the cookie is persisted.
|
||||
const { serialize } =
|
||||
require('next/dist/compiled/cookie') as typeof import('cookie')
|
||||
const previous = res.getHeader('Set-Cookie')
|
||||
res.setHeader(`Set-Cookie`, [
|
||||
...(typeof previous === 'string'
|
||||
? [previous]
|
||||
: Array.isArray(previous)
|
||||
? previous
|
||||
: []),
|
||||
serialize(COOKIE_NAME_PRERENDER_BYPASS, options.previewModeId, {
|
||||
httpOnly: true,
|
||||
sameSite: process.env.NODE_ENV !== 'development' ? 'none' : 'lax',
|
||||
secure: process.env.NODE_ENV !== 'development',
|
||||
path: '/',
|
||||
expires,
|
||||
}),
|
||||
])
|
||||
return res
|
||||
}
|
||||
|
||||
function setPreviewData<T>(
|
||||
|
@ -274,13 +325,13 @@ function setPreviewData<T>(
|
|||
path?: string
|
||||
} & __ApiPreviewProps
|
||||
): NextApiResponse<T> {
|
||||
if (isNotValidData(options.previewModeId)) {
|
||||
if (!isValidData(options.previewModeId)) {
|
||||
throw new Error('invariant: invalid previewModeId')
|
||||
}
|
||||
if (isNotValidData(options.previewModeEncryptionKey)) {
|
||||
if (!isValidData(options.previewModeEncryptionKey)) {
|
||||
throw new Error('invariant: invalid previewModeEncryptionKey')
|
||||
}
|
||||
if (isNotValidData(options.previewModeSigningKey)) {
|
||||
if (!isValidData(options.previewModeSigningKey)) {
|
||||
throw new Error('invariant: invalid previewModeSigningKey')
|
||||
}
|
||||
|
||||
|
@ -464,6 +515,8 @@ export async function apiResolver(
|
|||
setLazyProp({ req: apiReq }, 'preview', () =>
|
||||
apiReq.previewData !== false ? true : undefined
|
||||
)
|
||||
// Set draftMode to the same value as preview
|
||||
setLazyProp({ req: apiReq }, 'draftMode', () => apiReq.preview)
|
||||
|
||||
// Parsing of body
|
||||
if (bodyParser && !apiReq.body) {
|
||||
|
@ -503,6 +556,8 @@ export async function apiResolver(
|
|||
apiRes.json = (data) => sendJson(apiRes, data)
|
||||
apiRes.redirect = (statusOrUrl: number | string, url?: string) =>
|
||||
redirect(apiRes, statusOrUrl, url)
|
||||
apiRes.setDraftMode = (options = { enable: true }) =>
|
||||
setDraftMode(apiRes, Object.assign({}, apiContext, options))
|
||||
apiRes.setPreviewData = (data, options = {}) =>
|
||||
setPreviewData(apiRes, data, Object.assign({}, apiContext, options))
|
||||
apiRes.clearPreviewData = (options = {}) =>
|
||||
|
|
|
@ -807,7 +807,7 @@ export async function renderToHTML(
|
|||
? { params: query as ParsedUrlQuery }
|
||||
: undefined),
|
||||
...(isPreview
|
||||
? { preview: true, previewData: previewData }
|
||||
? { draftMode: true, preview: true, previewData: previewData }
|
||||
: undefined),
|
||||
locales: renderOpts.locales,
|
||||
locale: renderOpts.locale,
|
||||
|
@ -1023,7 +1023,7 @@ export async function renderToHTML(
|
|||
? { params: params as ParsedUrlQuery }
|
||||
: undefined),
|
||||
...(previewData !== false
|
||||
? { preview: true, previewData: previewData }
|
||||
? { draftMode: true, preview: true, previewData: previewData }
|
||||
: undefined),
|
||||
locales: renderOpts.locales,
|
||||
locale: renderOpts.locale,
|
||||
|
|
|
@ -215,6 +215,8 @@ export interface NextApiRequest extends IncomingMessage {
|
|||
|
||||
env: Env
|
||||
|
||||
draftMode?: boolean
|
||||
|
||||
preview?: boolean
|
||||
/**
|
||||
* Preview data set on the request, if any
|
||||
|
@ -243,6 +245,11 @@ export type NextApiResponse<Data = any> = ServerResponse & {
|
|||
redirect(url: string): NextApiResponse<Data>
|
||||
redirect(status: number, url: string): NextApiResponse<Data>
|
||||
|
||||
/**
|
||||
* Set draft mode
|
||||
*/
|
||||
setDraftMode: (options: { enable: boolean }) => NextApiResponse<Data>
|
||||
|
||||
/**
|
||||
* Set preview data for Next.js' prerender mode
|
||||
*/
|
||||
|
|
1
packages/next/types/index.d.ts
vendored
1
packages/next/types/index.d.ts
vendored
|
@ -152,6 +152,7 @@ export type GetStaticPropsContext<
|
|||
params?: Params
|
||||
preview?: boolean
|
||||
previewData?: Preview
|
||||
draftMode?: boolean
|
||||
locale?: string
|
||||
locales?: string[]
|
||||
defaultLocale?: string
|
||||
|
|
26
test/integration/draft-mode/pages/another.js
Normal file
26
test/integration/draft-mode/pages/another.js
Normal file
|
@ -0,0 +1,26 @@
|
|||
import Link from 'next/link'
|
||||
|
||||
export function getStaticProps({ draftMode }) {
|
||||
return {
|
||||
props: {
|
||||
random: Math.random(),
|
||||
draftMode: Boolean(draftMode).toString(),
|
||||
},
|
||||
revalidate: 100000,
|
||||
}
|
||||
}
|
||||
|
||||
export default function Another(props) {
|
||||
return (
|
||||
<>
|
||||
<h1>Another</h1>
|
||||
<p>
|
||||
Draft Mode: <em id="draft">{props.draftMode}</em>
|
||||
</p>
|
||||
<p>
|
||||
Random: <em id="rand">{props.random}</em>
|
||||
</p>
|
||||
<Link href="/">Go home</Link>
|
||||
</>
|
||||
)
|
||||
}
|
4
test/integration/draft-mode/pages/api/disable.js
Normal file
4
test/integration/draft-mode/pages/api/disable.js
Normal file
|
@ -0,0 +1,4 @@
|
|||
export default function handler(_req, res) {
|
||||
res.setDraftMode({ enable: false })
|
||||
res.end('Check your cookies...')
|
||||
}
|
4
test/integration/draft-mode/pages/api/enable.js
Normal file
4
test/integration/draft-mode/pages/api/enable.js
Normal file
|
@ -0,0 +1,4 @@
|
|||
export default function handler(_req, res) {
|
||||
res.setDraftMode({ enable: true })
|
||||
res.end('Check your cookies...')
|
||||
}
|
4
test/integration/draft-mode/pages/api/read.js
Normal file
4
test/integration/draft-mode/pages/api/read.js
Normal file
|
@ -0,0 +1,4 @@
|
|||
export default (req, res) => {
|
||||
const { draftMode } = req
|
||||
res.json({ draftMode })
|
||||
}
|
34
test/integration/draft-mode/pages/index.js
Normal file
34
test/integration/draft-mode/pages/index.js
Normal file
|
@ -0,0 +1,34 @@
|
|||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
|
||||
export function getStaticProps({ draftMode }) {
|
||||
return {
|
||||
props: {
|
||||
random: Math.random(),
|
||||
draftMode: Boolean(draftMode).toString(),
|
||||
},
|
||||
revalidate: 100000,
|
||||
}
|
||||
}
|
||||
|
||||
export default function Home(props) {
|
||||
const [count, setCount] = useState(0)
|
||||
return (
|
||||
<>
|
||||
<h1>Home</h1>
|
||||
<p>
|
||||
Draft Mode: <em id="draft">{props.draftMode}</em>
|
||||
</p>
|
||||
<button id="inc" onClick={() => setCount(count + 1)}>
|
||||
Increment
|
||||
</button>
|
||||
<p>
|
||||
Count: <span id="count">{count}</span>
|
||||
</p>
|
||||
<p>
|
||||
Random: <em id="rand">{props.random}</em>
|
||||
</p>
|
||||
<Link href="/another">Visit another page</Link>
|
||||
</>
|
||||
)
|
||||
}
|
27
test/integration/draft-mode/pages/ssp.js
Normal file
27
test/integration/draft-mode/pages/ssp.js
Normal file
|
@ -0,0 +1,27 @@
|
|||
import Link from 'next/link'
|
||||
|
||||
export function getServerSideProps({ res, draftMode }) {
|
||||
// test override header
|
||||
res.setHeader('Cache-Control', 'public, max-age=3600')
|
||||
return {
|
||||
props: {
|
||||
random: Math.random(),
|
||||
draftMode: Boolean(draftMode).toString(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default function SSP(props) {
|
||||
return (
|
||||
<>
|
||||
<h1>Server Side Props</h1>
|
||||
<p>
|
||||
Draft Mode: <em id="draft">{props.draftMode}</em>
|
||||
</p>
|
||||
<p>
|
||||
Random: <em id="rand">{props.random}</em>
|
||||
</p>
|
||||
<Link href="/">Go home</Link>
|
||||
</>
|
||||
)
|
||||
}
|
15
test/integration/draft-mode/pages/to-index.js
Normal file
15
test/integration/draft-mode/pages/to-index.js
Normal file
|
@ -0,0 +1,15 @@
|
|||
import Link from 'next/link'
|
||||
|
||||
export function getStaticProps() {
|
||||
return { props: {} }
|
||||
}
|
||||
|
||||
export default function () {
|
||||
return (
|
||||
<main>
|
||||
<Link href="/" id="to-index">
|
||||
To Index
|
||||
</Link>
|
||||
</main>
|
||||
)
|
||||
}
|
246
test/integration/draft-mode/test/index.test.ts
Normal file
246
test/integration/draft-mode/test/index.test.ts
Normal file
|
@ -0,0 +1,246 @@
|
|||
/* eslint-env jest */
|
||||
|
||||
import cheerio from 'cheerio'
|
||||
import cookie from 'cookie'
|
||||
import fs from 'fs-extra'
|
||||
import {
|
||||
fetchViaHTTP,
|
||||
findPort,
|
||||
killApp,
|
||||
launchApp,
|
||||
nextBuild,
|
||||
nextStart,
|
||||
renderViaHTTP,
|
||||
} from 'next-test-utils'
|
||||
import webdriver from 'next-webdriver'
|
||||
import { join } from 'path'
|
||||
|
||||
const appDir = join(__dirname, '..')
|
||||
|
||||
async function getBuildId() {
|
||||
return fs.readFile(join(appDir, '.next', 'BUILD_ID'), 'utf8')
|
||||
}
|
||||
|
||||
function getData(html: string) {
|
||||
const $ = cheerio.load(html)
|
||||
return {
|
||||
nextData: JSON.parse($('#__NEXT_DATA__').html()),
|
||||
draft: $('#draft').text(),
|
||||
rand: $('#rand').text(),
|
||||
count: $('#count').text(),
|
||||
}
|
||||
}
|
||||
|
||||
describe('Test Draft Mode', () => {
|
||||
describe('Development Mode', () => {
|
||||
let appPort, app, browser, cookieString
|
||||
it('should start development application', async () => {
|
||||
appPort = await findPort()
|
||||
app = await launchApp(appDir, appPort)
|
||||
})
|
||||
|
||||
it('should enable draft mode', async () => {
|
||||
const res = await fetchViaHTTP(appPort, '/api/enable')
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
const cookies = res.headers.get('set-cookie').split(',').map(cookie.parse)
|
||||
|
||||
expect(cookies[0]).toBeTruthy()
|
||||
expect(cookies[0].__prerender_bypass).toBeTruthy()
|
||||
cookieString = cookie.serialize(
|
||||
'__prerender_bypass',
|
||||
cookies[0].__prerender_bypass
|
||||
)
|
||||
})
|
||||
|
||||
it('should return cookies to be expired after dev server reboot', async () => {
|
||||
await killApp(app)
|
||||
appPort = await findPort()
|
||||
app = await launchApp(appDir, appPort)
|
||||
|
||||
const res = await fetchViaHTTP(
|
||||
appPort,
|
||||
'/',
|
||||
{},
|
||||
{ headers: { Cookie: cookieString } }
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
const body = await res.text()
|
||||
// "err":{"name":"TypeError","message":"Cannot read property 'previewModeId' of undefined"
|
||||
expect(body).not.toContain('err')
|
||||
expect(body).not.toContain('TypeError')
|
||||
expect(body).not.toContain('previewModeId')
|
||||
|
||||
const cookies = res.headers
|
||||
.get('set-cookie')
|
||||
.replace(/(=(?!Lax)\w{3}),/g, '$1')
|
||||
.split(',')
|
||||
.map(cookie.parse)
|
||||
|
||||
expect(cookies[0]).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should start the client-side browser', async () => {
|
||||
browser = await webdriver(appPort, '/api/enable')
|
||||
})
|
||||
|
||||
it('should fetch draft data on SSR', async () => {
|
||||
await browser.get(`http://localhost:${appPort}/`)
|
||||
await browser.waitForElementByCss('#draft')
|
||||
expect(await browser.elementById('draft').text()).toBe('true')
|
||||
})
|
||||
|
||||
it('should fetch draft data on CST', async () => {
|
||||
await browser.get(`http://localhost:${appPort}/to-index`)
|
||||
await browser.waitForElementByCss('#to-index')
|
||||
await browser.eval('window.itdidnotrefresh = "yep"')
|
||||
await browser.elementById('to-index').click()
|
||||
await browser.waitForElementByCss('#draft')
|
||||
expect(await browser.eval('window.itdidnotrefresh')).toBe('yep')
|
||||
expect(await browser.elementById('draft').text()).toBe('true')
|
||||
})
|
||||
|
||||
it('should disable draft mode', async () => {
|
||||
await browser.get(`http://localhost:${appPort}/api/disable`)
|
||||
|
||||
await browser.get(`http://localhost:${appPort}/`)
|
||||
await browser.waitForElementByCss('#draft')
|
||||
expect(await browser.elementById('draft').text()).toBe('false')
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await browser.close()
|
||||
await killApp(app)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Server Mode', () => {
|
||||
let appPort, app, cookieString, initialRand
|
||||
const getOpts = () => ({ headers: { Cookie: cookieString } })
|
||||
|
||||
it('should compile successfully', async () => {
|
||||
await fs.remove(join(appDir, '.next'))
|
||||
const { code, stdout } = await nextBuild(appDir, [], {
|
||||
stdout: true,
|
||||
})
|
||||
expect(code).toBe(0)
|
||||
expect(stdout).toMatch(/Compiled successfully/)
|
||||
})
|
||||
|
||||
it('should start production application', async () => {
|
||||
appPort = await findPort()
|
||||
app = await nextStart(appDir, appPort)
|
||||
})
|
||||
|
||||
it('should return prerendered page on first request', async () => {
|
||||
const html = await renderViaHTTP(appPort, '/')
|
||||
const { nextData, draft, rand } = getData(html)
|
||||
expect(nextData).toMatchObject({ isFallback: false })
|
||||
expect(draft).toBe('false')
|
||||
initialRand = rand
|
||||
})
|
||||
|
||||
it('should return prerendered page on second request', async () => {
|
||||
const html = await renderViaHTTP(appPort, '/')
|
||||
const { nextData, draft, rand } = getData(html)
|
||||
expect(nextData).toMatchObject({ isFallback: false })
|
||||
expect(draft).toBe('false')
|
||||
expect(rand).toBe(initialRand)
|
||||
})
|
||||
|
||||
it('should enable draft mode', async () => {
|
||||
const res = await fetchViaHTTP(appPort, '/api/enable')
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
const originalCookies = res.headers.get('set-cookie').split(',')
|
||||
const cookies = originalCookies.map(cookie.parse)
|
||||
|
||||
expect(cookies.length).toBe(1)
|
||||
expect(cookies[0]).toBeTruthy()
|
||||
expect(cookies[0]).toMatchObject({ Path: '/', SameSite: 'None' })
|
||||
expect(cookies[0]).toHaveProperty('__prerender_bypass')
|
||||
//expect(cookies[0]).toHaveProperty('Secure')
|
||||
expect(cookies[0]).not.toHaveProperty('Max-Age')
|
||||
|
||||
cookieString = cookie.serialize(
|
||||
'__prerender_bypass',
|
||||
cookies[0].__prerender_bypass
|
||||
)
|
||||
})
|
||||
|
||||
it('should return dynamic response when draft mode enabled', async () => {
|
||||
const html = await renderViaHTTP(appPort, '/', {}, getOpts())
|
||||
const { nextData, draft, rand } = getData(html)
|
||||
expect(nextData).toMatchObject({ isFallback: false })
|
||||
expect(draft).toBe('true')
|
||||
expect(rand).not.toBe(initialRand)
|
||||
})
|
||||
|
||||
it('should not return fallback page on draft request', async () => {
|
||||
const res = await fetchViaHTTP(appPort, '/ssp', {}, getOpts())
|
||||
const html = await res.text()
|
||||
|
||||
const { nextData, draft } = getData(html)
|
||||
expect(res.headers.get('cache-control')).toBe(
|
||||
'private, no-cache, no-store, max-age=0, must-revalidate'
|
||||
)
|
||||
expect(nextData).toMatchObject({ isFallback: false })
|
||||
expect(draft).toBe('true')
|
||||
})
|
||||
|
||||
it('should return correct caching headers for draft mode request', async () => {
|
||||
const url = `/_next/data/${encodeURI(await getBuildId())}/index.json`
|
||||
const res = await fetchViaHTTP(appPort, url, {}, getOpts())
|
||||
const json = await res.json()
|
||||
|
||||
expect(res.headers.get('cache-control')).toBe(
|
||||
'private, no-cache, no-store, max-age=0, must-revalidate'
|
||||
)
|
||||
expect(json).toMatchObject({
|
||||
pageProps: {
|
||||
draftMode: 'true',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should return cookies to be expired on disable request', async () => {
|
||||
const res = await fetchViaHTTP(appPort, '/api/disable', {}, getOpts())
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
const cookies = res.headers
|
||||
.get('set-cookie')
|
||||
.replace(/(=(?!Lax)\w{3}),/g, '$1')
|
||||
.split(',')
|
||||
.map(cookie.parse)
|
||||
|
||||
expect(cookies[0]).toBeTruthy()
|
||||
expect(cookies[0]).toMatchObject({
|
||||
Path: '/',
|
||||
SameSite: 'None',
|
||||
Expires: 'Thu 01 Jan 1970 00:00:00 GMT',
|
||||
})
|
||||
expect(cookies[0]).toHaveProperty('__prerender_bypass')
|
||||
expect(cookies[0]).not.toHaveProperty('Max-Age')
|
||||
})
|
||||
|
||||
it('should pass undefined to API routes when not in draft mode', async () => {
|
||||
const res = await fetchViaHTTP(appPort, `/api/read`)
|
||||
const json = await res.json()
|
||||
|
||||
expect(json).toMatchObject({})
|
||||
})
|
||||
it('should pass draft mode to API routes', async () => {
|
||||
const res = await fetchViaHTTP(appPort, '/api/read', {}, getOpts())
|
||||
const json = await res.json()
|
||||
|
||||
expect(json).toMatchObject({
|
||||
draftMode: true,
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await killApp(app)
|
||||
})
|
||||
})
|
||||
})
|
Loading…
Reference in a new issue