diff --git a/packages/next/src/build/index.ts b/packages/next/src/build/index.ts index ef8b373b12478..2d006d8f7fb33 100644 --- a/packages/next/src/build/index.ts +++ b/packages/next/src/build/index.ts @@ -523,7 +523,17 @@ export default async function build( appPaths = await nextBuildSpan .traceChild('collect-app-paths') .traceAsyncFn(() => - recursiveReadDir(appDir, validFileMatcher.isAppRouterPage) + recursiveReadDir(appDir, (absolutePath) => { + if (validFileMatcher.isAppRouterPage(absolutePath)) { + return true + } + // For now we only collect the root /not-found page in the app + // directory as the 404 fallback. + if (validFileMatcher.isRootNotFound(absolutePath)) { + return true + } + return false + }) ) } @@ -653,6 +663,7 @@ export default async function build( const conflictingPublicFiles: string[] = [] const hasPages404 = mappedPages['/404']?.startsWith(PAGES_DIR_ALIAS) + const hasApp404 = !!mappedAppPages?.['/not-found'] const hasCustomErrorPage = mappedPages['/_error'].startsWith(PAGES_DIR_ALIAS) @@ -2159,7 +2170,8 @@ export default async function build( // Since custom _app.js can wrap the 404 page we have to opt-out of static optimization if it has getInitialProps // Only export the static 404 when there is no /_error present const useStatic404 = - !customAppGetInitialProps && (!hasNonStaticErrorPage || hasPages404) + !customAppGetInitialProps && + (!hasNonStaticErrorPage || hasPages404 || hasApp404) if (invalidPages.size > 0) { const err = new Error( @@ -2463,6 +2475,7 @@ export default async function build( routes.forEach((route) => { if (isDynamicRoute(page) && route === page) return + if (route === '/not-found') return let revalidate = exportConfig.initialPageRevalidationMap[route] @@ -2664,9 +2677,36 @@ export default async function build( }) } - // Only move /404 to /404 when there is no custom 404 as in that case we don't know about the 404 page - if (!hasPages404 && useStatic404) { - await moveExportedPage('/_error', '/404', '/404', false, 'html') + async function moveExportedAppNotFoundTo404() { + return staticGenerationSpan + .traceChild('move-exported-app-not-found-') + .traceAsyncFn(async () => { + const orig = path.join( + distDir, + 'server', + 'app', + 'not-found.html' + ) + const updatedRelativeDest = path + .join('pages', '404.html') + .replace(/\\/g, '/') + await promises.copyFile( + orig, + path.join(distDir, 'server', updatedRelativeDest) + ) + pagesManifest['/404'] = updatedRelativeDest + }) + } + + // If there's /not-found inside app, we prefer it over the pages 404 + if (hasApp404 && useStatic404) { + // await moveExportedPage('/_error', '/404', '/404', false, 'html') + await moveExportedAppNotFoundTo404() + } else { + // Only move /404 to /404 when there is no custom 404 as in that case we don't know about the 404 page + if (!hasPages404 && useStatic404) { + await moveExportedPage('/_error', '/404', '/404', false, 'html') + } } if (useDefaultStatic500) { diff --git a/packages/next/src/build/webpack/loaders/next-app-loader.ts b/packages/next/src/build/webpack/loaders/next-app-loader.ts index b32bdf0c599b4..e6042b854cf93 100644 --- a/packages/next/src/build/webpack/loaders/next-app-loader.ts +++ b/packages/next/src/build/webpack/loaders/next-app-loader.ts @@ -219,9 +219,13 @@ async function createTreeCodeFromPath( }) ) + const definedFilePaths = filePaths.filter( + ([, filePath]) => filePath !== undefined + ) + if (!rootLayout) { - const layoutPath = filePaths.find( - ([type, filePath]) => type === 'layout' && !!filePath + const layoutPath = definedFilePaths.find( + ([type]) => type === 'layout' )?.[1] rootLayout = layoutPath @@ -232,9 +236,6 @@ async function createTreeCodeFromPath( } } - const definedFilePaths = filePaths.filter( - ([, filePath]) => filePath !== undefined - ) props[parallelKey] = `[ '${ Array.isArray(parallelSegment) ? parallelSegment[0] : parallelSegment diff --git a/packages/next/src/export/worker.ts b/packages/next/src/export/worker.ts index 3b100743533a9..62fb54ab12577 100644 --- a/packages/next/src/export/worker.ts +++ b/packages/next/src/export/worker.ts @@ -465,10 +465,11 @@ export default async function exportPage({ try { curRenderOpts.params ||= {} + const isNotFoundPage = page === '/not-found' const result = await renderToHTMLOrFlight( req as any, res as any, - page, + isNotFoundPage ? '/404' : page, query, curRenderOpts as any ) diff --git a/packages/next/src/server/app-render/index.tsx b/packages/next/src/server/app-render/index.tsx index f3b7c1461a0d4..90a2cf6281e1b 100644 --- a/packages/next/src/server/app-render/index.tsx +++ b/packages/next/src/server/app-render/index.tsx @@ -679,6 +679,7 @@ export async function renderToHTMLOrFlight( rootLayoutIncluded: rootLayoutIncludedAtThisLevelOrAbove, injectedCSS: injectedCSSWithCurrentLayout, injectedFontPreloadTags: injectedFontPreloadTagsWithCurrentLayout, + asNotFound, }) const childProp: ChildProp = { @@ -739,8 +740,24 @@ export async function renderToHTMLOrFlight( const isClientComponent = isClientReference(layoutOrPageMod) + // If it's a not found route, and we don't have any matched parallel + // routes, we try to render the not found component if it exists. + let notFoundComponent = {} + if (asNotFound && !parallelRouteMap.length && NotFound) { + notFoundComponent = { + children: ( + <> + + {notFoundStyles} + + + ), + } + } + const props = { ...parallelRouteComponents, + ...notFoundComponent, // TODO-APP: params and query have to be blocked parallel route names. Might have to add a reserved name list. // Params are always the current params that apply to the layout // 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( injectedCSS, injectedFontPreloadTags, rootLayoutIncluded, + asNotFound, }: { createSegmentPath: CreateSegmentPath loaderTreeToFilter: LoaderTree @@ -858,6 +876,7 @@ export async function renderToHTMLOrFlight( injectedCSS: Set injectedFontPreloadTags: Set rootLayoutIncluded: boolean + asNotFound?: boolean }): Promise => { const [segment, parallelRoutes, components] = loaderTreeToFilter @@ -931,6 +950,7 @@ export async function renderToHTMLOrFlight( injectedFontPreloadTags, // This is intentionally not "rootLayoutIncludedAtThisLevelOrAbove" as createComponentTree starts at the current level and does a check for "rootLayoutAtThisLevel" too. rootLayoutIncluded: rootLayoutIncluded, + asNotFound, } ) @@ -988,6 +1008,7 @@ export async function renderToHTMLOrFlight( injectedCSS: injectedCSSWithCurrentLayout, injectedFontPreloadTags: injectedFontPreloadTagsWithCurrentLayout, rootLayoutIncluded: rootLayoutIncludedAtThisLevelOrAbove, + asNotFound, }) if (typeof path[path.length - 1] !== 'string') { @@ -1025,6 +1046,7 @@ export async function renderToHTMLOrFlight( injectedCSS: new Set(), injectedFontPreloadTags: new Set(), rootLayoutIncluded: false, + asNotFound: pathname === '/404', }) ).slice(1), ] @@ -1410,7 +1432,11 @@ export async function renderToHTMLOrFlight( } // End of action request handling. - const renderResult = new RenderResult(await bodyResult({})) + const renderResult = new RenderResult( + await bodyResult({ + asNotFound: pathname === '/404', + }) + ) if (staticGenerationStore.pendingRevalidates) { await Promise.all(staticGenerationStore.pendingRevalidates) diff --git a/packages/next/src/server/base-server.ts b/packages/next/src/server/base-server.ts index 7b8c1843b6a4c..9bac59e9ebb71 100644 --- a/packages/next/src/server/base-server.ts +++ b/packages/next/src/server/base-server.ts @@ -2181,16 +2181,30 @@ export default abstract class Server { let using404Page = false // use static 404 page if available and is 404 response - if (is404 && (await this.hasPage('/404'))) { - result = await this.findPageComponents({ - pathname: '/404', - query, - params: {}, - isAppPath: false, - // Ensuring can't be done here because you never "match" a 404 route. - shouldEnsure: true, - }) - using404Page = result !== null + if (is404) { + if (this.hasAppDir) { + // Use the not-found entry in app directory + result = await this.findPageComponents({ + pathname: '/not-found', + query, + params: {}, + isAppPath: true, + shouldEnsure: true, + }) + using404Page = result !== null + } + + if (!result && (await this.hasPage('/404'))) { + result = await this.findPageComponents({ + pathname: '/404', + query, + params: {}, + isAppPath: false, + // Ensuring can't be done here because you never "match" a 404 route. + shouldEnsure: true, + }) + using404Page = result !== null + } } let statusPage = `/${res.statusCode}` diff --git a/packages/next/src/server/lib/app-dir-module.ts b/packages/next/src/server/lib/app-dir-module.ts index 77a35c215f25d..4bb9f69375d01 100644 --- a/packages/next/src/server/lib/app-dir-module.ts +++ b/packages/next/src/server/lib/app-dir-module.ts @@ -1,4 +1,4 @@ -import { ComponentsType } from '../../build/webpack/loaders/next-app-loader' +import type { ComponentsType } from '../../build/webpack/loaders/next-app-loader' /** * LoaderTree is generated in next-app-loader. diff --git a/packages/next/src/server/lib/find-page-file.ts b/packages/next/src/server/lib/find-page-file.ts index e0f6e84e3bd76..b3d01383b68d6 100644 --- a/packages/next/src/server/lib/find-page-file.ts +++ b/packages/next/src/server/lib/find-page-file.ts @@ -91,6 +91,9 @@ export function createValidFileMatcher( pageExtensions )}$` ) + const leafOnlyNotFoundFileRegex = new RegExp( + `^not-found\\.${getExtensionRegexString(pageExtensions)}$` + ) /** TODO-METADATA: support other metadata routes * regex for: * @@ -125,9 +128,21 @@ export function createValidFileMatcher( return validExtensionFileRegex.test(filePath) || isMetadataFile(filePath) } + function isRootNotFound(filePath: string) { + if (!appDirPath) { + return false + } + if (!filePath.startsWith(appDirPath + sep)) { + return false + } + const rest = filePath.slice(appDirPath.length + 1) + return leafOnlyNotFoundFileRegex.test(rest) + } + return { isPageFile, isAppRouterPage, isMetadataFile, + isRootNotFound, } } diff --git a/test/e2e/app-dir/not-found/app/layout.js b/test/e2e/app-dir/not-found/app/layout.js new file mode 100644 index 0000000000000..3faaeb3874a13 --- /dev/null +++ b/test/e2e/app-dir/not-found/app/layout.js @@ -0,0 +1,10 @@ +export default function Layout({ children }) { + return ( + + + Hello World + + {children} + + ) +} diff --git a/test/e2e/app-dir/not-found/app/not-found.js b/test/e2e/app-dir/not-found/app/not-found.js new file mode 100644 index 0000000000000..6adda9a790961 --- /dev/null +++ b/test/e2e/app-dir/not-found/app/not-found.js @@ -0,0 +1,3 @@ +export default function Page() { + return

This Is The Not Found Page

+} diff --git a/test/e2e/app-dir/not-found/app/page.js b/test/e2e/app-dir/not-found/app/page.js new file mode 100644 index 0000000000000..e5c1e42bcc385 --- /dev/null +++ b/test/e2e/app-dir/not-found/app/page.js @@ -0,0 +1,3 @@ +export default function Page() { + return

My page

+} diff --git a/test/e2e/app-dir/not-found/next.config.js b/test/e2e/app-dir/not-found/next.config.js new file mode 100644 index 0000000000000..cfa3ac3d7aa94 --- /dev/null +++ b/test/e2e/app-dir/not-found/next.config.js @@ -0,0 +1,5 @@ +module.exports = { + experimental: { + appDir: true, + }, +} diff --git a/test/e2e/app-dir/not-found/not-found.test.ts b/test/e2e/app-dir/not-found/not-found.test.ts new file mode 100644 index 0000000000000..b74951b408e4c --- /dev/null +++ b/test/e2e/app-dir/not-found/not-found.test.ts @@ -0,0 +1,27 @@ +import { createNextDescribe } from 'e2e-utils' + +createNextDescribe( + 'app dir - not-found', + { + files: __dirname, + skipDeployment: true, + }, + ({ next, isNextDev }) => { + describe('root not-found page', () => { + it('should use the not-found page for non-matching routes', async () => { + const html = await next.render('/random-content') + expect(html).toContain('This Is The Not Found Page') + }) + + if (!isNextDev) { + it('should create the 404 mapping and copy the file to pages', async () => { + const html = await next.readFile('.next/server/pages/404.html') + expect(html).toContain('This Is The Not Found Page') + expect( + await next.readFile('.next/server/pages-manifest.json') + ).toContain('"pages/404.html"') + }) + } + }) + } +)