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:
Steven 2023-04-23 17:33:34 -04:00 committed by GitHub
parent 2e99645b6c
commit 743a59dfab
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 430 additions and 7 deletions

View file

@ -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 = {}) =>

View file

@ -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,

View file

@ -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
*/

View file

@ -152,6 +152,7 @@ export type GetStaticPropsContext<
params?: Params
preview?: boolean
previewData?: Preview
draftMode?: boolean
locale?: string
locales?: string[]
defaultLocale?: string

View 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>
</>
)
}

View file

@ -0,0 +1,4 @@
export default function handler(_req, res) {
res.setDraftMode({ enable: false })
res.end('Check your cookies...')
}

View file

@ -0,0 +1,4 @@
export default function handler(_req, res) {
res.setDraftMode({ enable: true })
res.end('Check your cookies...')
}

View file

@ -0,0 +1,4 @@
export default (req, res) => {
const { draftMode } = req
res.json({ draftMode })
}

View 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>
</>
)
}

View 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>
</>
)
}

View 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>
)
}

View 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)
})
})
})