Skip to content

Commit

Permalink
Add support for returning 404 from getStaticProps (#17755)
Browse files Browse the repository at this point in the history
  • Loading branch information
ijjk committed Oct 15, 2020
1 parent 6ec3659 commit 2a94ae0
Show file tree
Hide file tree
Showing 12 changed files with 283 additions and 33 deletions.
18 changes: 14 additions & 4 deletions packages/next/build/index.ts
Expand Up @@ -96,6 +96,7 @@ export type PrerenderManifest = {
version: 2
routes: { [route: string]: SsgRoute }
dynamicRoutes: { [route: string]: DynamicSsgRoute }
notFoundRoutes: string[]
preview: __ApiPreviewProps
}

Expand Down Expand Up @@ -713,6 +714,7 @@ export default async function build(

const finalPrerenderRoutes: { [route: string]: SsgRoute } = {}
const tbdPrerenderRoutes: string[] = []
let ssgNotFoundPaths: string[] = []

if (postCompileSpinner) postCompileSpinner.stopAndPersist()

Expand All @@ -730,6 +732,7 @@ export default async function build(
const exportConfig: any = {
...config,
initialPageRevalidationMap: {},
ssgNotFoundPaths: [] as string[],
// Default map will be the collection of automatic statically exported
// pages and incremental pages.
// n.b. we cannot handle this above in combinedPages because the dynamic
Expand Down Expand Up @@ -821,6 +824,7 @@ export default async function build(
const postBuildSpinner = createSpinner({
prefixText: `${Log.prefixes.info} Finalizing page optimization`,
})
ssgNotFoundPaths = exportConfig.ssgNotFoundPaths

// remove server bundles that were exported
for (const page of staticPages) {
Expand Down Expand Up @@ -874,11 +878,12 @@ export default async function build(
}

const { i18n } = config.experimental
const isNotFound = ssgNotFoundPaths.includes(page)

// for SSG files with i18n the non-prerendered variants are
// output with the locale prefixed so don't attempt moving
// without the prefix
if (!i18n || additionalSsgFile) {
if ((!i18n || additionalSsgFile) && !isNotFound) {
await promises.mkdir(path.dirname(dest), { recursive: true })
await promises.rename(orig, dest)
} else if (i18n && !isSsg) {
Expand All @@ -891,9 +896,14 @@ export default async function build(
if (additionalSsgFile) return

for (const locale of i18n.locales) {
const curPath = `/${locale}${page === '/' ? '' : page}`
const localeExt = page === '/' ? path.extname(file) : ''
const relativeDestNoPages = relativeDest.substr('pages/'.length)

if (isSsg && ssgNotFoundPaths.includes(curPath)) {
continue
}

const updatedRelativeDest = path.join(
'pages',
locale + localeExt,
Expand All @@ -913,9 +923,7 @@ export default async function build(
)

if (!isSsg) {
pagesManifest[
`/${locale}${page === '/' ? '' : page}`
] = updatedRelativeDest
pagesManifest[curPath] = updatedRelativeDest
}
await promises.mkdir(path.dirname(updatedDest), { recursive: true })
await promises.rename(updatedOrig, updatedDest)
Expand Down Expand Up @@ -1066,6 +1074,7 @@ export default async function build(
version: 2,
routes: finalPrerenderRoutes,
dynamicRoutes: finalDynamicRoutes,
notFoundRoutes: ssgNotFoundPaths,
preview: previewProps,
}

Expand All @@ -1085,6 +1094,7 @@ export default async function build(
routes: {},
dynamicRoutes: {},
preview: previewProps,
notFoundRoutes: [],
}
await promises.writeFile(
path.join(distDir, PRERENDER_MANIFEST),
Expand Down
16 changes: 10 additions & 6 deletions packages/next/export/index.ts
Expand Up @@ -473,13 +473,17 @@ export default async function exportApp(
renderError = renderError || !!result.error
if (!!result.error) errorPaths.push(path)

if (
options.buildExport &&
typeof result.fromBuildExportRevalidate !== 'undefined'
) {
configuration.initialPageRevalidationMap[path] =
result.fromBuildExportRevalidate
if (options.buildExport) {
if (typeof result.fromBuildExportRevalidate !== 'undefined') {
configuration.initialPageRevalidationMap[path] =
result.fromBuildExportRevalidate
}

if (result.ssgNotFound === true) {
configuration.ssgNotFoundPaths.push(path)
}
}

if (progress) progress()
})
)
Expand Down
16 changes: 12 additions & 4 deletions packages/next/export/worker.ts
Expand Up @@ -55,6 +55,7 @@ interface ExportPageResults {
ampValidations: AmpValidation[]
fromBuildExportRevalidate?: number
error?: boolean
ssgNotFound?: boolean
}

interface RenderOpts {
Expand Down Expand Up @@ -252,11 +253,11 @@ export default async function exportPage({
// @ts-ignore
params
)
curRenderOpts = result.renderOpts || {}
html = result.html
curRenderOpts = (result as any).renderOpts || {}
html = (result as any).html
}

if (!html) {
if (!html && !(curRenderOpts as any).ssgNotFound) {
throw new Error(`Failed to render serverless page`)
}
} else {
Expand Down Expand Up @@ -311,6 +312,7 @@ export default async function exportPage({
html = await renderMethod(req, res, page, query, curRenderOpts)
}
}
results.ssgNotFound = (curRenderOpts as any).ssgNotFound

const validateAmp = async (
rawAmpHtml: string,
Expand All @@ -334,7 +336,9 @@ export default async function exportPage({
}

if (curRenderOpts.inAmpMode && !curRenderOpts.ampSkipValidation) {
await validateAmp(html, path, curRenderOpts.ampValidatorPath)
if (!results.ssgNotFound) {
await validateAmp(html, path, curRenderOpts.ampValidatorPath)
}
} else if (curRenderOpts.hybridAmp) {
// we need to render the AMP version
let ampHtmlFilename = `${ampPath}${sep}index.html`
Expand Down Expand Up @@ -396,6 +400,10 @@ export default async function exportPage({
}
results.fromBuildExportRevalidate = (curRenderOpts as any).revalidate

if (results.ssgNotFound) {
// don't attempt writing to disk if getStaticProps returned not found
return results
}
await promises.writeFile(htmlFilepath, html, 'utf8')
return results
} catch (error) {
Expand Down
18 changes: 16 additions & 2 deletions packages/next/next-server/lib/router/router.ts
Expand Up @@ -299,6 +299,8 @@ const manualScrollRestoration =
typeof window !== 'undefined' &&
'scrollRestoration' in window.history

const SSG_DATA_NOT_FOUND_ERROR = 'SSG Data NOT_FOUND'

function fetchRetry(url: string, attempts: number): Promise<any> {
return fetch(url, {
// Cookies are required to be present for Next.js' SSG "Preview Mode".
Expand All @@ -318,9 +320,13 @@ function fetchRetry(url: string, attempts: number): Promise<any> {
if (attempts > 1 && res.status >= 500) {
return fetchRetry(url, attempts - 1)
}
if (res.status === 404) {
// TODO: handle reloading in development from fallback returning 200
// to on-demand-entry-handler causing it to reload periodically
throw new Error(SSG_DATA_NOT_FOUND_ERROR)
}
throw new Error(`Failed to load static props`)
}

return res.json()
})
}
Expand All @@ -330,7 +336,8 @@ function fetchNextData(dataHref: string, isServerRender: boolean) {
// We should only trigger a server-side transition if this was caused
// on a client-side transition. Otherwise, we'd get into an infinite
// loop.
if (!isServerRender) {

if (!isServerRender || err.message === 'SSG Data NOT_FOUND') {
markLoadingError(err)
}
throw err
Expand Down Expand Up @@ -900,6 +907,13 @@ export default class Router implements BaseRouter {
// 3. Internal error while loading the page

// So, doing a hard reload is the proper way to deal with this.
if (process.env.NODE_ENV === 'development') {
// append __next404 query to prevent fallback from being re-served
// on reload in development
if (err.message === SSG_DATA_NOT_FOUND_ERROR && this.isSsr) {
as += `${as.indexOf('?') > -1 ? '&' : '?'}__next404=1`
}
}
window.location.href = as

// Changing the URL doesn't block executing the current code path.
Expand Down
20 changes: 14 additions & 6 deletions packages/next/next-server/server/incremental-cache.ts
Expand Up @@ -10,9 +10,10 @@ function toRoute(pathname: string): string {
}

type IncrementalCacheValue = {
html: string
pageData: any
html?: string
pageData?: any
isStale?: boolean
isNotFound?: boolean
curRevalidate?: number | false
// milliseconds to revalidate after
revalidateAfter: number | false
Expand Down Expand Up @@ -55,6 +56,7 @@ export class IncrementalCache {
version: -1 as any, // letting us know this doesn't conform to spec
routes: {},
dynamicRoutes: {},
notFoundRoutes: [],
preview: null as any, // `preview` is special case read in next-dev-server
}
} else {
Expand All @@ -67,8 +69,9 @@ export class IncrementalCache {
// default to 50MB limit
max: max || 50 * 1024 * 1024,
length(val) {
if (val.isNotFound) return 25
// rough estimate of size of cache value
return val.html.length + JSON.stringify(val.pageData).length
return val.html!.length + JSON.stringify(val.pageData).length
},
})
}
Expand Down Expand Up @@ -112,6 +115,10 @@ export class IncrementalCache {

// let's check the disk for seed data
if (!data) {
if (this.prerenderManifest.notFoundRoutes.includes(pathname)) {
return { isNotFound: true, revalidateAfter: false }
}

try {
const html = await promises.readFile(
this.getSeedPath(pathname, 'html'),
Expand Down Expand Up @@ -151,8 +158,9 @@ export class IncrementalCache {
async set(
pathname: string,
data: {
html: string
pageData: any
html?: string
pageData?: any
isNotFound?: boolean
},
revalidateSeconds?: number | false
) {
Expand All @@ -178,7 +186,7 @@ export class IncrementalCache {

// TODO: This option needs to cease to exist unless it stops mutating the
// `next build` output's manifest.
if (this.incrementalOptions.flushToDisk) {
if (this.incrementalOptions.flushToDisk && !data.isNotFound) {
try {
const seedPath = this.getSeedPath(pathname, 'html')
await promises.mkdir(path.dirname(seedPath), { recursive: true })
Expand Down
40 changes: 34 additions & 6 deletions packages/next/next-server/server/next-server.ts
Expand Up @@ -699,7 +699,7 @@ export default class Server {
)

const { query } = parsedDestination
delete parsedDestination.query
delete (parsedDestination as any).query

parsedDestination.search = stringifyQs(query, undefined, undefined, {
encodeURIComponent: (str: string) => str,
Expand Down Expand Up @@ -744,7 +744,7 @@ export default class Server {
// external rewrite, proxy it
if (parsedDestination.protocol) {
const { query } = parsedDestination
delete parsedDestination.query
delete (parsedDestination as any).query
parsedDestination.search = stringifyQs(
query,
undefined,
Expand Down Expand Up @@ -1115,6 +1115,7 @@ export default class Server {
...(components.getStaticProps
? {
amp: query.amp,
__next404: query.__next404,
_nextDataReq: query._nextDataReq,
__nextLocale: query.__nextLocale,
}
Expand Down Expand Up @@ -1240,12 +1241,27 @@ export default class Server {
query.amp ? '.amp' : ''
}`

// In development we use a __next404 query to allow signaling we should
// render the 404 page after attempting to fetch the _next/data for a
// fallback page since the fallback page will always be available after
// reload and we don't want to re-serve it and instead want to 404.
if (this.renderOpts.dev && isSSG && query.__next404) {
delete query.__next404
throw new NoFallbackError()
}

// Complete the response with cached data if its present
const cachedData = ssgCacheKey
? await this.incrementalCache.get(ssgCacheKey)
: undefined

if (cachedData) {
if (cachedData.isNotFound) {
// we don't currently revalidate when notFound is returned
// so trigger rendering 404 here
throw new NoFallbackError()
}

const data = isDataReq
? JSON.stringify(cachedData.pageData)
: cachedData.html
Expand Down Expand Up @@ -1290,10 +1306,12 @@ export default class Server {
html: string | null
pageData: any
sprRevalidate: number | false
isNotFound?: boolean
}> => {
let pageData: any
let html: string | null
let sprRevalidate: number | false
let isNotFound: boolean | undefined

let renderResult
// handle serverless
Expand All @@ -1313,6 +1331,7 @@ export default class Server {
html = renderResult.html
pageData = renderResult.renderOpts.pageData
sprRevalidate = renderResult.renderOpts.revalidate
isNotFound = renderResult.renderOpts.ssgNotFound
} else {
const origQuery = parseUrl(req.url || '', true).query
const resolvedUrl = formatUrl({
Expand Down Expand Up @@ -1354,9 +1373,10 @@ export default class Server {
// TODO: change this to a different passing mechanism
pageData = (renderOpts as any).pageData
sprRevalidate = (renderOpts as any).revalidate
isNotFound = (renderOpts as any).ssgNotFound
}

return { html, pageData, sprRevalidate }
return { html, pageData, sprRevalidate, isNotFound }
}
)

Expand Down Expand Up @@ -1438,10 +1458,15 @@ export default class Server {

const {
isOrigin,
value: { html, pageData, sprRevalidate },
value: { html, pageData, sprRevalidate, isNotFound },
} = await doRender()
let resHtml = html
if (!isResSent(res) && (isSSG || isDataReq || isServerProps)) {

if (
!isResSent(res) &&
!isNotFound &&
(isSSG || isDataReq || isServerProps)
) {
sendPayload(
req,
res,
Expand All @@ -1466,11 +1491,14 @@ export default class Server {
if (isOrigin && ssgCacheKey) {
await this.incrementalCache.set(
ssgCacheKey,
{ html: html!, pageData },
{ html: html!, pageData, isNotFound },
sprRevalidate
)
}

if (isNotFound) {
throw new NoFallbackError()
}
return resHtml
}

Expand Down

0 comments on commit 2a94ae0

Please sign in to comment.