diff --git a/packages/next/src/client/components/app-router.tsx b/packages/next/src/client/components/app-router.tsx index 93c023294995c..23cb1803103a3 100644 --- a/packages/next/src/client/components/app-router.tsx +++ b/packages/next/src/client/components/app-router.tsx @@ -564,14 +564,13 @@ function Router({ const layoutRouterContext = useMemo(() => { return { - childNodes: cache.parallelRoutes, - tree, + parentTree: tree, + parentCacheNode: cache, // Root node always has `url` // Provided in AppTreeContext to ensure it can be overwritten in layout-router url: canonicalUrl, - loading: cache.loading, } - }, [cache.parallelRoutes, tree, canonicalUrl, cache.loading]) + }, [tree, cache, canonicalUrl]) const globalLayoutRouterContext = useMemo(() => { return { diff --git a/packages/next/src/client/components/layout-router.tsx b/packages/next/src/client/components/layout-router.tsx index a8ee010ccd27e..5319797627790 100644 --- a/packages/next/src/client/components/layout-router.tsx +++ b/packages/next/src/client/components/layout-router.tsx @@ -1,14 +1,13 @@ 'use client' import type { - ChildSegmentMap, + CacheNode, LazyCacheNode, LoadingModuleData, } from '../../shared/lib/app-router-context.shared-runtime' import type { FlightRouterState, FlightSegmentPath, - Segment, } from '../../server/app-render/types' import type { ErrorComponent } from './error-boundary' import type { FocusAndScrollRef } from './router-reducer/router-reducer-types' @@ -34,7 +33,6 @@ import { matchSegment } from './match-segments' import { handleSmoothScroll } from '../../shared/lib/router/utils/handle-smooth-scroll' import { RedirectBoundary } from './redirect-boundary' import { HTTPAccessFallbackBoundary } from './http-access-fallback/error-boundary' -import { getSegmentValue } from './router-reducer/reducers/get-segment-value' import { createRouterCacheKey } from './router-reducer/create-router-cache-key' import { hasInterceptionRouteInCurrentTree } from './router-reducer/reducers/has-interception-route-in-current-tree' @@ -316,22 +314,15 @@ function ScrollAndFocusHandler({ * InnerLayoutRouter handles rendering the provided segment based on the cache. */ function InnerLayoutRouter({ - parallelRouterKey, - url, - childNodes, - segmentPath, tree, - // TODO-APP: implement `` when available. - // isActive, - cacheKey, + segmentPath, + cacheNode, + url, }: { - parallelRouterKey: string - url: string - childNodes: ChildSegmentMap - segmentPath: FlightSegmentPath tree: FlightRouterState - isActive: boolean - cacheKey: ReturnType + segmentPath: FlightSegmentPath + cacheNode: CacheNode + url: string }) { const context = useContext(GlobalLayoutRouterContext) if (!context) { @@ -340,29 +331,6 @@ function InnerLayoutRouter({ const { changeByServerResponse, tree: fullTree } = context - // Read segment path from the parallel router cache node. - let childNode = childNodes.get(cacheKey) - - // When data is not available during rendering client-side we need to fetch - // it from the server. - if (childNode === undefined) { - const newLazyCacheNode: LazyCacheNode = { - lazyData: null, - rsc: null, - prefetchRsc: null, - head: null, - prefetchHead: null, - parallelRoutes: new Map(), - loading: null, - } - - /** - * Flight data fetch kicked off during render and put into the cache. - */ - childNode = newLazyCacheNode - childNodes.set(cacheKey, newLazyCacheNode) - } - // `rsc` represents the renderable node for this segment. // If this segment has a `prefetchRsc`, it's the statically prefetched data. @@ -371,7 +339,7 @@ function InnerLayoutRouter({ // // If no prefetch data is available, then we go straight to rendering `rsc`. const resolvedPrefetchRsc = - childNode.prefetchRsc !== null ? childNode.prefetchRsc : childNode.rsc + cacheNode.prefetchRsc !== null ? cacheNode.prefetchRsc : cacheNode.rsc // We use `useDeferredValue` to handle switching between the prefetched and // final values. The second argument is returned on initial render, then it @@ -380,7 +348,7 @@ function InnerLayoutRouter({ // @ts-expect-error The second argument to `useDeferredValue` is only // available in the experimental builds. When its disabled, it will always // return `rsc`. - const rsc: any = useDeferredValue(childNode.rsc, resolvedPrefetchRsc) + const rsc: any = useDeferredValue(cacheNode.rsc, resolvedPrefetchRsc) // `rsc` is either a React node or a promise for a React node, except we // special case `null` to represent that this segment's data is missing. If @@ -397,7 +365,7 @@ function InnerLayoutRouter({ // the server and patch the cache. // Check if there's already a pending request. - let lazyData = childNode.lazyData + let lazyData = cacheNode.lazyData if (lazyData === null) { /** * Router state with refetch marker added @@ -405,7 +373,7 @@ function InnerLayoutRouter({ // TODO-APP: remove '' const refetchTree = walkAddRefetch(['', ...segmentPath], fullTree) const includeNextUrl = hasInterceptionRouteInCurrentTree(fullTree) - childNode.lazyData = lazyData = fetchServerResponse( + cacheNode.lazyData = lazyData = fetchServerResponse( new URL(url, location.origin), { flightRouterState: refetchTree, @@ -432,11 +400,11 @@ function InnerLayoutRouter({ // The layout router context narrows down tree and childNodes at each level. {resolvedRsc} @@ -533,86 +501,110 @@ export default function OuterLayoutRouter({ throw new Error('invariant expected layout router to be mounted') } - const { childNodes, tree, url, loading } = context + const { parentTree, parentCacheNode, url } = context - // Get the current parallelRouter cache node - let childNodesForParallelRouter = childNodes.get(parallelRouterKey) + // Get the CacheNode for this segment by reading it from the parent segment's + // child map. + const parentParallelRoutes = parentCacheNode.parallelRoutes + let segmentMap = parentParallelRoutes.get(parallelRouterKey) // If the parallel router cache node does not exist yet, create it. // This writes to the cache when there is no item in the cache yet. It never *overwrites* existing cache items which is why it's safe in concurrent mode. - if (!childNodesForParallelRouter) { - childNodesForParallelRouter = new Map() - childNodes.set(parallelRouterKey, childNodesForParallelRouter) + if (!segmentMap) { + segmentMap = new Map() + parentParallelRoutes.set(parallelRouterKey, segmentMap) } // Get the active segment in the tree // The reason arrays are used in the data format is that these are transferred from the server to the browser so it's optimized to save bytes. - const treeSegment = tree[1][parallelRouterKey][0] + const tree = parentTree[1][parallelRouterKey] + const treeSegment = tree[0] + + // The "state" key of a segment is the one passed to React — it represents the + // identity of the UI tree. Whenever the state key changes, the tree is + // recreated and the state is reset. In the App Router model, search params do + // not cause state to be lost, so two segments with the same segment path but + // different search params should have the same state key. + // + // The "cache" key of a segment, however, *does* include the search params, if + // it's possible that the segment accessed the search params on the server. + // (This only applies to page segments; layout segments cannot access search + // params on the server.) + const cacheKey = createRouterCacheKey(treeSegment) + const stateKey = createRouterCacheKey(treeSegment, true) // no search params + + // Read segment path from the parallel router cache node. + let cacheNode = segmentMap.get(cacheKey) + if (cacheNode === undefined) { + // When data is not available during rendering client-side we need to fetch + // it from the server. + const newLazyCacheNode: LazyCacheNode = { + lazyData: null, + rsc: null, + prefetchRsc: null, + head: null, + prefetchHead: null, + parallelRoutes: new Map(), + loading: null, + } - // If segment is an array it's a dynamic route and we want to read the dynamic route value as the segment to get from the cache. - const currentChildSegmentValue = getSegmentValue(treeSegment) + // Flight data fetch kicked off during render and put into the cache. + cacheNode = newLazyCacheNode + segmentMap.set(cacheKey, newLazyCacheNode) + } - /** - * Decides which segments to keep rendering, all segments that are not active will be wrapped in ``. - */ - // TODO-APP: Add handling of `` when it's available. - const preservedSegments: Segment[] = [treeSegment] + /* + - Error boundary + - Only renders error boundary if error component is provided. + - Rendered for each segment to ensure they have their own error state. + - Loading boundary + - Only renders suspense boundary if loading components is provided. + - Rendered for each segment to ensure they have their own loading state. + - Passed to the router during rendering to ensure it can be immediately rendered when suspending on a Flight fetch. + */ + + // TODO: The loading module data for a segment is stored on the parent, then + // applied to each of that parent segment's parallel route slots. In the + // simple case where there's only one parallel route (the `children` slot), + // this is no different from if the loading module data where stored on the + // child directly. But I'm not sure this actually makes sense when there are + // multiple parallel routes. It's not a huge issue because you always have + // the option to define a narrower loading boundary for a particular slot. But + // this sort of smells like an implementation accident to me. + const loadingModuleData = parentCacheNode.loading return ( - <> - {preservedSegments.map((preservedSegment) => { - const preservedSegmentValue = getSegmentValue(preservedSegment) - const cacheKey = createRouterCacheKey(preservedSegment) - - return ( - /* - - Error boundary - - Only renders error boundary if error component is provided. - - Rendered for each segment to ensure they have their own error state. - - Loading boundary - - Only renders suspense boundary if loading components is provided. - - Rendered for each segment to ensure they have their own loading state. - - Passed to the router during rendering to ensure it can be immediately rendered when suspending on a Flight fetch. - */ - - - - - - - - - - - - } + + - {templateStyles} - {templateScripts} - {template} - - ) - })} - + + + + + + + + + + } + > + {templateStyles} + {templateScripts} + {template} + ) } diff --git a/packages/next/src/client/components/navigation.ts b/packages/next/src/client/components/navigation.ts index 94d4d2414e8d1..33e318994a113 100644 --- a/packages/next/src/client/components/navigation.ts +++ b/packages/next/src/client/components/navigation.ts @@ -221,7 +221,7 @@ export function useSelectedLayoutSegments( // @ts-expect-error This only happens in `pages`. Type is overwritten in navigation.d.ts if (!context) return null - return getSelectedLayoutSegmentPath(context.tree, parallelRouteKey) + return getSelectedLayoutSegmentPath(context.parentTree, parallelRouteKey) } /** diff --git a/packages/next/src/shared/lib/app-router-context.shared-runtime.ts b/packages/next/src/shared/lib/app-router-context.shared-runtime.ts index 2ee50c11e45d0..7b1bcb941fdf2 100644 --- a/packages/next/src/shared/lib/app-router-context.shared-runtime.ts +++ b/packages/next/src/shared/lib/app-router-context.shared-runtime.ts @@ -146,10 +146,9 @@ export const AppRouterContext = React.createContext( null ) export const LayoutRouterContext = React.createContext<{ - childNodes: CacheNode['parallelRoutes'] - tree: FlightRouterState + parentTree: FlightRouterState + parentCacheNode: CacheNode url: string - loading: LoadingModuleData | Promise } | null>(null) export const GlobalLayoutRouterContext = React.createContext<{ diff --git a/test/development/acceptance-app/hydration-error.test.ts b/test/development/acceptance-app/hydration-error.test.ts index b9052eabc5f9a..b128336dfb1ac 100644 --- a/test/development/acceptance-app/hydration-error.test.ts +++ b/test/development/acceptance-app/hydration-error.test.ts @@ -425,27 +425,27 @@ describe('Error overlay for hydration errors in App router', () => { expect(await getRedboxTotalErrorCount(browser)).toBe(1) expect(await session.getRedboxDescription()).toMatchInlineSnapshot(` - "In HTML, whitespace text nodes cannot be a child of . Make sure you don't have any extra whitespace between tags on each line of your source code. - This will cause a hydration error. - - ... - - - - - - - - - - - - - >
- > {" "} - ... - ... - " + "In HTML, whitespace text nodes cannot be a child of
. Make sure you don't have any extra whitespace between tags on each line of your source code. + This will cause a hydration error. + + ... + + + + + + + + + + + + + >
+ > {" "} + ... + ... + " `) // FIXME: fix the `pseudoHtml` should be extracted from the description @@ -887,43 +887,43 @@ describe('Error overlay for hydration errors in App router', () => { const fullPseudoHtml = await session.getRedboxComponentStack() if (isTurbopack) { expect(fullPseudoHtml).toMatchInlineSnapshot(` - "... - - - - - - -
-
-
-
- -

- - ... - + client - - server" + "... + + + + + + +

+
+
+
+ +

+ + ... + + client + - server" `) } else { expect(fullPseudoHtml).toMatchInlineSnapshot(` - "... - - - - - - -

-
-
-
- -

- - ... - + client - - server" + "... + + + + + + +

+
+
+
+ +

+ + ... + + client + - server" `) } }) diff --git a/test/development/app-dir/dynamic-error-trace/index.test.ts b/test/development/app-dir/dynamic-error-trace/index.test.ts index 87908d110daa4..68f37d62dadfc 100644 --- a/test/development/app-dir/dynamic-error-trace/index.test.ts +++ b/test/development/app-dir/dynamic-error-trace/index.test.ts @@ -2,8 +2,6 @@ import { nextTestSetup } from 'e2e-utils' import { assertHasRedbox, getRedboxSource } from 'next-test-utils' import { outdent } from 'outdent' -const isReactExperimental = process.env.__NEXT_EXPERIMENTAL_PPR === 'true' - function normalizeStackTrace(trace) { return trace.replace(/ \(.*\)/g, '') } @@ -37,14 +35,7 @@ describe('app dir - dynamic error trace', () => { // TODO: Show useful stack const normalizedStack = normalizeStackTrace(stackFramesContent) - if (isReactExperimental) { - expect(normalizedStack).toMatchInlineSnapshot(` - "Array.map - " - `) - } else { - expect(normalizedStack).toMatchInlineSnapshot(`""`) - } + expect(normalizedStack).toMatchInlineSnapshot(`""`) const codeframe = await getRedboxSource(browser) expect(codeframe).toEqual( diff --git a/test/development/app-dir/dynamic-io-dev-errors/dynamic-io-dev-errors.test.ts b/test/development/app-dir/dynamic-io-dev-errors/dynamic-io-dev-errors.test.ts index 952ccda61ca7b..fa374b7eb7ee3 100644 --- a/test/development/app-dir/dynamic-io-dev-errors/dynamic-io-dev-errors.test.ts +++ b/test/development/app-dir/dynamic-io-dev-errors/dynamic-io-dev-errors.test.ts @@ -77,7 +77,7 @@ describe('Dynamic IO Dev Errors', () => { '\n at html ()' + '\n at Root [Server] ()' : // TODO(veil): Should be ignore-listed (see https://linear.app/vercel/issue/NDX-464/next-internals-not-ignore-listed-in-terminal-in-webpack#comment-1164a36a) - '\n at parallelRouterKey (..') + '\n at tree (..') ) const description = await getRedboxDescription(browser)