Skip to content

Commit 783d7d5

Browse files
authored
Extend not-found.js to catch all unmatched routes (#47328)
This PR continues the work of #45867, that treats the root-level `not-found.js` file inside app dir as the global 404 page, if it exists. Previously, it fallbacks to the /404 route inside pages (and the default one if `404.js` isn't specified). In the implementation, we include `/not-found` in `appPaths` during the build, and treat it as the special `/404` path during pre-rendering. In the renderer, if the `/404` pathname is being handled, we always render the not found boundary. And finally inside the server, we check if `/not-found` exists before picking up the `/404` component. A deployed example: https://not-found-shuding1.vercel.app/balasdkjfaklsdf fix NEXT-463 ([link](https://linear.app/vercel/issue/NEXT-463))
1 parent de92781 commit 783d7d5

File tree

12 files changed

+168
-23
lines changed

12 files changed

+168
-23
lines changed

packages/next/src/build/index.ts

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -523,7 +523,17 @@ export default async function build(
523523
appPaths = await nextBuildSpan
524524
.traceChild('collect-app-paths')
525525
.traceAsyncFn(() =>
526-
recursiveReadDir(appDir, validFileMatcher.isAppRouterPage)
526+
recursiveReadDir(appDir, (absolutePath) => {
527+
if (validFileMatcher.isAppRouterPage(absolutePath)) {
528+
return true
529+
}
530+
// For now we only collect the root /not-found page in the app
531+
// directory as the 404 fallback.
532+
if (validFileMatcher.isRootNotFound(absolutePath)) {
533+
return true
534+
}
535+
return false
536+
})
527537
)
528538
}
529539

@@ -653,6 +663,7 @@ export default async function build(
653663

654664
const conflictingPublicFiles: string[] = []
655665
const hasPages404 = mappedPages['/404']?.startsWith(PAGES_DIR_ALIAS)
666+
const hasApp404 = !!mappedAppPages?.['/not-found']
656667
const hasCustomErrorPage =
657668
mappedPages['/_error'].startsWith(PAGES_DIR_ALIAS)
658669

@@ -2159,7 +2170,8 @@ export default async function build(
21592170
// Since custom _app.js can wrap the 404 page we have to opt-out of static optimization if it has getInitialProps
21602171
// Only export the static 404 when there is no /_error present
21612172
const useStatic404 =
2162-
!customAppGetInitialProps && (!hasNonStaticErrorPage || hasPages404)
2173+
!customAppGetInitialProps &&
2174+
(!hasNonStaticErrorPage || hasPages404 || hasApp404)
21632175

21642176
if (invalidPages.size > 0) {
21652177
const err = new Error(
@@ -2463,6 +2475,7 @@ export default async function build(
24632475

24642476
routes.forEach((route) => {
24652477
if (isDynamicRoute(page) && route === page) return
2478+
if (route === '/not-found') return
24662479

24672480
let revalidate = exportConfig.initialPageRevalidationMap[route]
24682481

@@ -2664,9 +2677,36 @@ export default async function build(
26642677
})
26652678
}
26662679

2667-
// Only move /404 to /404 when there is no custom 404 as in that case we don't know about the 404 page
2668-
if (!hasPages404 && useStatic404) {
2669-
await moveExportedPage('/_error', '/404', '/404', false, 'html')
2680+
async function moveExportedAppNotFoundTo404() {
2681+
return staticGenerationSpan
2682+
.traceChild('move-exported-app-not-found-')
2683+
.traceAsyncFn(async () => {
2684+
const orig = path.join(
2685+
distDir,
2686+
'server',
2687+
'app',
2688+
'not-found.html'
2689+
)
2690+
const updatedRelativeDest = path
2691+
.join('pages', '404.html')
2692+
.replace(/\\/g, '/')
2693+
await promises.copyFile(
2694+
orig,
2695+
path.join(distDir, 'server', updatedRelativeDest)
2696+
)
2697+
pagesManifest['/404'] = updatedRelativeDest
2698+
})
2699+
}
2700+
2701+
// If there's /not-found inside app, we prefer it over the pages 404
2702+
if (hasApp404 && useStatic404) {
2703+
// await moveExportedPage('/_error', '/404', '/404', false, 'html')
2704+
await moveExportedAppNotFoundTo404()
2705+
} else {
2706+
// Only move /404 to /404 when there is no custom 404 as in that case we don't know about the 404 page
2707+
if (!hasPages404 && useStatic404) {
2708+
await moveExportedPage('/_error', '/404', '/404', false, 'html')
2709+
}
26702710
}
26712711

26722712
if (useDefaultStatic500) {

packages/next/src/build/webpack/loaders/next-app-loader.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -219,9 +219,13 @@ async function createTreeCodeFromPath(
219219
})
220220
)
221221

222+
const definedFilePaths = filePaths.filter(
223+
([, filePath]) => filePath !== undefined
224+
)
225+
222226
if (!rootLayout) {
223-
const layoutPath = filePaths.find(
224-
([type, filePath]) => type === 'layout' && !!filePath
227+
const layoutPath = definedFilePaths.find(
228+
([type]) => type === 'layout'
225229
)?.[1]
226230
rootLayout = layoutPath
227231

@@ -232,9 +236,6 @@ async function createTreeCodeFromPath(
232236
}
233237
}
234238

235-
const definedFilePaths = filePaths.filter(
236-
([, filePath]) => filePath !== undefined
237-
)
238239
props[parallelKey] = `[
239240
'${
240241
Array.isArray(parallelSegment) ? parallelSegment[0] : parallelSegment

packages/next/src/export/worker.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -465,10 +465,11 @@ export default async function exportPage({
465465
try {
466466
curRenderOpts.params ||= {}
467467

468+
const isNotFoundPage = page === '/not-found'
468469
const result = await renderToHTMLOrFlight(
469470
req as any,
470471
res as any,
471-
page,
472+
isNotFoundPage ? '/404' : page,
472473
query,
473474
curRenderOpts as any
474475
)

packages/next/src/server/app-render/index.tsx

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -679,6 +679,7 @@ export async function renderToHTMLOrFlight(
679679
rootLayoutIncluded: rootLayoutIncludedAtThisLevelOrAbove,
680680
injectedCSS: injectedCSSWithCurrentLayout,
681681
injectedFontPreloadTags: injectedFontPreloadTagsWithCurrentLayout,
682+
asNotFound,
682683
})
683684

684685
const childProp: ChildProp = {
@@ -739,8 +740,24 @@ export async function renderToHTMLOrFlight(
739740

740741
const isClientComponent = isClientReference(layoutOrPageMod)
741742

743+
// If it's a not found route, and we don't have any matched parallel
744+
// routes, we try to render the not found component if it exists.
745+
let notFoundComponent = {}
746+
if (asNotFound && !parallelRouteMap.length && NotFound) {
747+
notFoundComponent = {
748+
children: (
749+
<>
750+
<meta name="robots" content="noindex" />
751+
{notFoundStyles}
752+
<NotFound />
753+
</>
754+
),
755+
}
756+
}
757+
742758
const props = {
743759
...parallelRouteComponents,
760+
...notFoundComponent,
744761
// TODO-APP: params and query have to be blocked parallel route names. Might have to add a reserved name list.
745762
// Params are always the current params that apply to the layout
746763
// If you have a `/dashboard/[team]/layout.js` it will provide `team` as a param but not anything further down.
@@ -847,6 +864,7 @@ export async function renderToHTMLOrFlight(
847864
injectedCSS,
848865
injectedFontPreloadTags,
849866
rootLayoutIncluded,
867+
asNotFound,
850868
}: {
851869
createSegmentPath: CreateSegmentPath
852870
loaderTreeToFilter: LoaderTree
@@ -858,6 +876,7 @@ export async function renderToHTMLOrFlight(
858876
injectedCSS: Set<string>
859877
injectedFontPreloadTags: Set<string>
860878
rootLayoutIncluded: boolean
879+
asNotFound?: boolean
861880
}): Promise<FlightDataPath> => {
862881
const [segment, parallelRoutes, components] = loaderTreeToFilter
863882

@@ -931,6 +950,7 @@ export async function renderToHTMLOrFlight(
931950
injectedFontPreloadTags,
932951
// This is intentionally not "rootLayoutIncludedAtThisLevelOrAbove" as createComponentTree starts at the current level and does a check for "rootLayoutAtThisLevel" too.
933952
rootLayoutIncluded: rootLayoutIncluded,
953+
asNotFound,
934954
}
935955
)
936956

@@ -988,6 +1008,7 @@ export async function renderToHTMLOrFlight(
9881008
injectedCSS: injectedCSSWithCurrentLayout,
9891009
injectedFontPreloadTags: injectedFontPreloadTagsWithCurrentLayout,
9901010
rootLayoutIncluded: rootLayoutIncludedAtThisLevelOrAbove,
1011+
asNotFound,
9911012
})
9921013

9931014
if (typeof path[path.length - 1] !== 'string') {
@@ -1025,6 +1046,7 @@ export async function renderToHTMLOrFlight(
10251046
injectedCSS: new Set(),
10261047
injectedFontPreloadTags: new Set(),
10271048
rootLayoutIncluded: false,
1049+
asNotFound: pathname === '/404',
10281050
})
10291051
).slice(1),
10301052
]
@@ -1410,7 +1432,11 @@ export async function renderToHTMLOrFlight(
14101432
}
14111433
// End of action request handling.
14121434

1413-
const renderResult = new RenderResult(await bodyResult({}))
1435+
const renderResult = new RenderResult(
1436+
await bodyResult({
1437+
asNotFound: pathname === '/404',
1438+
})
1439+
)
14141440

14151441
if (staticGenerationStore.pendingRevalidates) {
14161442
await Promise.all(staticGenerationStore.pendingRevalidates)

packages/next/src/server/base-server.ts

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2181,16 +2181,30 @@ export default abstract class Server<ServerOptions extends Options = Options> {
21812181
let using404Page = false
21822182

21832183
// use static 404 page if available and is 404 response
2184-
if (is404 && (await this.hasPage('/404'))) {
2185-
result = await this.findPageComponents({
2186-
pathname: '/404',
2187-
query,
2188-
params: {},
2189-
isAppPath: false,
2190-
// Ensuring can't be done here because you never "match" a 404 route.
2191-
shouldEnsure: true,
2192-
})
2193-
using404Page = result !== null
2184+
if (is404) {
2185+
if (this.hasAppDir) {
2186+
// Use the not-found entry in app directory
2187+
result = await this.findPageComponents({
2188+
pathname: '/not-found',
2189+
query,
2190+
params: {},
2191+
isAppPath: true,
2192+
shouldEnsure: true,
2193+
})
2194+
using404Page = result !== null
2195+
}
2196+
2197+
if (!result && (await this.hasPage('/404'))) {
2198+
result = await this.findPageComponents({
2199+
pathname: '/404',
2200+
query,
2201+
params: {},
2202+
isAppPath: false,
2203+
// Ensuring can't be done here because you never "match" a 404 route.
2204+
shouldEnsure: true,
2205+
})
2206+
using404Page = result !== null
2207+
}
21942208
}
21952209
let statusPage = `/${res.statusCode}`
21962210

packages/next/src/server/lib/app-dir-module.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { ComponentsType } from '../../build/webpack/loaders/next-app-loader'
1+
import type { ComponentsType } from '../../build/webpack/loaders/next-app-loader'
22

33
/**
44
* LoaderTree is generated in next-app-loader.

packages/next/src/server/lib/find-page-file.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,9 @@ export function createValidFileMatcher(
9191
pageExtensions
9292
)}$`
9393
)
94+
const leafOnlyNotFoundFileRegex = new RegExp(
95+
`^not-found\\.${getExtensionRegexString(pageExtensions)}$`
96+
)
9497
/** TODO-METADATA: support other metadata routes
9598
* regex for:
9699
*
@@ -125,9 +128,21 @@ export function createValidFileMatcher(
125128
return validExtensionFileRegex.test(filePath) || isMetadataFile(filePath)
126129
}
127130

131+
function isRootNotFound(filePath: string) {
132+
if (!appDirPath) {
133+
return false
134+
}
135+
if (!filePath.startsWith(appDirPath + sep)) {
136+
return false
137+
}
138+
const rest = filePath.slice(appDirPath.length + 1)
139+
return leafOnlyNotFoundFileRegex.test(rest)
140+
}
141+
128142
return {
129143
isPageFile,
130144
isAppRouterPage,
131145
isMetadataFile,
146+
isRootNotFound,
132147
}
133148
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
export default function Layout({ children }) {
2+
return (
3+
<html>
4+
<head>
5+
<title>Hello World</title>
6+
</head>
7+
<body>{children}</body>
8+
</html>
9+
)
10+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export default function Page() {
2+
return <h1>This Is The Not Found Page</h1>
3+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export default function Page() {
2+
return <h1>My page</h1>
3+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
module.exports = {
2+
experimental: {
3+
appDir: true,
4+
},
5+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { createNextDescribe } from 'e2e-utils'
2+
3+
createNextDescribe(
4+
'app dir - not-found',
5+
{
6+
files: __dirname,
7+
skipDeployment: true,
8+
},
9+
({ next, isNextDev }) => {
10+
describe('root not-found page', () => {
11+
it('should use the not-found page for non-matching routes', async () => {
12+
const html = await next.render('/random-content')
13+
expect(html).toContain('This Is The Not Found Page')
14+
})
15+
16+
if (!isNextDev) {
17+
it('should create the 404 mapping and copy the file to pages', async () => {
18+
const html = await next.readFile('.next/server/pages/404.html')
19+
expect(html).toContain('This Is The Not Found Page')
20+
expect(
21+
await next.readFile('.next/server/pages-manifest.json')
22+
).toContain('"pages/404.html"')
23+
})
24+
}
25+
})
26+
}
27+
)

0 commit comments

Comments
 (0)