Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for returning 404 from getStaticProps #17755

Merged
merged 5 commits into from Oct 15, 2020
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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 @@ -703,6 +704,7 @@ export default async function build(

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

if (postCompileSpinner) postCompileSpinner.stopAndPersist()

Expand All @@ -720,6 +722,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 @@ -811,6 +814,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 @@ -864,11 +868,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 @@ -881,9 +886,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 @@ -903,9 +913,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 @@ -1056,6 +1064,7 @@ export default async function build(
version: 2,
routes: finalPrerenderRoutes,
dynamicRoutes: finalDynamicRoutes,
notFoundRoutes: ssgNotFoundPaths,
preview: previewProps,
}

Expand All @@ -1075,6 +1084,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 @@ -467,13 +467,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 @@ -395,6 +399,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 @@ -298,6 +298,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 @@ -317,9 +319,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 @@ -329,7 +335,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 @@ -898,6 +905,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 @@ -660,7 +660,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 @@ -705,7 +705,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 @@ -1076,6 +1076,7 @@ export default class Server {
...(components.getStaticProps
? {
amp: query.amp,
__next404: query.__next404,
_nextDataReq: query._nextDataReq,
__nextLocale: query.__nextLocale,
__nextLocales: query.__nextLocales,
Expand Down Expand Up @@ -1204,12 +1205,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 @@ -1254,10 +1270,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 @@ -1277,6 +1295,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 @@ -1318,9 +1337,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 @@ -1402,10 +1422,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 @@ -1430,11 +1455,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