diff --git a/packages/next/src/client/components/app-router.tsx b/packages/next/src/client/components/app-router.tsx index 724a53fba07a18..8a0aad7d9e7b8b 100644 --- a/packages/next/src/client/components/app-router.tsx +++ b/packages/next/src/client/components/app-router.tsx @@ -42,6 +42,8 @@ import { isBot } from '../../shared/lib/router/utils/is-bot' import { addBasePath } from '../add-base-path' import { AppRouterAnnouncer } from './app-router-announcer' import { RedirectBoundary } from './redirect-boundary' +import { NotFoundBoundary } from './not-found-boundary' +import { findHeadInCache } from './router-reducer/reducers/find-head-in-cache' const isServer = typeof window === 'undefined' @@ -70,6 +72,10 @@ type AppRouterProps = Omit< > & { initialHead: ReactNode assetPrefix: string + // Top level boundaries props + notFound: React.ReactNode | undefined + notFoundStyles: React.ReactNode | undefined + asNotFound?: boolean } function isExternalURL(url: URL) { @@ -85,6 +91,9 @@ function Router({ initialCanonicalUrl, children, assetPrefix, + notFound, + notFoundStyles, + asNotFound, }: AppRouterProps) { const initialState = useMemo( () => @@ -311,13 +320,22 @@ function Router({ } }, [onPopState]) + const head = useMemo(() => { + return findHeadInCache(cache, tree[1]) + }, [cache, tree]) + const content = ( - <> + + {head} {cache.subTreeData} - + ) return ( @@ -338,7 +356,6 @@ function Router({ // Root node always has `url` // Provided in AppTreeContext to ensure it can be overwritten in layout-router url: canonicalUrl, - headRenderedAboveThisLevel: false, }} > {HotReloader ? ( diff --git a/packages/next/src/client/components/layout-router.tsx b/packages/next/src/client/components/layout-router.tsx index e494835d37e1e8..beedf2f9347027 100644 --- a/packages/next/src/client/components/layout-router.tsx +++ b/packages/next/src/client/components/layout-router.tsx @@ -9,7 +9,7 @@ import type { import type { ErrorComponent } from './error-boundary' import { FocusAndScrollRef } from './router-reducer/router-reducer-types' -import React, { useContext, useMemo, use } from 'react' +import React, { useContext, use } from 'react' import ReactDOM from 'react-dom' import { CacheStates, @@ -22,7 +22,6 @@ import { createInfinitePromise } from './infinite-promise' import { ErrorBoundary } from './error-boundary' import { matchSegment } from './match-segments' import { handleSmoothScroll } from '../../shared/lib/router/utils/handle-smooth-scroll' -import { findHeadInCache } from './router-reducer/reducers/find-head-in-cache' import { RedirectBoundary } from './redirect-boundary' import { NotFoundBoundary } from './not-found-boundary' @@ -232,7 +231,6 @@ function InnerLayoutRouter({ // TODO-APP: implement `` when available. // isActive, path, - headRenderedAboveThisLevel, }: { parallelRouterKey: string url: string @@ -242,7 +240,6 @@ function InnerLayoutRouter({ tree: FlightRouterState isActive: boolean path: string - headRenderedAboveThisLevel: boolean }) { const context = useContext(GlobalLayoutRouterContext) if (!context) { @@ -251,13 +248,6 @@ function InnerLayoutRouter({ const { changeByServerResponse, tree: fullTree, focusAndScrollRef } = context - const head = useMemo(() => { - if (headRenderedAboveThisLevel) { - return null - } - return findHeadInCache(childNodes, tree[1]) - }, [childNodes, tree, headRenderedAboveThisLevel]) - // Read segment path from the parallel router cache node. let childNode = childNodes.get(path) @@ -370,10 +360,8 @@ function InnerLayoutRouter({ childNodes: childNode.parallelRoutes, // TODO-APP: overriding of url for parallel routes url: url, - headRenderedAboveThisLevel: true, }} > - {head} {childNode.subTreeData} ) @@ -457,7 +445,7 @@ export default function OuterLayoutRouter({ throw new Error('invariant expected layout router to be mounted') } - const { childNodes, tree, url, headRenderedAboveThisLevel } = context + const { childNodes, tree, url } = context // Get the current parallelRouter cache node let childNodesForParallelRouter = childNodes.get(parallelRouterKey) @@ -528,7 +516,6 @@ export default function OuterLayoutRouter({ segmentPath={segmentPath} path={preservedSegment} isActive={currentChildSegment === preservedSegment} - headRenderedAboveThisLevel={headRenderedAboveThisLevel} /> diff --git a/packages/next/src/client/components/router-reducer/reducers/find-head-in-cache.test.tsx b/packages/next/src/client/components/router-reducer/reducers/find-head-in-cache.test.tsx index d4b600f401e751..807374c855577f 100644 --- a/packages/next/src/client/components/router-reducer/reducers/find-head-in-cache.test.tsx +++ b/packages/next/src/client/components/router-reducer/reducers/find-head-in-cache.test.tsx @@ -95,10 +95,7 @@ describe('findHeadInCache', () => { ]), } - const result = findHeadInCache( - cache.parallelRoutes.get('children')!, - routerTree[1] - ) + const result = findHeadInCache(cache, routerTree[1]) expect(result).toMatchObject( <> diff --git a/packages/next/src/client/components/router-reducer/reducers/find-head-in-cache.ts b/packages/next/src/client/components/router-reducer/reducers/find-head-in-cache.ts index f698b2eed5a909..8d1652287654b1 100644 --- a/packages/next/src/client/components/router-reducer/reducers/find-head-in-cache.ts +++ b/packages/next/src/client/components/router-reducer/reducers/find-head-in-cache.ts @@ -1,16 +1,20 @@ import type { FlightRouterState } from '../../../../server/app-render/types' -import type { ChildSegmentMap } from '../../../../shared/lib/app-router-context' +import type { CacheNode } from '../../../../shared/lib/app-router-context' export function findHeadInCache( - childSegmentMap: ChildSegmentMap, + cache: CacheNode, parallelRoutes: FlightRouterState[1] ): React.ReactNode { - if (!childSegmentMap) { - return undefined + const isLastItem = Object.keys(parallelRoutes).length === 0 + if (isLastItem) { + return cache.head } for (const key in parallelRoutes) { const [segment, childParallelRoutes] = parallelRoutes[key] - const isLastItem = Object.keys(childParallelRoutes).length === 0 + const childSegmentMap = cache.parallelRoutes.get(key) + if (!childSegmentMap) { + continue + } const cacheKey = Array.isArray(segment) ? segment[1] : segment @@ -19,14 +23,9 @@ export function findHeadInCache( continue } - if (isLastItem && cacheNode.head) return cacheNode.head - - const segmentMap = cacheNode.parallelRoutes.get(key) - if (segmentMap) { - const item = findHeadInCache(segmentMap, childParallelRoutes) - if (item) { - return item - } + const item = findHeadInCache(cacheNode, childParallelRoutes) + if (item) { + return item } } diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index 1390d26165759f..7895304f285c3f 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -1124,17 +1124,35 @@ export async function renderToHTMLOrFlight( }>( async (props) => { // Create full component tree from root to leaf. + const injectedCSS = new Set() const { Component: ComponentTree } = await createComponentTree({ createSegmentPath: (child) => child, - loaderTree: loaderTree, + loaderTree, parentParams: {}, firstItem: true, - injectedCSS: new Set(), + injectedCSS, injectedFontPreloadTags: new Set(), rootLayoutIncluded: false, asNotFound: props.asNotFound, }) + const { 'not-found': notFound, layout } = loaderTree[2] + const isLayout = typeof layout !== 'undefined' + const rootLayoutModule = layout?.[0] + const RootLayout = rootLayoutModule + ? interopDefault(await rootLayoutModule()) + : null + const rootLayoutAtThisLevel = isLayout + const [NotFound, notFoundStyles] = notFound + ? await createComponentAndStyles({ + filePath: notFound[1], + getComponent: notFound[0], + injectedCSS, + }) + : rootLayoutAtThisLevel + ? [DefaultNotFound] + : [] + const initialTree = createFlightRouterStateFromLoaderTree( loaderTree, getDynamicParamFromSegment, @@ -1155,6 +1173,15 @@ export async function renderToHTMLOrFlight( } globalErrorComponent={GlobalError} + notFound={ + NotFound && RootLayout ? ( + + + + ) : undefined + } + notFoundStyles={notFoundStyles} + asNotFound={props.asNotFound} > diff --git a/packages/next/src/shared/lib/app-router-context.ts b/packages/next/src/shared/lib/app-router-context.ts index 3d24498492196b..4567dfe36296aa 100644 --- a/packages/next/src/shared/lib/app-router-context.ts +++ b/packages/next/src/shared/lib/app-router-context.ts @@ -103,7 +103,6 @@ export const LayoutRouterContext = React.createContext<{ childNodes: CacheNode['parallelRoutes'] tree: FlightRouterState url: string - headRenderedAboveThisLevel: boolean }>(null as any) export const GlobalLayoutRouterContext = React.createContext<{ tree: FlightRouterState diff --git a/test/e2e/app-dir/metadata-suspense/app/layout.tsx b/test/e2e/app-dir/metadata-suspense/app/layout.tsx new file mode 100644 index 00000000000000..a4e1cf73b3f97d --- /dev/null +++ b/test/e2e/app-dir/metadata-suspense/app/layout.tsx @@ -0,0 +1,12 @@ +import { Suspense } from 'react' + +export default function Layout({ children }) { + return ( + + + + loading...}>{children} + + + ) +} diff --git a/test/e2e/app-dir/metadata-suspense/app/loading.tsx b/test/e2e/app-dir/metadata-suspense/app/loading.tsx new file mode 100644 index 00000000000000..45c206039331ff --- /dev/null +++ b/test/e2e/app-dir/metadata-suspense/app/loading.tsx @@ -0,0 +1,3 @@ +export default function Loading() { + return

Loading...

+} diff --git a/test/e2e/app-dir/metadata-suspense/app/page.tsx b/test/e2e/app-dir/metadata-suspense/app/page.tsx new file mode 100644 index 00000000000000..b9b7ad74b9f97b --- /dev/null +++ b/test/e2e/app-dir/metadata-suspense/app/page.tsx @@ -0,0 +1,9 @@ +export default function Page() { + return
Page
+} + +export const metadata = { + title: 'My title', + description: 'My description', + themeColor: '#eee', +} diff --git a/test/e2e/app-dir/metadata-suspense/index.test.ts b/test/e2e/app-dir/metadata-suspense/index.test.ts new file mode 100644 index 00000000000000..b961daf17e6aa6 --- /dev/null +++ b/test/e2e/app-dir/metadata-suspense/index.test.ts @@ -0,0 +1,18 @@ +import { createNextDescribe } from 'e2e-utils' + +createNextDescribe( + 'app dir - metadata dynamic routes', + { + files: __dirname, + skipDeployment: true, + }, + ({ next }) => { + it('should render metadata in head even root layout is wrapped with Suspense', async () => { + const $ = await next.render$('/') + expect($('head title').text()).toBe('My title') + expect($('head meta[name="theme-color"]').attr('content')).toBe('#eee') + + expect($('body meta').length).toBe(0) + }) + } +) diff --git a/test/e2e/app-dir/metadata-suspense/next.config.js b/test/e2e/app-dir/metadata-suspense/next.config.js new file mode 100644 index 00000000000000..cfa3ac3d7aa94b --- /dev/null +++ b/test/e2e/app-dir/metadata-suspense/next.config.js @@ -0,0 +1,5 @@ +module.exports = { + experimental: { + appDir: true, + }, +} diff --git a/test/e2e/app-dir/metadata-suspense/tsconfig.json b/test/e2e/app-dir/metadata-suspense/tsconfig.json new file mode 100644 index 00000000000000..d2bc2ac5e3cea1 --- /dev/null +++ b/test/e2e/app-dir/metadata-suspense/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": false, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "incremental": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "plugins": [ + { + "name": "next" + } + ] + }, + "include": ["next-env.d.ts", ".next/types/**/*.ts", "**/*.ts", "**/*.tsx"], + "exclude": ["node_modules"] +} diff --git a/test/e2e/app-dir/metadata/app/not-found.tsx b/test/e2e/app-dir/metadata/app/not-found.tsx new file mode 100644 index 00000000000000..6030ea22604bd8 --- /dev/null +++ b/test/e2e/app-dir/metadata/app/not-found.tsx @@ -0,0 +1,3 @@ +export default function notFound() { + return

root not found page

+} diff --git a/test/e2e/app-dir/metadata/metadata.test.ts b/test/e2e/app-dir/metadata/metadata.test.ts index f97a5797b81d85..b298b7dd828fdc 100644 --- a/test/e2e/app-dir/metadata/metadata.test.ts +++ b/test/e2e/app-dir/metadata/metadata.test.ts @@ -364,15 +364,22 @@ createNextDescribe( ) }) - it('should support notFound and redirect in generateMetadata', async () => { - const resNotFound = await next.fetch('/async/not-found') - expect(resNotFound.status).toBe(404) - const notFoundHtml = await resNotFound.text() - expect(notFoundHtml).not.toBe('not-found-text') - expect(notFoundHtml).toContain('This page could not be found.') - - const resRedirect = await next.fetch('/async/redirect') - expect(resRedirect.status).toBe(307) + it('should support notFound in generateMetadata', async () => { + // TODO-APP: support custom not-found for generateMetadata + const res = await next.fetch('/async/not-found') + expect(res.status).toBe(404) + const html = await res.text() + expect(html).toContain('root not found page') + + const browser = await next.browser('/async/not-found') + expect(await browser.elementByCss('h2').text()).toBe( + 'root not found page' + ) + }) + + it('should support redirect in generateMetadata', async () => { + const res = await next.fetch('/async/redirect') + expect(res.status).toBe(307) }) it('should handle metadataBase for urls resolved as only URL type', async () => { diff --git a/test/e2e/app-dir/not-found/app/not-found/not-found.js b/test/e2e/app-dir/not-found/app/not-found/not-found.js new file mode 100644 index 00000000000000..de5e0a89556205 --- /dev/null +++ b/test/e2e/app-dir/not-found/app/not-found/not-found.js @@ -0,0 +1,3 @@ +export default function notFound() { + return

custom not found

+}