Ability to Disable SSG Fallback (#10701)

* Ability to Disable SSG Fallback

* Throw error when value is missing

* Fix existing tests

* Adjust error message

* Do not render fallback at build time for `fallback: false` page

* Fix existing fallback behavior

* fix build

* fix version

* fix some tests

* Fix last test

* Add docs for get static paths

* Add explicit mode tests

* test for fallback error message
This commit is contained in:
Joe Haddad 2020-02-27 07:23:28 -05:00 committed by GitHub
parent b3ffdabbad
commit 47ff1eb95a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 292 additions and 57 deletions

View file

@ -11,7 +11,30 @@ Make sure to return the following shape from `unstable_getStaticPaths`:
```js
export async function unstable_getStaticPaths() {
return {
paths: Array<string | { params: { [key: string]: string } }>
paths: Array<string | { params: { [key: string]: string } }>,
fallback: boolean
}
}
```
There are two required properties:
1. `paths`: this property is an [Array](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array) of URLs ("paths") that should be statically generated at build-time. The returned paths must match the dynamic route shape.
- You may return a [String](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String) or an [Object](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object) that explicitly defines all URL `params`.
```js
// pages/blog/[slug].js
export async function unstable_getStaticPaths() {
return {
paths: [
// String variant:
'/blog/first-post',
// Object variant:
{ params: { slug: 'second-post' } },
],
fallback: true,
}
}
```
1. `fallback`: this property is a [Boolean](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Boolean), specifying whether or not a fallback version of this page should be generated.
- Enabling `fallback` (via `true`) allows you to return a subset of all the possible paths that should be statically generated. At runtime, Next.js will statically generate the remaining paths the **first time they are requested**. Consecutive calls to the path will be served as-if it was statically generated at build-time. This reduces build times when dealing with thousands or millions of pages.
- Disabling `fallback` (via `false`) requires you return the full collection of paths you would like to statically generate at build-time. At runtime, any path that was not generated at build-time **will 404**.

View file

@ -90,13 +90,13 @@ export type SsgRoute = {
export type DynamicSsgRoute = {
routeRegex: string
fallback: string
fallback: string | false
dataRoute: string
dataRouteRegex: string
}
export type PrerenderManifest = {
version: number
version: 2
routes: { [route: string]: SsgRoute }
dynamicRoutes: { [route: string]: DynamicSsgRoute }
preview: __ApiPreviewProps
@ -432,6 +432,7 @@ export default async function build(dir: string, conf = null): Promise<void> {
const buildManifestPath = path.join(distDir, BUILD_MANIFEST)
const ssgPages = new Set<string>()
const ssgFallbackPages = new Set<string>()
const staticPages = new Set<string>()
const invalidPages = new Set<string>()
const hybridAmpPages = new Set<string>()
@ -478,6 +479,7 @@ export default async function build(dir: string, conf = null): Promise<void> {
let isStatic = false
let isHybridAmp = false
let ssgPageRoutes: string[] | null = null
let hasSsgFallback: boolean = false
pagesManifest[page] = bundleRelative.replace(/\\/g, '/')
@ -533,6 +535,10 @@ export default async function build(dir: string, conf = null): Promise<void> {
additionalSsgPaths.set(page, result.prerenderRoutes)
ssgPageRoutes = result.prerenderRoutes
}
if (result.prerenderFallback) {
hasSsgFallback = true
ssgFallbackPages.add(page)
}
} else if (result.hasServerProps) {
serverPropsPages.add(page)
} else if (result.isStatic && customAppGetInitialProps === false) {
@ -564,6 +570,7 @@ export default async function build(dir: string, conf = null): Promise<void> {
isSsg,
isHybridAmp,
ssgPageRoutes,
hasSsgFallback,
})
})
)
@ -669,9 +676,15 @@ export default async function build(dir: string, conf = null): Promise<void> {
if (isDynamicRoute(page)) {
tbdPrerenderRoutes.push(page)
// Override the rendering for the dynamic page to be treated as a
// fallback render.
defaultMap[page] = { page, query: { __nextFallback: true } }
if (ssgFallbackPages.has(page)) {
// Override the rendering for the dynamic page to be treated as a
// fallback render.
defaultMap[page] = { page, query: { __nextFallback: true } }
} else {
// Remove dynamically routed pages from the default path map when
// fallback behavior is disabled.
delete defaultMap[page]
}
}
})
// Append the "well-known" routes we should prerender for, e.g. blog
@ -736,12 +749,16 @@ export default async function build(dir: string, conf = null): Promise<void> {
for (const page of combinedPages) {
const isSsg = ssgPages.has(page)
const isSsgFallback = ssgFallbackPages.has(page)
const isDynamic = isDynamicRoute(page)
const hasAmp = hybridAmpPages.has(page)
let file = normalizePagePath(page)
// We should always have an HTML file to move for each page
await moveExportedPage(page, file, isSsg, 'html')
// The dynamic version of SSG pages are only prerendered if the fallback
// is enabled. Below, we handle the specific prerenders of these.
if (!(isSsg && isDynamic && !isSsgFallback)) {
await moveExportedPage(page, file, isSsg, 'html')
}
if (hasAmp) {
await moveExportedPage(`${page}.amp`, `${file}.amp`, isSsg, 'html')
@ -760,7 +777,7 @@ export default async function build(dir: string, conf = null): Promise<void> {
}
} else {
// For a dynamic SSG page, we did not copy its data exports and only
// copy the fallback HTML file.
// copy the fallback HTML file (if present).
// We must also copy specific versions of this page as defined by
// `unstable_getStaticPaths` (additionalSsgPaths).
const extraRoutes = additionalSsgPaths.get(page) || []
@ -814,14 +831,16 @@ export default async function build(dir: string, conf = null): Promise<void> {
finalDynamicRoutes[tbdRoute] = {
routeRegex: getRouteRegex(tbdRoute).re.source,
dataRoute,
fallback: `${normalizedRoute}.html`,
fallback: ssgFallbackPages.has(tbdRoute)
? `${normalizedRoute}.html`
: false,
dataRouteRegex: getRouteRegex(
dataRoute.replace(/\.json$/, '')
).re.source.replace(/\(\?:\\\/\)\?\$$/, '\\.json$'),
}
})
const prerenderManifest: PrerenderManifest = {
version: 1,
version: 2,
routes: finalPrerenderRoutes,
dynamicRoutes: finalDynamicRoutes,
preview: previewProps,
@ -834,7 +853,7 @@ export default async function build(dir: string, conf = null): Promise<void> {
)
} else {
const prerenderManifest: PrerenderManifest = {
version: 1,
version: 2,
routes: {},
dynamicRoutes: {},
preview: previewProps,

View file

@ -42,6 +42,7 @@ export interface PageInfo {
static: boolean
isSsg: boolean
ssgPageRoutes: string[] | null
hasSsgFallback: boolean
serverBundle: string
}
@ -500,7 +501,7 @@ export async function getPageSizeInKb(
export async function buildStaticPaths(
page: string,
unstable_getStaticPaths: Unstable_getStaticPaths
): Promise<Array<string>> {
): Promise<{ paths: string[]; fallback: boolean }> {
const prerenderPaths = new Set<string>()
const _routeRegex = getRouteRegex(page)
const _routeMatcher = getRouteMatcher(_routeRegex)
@ -511,7 +512,7 @@ export async function buildStaticPaths(
const staticPathsResult = await unstable_getStaticPaths()
const expectedReturnVal =
`Expected: { paths: [] }\n` +
`Expected: { paths: [], fallback: boolean }\n` +
`See here for more info: https://err.sh/zeit/next.js/invalid-getstaticpaths-value`
if (
@ -525,7 +526,7 @@ export async function buildStaticPaths(
}
const invalidStaticPathKeys = Object.keys(staticPathsResult).filter(
key => key !== 'paths'
key => !(key === 'paths' || key === 'fallback')
)
if (invalidStaticPathKeys.length > 0) {
@ -536,6 +537,13 @@ export async function buildStaticPaths(
)
}
if (typeof staticPathsResult.fallback !== 'boolean') {
throw new Error(
`The \`fallback\` key must be returned from unstable_getStaticProps in ${page}.\n` +
expectedReturnVal
)
}
const toPrerender = staticPathsResult.paths
if (!Array.isArray(toPrerender)) {
@ -601,7 +609,7 @@ export async function buildStaticPaths(
}
})
return [...prerenderPaths]
return { paths: [...prerenderPaths], fallback: staticPathsResult.fallback }
}
export async function isPageStatic(
@ -614,6 +622,7 @@ export async function isPageStatic(
hasServerProps?: boolean
hasStaticProps?: boolean
prerenderRoutes?: string[] | undefined
prerenderFallback?: boolean | undefined
}> {
try {
require('../next-server/lib/runtime-config').setConfig(runtimeEnvConfig)
@ -667,11 +676,12 @@ export async function isPageStatic(
}
let prerenderRoutes: Array<string> | undefined
let prerenderFallback: boolean | undefined
if (hasStaticProps && hasStaticPaths) {
prerenderRoutes = await buildStaticPaths(
page,
mod.unstable_getStaticPaths
)
;({
paths: prerenderRoutes,
fallback: prerenderFallback,
} = await buildStaticPaths(page, mod.unstable_getStaticPaths))
}
const config = mod.config || {}
@ -679,6 +689,7 @@ export async function isPageStatic(
isStatic: !hasStaticProps && !hasGetInitialProps && !hasServerProps,
isHybridAmp: config.amp === 'hybrid',
prerenderRoutes,
prerenderFallback,
hasStaticProps,
hasServerProps,
}

View file

@ -264,7 +264,7 @@ export default async function({
return results
} catch (error) {
console.error(
`\nError occurred prerendering page "${path}" https://err.sh/next.js/prerender-error:\n` +
`\nError occurred prerendering page "${path}". Read more: https://err.sh/next.js/prerender-error:\n` +
error
)
return { ...results, error: true }

View file

@ -52,6 +52,7 @@ type Unstable_getStaticProps = (ctx: {
export type Unstable_getStaticPaths = () => Promise<{
paths: Array<string | { params: ParsedUrlQuery }>
fallback: boolean
}>
type Unstable_getServerProps = (context: {

View file

@ -6,6 +6,7 @@ import nanoid from 'next/dist/compiled/nanoid/index.js'
import { join, resolve, sep } from 'path'
import { parse as parseQs, ParsedUrlQuery } from 'querystring'
import { format as formatUrl, parse as parseUrl, UrlWithParsedQuery } from 'url'
import { PrerenderManifest } from '../../build'
import {
getRedirectStatus,
Header,
@ -292,15 +293,17 @@ export default class Server {
return require(join(this.distDir, ROUTES_MANIFEST))
}
private _cachedPreviewProps: __ApiPreviewProps | undefined
protected getPreviewProps(): __ApiPreviewProps {
if (this._cachedPreviewProps) {
return this._cachedPreviewProps
private _cachedPreviewManifest: PrerenderManifest | undefined
protected getPrerenderManifest(): PrerenderManifest {
if (this._cachedPreviewManifest) {
return this._cachedPreviewManifest
}
return (this._cachedPreviewProps = require(join(
this.distDir,
PRERENDER_MANIFEST
)).preview)
const manifest = require(join(this.distDir, PRERENDER_MANIFEST))
return (this._cachedPreviewManifest = manifest)
}
protected getPreviewProps(): __ApiPreviewProps {
return this.getPrerenderManifest().preview
}
protected generateRoutes(): {
@ -870,7 +873,7 @@ export default class Server {
pathname: string,
{ components, query }: FindComponentsResult,
opts: any
): Promise<string | null> {
): Promise<string | false | null> {
// we need to ensure the status code if /404 is visited directly
if (pathname === '/404') {
res.statusCode = 404
@ -1029,24 +1032,35 @@ export default class Server {
// we lazy load the staticPaths to prevent the user
// from waiting on them for the page to load in dev mode
let staticPaths: string[] | undefined
let hasStaticFallback = false
if (!isProduction && hasStaticPaths) {
const __getStaticPaths = async () => {
const paths = await this.staticPathsWorker!.loadStaticPaths(
this.distDir,
this.buildId,
pathname,
!this.renderOpts.dev && this._isLikeServerless
)
return paths
if (hasStaticPaths) {
if (isProduction) {
// `staticPaths` is intentionally set to `undefined` as it should've
// been caught above when checking disk data.
staticPaths = undefined
// Read whether or not fallback should exist from the manifest.
hasStaticFallback =
typeof this.getPrerenderManifest().dynamicRoutes[pathname]
.fallback === 'string'
} else {
const __getStaticPaths = async () => {
const paths = await this.staticPathsWorker!.loadStaticPaths(
this.distDir,
this.buildId,
pathname,
!this.renderOpts.dev && this._isLikeServerless
)
return paths
}
;({ paths: staticPaths, fallback: hasStaticFallback } = (
await withCoalescedInvoke(__getStaticPaths)(
`staticPaths-${pathname}`,
[]
)
).value)
}
staticPaths = (
await withCoalescedInvoke(__getStaticPaths)(
`staticPaths-${pathname}`,
[]
)
).value
}
// const isForcedBlocking =
@ -1074,6 +1088,16 @@ export default class Server {
// `getStaticPaths`
(isProduction || !staticPaths || !staticPaths.includes(urlPathname))
) {
if (
// In development, fall through to render to handle missing
// getStaticPaths.
(isProduction || staticPaths) &&
// When fallback isn't present, abort this render so we 404
!hasStaticFallback
) {
return false
}
let html: string
// Production already emitted the fallback as static HTML.
@ -1137,13 +1161,16 @@ export default class Server {
try {
const result = await this.findPageComponents(pathname, query)
if (result) {
return await this.renderToHTMLWithComponents(
const result2 = await this.renderToHTMLWithComponents(
req,
res,
pathname,
result,
{ ...this.renderOpts, amphtml, hasAmp }
)
if (result2 !== false) {
return result2
}
}
if (this.dynamicRoutes) {
@ -1159,7 +1186,7 @@ export default class Server {
params
)
if (result) {
return await this.renderToHTMLWithComponents(
const result2 = await this.renderToHTMLWithComponents(
req,
res,
dynamicRoute.page,
@ -1171,6 +1198,9 @@ export default class Server {
hasAmp,
}
)
if (result2 !== false) {
return result2
}
}
}
}
@ -1224,9 +1254,9 @@ export default class Server {
result = await this.findPageComponents('/_error', query)
}
let html
let html: string | null
try {
html = await this.renderToHTMLWithComponents(
const result2 = await this.renderToHTMLWithComponents(
req,
res,
using404Page ? '/404' : '/_error',
@ -1236,6 +1266,10 @@ export default class Server {
err,
}
)
if (result2 === false) {
throw new Error('invariant: failed to render error page')
}
html = result2
} catch (err) {
console.error(err)
res.statusCode = 500

View file

@ -74,7 +74,7 @@ export function initializeSprCache({
if (dev) {
prerenderManifest = {
version: -1,
version: -1 as any, // letting us know this doesn't conform to spec
routes: {},
dynamicRoutes: {},
preview: null as any, // `preview` is special case read in next-dev-server

View file

@ -1,5 +1,5 @@
export async function unstable_getStaticPaths() {
return { paths: ['/hello', '/world'] }
return { paths: ['/hello', '/world'], fallback: true }
}
export default () => <p>something is missing 🤔</p>

View file

@ -9,6 +9,7 @@ export function unstable_getStaticProps({ params }) {
export function unstable_getStaticPaths() {
return {
paths: [],
fallback: true,
}
}

View file

@ -15,6 +15,7 @@ export function unstable_getStaticProps({ params }) {
export function unstable_getStaticPaths() {
return {
paths: [],
fallback: true,
}
}

View file

@ -12,6 +12,7 @@ export function unstable_getStaticPaths() {
`/p1/p2/predefined-ssg/one-level`,
`/p1/p2/predefined-ssg/1st-level/2nd-level`,
],
fallback: true,
}
}

View file

@ -1,6 +1,6 @@
export const unstable_getStaticPaths = async () => {
return {
props: { world: 'world' }
props: { world: 'world' }, fallback: true
}
}

View file

@ -2,7 +2,7 @@ import React from 'react'
// eslint-disable-next-line camelcase
export async function unstable_getStaticPaths() {
return { paths: [{ params: { slug: 'hello' } }] }
return { paths: [{ params: { slug: 'hello' } }], fallback: true }
}
// eslint-disable-next-line camelcase

View file

@ -2,7 +2,7 @@ import React from 'react'
// eslint-disable-next-line camelcase
export async function unstable_getStaticPaths() {
return { paths: [{ foo: 'bad', baz: 'herro' }] }
return { paths: [{ foo: 'bad', baz: 'herro' }], fallback: true }
}
// eslint-disable-next-line camelcase

View file

@ -8,6 +8,7 @@ export async function unstable_getStaticPaths() {
'/blog/post-1/comment-1',
{ params: { post: 'post-2', comment: 'comment-2' } },
],
fallback: true,
}
}

View file

@ -13,6 +13,7 @@ export async function unstable_getStaticPaths() {
'/blog/post.1',
'/blog/post.1', // handle duplicates
],
fallback: true,
}
}

View file

@ -0,0 +1,30 @@
export async function unstable_getStaticProps({ params: { slug } }) {
if (slug[0] === 'delayby3s') {
await new Promise(resolve => setTimeout(resolve, 3000))
}
return {
props: {
slug,
},
revalidate: 1,
}
}
export async function unstable_getStaticPaths() {
return {
paths: [
{ params: { slug: ['first'] } },
'/catchall-explicit/second',
{ params: { slug: ['another', 'value'] } },
'/catchall-explicit/hello/another',
],
fallback: false,
}
}
export default ({ slug }) => {
// Important to not check for `slug` existence (testing that build does not
// render fallback version and error)
return <p id="catchall">Hi {slug.join(' ')}</p>
}

View file

@ -21,6 +21,7 @@ export async function unstable_getStaticPaths() {
{ params: { slug: ['another', 'value'] } },
'/catchall/hello/another',
],
fallback: true,
}
}

View file

@ -3,7 +3,7 @@ import Link from 'next/link'
// eslint-disable-next-line camelcase
export async function unstable_getStaticPaths() {
return { paths: [] }
return { paths: [], fallback: true }
}
// eslint-disable-next-line camelcase

View file

@ -92,6 +92,26 @@ const expectedManifestRoutes = () => ({
initialRevalidateSeconds: 10,
srcRoute: '/blog/[post]',
},
'/catchall-explicit/another/value': {
dataRoute: `/_next/data/${buildId}/catchall-explicit/another/value.json`,
initialRevalidateSeconds: 1,
srcRoute: '/catchall-explicit/[...slug]',
},
'/catchall-explicit/first': {
dataRoute: `/_next/data/${buildId}/catchall-explicit/first.json`,
initialRevalidateSeconds: 1,
srcRoute: '/catchall-explicit/[...slug]',
},
'/catchall-explicit/hello/another': {
dataRoute: `/_next/data/${buildId}/catchall-explicit/hello/another.json`,
initialRevalidateSeconds: 1,
srcRoute: '/catchall-explicit/[...slug]',
},
'/catchall-explicit/second': {
dataRoute: `/_next/data/${buildId}/catchall-explicit/second.json`,
initialRevalidateSeconds: 1,
srcRoute: '/catchall-explicit/[...slug]',
},
'/another': {
dataRoute: `/_next/data/${buildId}/another.json`,
initialRevalidateSeconds: 1,
@ -418,6 +438,49 @@ const runTests = (dev = false, looseMode = false) => {
)
})
it('should support prerendered catchall-explicit route (nested)', async () => {
const html = await renderViaHTTP(
appPort,
'/catchall-explicit/another/value'
)
const $ = cheerio.load(html)
expect(
JSON.parse(
cheerio
.load(html)('#__NEXT_DATA__')
.text()
).isFallback
).toBe(false)
expect($('#catchall').text()).toMatch(/Hi.*?another value/)
})
it('should support prerendered catchall-explicit route (single)', async () => {
const html = await renderViaHTTP(appPort, '/catchall-explicit/second')
const $ = cheerio.load(html)
expect(
JSON.parse(
cheerio
.load(html)('#__NEXT_DATA__')
.text()
).isFallback
).toBe(false)
expect($('#catchall').text()).toMatch(/Hi.*?second/)
})
if (!looseMode) {
it('should 404 for a missing catchall explicit route', async () => {
const res = await fetchViaHTTP(
appPort,
'/catchall-explicit/notreturnedinpaths'
)
expect(res.status).toBe(404)
const html = await res.text()
expect(html).toMatch(/This page could not be found/)
})
}
if (dev) {
// TODO: re-enable when this is supported in dev
// it('should show error when rewriting to dynamic SSG page', async () => {
@ -561,6 +624,36 @@ const runTests = (dev = false, looseMode = false) => {
}
})
it('should error on dynamic page without getStaticPaths returning fallback property', async () => {
const curPage = join(__dirname, '../pages/temp2/[slug].js')
await fs.mkdirp(dirname(curPage))
await fs.writeFile(
curPage,
`
export async function unstable_getStaticPaths() {
return {
paths: []
}
}
export async function unstable_getStaticProps() {
return {
props: {
hello: 'world'
}
}
}
export default () => 'oops'
`
)
await waitFor(1000)
try {
const html = await renderViaHTTP(appPort, '/temp2/hello')
expect(html).toMatch(/`fallback` key must be returned from/)
} finally {
await fs.remove(curPage)
}
})
it('should not re-call getStaticProps when updating query', async () => {
const browser = await webdriver(appPort, '/something?hello=world')
await waitFor(2000)
@ -641,6 +734,14 @@ const runTests = (dev = false, looseMode = false) => {
),
page: '/catchall/[...slug]',
},
{
dataRouteRegex: normalizeRegEx(
`^\\/_next\\/data\\/${escapeRegex(
buildId
)}\\/catchall\\-explicit\\/(.+?)\\.json$`
),
page: '/catchall-explicit/[...slug]',
},
{
dataRouteRegex: normalizeRegEx(
`^\\/_next\\/data\\/${escapeRegex(
@ -683,7 +784,7 @@ const runTests = (dev = false, looseMode = false) => {
}
})
expect(manifest.version).toBe(1)
expect(manifest.version).toBe(2)
expect(manifest.routes).toEqual(expectedManifestRoutes())
expect(manifest.dynamicRoutes).toEqual({
'/blog/[post]': {
@ -722,6 +823,16 @@ const runTests = (dev = false, looseMode = false) => {
`^\\/_next\\/data\\/${escapedBuildId}\\/catchall\\/(.+?)\\.json$`
),
},
'/catchall-explicit/[...slug]': {
dataRoute: `/_next/data/${buildId}/catchall-explicit/[...slug].json`,
dataRouteRegex: normalizeRegEx(
`^\\/_next\\/data\\/${escapedBuildId}\\/catchall\\-explicit\\/(.+?)\\.json$`
),
fallback: false,
routeRegex: normalizeRegEx(
'^\\/catchall\\-explicit\\/(.+?)(?:\\/)?$'
),
},
})
})