diff --git a/packages/next-swc/crates/next-core/src/app_structure.rs b/packages/next-swc/crates/next-core/src/app_structure.rs index f49278c52cb69..e264e26197ed9 100644 --- a/packages/next-swc/crates/next-core/src/app_structure.rs +++ b/packages/next-swc/crates/next-core/src/app_structure.rs @@ -754,7 +754,7 @@ async fn directory_tree_to_entrypoints_internal( // Next.js has this logic in "collect-app-paths", where the root not-found page // is considered as its own entry point. if let Some(_not_found) = components.not_found { - let tree = LoaderTree { + let dev_not_found_tree = LoaderTree { segment: directory_name.to_string(), parallel_routes: indexmap! { "children".to_string() => LoaderTree { @@ -771,12 +771,13 @@ async fn directory_tree_to_entrypoints_internal( components: components.without_leafs().cell(), } .cell(); + add_app_page( app_dir, &mut result, "/not-found".to_string(), "/not-found".to_string(), - tree, + dev_not_found_tree, ) .await?; add_app_page( @@ -784,10 +785,39 @@ async fn directory_tree_to_entrypoints_internal( &mut result, "/_not-found".to_string(), "/_not-found".to_string(), - tree, + dev_not_found_tree, ) .await?; } + } else { + // Create default not-found page for production if there's no customized + // not-found + let prod_not_found_tree = LoaderTree { + segment: directory_name.to_string(), + parallel_routes: indexmap! { + "children".to_string() => LoaderTree { + segment: "__PAGE__".to_string(), + parallel_routes: IndexMap::new(), + components: Components { + page: Some(get_next_package(app_dir).join("dist/client/components/not-found-error.js".to_string())), + ..Default::default() + } + .cell(), + } + .cell(), + }, + components: components.without_leafs().cell(), + } + .cell(); + + add_app_page( + app_dir, + &mut result, + "/_not-found".to_string(), + "/_not-found".to_string(), + prod_not_found_tree, + ) + .await?; } for (subdir_name, &subdirectory) in subdirectories.iter() { diff --git a/packages/next/src/build/entries.ts b/packages/next/src/build/entries.ts index bdbf4431c48fe..28d0cfb5ee826 100644 --- a/packages/next/src/build/entries.ts +++ b/packages/next/src/build/entries.ts @@ -264,7 +264,19 @@ export function createPagesMapping({ {} ) - if (pagesType !== 'pages') { + if (pagesType === 'app') { + const hasAppPages = Object.keys(pages).some((page) => + page.endsWith('/page') + ) + return { + // If there's any app pages existed, add a default not-found page. + // If there's any custom not-found page existed, it will override the default one. + ...(hasAppPages && { + '/_not-found': 'next/dist/client/components/not-found-error', + }), + ...pages, + } + } else if (pagesType === 'root') { return pages } diff --git a/packages/next/src/build/index.ts b/packages/next/src/build/index.ts index 6449c5f781349..eeda0ee96c6a0 100644 --- a/packages/next/src/build/index.ts +++ b/packages/next/src/build/index.ts @@ -109,6 +109,7 @@ import { copyTracedFiles, isReservedPage, AppConfig, + isAppBuiltinNotFoundPage, } from './utils' import { writeBuildId } from './write-build-id' import { normalizeLocalePath } from '../shared/lib/i18n/normalize-locale-path' @@ -1495,12 +1496,18 @@ export default async function build( } } + const pageFilePath = isAppBuiltinNotFoundPage(pagePath) + ? require.resolve( + 'next/dist/client/components/not-found-error' + ) + : path.join( + (pageType === 'pages' ? pagesDir : appDir) || '', + pagePath + ) + const staticInfo = pagePath ? await getPageStaticInfo({ - pageFilePath: path.join( - (pageType === 'pages' ? pagesDir : appDir) || '', - pagePath - ), + pageFilePath, nextConfig: config, pageType, }) diff --git a/packages/next/src/build/utils.ts b/packages/next/src/build/utils.ts index 6e98ad4987263..8cb1299618af9 100644 --- a/packages/next/src/build/utils.ts +++ b/packages/next/src/build/utils.ts @@ -1999,6 +1999,12 @@ export function isReservedPage(page: string) { return RESERVED_PAGE.test(page) } +export function isAppBuiltinNotFoundPage(page: string) { + return /next[\\/]dist[\\/]client[\\/]components[\\/]not-found-error/.test( + page + ) +} + export function isCustomErrorPage(page: string) { return page === '/404' || page === '/500' } 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 e89e6854fc4db..f7118a38a66e4 100644 --- a/packages/next/src/build/webpack/loaders/next-app-loader.ts +++ b/packages/next/src/build/webpack/loaders/next-app-loader.ts @@ -21,6 +21,7 @@ import { AppPathnameNormalizer } from '../../../server/future/normalizers/built/ import { AppBundlePathNormalizer } from '../../../server/future/normalizers/built/app/app-bundle-path-normalizer' import { MiddlewareConfig } from '../../analysis/get-page-static-info' import { getFilenameAndExtension } from './next-metadata-route-loader' +import { isAppBuiltinNotFoundPage } from '../../utils' import { loadEntrypoint } from '../../load-entrypoint' export type AppLoaderOptions = { @@ -181,9 +182,10 @@ async function createTreeCodeFromPath( globalError: string | undefined }> { const splittedPath = pagePath.split(/[\\/]/) - const appDirPrefix = splittedPath[0] - const pages: string[] = [] const isNotFoundRoute = page === '/_not-found' + const isDefaultNotFound = isAppBuiltinNotFoundPage(pagePath) + const appDirPrefix = isDefaultNotFound ? APP_DIR_ALIAS : splittedPath[0] + const pages: string[] = [] let rootLayout: string | undefined let globalError: string | undefined @@ -244,7 +246,10 @@ async function createTreeCodeFromPath( let metadata: Awaited> = null const routerDirPath = `${appDirPrefix}${segmentPath}` - const resolvedRouteDir = await resolveDir(routerDirPath) + // For default not-found, don't traverse the directory to find metadata. + const resolvedRouteDir = isDefaultNotFound + ? '' + : await resolveDir(routerDirPath) if (resolvedRouteDir) { metadata = await createStaticMetadataFromRoute(resolvedRouteDir, { @@ -336,6 +341,11 @@ async function createTreeCodeFromPath( )?.[1] rootLayout = layoutPath + if (isDefaultNotFound && !layoutPath) { + rootLayout = 'next/dist/client/components/default-layout' + definedFilePaths.push(['layout', rootLayout]) + } + if (layoutPath) { globalError = await resolver( `${path.dirname(layoutPath)}/${GLOBAL_ERROR_FILE_TYPE}` diff --git a/packages/next/src/build/webpack/plugins/flight-manifest-plugin.ts b/packages/next/src/build/webpack/plugins/flight-manifest-plugin.ts index 635c34d5718ba..5150bd08dcbe1 100644 --- a/packages/next/src/build/webpack/plugins/flight-manifest-plugin.ts +++ b/packages/next/src/build/webpack/plugins/flight-manifest-plugin.ts @@ -367,8 +367,10 @@ export class ClientReferenceManifestPlugin { } // Special case for the root not-found page. - if (/^app\/not-found(\.[^.]+)?$/.test(entryName)) { - manifestEntryFiles.push('app/not-found') + // dev: app/not-found + // prod: app/_not-found + if (/^app\/_?not-found(\.[^.]+)?$/.test(entryName)) { + manifestEntryFiles.push(this.dev ? 'app/not-found' : 'app/_not-found') } const groupName = entryNameToGroupName(entryName) diff --git a/packages/next/src/client/components/default-layout.tsx b/packages/next/src/client/components/default-layout.tsx new file mode 100644 index 0000000000000..13dc5e8ea8189 --- /dev/null +++ b/packages/next/src/client/components/default-layout.tsx @@ -0,0 +1,13 @@ +import React from 'react' + +export default function DefaultLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + + {children} + + ) +} diff --git a/packages/next/src/lib/verifyRootLayout.ts b/packages/next/src/lib/verifyRootLayout.ts index 008316ec0ec2a..a4ae2318f28dc 100644 --- a/packages/next/src/lib/verifyRootLayout.ts +++ b/packages/next/src/lib/verifyRootLayout.ts @@ -41,7 +41,7 @@ export default function RootLayout({ title: 'Next.js', description: 'Generated by Next.js', } - + export default function RootLayout({ children }) { return ( @@ -71,6 +71,7 @@ export async function verifyRootLayout({ appDir, `**/layout.{${pageExtensions.join(',')}}` ) + const isFileUnderAppDir = pagePath.startsWith(`${APP_DIR_ALIAS}/`) const normalizedPagePath = pagePath.replace(`${APP_DIR_ALIAS}/`, '') const pagePathSegments = normalizedPagePath.split('/') @@ -78,28 +79,34 @@ export async function verifyRootLayout({ // Place the layout as close to app/ as possible. let availableDir: string | undefined - if (layoutFiles.length === 0) { - // If there's no other layout file we can place the layout file in the app dir. - // However, if the page is within a route group directly under app (e.g. app/(routegroup)/page.js) - // prefer creating the root layout in that route group. - const firstSegmentValue = pagePathSegments[0] - availableDir = firstSegmentValue.startsWith('(') ? firstSegmentValue : '' - } else { - pagePathSegments.pop() // remove the page from segments + if (isFileUnderAppDir) { + if (layoutFiles.length === 0) { + // If there's no other layout file we can place the layout file in the app dir. + // However, if the page is within a route group directly under app (e.g. app/(routegroup)/page.js) + // prefer creating the root layout in that route group. + const firstSegmentValue = pagePathSegments[0] + availableDir = firstSegmentValue.startsWith('(') + ? firstSegmentValue + : '' + } else { + pagePathSegments.pop() // remove the page from segments - let currentSegments: string[] = [] - for (const segment of pagePathSegments) { - currentSegments.push(segment) - // Find the dir closest to app/ where a layout can be created without affecting other layouts. - if ( - !layoutFiles.some((file) => - file.startsWith(currentSegments.join('/')) - ) - ) { - availableDir = currentSegments.join('/') - break + let currentSegments: string[] = [] + for (const segment of pagePathSegments) { + currentSegments.push(segment) + // Find the dir closest to app/ where a layout can be created without affecting other layouts. + if ( + !layoutFiles.some((file) => + file.startsWith(currentSegments.join('/')) + ) + ) { + availableDir = currentSegments.join('/') + break + } } } + } else { + availableDir = '' } if (typeof availableDir === 'string') { diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index 08d683e42348f..638a445e2f7b6 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -28,7 +28,6 @@ import { streamToBufferedResult, cloneTransformStream, } from '../stream-utils/node-web-streams-helper' -import DefaultNotFound from '../../client/components/not-found-error' import { canSegmentBeOverridden, matchSegment, @@ -735,7 +734,7 @@ export async function renderToHTMLOrFlight( const NotFoundBoundary = ComponentMod.NotFoundBoundary as typeof import('../../client/components/not-found-boundary').NotFoundBoundary Component = (componentProps: any) => { - const NotFoundComponent = NotFound || DefaultNotFound + const NotFoundComponent = NotFound const RootLayoutComponent = LayoutOrPage return ( { expect(entrypoints.done).toBe(false) expect(Array.from(entrypoints.value.routes.keys()).sort()).toEqual([ '/', + '/_not-found', '/api/edge', '/api/nodejs', '/app', diff --git a/test/e2e/app-dir/app-static/app-static.test.ts b/test/e2e/app-dir/app-static/app-static.test.ts index a51553185df11..83a5b21316f34 100644 --- a/test/e2e/app-dir/app-static/app-static.test.ts +++ b/test/e2e/app-dir/app-static/app-static.test.ts @@ -485,6 +485,10 @@ createNextDescribe( expect(files.sort()).toEqual( [ + '_not-found.html', + '_not-found.js', + '_not-found.rsc', + '_not-found_client-reference-manifest.js', 'page.js', 'index.rsc', 'index.html', diff --git a/test/e2e/app-dir/create-root-layout/create-root-layout.test.ts b/test/e2e/app-dir/create-root-layout/create-root-layout.test.ts index 12fcec912436e..f8c8f2f566b0c 100644 --- a/test/e2e/app-dir/create-root-layout/create-root-layout.test.ts +++ b/test/e2e/app-dir/create-root-layout/create-root-layout.test.ts @@ -54,7 +54,7 @@ describe('app-dir create root layout', () => { title: 'Next.js', description: 'Generated by Next.js', } - + export default function RootLayout({ children }) { return ( @@ -106,7 +106,7 @@ describe('app-dir create root layout', () => { title: 'Next.js', description: 'Generated by Next.js', } - + export default function RootLayout({ children }) { return ( @@ -158,7 +158,7 @@ describe('app-dir create root layout', () => { title: 'Next.js', description: 'Generated by Next.js', } - + export default function RootLayout({ children }) { return ( diff --git a/test/e2e/app-dir/not-found-default/app/layout.js b/test/e2e/app-dir/not-found-default/app/layout.js index a18b12ceb3cc1..5b344e26143ea 100644 --- a/test/e2e/app-dir/not-found-default/app/layout.js +++ b/test/e2e/app-dir/not-found-default/app/layout.js @@ -5,7 +5,6 @@ import { notFound } from 'next/navigation' import NotFoundTrigger from './not-found-trigger' export default function Root({ children }) { - // notFound() const [clicked, setClicked] = useState(false) if (clicked) { notFound() diff --git a/test/e2e/app-dir/not-found-default/index.test.ts b/test/e2e/app-dir/not-found-default/index.test.ts index 1919731972098..1a8bb252cecce 100644 --- a/test/e2e/app-dir/not-found-default/index.test.ts +++ b/test/e2e/app-dir/not-found-default/index.test.ts @@ -31,6 +31,21 @@ createNextDescribe( } }) + it('should render default 404 with root layout for non-existent page', async () => { + const browser = await next.browser('/non-existent') + await browser.waitForElementByCss('.next-error-h1') + expect(await browser.elementByCss('.next-error-h1').text()).toBe('404') + expect(await browser.elementByCss('html').getAttribute('class')).toBe( + 'root-layout-html' + ) + + if (isNextDev) { + const cliOutput = next.cliOutput + expect(cliOutput).toContain('/not-found') + expect(cliOutput).not.toContain('/_error') + } + }) + it('should error on server notFound from root layout on server-side', async () => { const browser = await next.browser('/?root-not-found=1') @@ -81,6 +96,7 @@ createNextDescribe( await browser.loadPage(next.url + '/group-dynamic/404') expect(await browser.elementByCss('.next-error-h1').text()).toBe('404') + // Using default layout expect(await browser.elementByCss('html').getAttribute('class')).toBe( 'group-root-layout' ) diff --git a/test/integration/telemetry/test/page-features.test.js b/test/integration/telemetry/test/page-features.test.js index 47c11f85ef138..1b4a56a92a8fe 100644 --- a/test/integration/telemetry/test/page-features.test.js +++ b/test/integration/telemetry/test/page-features.test.js @@ -219,7 +219,7 @@ describe('page features telemetry', () => { expect(event1).toMatch(/"ssrPageCount": 3/) expect(event1).toMatch(/"staticPageCount": 4/) expect(event1).toMatch(/"totalPageCount": 11/) - expect(event1).toMatch(/"totalAppPagesCount": 4/) + expect(event1).toMatch(/"totalAppPagesCount": 5/) expect(event1).toMatch(/"serverAppPagesCount": 2/) expect(event1).toMatch(/"edgeRuntimeAppCount": 1/) expect(event1).toMatch(/"edgeRuntimePagesCount": 2/) @@ -229,7 +229,7 @@ describe('page features telemetry', () => { .exec(stderr) .pop() - expect(event2).toMatch(/"totalAppPagesCount": 4/) + expect(event2).toMatch(/"totalAppPagesCount": 5/) } catch (err) { require('console').error('failing stderr', stderr, err) throw err