From 6f4a34a03a1641f05ea240f5da79b599d53cb862 Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Mon, 20 Oct 2025 21:47:24 -0700 Subject: [PATCH] Update Activity names given to routes In practical terms, clicking the name of a route segment in the Suspense DevTools should select the child slots of that layout, because when you focus on it, what you're interested in debugging are navigations that occur within the shared layout. So the name we apply to the Activity boundary is actually based on the name of the *parent* segment. --- .../next/src/client/components/app-router.tsx | 3 + .../src/client/components/layout-router.tsx | 94 ++++++++++--------- .../lib/app-router-context.shared-runtime.ts | 1 + .../acceptance-app/hydration-error.test.ts | 30 +++--- .../hydration-error-count.test.ts | 18 ++-- .../cache-components-errors.test.ts | 48 +++++----- 6 files changed, 102 insertions(+), 92 deletions(-) diff --git a/packages/next/src/client/components/app-router.tsx b/packages/next/src/client/components/app-router.tsx index a69058fe89e7b0..937eb6d037d149 100644 --- a/packages/next/src/client/components/app-router.tsx +++ b/packages/next/src/client/components/app-router.tsx @@ -435,6 +435,9 @@ function Router({ parentCacheNode: cache, parentSegmentPath: null, parentParams: {}, + // This is the "name" that shows up in the Suspense DevTools. + // It represents the root of the app. + debugNameContext: '/', // Root node always has `url` // Provided in AppTreeContext to ensure it can be overwritten in layout-router url: canonicalUrl, diff --git a/packages/next/src/client/components/layout-router.tsx b/packages/next/src/client/components/layout-router.tsx index 188a9994746bfc..3ae70d20ab354a 100644 --- a/packages/next/src/client/components/layout-router.tsx +++ b/packages/next/src/client/components/layout-router.tsx @@ -8,6 +8,7 @@ import type { LoadingModuleData } from '../../shared/lib/app-router-types' import type { FlightRouterState, FlightSegmentPath, + Segment, } from '../../shared/lib/app-router-types' import type { ErrorComponent } from './error-boundary' import { @@ -23,6 +24,7 @@ import React, { Suspense, useDeferredValue, type JSX, + type ActivityProps, } from 'react' import ReactDOM from 'react-dom' import { @@ -338,6 +340,7 @@ function ScrollAndFocusHandler({ function InnerLayoutRouter({ tree, segmentPath, + debugNameContext, cacheNode, params, url, @@ -345,6 +348,7 @@ function InnerLayoutRouter({ }: { tree: FlightRouterState segmentPath: FlightSegmentPath + debugNameContext: ActivityProps['name'] cacheNode: CacheNode params: Params url: string @@ -469,6 +473,7 @@ function InnerLayoutRouter({ parentCacheNode: cacheNode, parentSegmentPath: segmentPath, parentParams: params, + debugNameContext: debugNameContext, // TODO-APP: overriding of url for parallel routes url: url, @@ -487,9 +492,11 @@ function InnerLayoutRouter({ * If no loading property is provided it renders the children without a suspense boundary. */ function LoadingBoundary({ + name, loading, children, }: { + name: ActivityProps['name'] loading: LoadingModuleData | Promise children: React.ReactNode }): JSX.Element { @@ -519,7 +526,7 @@ function LoadingBoundary({ const loadingScripts = loadingModuleData[2] return ( {loadingStyles} @@ -577,6 +584,7 @@ export default function OuterLayoutRouter({ parentParams, url, isActive, + debugNameContext: parentDebugNameContext, } = context // Get the CacheNode for this segment by reading it from the parent segment's @@ -696,6 +704,12 @@ export default function OuterLayoutRouter({ } } + const debugName = getBoundaryDebugNameFromSegment(segment) + // `debugNameContext` represents the nearest non-"virtual" parent segment. + // `getBoundaryDebugNameFromSegment` returns null for virtual segments. + // So if `debugName` is null, the context is passed through unchanged. + const debugNameContext = debugName ?? parentDebugNameContext + // 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), @@ -715,7 +729,7 @@ export default function OuterLayoutRouter({ errorStyles={errorStyles} errorScripts={errorScripts} > - + {segmentBoundaryTriggerNode} @@ -758,10 +773,17 @@ export default function OuterLayoutRouter({ } if (process.env.__NEXT_CACHE_COMPONENTS) { - const boundaryName = getBoundaryNameForSuspenseDevTools(tree) child = ( @@ -778,49 +800,33 @@ export default function OuterLayoutRouter({ return children } -function getBoundaryNameForSuspenseDevTools( - subtree: FlightRouterState -): string | undefined { - const segment = subtree[0] - +function getBoundaryDebugNameFromSegment(segment: Segment): string | undefined { + if (segment === '/') { + // Reached the root + return '/' + } if (typeof segment === 'string') { - const children = subtree[1] - const isPage = Object.keys(children).length === 0 - if (isPage) { - // Page segment - return '/' - } - - // Layout segment - - // Skip over "virtual" layouts that don't correspond to app- - // defined components. - if ( - segment === '' || - // For some reason, the loader tree sometimes includes extra __PAGE__ - // "layouts" when part of a parallel route. But it's not a leaf node. - // Otherwise, we wouldn't need this special case because pages are - // always leaf nodes. - // TODO: Investigate why the loader produces these fake page segments. - segment.startsWith(PAGE_SEGMENT_KEY) || - // This is inserted by the loader. We should consider encoding these - // in a more special way instead of checking the name, to distinguish them - // from app-defined groups. - segment[0] === '(virtual)' - ) { + if (isVirtualLayout(segment)) { return undefined + } else { + return segment + '/' } - - if (segment === '/_not-found') { - // Special case. For some reason, the name itself already has a - // leading slash. - return '/_not-found/' - } - - return `/${segment}/` } - - // Parameterized segments are always layouts const paramCacheKey = segment[1] - return `/${paramCacheKey}/` + return paramCacheKey + '/' +} + +function isVirtualLayout(segment: string): boolean { + return ( + // This is inserted by the loader. We should consider encoding these + // in a more special way instead of checking the name, to distinguish them + // from app-defined groups. + segment === '(slot)' || + // For some reason, the loader tree sometimes includes extra __PAGE__ + // "layouts" when part of a parallel route. But it's not a leaf node. + // Otherwise, we wouldn't need this special case because pages are + // always leaf nodes. + // TODO: Investigate why the loader produces these fake page segments. + segment.startsWith(PAGE_SEGMENT_KEY) + ) } 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 8051359166fe0d..10de1aac05f1f7 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 @@ -63,6 +63,7 @@ export const LayoutRouterContext = React.createContext<{ parentCacheNode: CacheNode parentSegmentPath: FlightSegmentPath | null parentParams: Params + debugNameContext: string | undefined url: string isActive: boolean } | null>(null) diff --git a/test/development/acceptance-app/hydration-error.test.ts b/test/development/acceptance-app/hydration-error.test.ts index 2f9673fca742d6..d266ef6d86127f 100644 --- a/test/development/acceptance-app/hydration-error.test.ts +++ b/test/development/acceptance-app/hydration-error.test.ts @@ -45,7 +45,7 @@ describe('Error overlay for hydration errors in App router', () => { - + @@ -107,7 +107,7 @@ describe('Error overlay for hydration errors in App router', () => { - + @@ -181,7 +181,7 @@ describe('Error overlay for hydration errors in App router', () => { - + } forbidden={undefined} unauthorized={undefined}> } ...> @@ -221,7 +221,7 @@ describe('Error overlay for hydration errors in App router', () => { - + @@ -262,7 +262,7 @@ describe('Error overlay for hydration errors in App router', () => { - + @@ -300,7 +300,7 @@ describe('Error overlay for hydration errors in App router', () => { - + @@ -342,7 +342,7 @@ describe('Error overlay for hydration errors in App router', () => { - + @@ -376,7 +376,7 @@ describe('Error overlay for hydration errors in App router', () => { - + @@ -415,7 +415,7 @@ describe('Error overlay for hydration errors in App router', () => { - + @@ -453,7 +453,7 @@ describe('Error overlay for hydration errors in App router', () => { "componentStack": "... - + @@ -510,7 +510,7 @@ describe('Error overlay for hydration errors in App router', () => { - + @@ -566,7 +566,7 @@ describe('Error overlay for hydration errors in App router', () => { - + @@ -625,7 +625,7 @@ describe('Error overlay for hydration errors in App router', () => { - + @@ -682,7 +682,7 @@ describe('Error overlay for hydration errors in App router', () => { - + @@ -966,7 +966,7 @@ describe('Error overlay for hydration errors in App router', () => { - + } forbidden={undefined} unauthorized={undefined}> } ...> diff --git a/test/development/app-dir/hydration-error-count/hydration-error-count.test.ts b/test/development/app-dir/hydration-error-count/hydration-error-count.test.ts index ed652792cb97dd..ba470b5b3359d8 100644 --- a/test/development/app-dir/hydration-error-count/hydration-error-count.test.ts +++ b/test/development/app-dir/hydration-error-count/hydration-error-count.test.ts @@ -17,7 +17,7 @@ describe('hydration-error-count', () => { - + @@ -66,7 +66,7 @@ describe('hydration-error-count', () => { - + @@ -120,7 +120,7 @@ describe('hydration-error-count', () => { - + @@ -155,7 +155,7 @@ describe('hydration-error-count', () => { - + @@ -197,7 +197,7 @@ describe('hydration-error-count', () => { - + @@ -229,7 +229,7 @@ describe('hydration-error-count', () => { - + @@ -267,7 +267,7 @@ describe('hydration-error-count', () => { - + @@ -299,7 +299,7 @@ describe('hydration-error-count', () => { - + @@ -343,7 +343,7 @@ describe('hydration-error-count', () => { - + diff --git a/test/e2e/app-dir/cache-components-errors/cache-components-errors.test.ts b/test/e2e/app-dir/cache-components-errors/cache-components-errors.test.ts index 9cef229eff676d..8c119c650d2ff6 100644 --- a/test/e2e/app-dir/cache-components-errors/cache-components-errors.test.ts +++ b/test/e2e/app-dir/cache-components-errors/cache-components-errors.test.ts @@ -238,13 +238,13 @@ describe('Cache Components Errors', () => { at ScrollAndFocusHandler (bundler:///) at RenderFromTemplateContext (bundler:///) at OuterLayoutRouter (bundler:///) - 337 | */ - 338 | function InnerLayoutRouter({ - > 339 | tree, + 340 | */ + 341 | function InnerLayoutRouter({ + > 342 | tree, | ^ - 340 | segmentPath, - 341 | cacheNode, - 342 | params, + 343 | segmentPath, + 344 | debugNameContext, + 345 | cacheNode, To get a more detailed stack trace and pinpoint the issue, start the app in development mode by running \`next dev\`, then open "/dynamic-metadata-error-route" in your browser to investigate the error. Error occurred prerendering page "/dynamic-metadata-error-route". Read more: https://nextjs.org/docs/messages/prerender-error @@ -745,13 +745,13 @@ describe('Cache Components Errors', () => { at ScrollAndFocusHandler (bundler:///) at RenderFromTemplateContext (bundler:///) at OuterLayoutRouter (bundler:///) - 337 | */ - 338 | function InnerLayoutRouter({ - > 339 | tree, + 340 | */ + 341 | function InnerLayoutRouter({ + > 342 | tree, | ^ - 340 | segmentPath, - 341 | cacheNode, - 342 | params, + 343 | segmentPath, + 344 | debugNameContext, + 345 | cacheNode, To get a more detailed stack trace and pinpoint the issue, start the app in development mode by running \`next dev\`, then open "/dynamic-root" in your browser to investigate the error. Error occurred prerendering page "/dynamic-root". Read more: https://nextjs.org/docs/messages/prerender-error @@ -2141,13 +2141,13 @@ describe('Cache Components Errors', () => { at ScrollAndFocusHandler (bundler:///) at RenderFromTemplateContext () at OuterLayoutRouter (bundler:///) - 337 | */ - 338 | function InnerLayoutRouter({ - > 339 | tree, + 340 | */ + 341 | function InnerLayoutRouter({ + > 342 | tree, | ^ - 340 | segmentPath, - 341 | cacheNode, - 342 | params, + 343 | segmentPath, + 344 | debugNameContext, + 345 | cacheNode, To get a more detailed stack trace and pinpoint the issue, start the app in development mode by running \`next dev\`, then open "/sync-attribution/unguarded-async-guarded-clientsync" in your browser to investigate the error. Error occurred prerendering page "/sync-attribution/unguarded-async-guarded-clientsync". Read more: https://nextjs.org/docs/messages/prerender-error @@ -3157,13 +3157,13 @@ describe('Cache Components Errors', () => { at ScrollAndFocusHandler (bundler:///) at RenderFromTemplateContext (bundler:///) at OuterLayoutRouter (bundler:///) - 337 | */ - 338 | function InnerLayoutRouter({ - > 339 | tree, + 340 | */ + 341 | function InnerLayoutRouter({ + > 342 | tree, | ^ - 340 | segmentPath, - 341 | cacheNode, - 342 | params, + 343 | segmentPath, + 344 | debugNameContext, + 345 | cacheNode, To get a more detailed stack trace and pinpoint the issue, start the app in development mode by running \`next dev\`, then open "/use-cache-private-without-suspense" in your browser to investigate the error. Error occurred prerendering page "/use-cache-private-without-suspense". Read more: https://nextjs.org/docs/messages/prerender-error