Skip to content

Commit 2a94ae0

Browse files
authored
Add support for returning 404 from getStaticProps (#17755)
1 parent 6ec3659 commit 2a94ae0

File tree

12 files changed

+283
-33
lines changed

12 files changed

+283
-33
lines changed

packages/next/build/index.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ export type PrerenderManifest = {
9696
version: 2
9797
routes: { [route: string]: SsgRoute }
9898
dynamicRoutes: { [route: string]: DynamicSsgRoute }
99+
notFoundRoutes: string[]
99100
preview: __ApiPreviewProps
100101
}
101102

@@ -713,6 +714,7 @@ export default async function build(
713714

714715
const finalPrerenderRoutes: { [route: string]: SsgRoute } = {}
715716
const tbdPrerenderRoutes: string[] = []
717+
let ssgNotFoundPaths: string[] = []
716718

717719
if (postCompileSpinner) postCompileSpinner.stopAndPersist()
718720

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

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

876880
const { i18n } = config.experimental
881+
const isNotFound = ssgNotFoundPaths.includes(page)
877882

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

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

903+
if (isSsg && ssgNotFoundPaths.includes(curPath)) {
904+
continue
905+
}
906+
897907
const updatedRelativeDest = path.join(
898908
'pages',
899909
locale + localeExt,
@@ -913,9 +923,7 @@ export default async function build(
913923
)
914924

915925
if (!isSsg) {
916-
pagesManifest[
917-
`/${locale}${page === '/' ? '' : page}`
918-
] = updatedRelativeDest
926+
pagesManifest[curPath] = updatedRelativeDest
919927
}
920928
await promises.mkdir(path.dirname(updatedDest), { recursive: true })
921929
await promises.rename(updatedOrig, updatedDest)
@@ -1066,6 +1074,7 @@ export default async function build(
10661074
version: 2,
10671075
routes: finalPrerenderRoutes,
10681076
dynamicRoutes: finalDynamicRoutes,
1077+
notFoundRoutes: ssgNotFoundPaths,
10691078
preview: previewProps,
10701079
}
10711080

@@ -1085,6 +1094,7 @@ export default async function build(
10851094
routes: {},
10861095
dynamicRoutes: {},
10871096
preview: previewProps,
1097+
notFoundRoutes: [],
10881098
}
10891099
await promises.writeFile(
10901100
path.join(distDir, PRERENDER_MANIFEST),

packages/next/export/index.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -473,13 +473,17 @@ export default async function exportApp(
473473
renderError = renderError || !!result.error
474474
if (!!result.error) errorPaths.push(path)
475475

476-
if (
477-
options.buildExport &&
478-
typeof result.fromBuildExportRevalidate !== 'undefined'
479-
) {
480-
configuration.initialPageRevalidationMap[path] =
481-
result.fromBuildExportRevalidate
476+
if (options.buildExport) {
477+
if (typeof result.fromBuildExportRevalidate !== 'undefined') {
478+
configuration.initialPageRevalidationMap[path] =
479+
result.fromBuildExportRevalidate
480+
}
481+
482+
if (result.ssgNotFound === true) {
483+
configuration.ssgNotFoundPaths.push(path)
484+
}
482485
}
486+
483487
if (progress) progress()
484488
})
485489
)

packages/next/export/worker.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ interface ExportPageResults {
5555
ampValidations: AmpValidation[]
5656
fromBuildExportRevalidate?: number
5757
error?: boolean
58+
ssgNotFound?: boolean
5859
}
5960

6061
interface RenderOpts {
@@ -252,11 +253,11 @@ export default async function exportPage({
252253
// @ts-ignore
253254
params
254255
)
255-
curRenderOpts = result.renderOpts || {}
256-
html = result.html
256+
curRenderOpts = (result as any).renderOpts || {}
257+
html = (result as any).html
257258
}
258259

259-
if (!html) {
260+
if (!html && !(curRenderOpts as any).ssgNotFound) {
260261
throw new Error(`Failed to render serverless page`)
261262
}
262263
} else {
@@ -311,6 +312,7 @@ export default async function exportPage({
311312
html = await renderMethod(req, res, page, query, curRenderOpts)
312313
}
313314
}
315+
results.ssgNotFound = (curRenderOpts as any).ssgNotFound
314316

315317
const validateAmp = async (
316318
rawAmpHtml: string,
@@ -334,7 +336,9 @@ export default async function exportPage({
334336
}
335337

336338
if (curRenderOpts.inAmpMode && !curRenderOpts.ampSkipValidation) {
337-
await validateAmp(html, path, curRenderOpts.ampValidatorPath)
339+
if (!results.ssgNotFound) {
340+
await validateAmp(html, path, curRenderOpts.ampValidatorPath)
341+
}
338342
} else if (curRenderOpts.hybridAmp) {
339343
// we need to render the AMP version
340344
let ampHtmlFilename = `${ampPath}${sep}index.html`
@@ -396,6 +400,10 @@ export default async function exportPage({
396400
}
397401
results.fromBuildExportRevalidate = (curRenderOpts as any).revalidate
398402

403+
if (results.ssgNotFound) {
404+
// don't attempt writing to disk if getStaticProps returned not found
405+
return results
406+
}
399407
await promises.writeFile(htmlFilepath, html, 'utf8')
400408
return results
401409
} catch (error) {

packages/next/next-server/lib/router/router.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,8 @@ const manualScrollRestoration =
299299
typeof window !== 'undefined' &&
300300
'scrollRestoration' in window.history
301301

302+
const SSG_DATA_NOT_FOUND_ERROR = 'SSG Data NOT_FOUND'
303+
302304
function fetchRetry(url: string, attempts: number): Promise<any> {
303305
return fetch(url, {
304306
// Cookies are required to be present for Next.js' SSG "Preview Mode".
@@ -318,9 +320,13 @@ function fetchRetry(url: string, attempts: number): Promise<any> {
318320
if (attempts > 1 && res.status >= 500) {
319321
return fetchRetry(url, attempts - 1)
320322
}
323+
if (res.status === 404) {
324+
// TODO: handle reloading in development from fallback returning 200
325+
// to on-demand-entry-handler causing it to reload periodically
326+
throw new Error(SSG_DATA_NOT_FOUND_ERROR)
327+
}
321328
throw new Error(`Failed to load static props`)
322329
}
323-
324330
return res.json()
325331
})
326332
}
@@ -330,7 +336,8 @@ function fetchNextData(dataHref: string, isServerRender: boolean) {
330336
// We should only trigger a server-side transition if this was caused
331337
// on a client-side transition. Otherwise, we'd get into an infinite
332338
// loop.
333-
if (!isServerRender) {
339+
340+
if (!isServerRender || err.message === 'SSG Data NOT_FOUND') {
334341
markLoadingError(err)
335342
}
336343
throw err
@@ -900,6 +907,13 @@ export default class Router implements BaseRouter {
900907
// 3. Internal error while loading the page
901908

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

905919
// Changing the URL doesn't block executing the current code path.

packages/next/next-server/server/incremental-cache.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,10 @@ function toRoute(pathname: string): string {
1010
}
1111

1212
type IncrementalCacheValue = {
13-
html: string
14-
pageData: any
13+
html?: string
14+
pageData?: any
1515
isStale?: boolean
16+
isNotFound?: boolean
1617
curRevalidate?: number | false
1718
// milliseconds to revalidate after
1819
revalidateAfter: number | false
@@ -55,6 +56,7 @@ export class IncrementalCache {
5556
version: -1 as any, // letting us know this doesn't conform to spec
5657
routes: {},
5758
dynamicRoutes: {},
59+
notFoundRoutes: [],
5860
preview: null as any, // `preview` is special case read in next-dev-server
5961
}
6062
} else {
@@ -67,8 +69,9 @@ export class IncrementalCache {
6769
// default to 50MB limit
6870
max: max || 50 * 1024 * 1024,
6971
length(val) {
72+
if (val.isNotFound) return 25
7073
// rough estimate of size of cache value
71-
return val.html.length + JSON.stringify(val.pageData).length
74+
return val.html!.length + JSON.stringify(val.pageData).length
7275
},
7376
})
7477
}
@@ -112,6 +115,10 @@ export class IncrementalCache {
112115

113116
// let's check the disk for seed data
114117
if (!data) {
118+
if (this.prerenderManifest.notFoundRoutes.includes(pathname)) {
119+
return { isNotFound: true, revalidateAfter: false }
120+
}
121+
115122
try {
116123
const html = await promises.readFile(
117124
this.getSeedPath(pathname, 'html'),
@@ -151,8 +158,9 @@ export class IncrementalCache {
151158
async set(
152159
pathname: string,
153160
data: {
154-
html: string
155-
pageData: any
161+
html?: string
162+
pageData?: any
163+
isNotFound?: boolean
156164
},
157165
revalidateSeconds?: number | false
158166
) {
@@ -178,7 +186,7 @@ export class IncrementalCache {
178186

179187
// TODO: This option needs to cease to exist unless it stops mutating the
180188
// `next build` output's manifest.
181-
if (this.incrementalOptions.flushToDisk) {
189+
if (this.incrementalOptions.flushToDisk && !data.isNotFound) {
182190
try {
183191
const seedPath = this.getSeedPath(pathname, 'html')
184192
await promises.mkdir(path.dirname(seedPath), { recursive: true })

packages/next/next-server/server/next-server.ts

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -699,7 +699,7 @@ export default class Server {
699699
)
700700

701701
const { query } = parsedDestination
702-
delete parsedDestination.query
702+
delete (parsedDestination as any).query
703703

704704
parsedDestination.search = stringifyQs(query, undefined, undefined, {
705705
encodeURIComponent: (str: string) => str,
@@ -744,7 +744,7 @@ export default class Server {
744744
// external rewrite, proxy it
745745
if (parsedDestination.protocol) {
746746
const { query } = parsedDestination
747-
delete parsedDestination.query
747+
delete (parsedDestination as any).query
748748
parsedDestination.search = stringifyQs(
749749
query,
750750
undefined,
@@ -1115,6 +1115,7 @@ export default class Server {
11151115
...(components.getStaticProps
11161116
? {
11171117
amp: query.amp,
1118+
__next404: query.__next404,
11181119
_nextDataReq: query._nextDataReq,
11191120
__nextLocale: query.__nextLocale,
11201121
}
@@ -1240,12 +1241,27 @@ export default class Server {
12401241
query.amp ? '.amp' : ''
12411242
}`
12421243

1244+
// In development we use a __next404 query to allow signaling we should
1245+
// render the 404 page after attempting to fetch the _next/data for a
1246+
// fallback page since the fallback page will always be available after
1247+
// reload and we don't want to re-serve it and instead want to 404.
1248+
if (this.renderOpts.dev && isSSG && query.__next404) {
1249+
delete query.__next404
1250+
throw new NoFallbackError()
1251+
}
1252+
12431253
// Complete the response with cached data if its present
12441254
const cachedData = ssgCacheKey
12451255
? await this.incrementalCache.get(ssgCacheKey)
12461256
: undefined
12471257

12481258
if (cachedData) {
1259+
if (cachedData.isNotFound) {
1260+
// we don't currently revalidate when notFound is returned
1261+
// so trigger rendering 404 here
1262+
throw new NoFallbackError()
1263+
}
1264+
12491265
const data = isDataReq
12501266
? JSON.stringify(cachedData.pageData)
12511267
: cachedData.html
@@ -1290,10 +1306,12 @@ export default class Server {
12901306
html: string | null
12911307
pageData: any
12921308
sprRevalidate: number | false
1309+
isNotFound?: boolean
12931310
}> => {
12941311
let pageData: any
12951312
let html: string | null
12961313
let sprRevalidate: number | false
1314+
let isNotFound: boolean | undefined
12971315

12981316
let renderResult
12991317
// handle serverless
@@ -1313,6 +1331,7 @@ export default class Server {
13131331
html = renderResult.html
13141332
pageData = renderResult.renderOpts.pageData
13151333
sprRevalidate = renderResult.renderOpts.revalidate
1334+
isNotFound = renderResult.renderOpts.ssgNotFound
13161335
} else {
13171336
const origQuery = parseUrl(req.url || '', true).query
13181337
const resolvedUrl = formatUrl({
@@ -1354,9 +1373,10 @@ export default class Server {
13541373
// TODO: change this to a different passing mechanism
13551374
pageData = (renderOpts as any).pageData
13561375
sprRevalidate = (renderOpts as any).revalidate
1376+
isNotFound = (renderOpts as any).ssgNotFound
13571377
}
13581378

1359-
return { html, pageData, sprRevalidate }
1379+
return { html, pageData, sprRevalidate, isNotFound }
13601380
}
13611381
)
13621382

@@ -1438,10 +1458,15 @@ export default class Server {
14381458

14391459
const {
14401460
isOrigin,
1441-
value: { html, pageData, sprRevalidate },
1461+
value: { html, pageData, sprRevalidate, isNotFound },
14421462
} = await doRender()
14431463
let resHtml = html
1444-
if (!isResSent(res) && (isSSG || isDataReq || isServerProps)) {
1464+
1465+
if (
1466+
!isResSent(res) &&
1467+
!isNotFound &&
1468+
(isSSG || isDataReq || isServerProps)
1469+
) {
14451470
sendPayload(
14461471
req,
14471472
res,
@@ -1466,11 +1491,14 @@ export default class Server {
14661491
if (isOrigin && ssgCacheKey) {
14671492
await this.incrementalCache.set(
14681493
ssgCacheKey,
1469-
{ html: html!, pageData },
1494+
{ html: html!, pageData, isNotFound },
14701495
sprRevalidate
14711496
)
14721497
}
14731498

1499+
if (isNotFound) {
1500+
throw new NoFallbackError()
1501+
}
14741502
return resHtml
14751503
}
14761504

0 commit comments

Comments
 (0)