diff --git a/crates/next-api/src/app.rs b/crates/next-api/src/app.rs index de3079b64da2c4..af3a7314644ad6 100644 --- a/crates/next-api/src/app.rs +++ b/crates/next-api/src/app.rs @@ -194,7 +194,12 @@ impl AppProject { #[turbo_tasks::function] fn app_entrypoints(&self) -> Vc { - get_entrypoints(*self.app_dir, self.project.next_config().page_extensions()) + let conf = self.project.next_config(); + get_entrypoints( + *self.app_dir, + conf.page_extensions(), + conf.is_global_not_found_enabled(), + ) } #[turbo_tasks::function] diff --git a/crates/next-core/src/app_page_loader_tree.rs b/crates/next-core/src/app_page_loader_tree.rs index d92b6efead50f5..38a974688a878c 100644 --- a/crates/next-core/src/app_page_loader_tree.rs +++ b/crates/next-core/src/app_page_loader_tree.rs @@ -335,6 +335,7 @@ impl AppPageLoaderTreeBuilder { default, error, global_error, + global_not_found, layout, loading, template, @@ -375,6 +376,8 @@ impl AppPageLoaderTreeBuilder { .await?; self.write_modules_entry(AppDirModuleType::GlobalError, *global_error) .await?; + self.write_modules_entry(AppDirModuleType::GlobalNotFound, *global_not_found) + .await?; let modules_code = replace(&mut self.loader_tree_code, temp_loader_tree_code); @@ -399,6 +402,7 @@ impl AppPageLoaderTreeBuilder { let loader_tree = &*loader_tree.await?; let modules = &loader_tree.modules; + // load global-error module if let Some(global_error) = modules.global_error { let module = self .base @@ -407,6 +411,17 @@ impl AppPageLoaderTreeBuilder { .await?; self.base.inner_assets.insert(GLOBAL_ERROR.into(), module); }; + // load global-not-found module + if let Some(global_not_found) = modules.global_not_found { + let module = self + .base + .process_source(Vc::upcast(FileSource::new(*global_not_found))) + .to_resolved() + .await?; + self.base + .inner_assets + .insert(GLOBAL_NOT_FOUND.into(), module); + }; self.walk_tree(loader_tree, true).await?; Ok(AppPageLoaderTreeModule { @@ -439,3 +454,4 @@ impl AppPageLoaderTreeModule { } pub const GLOBAL_ERROR: &str = "GLOBAL_ERROR_MODULE"; +pub const GLOBAL_NOT_FOUND: &str = "GLOBAL_NOT_FOUND_MODULE"; diff --git a/crates/next-core/src/app_structure.rs b/crates/next-core/src/app_structure.rs index dd2ed86854fc22..4c5dc550526288 100644 --- a/crates/next-core/src/app_structure.rs +++ b/crates/next-core/src/app_structure.rs @@ -39,6 +39,8 @@ pub struct AppDirModules { #[serde(skip_serializing_if = "Option::is_none")] pub global_error: Option>, #[serde(skip_serializing_if = "Option::is_none")] + pub global_not_found: Option>, + #[serde(skip_serializing_if = "Option::is_none")] pub loading: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub template: Option>, @@ -63,6 +65,7 @@ impl AppDirModules { layout: self.layout, error: self.error, global_error: self.global_error, + global_not_found: self.global_not_found, loading: self.loading, template: self.template, not_found: self.not_found, @@ -322,6 +325,7 @@ async fn get_directory_tree_internal( "layout" => modules.layout = Some(file), "error" => modules.error = Some(file), "global-error" => modules.global_error = Some(file), + "global-not-found" => modules.global_not_found = Some(file), "loading" => modules.loading = Some(file), "template" => modules.template = Some(file), "forbidden" => modules.forbidden = Some(file), @@ -730,11 +734,13 @@ fn add_app_metadata_route( pub fn get_entrypoints( app_dir: Vc, page_extensions: Vc>, + is_global_not_found_enabled: Vc, ) -> Vc { directory_tree_to_entrypoints( app_dir, get_directory_tree(app_dir, page_extensions), get_global_metadata(app_dir, page_extensions), + is_global_not_found_enabled, Default::default(), ) } @@ -744,11 +750,13 @@ fn directory_tree_to_entrypoints( app_dir: Vc, directory_tree: Vc, global_metadata: Vc, + is_global_not_found_enabled: Vc, root_layouts: Vc, ) -> Vc { directory_tree_to_entrypoints_internal( app_dir, global_metadata, + is_global_not_found_enabled, "".into(), directory_tree, AppPage::new(), @@ -1124,6 +1132,7 @@ async fn default_route_tree( async fn directory_tree_to_entrypoints_internal( app_dir: ResolvedVc, global_metadata: Vc, + is_global_not_found_enabled: Vc, directory_name: RcStr, directory_tree: Vc, app_page: AppPage, @@ -1133,6 +1142,7 @@ async fn directory_tree_to_entrypoints_internal( directory_tree_to_entrypoints_internal_untraced( app_dir, global_metadata, + is_global_not_found_enabled, directory_name, directory_tree, app_page, @@ -1145,6 +1155,7 @@ async fn directory_tree_to_entrypoints_internal( async fn directory_tree_to_entrypoints_internal_untraced( app_dir: ResolvedVc, global_metadata: Vc, + is_global_not_found_enabled: Vc, directory_name: RcStr, directory_tree: Vc, app_page: AppPage, @@ -1284,6 +1295,13 @@ async fn directory_tree_to_entrypoints_internal_untraced( // Next.js has this logic in "collect-app-paths", where the root not-found page // is considered as its own entry point. + + // Determine if we enable the global not-found feature. + let is_global_not_found_enabled = *is_global_not_found_enabled.await?; + let use_global_not_found = + is_global_not_found_enabled || modules.global_not_found.is_some(); + + let not_found_root_modules = modules.without_leafs(); let not_found_tree = AppPageLoaderTree { page: app_page.clone(), segment: directory_name.clone(), @@ -1296,24 +1314,54 @@ async fn directory_tree_to_entrypoints_internal_untraced( page: app_page.clone(), segment: "__PAGE__".into(), parallel_routes: FxIndexMap::default(), - modules: AppDirModules { - page: match modules.not_found { - Some(v) => Some(v), - None => Some(get_next_package(*app_dir) - .join("dist/client/components/not-found-error.js".into()) - .to_resolved() - .await?), - }, - ..Default::default() + modules: if use_global_not_found { + // if global-not-found.js is present: + // we use it for the page and no layout, since layout is included in global-not-found.js; + AppDirModules { + layout: None, + page: match modules.global_not_found { + Some(v) => Some(v), + None => Some(get_next_package(*app_dir) + .join("dist/client/components/global-not-found.js".into()) + .to_resolved() + .await?), + }, + ..Default::default() + } + } else { + // if global-not-found.js is not present: + // we search if we can compose root layout with the root not-found.js; + AppDirModules { + page: match modules.not_found { + Some(v) => Some(v), + None => Some(get_next_package(*app_dir) + .join("dist/client/components/not-found-error.js".into()) + .to_resolved() + .await?), + }, + ..Default::default() + } }, global_metadata: global_metadata.to_resolved().await?, } }, - modules: AppDirModules::default(), + modules: AppDirModules { + ..Default::default() + }, global_metadata: global_metadata.to_resolved().await?, }, }, - modules: modules.without_leafs(), + modules: AppDirModules { + // `global-not-found.js` does not need a layout since it's included. + // Skip it if it's present. + // Otherwise, we need to compose it with the root layout to compose with not-found.js boundary. + layout: if use_global_not_found { + None + } else { + modules.layout + }, + ..not_found_root_modules + }, global_metadata: global_metadata.to_resolved().await?, } .resolved_cell(); @@ -1345,6 +1393,7 @@ async fn directory_tree_to_entrypoints_internal_untraced( let map = directory_tree_to_entrypoints_internal( *app_dir, global_metadata, + is_global_not_found_enabled, subdir_name.clone(), *subdirectory, child_app_page.clone(), diff --git a/crates/next-core/src/base_loader_tree.rs b/crates/next-core/src/base_loader_tree.rs index 20020a503ad727..f6ece7e1d51e65 100644 --- a/crates/next-core/src/base_loader_tree.rs +++ b/crates/next-core/src/base_loader_tree.rs @@ -32,6 +32,7 @@ pub enum AppDirModuleType { Forbidden, Unauthorized, GlobalError, + GlobalNotFound, } impl AppDirModuleType { @@ -47,6 +48,7 @@ impl AppDirModuleType { AppDirModuleType::Forbidden => "forbidden", AppDirModuleType::Unauthorized => "unauthorized", AppDirModuleType::GlobalError => "global-error", + AppDirModuleType::GlobalNotFound => "global-not-found", } } } diff --git a/crates/next-core/src/next_config.rs b/crates/next-core/src/next_config.rs index 1ceb4e98498e99..1e2875b238cbb4 100644 --- a/crates/next-core/src/next_config.rs +++ b/crates/next-core/src/next_config.rs @@ -750,6 +750,8 @@ pub struct ExperimentalConfig { turbopack_persistent_caching: Option, turbopack_source_maps: Option, turbopack_tree_shaking: Option, + // Whether to enable the global-not-found convention + global_not_found: Option, } #[derive( @@ -1139,6 +1141,11 @@ impl NextConfig { Vc::cell(self.page_extensions.clone()) } + #[turbo_tasks::function] + pub fn is_global_not_found_enabled(&self) -> Vc { + Vc::cell(self.experimental.global_not_found.unwrap_or_default()) + } + #[turbo_tasks::function] pub fn transpile_packages(&self) -> Vc> { Vc::cell(self.transpile_packages.clone().unwrap_or_default()) diff --git a/packages/next/src/build/entries.ts b/packages/next/src/build/entries.ts index f9d615d4e2816e..64c3cc213bf537 100644 --- a/packages/next/src/build/entries.ts +++ b/packages/next/src/build/entries.ts @@ -301,11 +301,12 @@ export async function createPagesMapping({ 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. + // If there's any app pages existed, add a default /_not-found route as 404. + // If there's any custom /_not-found page, it will override the default one. ...(hasAppPages && { - [UNDERSCORE_NOT_FOUND_ROUTE_ENTRY]: - 'next/dist/client/components/not-found-error', + [UNDERSCORE_NOT_FOUND_ROUTE_ENTRY]: require.resolve( + 'next/dist/client/components/global-not-found' + ), }), ...pages, } @@ -720,6 +721,9 @@ export async function createEntrypoints( : undefined, preferredRegion: staticInfo.preferredRegion, middlewareConfig: encodeToBase64(staticInfo.middleware || {}), + isGlobalNotFoundEnabled: config.experimental.globalNotFound + ? true + : undefined, }) } else if (isInstrumentation) { server[serverBundlePath.replace('src/', '')] = @@ -799,6 +803,9 @@ export async function createEntrypoints( middlewareConfig: Buffer.from( JSON.stringify(staticInfo.middleware || {}) ).toString('base64'), + isGlobalNotFoundEnabled: config.experimental.globalNotFound + ? true + : undefined, }).import } edgeServer[serverBundlePath] = getEdgeServerEntry({ diff --git a/packages/next/src/build/utils.ts b/packages/next/src/build/utils.ts index 845a43a7a4692b..475dd6b8fd630c 100644 --- a/packages/next/src/build/utils.ts +++ b/packages/next/src/build/utils.ts @@ -1751,7 +1751,7 @@ export function isReservedPage(page: string) { } export function isAppBuiltinNotFoundPage(page: string) { - return /next[\\/]dist[\\/]client[\\/]components[\\/]not-found-error/.test( + return /next[\\/]dist[\\/]client[\\/]components[\\/](not-found-error|global-not-found)/.test( page ) } diff --git a/packages/next/src/build/webpack/loaders/next-app-loader/index.ts b/packages/next/src/build/webpack/loaders/next-app-loader/index.ts index c9b70eb64f3014..dfbe103fe87b1a 100644 --- a/packages/next/src/build/webpack/loaders/next-app-loader/index.ts +++ b/packages/next/src/build/webpack/loaders/next-app-loader/index.ts @@ -50,6 +50,7 @@ export type AppLoaderOptions = { nextConfigOutput?: NextConfig['output'] nextConfigExperimentalUseEarlyImport?: true middlewareConfig: string + isGlobalNotFoundEnabled: true | undefined } type AppLoader = webpack.LoaderDefinitionFunction @@ -70,15 +71,19 @@ const FILE_TYPES = { error: 'error', loading: 'loading', 'global-error': 'global-error', + 'global-not-found': 'global-not-found', ...HTTP_ACCESS_FALLBACKS, } as const const GLOBAL_ERROR_FILE_TYPE = 'global-error' +const GLOBAL_NOT_FOUND_FILE_TYPE = 'global-not-found' const PAGE_SEGMENT = 'page$' const PARALLEL_CHILDREN_SEGMENT = 'children$' const defaultGlobalErrorPath = 'next/dist/client/components/global-error' +const defaultNotFoundPath = 'next/dist/client/components/not-found-error' const defaultLayoutPath = 'next/dist/client/components/default-layout' +const defaultGlobalNotFoundPath = 'next/dist/client/components/global-not-found' type DirResolver = (pathToResolve: string) => string type PathResolver = ( @@ -123,6 +128,7 @@ async function createTreeCodeFromPath( pageExtensions, basePath, collectedDeclarations, + isGlobalNotFoundEnabled, }: { page: string resolveDir: DirResolver @@ -135,22 +141,25 @@ async function createTreeCodeFromPath( pageExtensions: PageExtensions basePath: string collectedDeclarations: [string, string][] + isGlobalNotFoundEnabled: boolean } ): Promise<{ treeCode: string pages: string rootLayout: string | undefined globalError: string + globalNotFound: string }> { const splittedPath = pagePath.split(/[\\/]/, 1) const isNotFoundRoute = page === UNDERSCORE_NOT_FOUND_ROUTE_ENTRY - const isDefaultNotFound = isAppBuiltinNotFoundPage(pagePath) + const appDirPrefix = isDefaultNotFound ? APP_DIR_ALIAS : splittedPath[0] const pages: string[] = [] let rootLayout: string | undefined let globalError: string | undefined + let globalNotFound: string | undefined async function resolveAdjacentParallelSegments( segmentPath: string @@ -210,9 +219,7 @@ async function createTreeCodeFromPath( null const routerDirPath = `${appDirPrefix}${segmentPath}` // For default not-found, don't traverse the directory to find metadata. - const resolvedRouteDir = isDefaultNotFound - ? '' - : await resolveDir(routerDirPath) + const resolvedRouteDir = isDefaultNotFound ? '' : resolveDir(routerDirPath) if (resolvedRouteDir) { metadata = await createStaticMetadataFromRoute(resolvedRouteDir, { @@ -294,7 +301,7 @@ async function createTreeCodeFromPath( }) ) - const definedFilePaths = filePaths.filter( + let definedFilePaths = filePaths.filter( ([, filePath]) => filePath !== undefined ) as [ValueOf, string][] @@ -325,7 +332,9 @@ async function createTreeCodeFromPath( !hasLayerFallbackFile ) { const defaultFallbackPath = defaultHTTPAccessFallbackPaths[type] - definedFilePaths.push([type, defaultFallbackPath]) + if (!(isDefaultNotFound && type === 'not-found')) { + definedFilePaths.push([type, defaultFallbackPath]) + } } } } @@ -336,7 +345,15 @@ async function createTreeCodeFromPath( )?.[1] rootLayout = layoutPath - if (isDefaultNotFound && !layoutPath && !rootLayout) { + // When `global-not-found` is disabled, we insert a default layout if + // root layout is presented. This logic and the default layout will be removed + // once `global-not-found` is stabilized. + if ( + !isGlobalNotFoundEnabled && + isDefaultNotFound && + !layoutPath && + !rootLayout + ) { rootLayout = defaultLayoutPath definedFilePaths.push(['layout', rootLayout]) } @@ -350,6 +367,16 @@ async function createTreeCodeFromPath( globalError = resolvedGlobalErrorPath } } + // TODO(global-not-found): remove this flag assertion condition + // once global-not-found is stable + if (isGlobalNotFoundEnabled && !globalNotFound) { + const resolvedGlobalNotFoundPath = await resolver( + `${appDirPrefix}/${GLOBAL_NOT_FOUND_FILE_TYPE}` + ) + if (resolvedGlobalNotFoundPath) { + globalNotFound = resolvedGlobalNotFoundPath + } + } let parallelSegmentKey = Array.isArray(parallelSegment) ? parallelSegment[0] @@ -368,23 +395,57 @@ async function createTreeCodeFromPath( const normalizedParallelKey = normalizeParallelKey(parallelKey) let subtreeCode // If it's root not found page, set not-found boundary as children page - if (isNotFoundRoute && normalizedParallelKey === 'children') { - const notFoundPath = - definedFilePaths.find(([type]) => type === 'not-found')?.[1] ?? - defaultHTTPAccessFallbackPaths['not-found'] - - const varName = `notFound${nestedCollectedDeclarations.length}` - nestedCollectedDeclarations.push([varName, notFoundPath]) - subtreeCode = `{ - children: [${JSON.stringify(UNDERSCORE_NOT_FOUND_ROUTE)}, { - children: ['${PAGE_SEGMENT_KEY}', {}, { - page: [ - ${varName}, - ${JSON.stringify(notFoundPath)} - ] - }] - }, {}] - }` + if (isNotFoundRoute) { + if (normalizedParallelKey === 'children') { + const matchedGlobalNotFound = isGlobalNotFoundEnabled + ? definedFilePaths.find( + ([type]) => type === GLOBAL_NOT_FOUND_FILE_TYPE + )?.[1] ?? defaultGlobalNotFoundPath + : undefined + + // If custom global-not-found.js is defined, use global-not-found.js + if (matchedGlobalNotFound) { + const varName = `notFound${nestedCollectedDeclarations.length}` + nestedCollectedDeclarations.push([varName, matchedGlobalNotFound]) + subtreeCode = `{ + children: [${JSON.stringify(UNDERSCORE_NOT_FOUND_ROUTE)}, { + children: ['${PAGE_SEGMENT_KEY}', {}, { + page: [ + ${varName}, + ${JSON.stringify(matchedGlobalNotFound)} + ] + }] + }, {}] + }` + } else { + // If custom not-found.js is found, use it and layout to compose the page, + // and fallback to built-in not-found component if doesn't exist. + const notFoundPath = + definedFilePaths.find(([type]) => type === 'not-found')?.[1] ?? + defaultNotFoundPath + const varName = `notFound${nestedCollectedDeclarations.length}` + nestedCollectedDeclarations.push([varName, notFoundPath]) + subtreeCode = `{ + children: [${JSON.stringify(UNDERSCORE_NOT_FOUND_ROUTE)}, { + children: ['${PAGE_SEGMENT_KEY}', {}, { + page: [ + ${varName}, + ${JSON.stringify(notFoundPath)} + ] + }] + }, {}] + }` + } + } + } + + // For 404 route + // if global-not-found is in definedFilePaths, remove root layout for /_not-found + // TODO: remove this once global-not-found is stable. + if (isNotFoundRoute && isGlobalNotFoundEnabled) { + definedFilePaths = definedFilePaths.filter( + ([type]) => type !== 'layout' + ) } const modulesCode = `{ @@ -461,6 +522,7 @@ async function createTreeCodeFromPath( pages: `${JSON.stringify(pages)};`, rootLayout, globalError: globalError ?? defaultGlobalErrorPath, + globalNotFound: globalNotFound ?? defaultNotFoundPath, } } @@ -495,6 +557,14 @@ const nextAppLoader: AppLoader = async function nextAppLoader() { nextConfigExperimentalUseEarlyImport, } = loaderOptions + const isGlobalNotFoundEnabled = !!loaderOptions.isGlobalNotFoundEnabled + + // Update FILE_TYPES on the very top-level of the loader + if (!isGlobalNotFoundEnabled) { + // @ts-expect-error this delete is only necessary while experimental + delete FILE_TYPES['global-not-found'] + } + const buildInfo = getModuleBuildInfo((this as any)._module) const collectedDeclarations: [string, string][] = [] const page = name.replace(/^app/, '') @@ -509,7 +579,10 @@ const nextAppLoader: AppLoader = async function nextAppLoader() { relatedModules: [], } - const extensions = pageExtensions.map((extension) => `.${extension}`) + const extensions = + typeof pageExtensions === 'string' + ? [pageExtensions] + : pageExtensions.map((extension) => `.${extension}`) const normalizedAppPaths = typeof appPaths === 'string' ? [appPaths] : appPaths || [] @@ -687,9 +760,15 @@ const nextAppLoader: AppLoader = async function nextAppLoader() { pageExtensions, basePath, collectedDeclarations, + isGlobalNotFoundEnabled, }) - if (!treeCodeResult.rootLayout) { + const isGlobalNotFoundPath = + page === UNDERSCORE_NOT_FOUND_ROUTE_ENTRY && + !!treeCodeResult.globalNotFound && + isGlobalNotFoundEnabled + + if (!treeCodeResult.rootLayout && !isGlobalNotFoundPath) { if (!isDev) { // If we're building and missing a root layout, exit the build Log.error( @@ -736,6 +815,7 @@ const nextAppLoader: AppLoader = async function nextAppLoader() { pageExtensions, basePath, collectedDeclarations, + isGlobalNotFoundEnabled, }) } } diff --git a/packages/next/src/build/webpack/plugins/flight-client-entry-plugin.ts b/packages/next/src/build/webpack/plugins/flight-client-entry-plugin.ts index 24e53380fa99f5..6d48186a73523f 100644 --- a/packages/next/src/build/webpack/plugins/flight-client-entry-plugin.ts +++ b/packages/next/src/build/webpack/plugins/flight-client-entry-plugin.ts @@ -400,6 +400,20 @@ export class FlightClientEntryPlugin { absolutePagePath: entryRequest, }) } + + if ( + name === `app${UNDERSCORE_NOT_FOUND_ROUTE_ENTRY}` && + bundlePath === 'app/global-not-found' + ) { + clientEntriesToInject.push({ + compiler, + compilation, + entryName: name, + clientComponentImports, + bundlePath: `app${UNDERSCORE_NOT_FOUND_ROUTE_ENTRY}`, + absolutePagePath: entryRequest, + }) + } } // Make sure CSS imports are deduplicated before injecting the client entry diff --git a/packages/next/src/client/components/global-not-found.tsx b/packages/next/src/client/components/global-not-found.tsx new file mode 100644 index 00000000000000..1d903ed6d6df3f --- /dev/null +++ b/packages/next/src/client/components/global-not-found.tsx @@ -0,0 +1,16 @@ +import { HTTPAccessErrorFallback } from './http-access-fallback/error-fallback' + +function GlobalNotFound() { + return ( + + + + + + ) +} + +export default GlobalNotFound diff --git a/packages/next/src/client/components/http-access-fallback/error-fallback.tsx b/packages/next/src/client/components/http-access-fallback/error-fallback.tsx index 81668495161c11..ca1805de03d8f4 100644 --- a/packages/next/src/client/components/http-access-fallback/error-fallback.tsx +++ b/packages/next/src/client/components/http-access-fallback/error-fallback.tsx @@ -1,39 +1,4 @@ -import React from 'react' - -const styles: Record = { - error: { - // https://github.com/sindresorhus/modern-normalize/blob/main/modern-normalize.css#L38-L52 - fontFamily: - 'system-ui,"Segoe UI",Roboto,Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji"', - height: '100vh', - textAlign: 'center', - display: 'flex', - flexDirection: 'column', - alignItems: 'center', - justifyContent: 'center', - }, - - desc: { - display: 'inline-block', - }, - - h1: { - display: 'inline-block', - margin: '0 20px 0 0', - padding: '0 23px 0 0', - fontSize: 24, - fontWeight: 500, - verticalAlign: 'top', - lineHeight: '49px', - }, - - h2: { - fontSize: 14, - fontWeight: 400, - lineHeight: '49px', - margin: 0, - }, -} +import { styles } from '../styles/access-error-styles' export function HTTPAccessErrorFallback({ status, diff --git a/packages/next/src/client/components/styles/access-error-styles.ts b/packages/next/src/client/components/styles/access-error-styles.ts new file mode 100644 index 00000000000000..6c0e52c633d82a --- /dev/null +++ b/packages/next/src/client/components/styles/access-error-styles.ts @@ -0,0 +1,34 @@ +export const styles: Record = { + error: { + // https://github.com/sindresorhus/modern-normalize/blob/main/modern-normalize.css#L38-L52 + fontFamily: + 'system-ui,"Segoe UI",Roboto,Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji"', + height: '100vh', + textAlign: 'center', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + }, + + desc: { + display: 'inline-block', + }, + + h1: { + display: 'inline-block', + margin: '0 20px 0 0', + padding: '0 23px 0 0', + fontSize: 24, + fontWeight: 500, + verticalAlign: 'top', + lineHeight: '49px', + }, + + h2: { + fontSize: 14, + fontWeight: 400, + lineHeight: '49px', + margin: 0, + }, +} diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index 1176ccf4f6c7bc..726aae9d03fc48 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -321,8 +321,8 @@ function parseRequestHeaders( } function createNotFoundLoaderTree(loaderTree: LoaderTree): LoaderTree { - // Align the segment with parallel-route-default in next-app-loader const components = loaderTree[2] + const hasGlobalNotFound = !!components['global-not-found'] return [ '', { @@ -330,11 +330,12 @@ function createNotFoundLoaderTree(loaderTree: LoaderTree): LoaderTree { PAGE_SEGMENT_KEY, {}, { - page: components['not-found'], + page: components['global-not-found'] ?? components['not-found'], }, ], }, - components, + // When global-not-found is present, skip layout from components + hasGlobalNotFound ? components : {}, ] } @@ -1267,7 +1268,6 @@ async function renderToHTMLOrFlightImpl( // Pull out the hooks/references from the component. const { tree: loaderTree, taintObjectReference } = ComponentMod - if (enableTainting) { taintObjectReference( 'Do not pass process.env to Client Components since it will leak sensitive data', diff --git a/packages/next/src/server/config-schema.ts b/packages/next/src/server/config-schema.ts index d6482fc6b06ea5..3003950c65016b 100644 --- a/packages/next/src/server/config-schema.ts +++ b/packages/next/src/server/config-schema.ts @@ -486,6 +486,7 @@ export const configSchema: zod.ZodType = z.lazy(() => buildTimeThresholdMs: z.number().int(), }) .optional(), + globalNotFound: z.boolean().optional(), }) .optional(), exportPathMap: z diff --git a/packages/next/src/server/config-shared.ts b/packages/next/src/server/config-shared.ts index 0947d3776be128..c3560407fd1e8c 100644 --- a/packages/next/src/server/config-shared.ts +++ b/packages/next/src/server/config-shared.ts @@ -659,6 +659,12 @@ export interface ExperimentalConfig { * Note: Use with caution as this can negatively impact page loading performance. */ clientInstrumentationHook?: boolean + + /** + * Enables using the global-not-found.js file in the app directory + * + */ + globalNotFound?: boolean } export type ExportPathMap = { @@ -1353,6 +1359,7 @@ export const defaultConfig: NextConfig = { inlineCss: false, useCache: undefined, slowModuleDetection: undefined, + globalNotFound: false, }, htmlLimitedBots: undefined, bundlePagesRouterDependencies: false, diff --git a/packages/next/src/server/dev/hot-reloader-turbopack.ts b/packages/next/src/server/dev/hot-reloader-turbopack.ts index 33471bc56fa285..8f89197850a66c 100644 --- a/packages/next/src/server/dev/hot-reloader-turbopack.ts +++ b/packages/next/src/server/dev/hot-reloader-turbopack.ts @@ -979,7 +979,8 @@ export async function createHotReloaderTurbopack( inputPage, nextConfig.pageExtensions, opts.pagesDir, - opts.appDir + opts.appDir, + !!nextConfig.experimental.globalNotFound )) // If the route is actually an app page route, then we should have access diff --git a/packages/next/src/server/dev/hot-reloader-webpack.ts b/packages/next/src/server/dev/hot-reloader-webpack.ts index 0f08d04c08fa1e..abd4dcaa9a1615 100644 --- a/packages/next/src/server/dev/hot-reloader-webpack.ts +++ b/packages/next/src/server/dev/hot-reloader-webpack.ts @@ -12,7 +12,7 @@ import { getSourceMapMiddleware, } from '../../client/components/react-dev-overlay/server/middleware-webpack' import { WebpackHotMiddleware } from './hot-middleware' -import { join, relative, isAbsolute, posix } from 'path' +import { join, relative, isAbsolute, posix, dirname } from 'path' import { createEntrypoints, createPagesMapping, @@ -962,6 +962,10 @@ export default class HotReloaderWebpack implements NextJsHotReloaderInterface { middlewareConfig: Buffer.from( JSON.stringify(staticInfo?.middleware || {}) ).toString('base64'), + isGlobalNotFoundEnabled: this.config.experimental + .globalNotFound + ? true + : undefined, }).import : undefined @@ -1052,17 +1056,23 @@ export default class HotReloaderWebpack implements NextJsHotReloaderInterface { preferredRegion: staticInfo?.preferredRegion, }) } else if (isAppPath) { + // This path normalization is critical for webpack to resolve the next internals as entry. + const pagePath = entryData.absolutePagePath.startsWith( + dirname(require.resolve('next/package.json')) + ) + ? entryData.absolutePagePath + : posix.join( + APP_DIR_ALIAS, + relative( + this.appDir!, + entryData.absolutePagePath + ).replace(/\\/g, '/') + ) value = getAppEntry({ name: bundlePath, page, appPaths: entryData.appPaths, - pagePath: posix.join( - APP_DIR_ALIAS, - relative( - this.appDir!, - entryData.absolutePagePath - ).replace(/\\/g, '/') - ), + pagePath, appDir: this.appDir!, pageExtensions: this.config.pageExtensions, rootDir: this.dir, @@ -1075,6 +1085,10 @@ export default class HotReloaderWebpack implements NextJsHotReloaderInterface { middlewareConfig: Buffer.from( JSON.stringify(staticInfo?.middleware || {}) ).toString('base64'), + isGlobalNotFoundEnabled: this.config.experimental + .globalNotFound + ? true + : undefined, }) } else if (isAPIRoute(page)) { value = getRouteLoaderEntry({ diff --git a/packages/next/src/server/dev/on-demand-entry-handler.ts b/packages/next/src/server/dev/on-demand-entry-handler.ts index ef555c5ed47c1f..5f6f26c59b3eca 100644 --- a/packages/next/src/server/dev/on-demand-entry-handler.ts +++ b/packages/next/src/server/dev/on-demand-entry-handler.ts @@ -393,8 +393,9 @@ export async function findPagePathData( rootDir: string, page: string, extensions: string[], - pagesDir?: string, - appDir?: string + pagesDir: string | undefined, + appDir: string | undefined, + isGlobalNotFoundEnabled: boolean ): Promise { const normalizedPagePath = tryToNormalizePagePath(page) let pagePath: string | null = null @@ -436,23 +437,43 @@ export async function findPagePathData( // Check appDir first falling back to pagesDir if (appDir) { if (page === UNDERSCORE_NOT_FOUND_ROUTE_ENTRY) { - const notFoundPath = await findPageFile( - appDir, - 'not-found', - extensions, - true - ) - if (notFoundPath) { - return { - filename: join(appDir, notFoundPath), - bundlePath: `app${UNDERSCORE_NOT_FOUND_ROUTE_ENTRY}`, - page: UNDERSCORE_NOT_FOUND_ROUTE_ENTRY, + // Load `global-not-found` when global-not-found is enabled. + // Prefer to load it when both `global-not-found` and root `not-found` present. + if (isGlobalNotFoundEnabled) { + const globalNotFoundPath = await findPageFile( + appDir, + 'global-not-found', + extensions, + true + ) + if (globalNotFoundPath) { + return { + filename: join(appDir, globalNotFoundPath), + bundlePath: `app${UNDERSCORE_NOT_FOUND_ROUTE_ENTRY}`, + page: UNDERSCORE_NOT_FOUND_ROUTE_ENTRY, + } + } + } else { + // Then if global-not-found.js doesn't exist then load not-found.js + const notFoundPath = await findPageFile( + appDir, + 'not-found', + extensions, + true + ) + if (notFoundPath) { + return { + filename: join(appDir, notFoundPath), + bundlePath: `app${UNDERSCORE_NOT_FOUND_ROUTE_ENTRY}`, + page: UNDERSCORE_NOT_FOUND_ROUTE_ENTRY, + } } } + // If they're not presented, then fallback to global-not-found return { filename: require.resolve( - 'next/dist/client/components/not-found-error' + 'next/dist/client/components/global-not-found' ), bundlePath: `app${UNDERSCORE_NOT_FOUND_ROUTE_ENTRY}`, page: UNDERSCORE_NOT_FOUND_ROUTE_ENTRY, @@ -726,7 +747,8 @@ export function onDemandEntryHandler({ page, nextConfig.pageExtensions, pagesDir, - appDir + appDir, + !!nextConfig.experimental.globalNotFound ) } diff --git a/packages/next/types/$$compiled.internal.d.ts b/packages/next/types/$$compiled.internal.d.ts index 1817701d60ee1d..dec7ffddcdd034 100644 --- a/packages/next/types/$$compiled.internal.d.ts +++ b/packages/next/types/$$compiled.internal.d.ts @@ -337,6 +337,7 @@ declare module 'react-server-dom-webpack/client.edge' { } declare module 'VAR_MODULE_GLOBAL_ERROR' +declare module 'VAR_MODULE_GLOBAL_NOT_FOUND' declare module 'VAR_USERLAND' declare module 'VAR_MODULE_DOCUMENT' declare module 'VAR_MODULE_APP' diff --git a/test/e2e/app-dir/global-not-found/basic/app/call-not-found/page.tsx b/test/e2e/app-dir/global-not-found/basic/app/call-not-found/page.tsx new file mode 100644 index 00000000000000..6f7be9458297a3 --- /dev/null +++ b/test/e2e/app-dir/global-not-found/basic/app/call-not-found/page.tsx @@ -0,0 +1,7 @@ +'use client' + +import { notFound } from 'next/navigation' + +export default function Page() { + notFound() +} diff --git a/test/e2e/app-dir/global-not-found/basic/app/client.tsx b/test/e2e/app-dir/global-not-found/basic/app/client.tsx new file mode 100644 index 00000000000000..f5733496f45850 --- /dev/null +++ b/test/e2e/app-dir/global-not-found/basic/app/client.tsx @@ -0,0 +1,5 @@ +'use client' + +export function Client() { + return
client
+} diff --git a/test/e2e/app-dir/global-not-found/basic/app/global-not-found.tsx b/test/e2e/app-dir/global-not-found/basic/app/global-not-found.tsx new file mode 100644 index 00000000000000..6ac8620dd99ddc --- /dev/null +++ b/test/e2e/app-dir/global-not-found/basic/app/global-not-found.tsx @@ -0,0 +1,13 @@ +import { Client } from './client' + +export default function GlobalNotFound() { + return ( + // html tag is different from actual page's layout + + +

global-not-found

+ + + + ) +} diff --git a/test/e2e/app-dir/global-not-found/basic/app/layout.tsx b/test/e2e/app-dir/global-not-found/basic/app/layout.tsx new file mode 100644 index 00000000000000..dbce4ea8e3aeb6 --- /dev/null +++ b/test/e2e/app-dir/global-not-found/basic/app/layout.tsx @@ -0,0 +1,11 @@ +export default function RootLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + + {children} + + ) +} diff --git a/test/e2e/app-dir/global-not-found/basic/app/page.tsx b/test/e2e/app-dir/global-not-found/basic/app/page.tsx new file mode 100644 index 00000000000000..ff7159d9149fee --- /dev/null +++ b/test/e2e/app-dir/global-not-found/basic/app/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return

hello world

+} diff --git a/test/e2e/app-dir/global-not-found/basic/global-not-found-basic.test.ts b/test/e2e/app-dir/global-not-found/basic/global-not-found-basic.test.ts new file mode 100644 index 00000000000000..bded218938936b --- /dev/null +++ b/test/e2e/app-dir/global-not-found/basic/global-not-found-basic.test.ts @@ -0,0 +1,44 @@ +import { nextTestSetup } from 'e2e-utils' +import { assertNoRedbox } from 'next-test-utils' + +describe('global-not-found - basic', () => { + const { next, isNextDev } = nextTestSetup({ + files: __dirname, + }) + + it('should render global-not-found for 404', async () => { + const browser = await next.browser('/does-not-exist') + if (isNextDev) { + await assertNoRedbox(browser) + } + + const errorTitle = await browser.elementByCss('#global-error-title').text() + expect(errorTitle).toBe('global-not-found') + const notFoundHtmlProp = await browser + .elementByCss('html') + .getAttribute('data-global-not-found') + expect(notFoundHtmlProp).toBe('true') + }) + + it('should ssr global-not-found for 404', async () => { + const $ = await next.render$('/does-not-exist') + const errorTitle = $('#global-error-title').text() + expect(errorTitle).toBe('global-not-found') + const notFoundHtmlProp = $('html').attr('data-global-not-found') + expect(notFoundHtmlProp).toBe('true') + }) + + it('should render not-found boundary when calling notFound() in a page', async () => { + const browser = await next.browser('/call-not-found') + // Still using the root layout + expect( + await browser.elementByCss('html').getAttribute('data-global-not-found') + ).toBeNull() + expect(await browser.elementByCss('html').getAttribute('lang')).toBe('en') + + // There's no not-found boundary in the root layout, show the default not-found.js + expect(await browser.elementByCss('body').text()).toBe( + '404\nThis page could not be found.' + ) + }) +}) diff --git a/test/e2e/app-dir/global-not-found/basic/next.config.js b/test/e2e/app-dir/global-not-found/basic/next.config.js new file mode 100644 index 00000000000000..a198cf5a769a10 --- /dev/null +++ b/test/e2e/app-dir/global-not-found/basic/next.config.js @@ -0,0 +1,10 @@ +/** + * @type {import('next').NextConfig} + */ +const nextConfig = { + experimental: { + globalNotFound: true, + }, +} + +module.exports = nextConfig diff --git a/test/e2e/app-dir/global-not-found/both-present/app/call-not-found/page.tsx b/test/e2e/app-dir/global-not-found/both-present/app/call-not-found/page.tsx new file mode 100644 index 00000000000000..6f7be9458297a3 --- /dev/null +++ b/test/e2e/app-dir/global-not-found/both-present/app/call-not-found/page.tsx @@ -0,0 +1,7 @@ +'use client' + +import { notFound } from 'next/navigation' + +export default function Page() { + notFound() +} diff --git a/test/e2e/app-dir/global-not-found/both-present/app/global-not-found.tsx b/test/e2e/app-dir/global-not-found/both-present/app/global-not-found.tsx new file mode 100644 index 00000000000000..69bea87e0c5b81 --- /dev/null +++ b/test/e2e/app-dir/global-not-found/both-present/app/global-not-found.tsx @@ -0,0 +1,10 @@ +export default function GlobalNotFound() { + return ( + // html tag is different from actual page's layout + + +

global-not-found

+ + + ) +} diff --git a/test/e2e/app-dir/global-not-found/both-present/app/layout.tsx b/test/e2e/app-dir/global-not-found/both-present/app/layout.tsx new file mode 100644 index 00000000000000..dbce4ea8e3aeb6 --- /dev/null +++ b/test/e2e/app-dir/global-not-found/both-present/app/layout.tsx @@ -0,0 +1,11 @@ +export default function RootLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + + {children} + + ) +} diff --git a/test/e2e/app-dir/global-not-found/both-present/app/not-found.tsx b/test/e2e/app-dir/global-not-found/both-present/app/not-found.tsx new file mode 100644 index 00000000000000..ea2bfb546029e8 --- /dev/null +++ b/test/e2e/app-dir/global-not-found/both-present/app/not-found.tsx @@ -0,0 +1,3 @@ +export default function NotFound() { + return
not-found.js
+} diff --git a/test/e2e/app-dir/global-not-found/both-present/both-present.test.ts b/test/e2e/app-dir/global-not-found/both-present/both-present.test.ts new file mode 100644 index 00000000000000..035dcd193c967c --- /dev/null +++ b/test/e2e/app-dir/global-not-found/both-present/both-present.test.ts @@ -0,0 +1,31 @@ +import { nextTestSetup } from 'e2e-utils' + +describe('global-not-found - both-present', () => { + const { next } = nextTestSetup({ + files: __dirname, + }) + + it('should render global-not-found for 404 routes', async () => { + const $ = await next.render$('/does-not-exist') + expect($('html').attr('data-global-not-found')).toBe('true') + expect($('#global-error-title').text()).toBe('global-not-found') + + const browser = await next.browser('/does-not-exist') + expect(await browser.elementByCss('#global-error-title').text()).toBe( + 'global-not-found' + ) + expect( + await browser.elementByCss('html').getAttribute('data-global-not-found') + ).toBe('true') + }) + + it('should render not-found boundary when calling notFound() in a page', async () => { + const browser = await next.browser('/call-not-found') + expect(await browser.elementByCss('#not-found-boundary').text()).toBe( + 'not-found.js' + ) + expect( + await browser.elementByCss('html').getAttribute('data-global-not-found') + ).toBeNull() + }) +}) diff --git a/test/e2e/app-dir/global-not-found/both-present/next.config.js b/test/e2e/app-dir/global-not-found/both-present/next.config.js new file mode 100644 index 00000000000000..a198cf5a769a10 --- /dev/null +++ b/test/e2e/app-dir/global-not-found/both-present/next.config.js @@ -0,0 +1,10 @@ +/** + * @type {import('next').NextConfig} + */ +const nextConfig = { + experimental: { + globalNotFound: true, + }, +} + +module.exports = nextConfig diff --git a/test/e2e/app-dir/global-not-found/no-root-layout/app/(bar)/bar/layout.tsx b/test/e2e/app-dir/global-not-found/no-root-layout/app/(bar)/bar/layout.tsx new file mode 100644 index 00000000000000..a14e64fcd5e33e --- /dev/null +++ b/test/e2e/app-dir/global-not-found/no-root-layout/app/(bar)/bar/layout.tsx @@ -0,0 +1,16 @@ +export const metadata = { + title: 'Next.js', + description: 'Generated by Next.js', +} + +export default function RootLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + + {children} + + ) +} diff --git a/test/e2e/app-dir/global-not-found/no-root-layout/app/(bar)/bar/page.tsx b/test/e2e/app-dir/global-not-found/no-root-layout/app/(bar)/bar/page.tsx new file mode 100644 index 00000000000000..51fdbbc49cc106 --- /dev/null +++ b/test/e2e/app-dir/global-not-found/no-root-layout/app/(bar)/bar/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return

bar

+} diff --git a/test/e2e/app-dir/global-not-found/no-root-layout/app/(foo)/foo/layout.tsx b/test/e2e/app-dir/global-not-found/no-root-layout/app/(foo)/foo/layout.tsx new file mode 100644 index 00000000000000..a14e64fcd5e33e --- /dev/null +++ b/test/e2e/app-dir/global-not-found/no-root-layout/app/(foo)/foo/layout.tsx @@ -0,0 +1,16 @@ +export const metadata = { + title: 'Next.js', + description: 'Generated by Next.js', +} + +export default function RootLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + + {children} + + ) +} diff --git a/test/e2e/app-dir/global-not-found/no-root-layout/app/(foo)/foo/page.tsx b/test/e2e/app-dir/global-not-found/no-root-layout/app/(foo)/foo/page.tsx new file mode 100644 index 00000000000000..7e900dc1e89149 --- /dev/null +++ b/test/e2e/app-dir/global-not-found/no-root-layout/app/(foo)/foo/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return

foo

+} diff --git a/test/e2e/app-dir/global-not-found/no-root-layout/app/global-not-found.tsx b/test/e2e/app-dir/global-not-found/no-root-layout/app/global-not-found.tsx new file mode 100644 index 00000000000000..69bea87e0c5b81 --- /dev/null +++ b/test/e2e/app-dir/global-not-found/no-root-layout/app/global-not-found.tsx @@ -0,0 +1,10 @@ +export default function GlobalNotFound() { + return ( + // html tag is different from actual page's layout + + +

global-not-found

+ + + ) +} diff --git a/test/e2e/app-dir/global-not-found/no-root-layout/next.config.js b/test/e2e/app-dir/global-not-found/no-root-layout/next.config.js new file mode 100644 index 00000000000000..a198cf5a769a10 --- /dev/null +++ b/test/e2e/app-dir/global-not-found/no-root-layout/next.config.js @@ -0,0 +1,10 @@ +/** + * @type {import('next').NextConfig} + */ +const nextConfig = { + experimental: { + globalNotFound: true, + }, +} + +module.exports = nextConfig diff --git a/test/e2e/app-dir/global-not-found/no-root-layout/no-root-layout.test.ts b/test/e2e/app-dir/global-not-found/no-root-layout/no-root-layout.test.ts new file mode 100644 index 00000000000000..2f86336fcbe9fc --- /dev/null +++ b/test/e2e/app-dir/global-not-found/no-root-layout/no-root-layout.test.ts @@ -0,0 +1,30 @@ +import { nextTestSetup } from 'e2e-utils' +import { assertNoRedbox } from 'next-test-utils' + +describe('global-not-found - no-root-layout', () => { + const { next, isNextDev } = nextTestSetup({ + files: __dirname, + }) + + it('should render global-not-found for 404', async () => { + const browser = await next.browser('/does-not-exist') + if (isNextDev) { + await assertNoRedbox(browser) + } + + const errorTitle = await browser.elementByCss('#global-error-title').text() + expect(errorTitle).toBe('global-not-found') + const notFoundHtmlProp = await browser + .elementByCss('html') + .getAttribute('data-global-not-found') + expect(notFoundHtmlProp).toBe('true') + }) + + it('should ssr global-not-found for 404', async () => { + const $ = await next.render$('/does-not-exist') + const errorTitle = $('#global-error-title').text() + expect(errorTitle).toBe('global-not-found') + const notFoundHtmlProp = $('html').attr('data-global-not-found') + expect(notFoundHtmlProp).toBe('true') + }) +}) diff --git a/test/e2e/app-dir/global-not-found/not-present/app/call-not-found/page.tsx b/test/e2e/app-dir/global-not-found/not-present/app/call-not-found/page.tsx new file mode 100644 index 00000000000000..6f7be9458297a3 --- /dev/null +++ b/test/e2e/app-dir/global-not-found/not-present/app/call-not-found/page.tsx @@ -0,0 +1,7 @@ +'use client' + +import { notFound } from 'next/navigation' + +export default function Page() { + notFound() +} diff --git a/test/e2e/app-dir/global-not-found/not-present/app/layout.tsx b/test/e2e/app-dir/global-not-found/not-present/app/layout.tsx new file mode 100644 index 00000000000000..a14e64fcd5e33e --- /dev/null +++ b/test/e2e/app-dir/global-not-found/not-present/app/layout.tsx @@ -0,0 +1,16 @@ +export const metadata = { + title: 'Next.js', + description: 'Generated by Next.js', +} + +export default function RootLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + + {children} + + ) +} diff --git a/test/e2e/app-dir/global-not-found/not-present/app/not-found.tsx b/test/e2e/app-dir/global-not-found/not-present/app/not-found.tsx new file mode 100644 index 00000000000000..ea2bfb546029e8 --- /dev/null +++ b/test/e2e/app-dir/global-not-found/not-present/app/not-found.tsx @@ -0,0 +1,3 @@ +export default function NotFound() { + return
not-found.js
+} diff --git a/test/e2e/app-dir/global-not-found/not-present/app/page.tsx b/test/e2e/app-dir/global-not-found/not-present/app/page.tsx new file mode 100644 index 00000000000000..ff7159d9149fee --- /dev/null +++ b/test/e2e/app-dir/global-not-found/not-present/app/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return

hello world

+} diff --git a/test/e2e/app-dir/global-not-found/not-present/next.config.js b/test/e2e/app-dir/global-not-found/not-present/next.config.js new file mode 100644 index 00000000000000..a198cf5a769a10 --- /dev/null +++ b/test/e2e/app-dir/global-not-found/not-present/next.config.js @@ -0,0 +1,10 @@ +/** + * @type {import('next').NextConfig} + */ +const nextConfig = { + experimental: { + globalNotFound: true, + }, +} + +module.exports = nextConfig diff --git a/test/e2e/app-dir/global-not-found/not-present/not-present.test.ts b/test/e2e/app-dir/global-not-found/not-present/not-present.test.ts new file mode 100644 index 00000000000000..0c1ce88b61f999 --- /dev/null +++ b/test/e2e/app-dir/global-not-found/not-present/not-present.test.ts @@ -0,0 +1,24 @@ +import { nextTestSetup } from 'e2e-utils' + +// TODO(global-not-found): remove this test when the feature is stable +describe('global-not-found - not-present', () => { + const { next } = nextTestSetup({ + files: __dirname, + }) + + it('should render default 404 when global-not-found is not defined but enabled', async () => { + const browser = await next.browser('/does-not-exist') + const bodyText = await browser.elementByCss('body').text() + expect(bodyText).toBe('404\nThis page could not be found.') + }) + + it('should render custom not-found.js boundary when global-not-found is not defined but enabled', async () => { + const browser = await next.browser('/call-not-found') + const bodyText = await browser.elementByCss('body').text() + const htmlLang = await browser.elementByCss('html').getAttribute('lang') + // Render the root layout + expect(htmlLang).toBe('en') + // Render the not-found.js boundary + expect(bodyText).toBe('not-found.js') + }) +})