Skip to content

App Router App Shell: key client shell cache by root params (preserve locale/root segment values in shell vary path) #2001

@github-actions

Description

@github-actions

Next.js Change

Commit: 643e34a
PR: no PR linked; direct push by Andrew Clark

What changed

Refines the App Shell client-side cache keying tracked in #1614. Previously the shell vary path replaced every param node (path params and search params) with Fallback, making the shell strictly param-independent. This commit special-cases root params — path params that appear at or above the topmost layout for a given route — and keeps their concrete value in the shell vary path.

Root params are typically low-cardinality, session-scoped values like locale (e.g. /[locale]/(home)/...). Treating them as varying across navigations was overly conservative: the shell content above the root layout can legitimately read them, so it has to vary on them. Conversely, replacing them with Fallback would mean a single locale's shell could be served to a navigation into a different locale, which is wrong.

Mechanism (from the diff)

A new bit is added to PrefetchHint, replacing the previous IsRootLayout flag:

// packages/next/src/shared/lib/app-router-types.ts
export const enum PrefetchHint {
  // ...
  // Was: IsRootLayout. Now: every segment at or above the root layout
  // (the root layout itself and all of its ancestors).
  IsRootLayoutOrAbove = 0b10000,
  // ...
}

The server marks every segment at or above the root layout, not just the root layout segment itself, when emitting flight router state:

// packages/next/src/server/app-render/create-flight-router-state-from-loader-tree.ts
if (!didFindRootLayout) {
  prefetchHints |= PrefetchHint.IsRootLayoutOrAbove
  if (typeof layout !== 'undefined') {
    didFindRootLayout = true
  }
}

The walkTreeWithFlightRouterState codepath threads rootLayoutIncluded into both createFlightRouterState… and createRouteTreePrefetch so a slice that starts below the root layout doesn't re-mark a sub-layout as the root.

On the client, appendLayoutVaryPath gains an isRootParam boolean that is stored on each VaryPath node:

// packages/next/src/client/components/segment-cache/vary-path.ts
export type VaryPath = {
  id: string | null
  value: string | null | FallbackType
  isRootParam?: boolean   // only set on path param nodes
  parent: VaryPath | null
}

getShellSegmentVaryPath is updated to keep concrete values for nodes where isRootParam === true, and to replace every other param node with Fallback:

const clone: VaryPath = {
  id: original.id,
  value:
    original.id === null || original.isRootParam === true
      ? original.value
      : Fallback,
  isRootParam: original.isRootParam,
  parent: original.parent === null ? null : getShellSegmentVaryPath(original.parent),
}

The route tree caches the shell vary path on each RouteTree node (new shellVaryPath field) so it doesn't have to be recomputed on every shell request:

// packages/next/src/client/components/segment-cache/cache.ts
type RouteTreeShared = {
  // ...
  segment: FlightRouterStateSegment
  // The vary path used to key this segment's App Shell entry: the segment's
  // vary path with every non-root param replaced with Fallback. Precomputed
  // once during tree construction so we don't have to recompute it on every
  // shell request.
  shellVaryPath: SegmentVaryPath
  // ...
}

fulfillEntrySpawnedByRuntimePrefetch now reads tree.shellVaryPath directly for FetchStrategy.RuntimeShell instead of recomputing the path from EMPTY_VARY_PARAMS. The previous "override to empty set on the client" comment is gone; the server reports the right set of params via IsRootLayoutOrAbove.

The optimistic-routes path (reifyRouteTree) and the route-tree-prefetch conversion path (convertTreePrefetchToRouteTree, convertFlightRouterStateToRouteTree) all read the new hint and propagate isRootParam into the appended vary-path node.

Headline property

Two navigations into the same route under different non-root params (different slug, different ?q=) share one shell entry. Two navigations under different root params (different locale) get distinct shell entries. This is consistent with how static fallback shell generation already treats root params at build time.

The previous "TODO: cache the shell across root param values via root-param-change eviction" comment in getShellSegmentVaryPath is gone — this commit picks the keep-in-the-vary-path direction instead.

Impact on vinext

vinext does not yet implement the App Shell client-side cache architecture tracked in #1614 (segment-cache scheduler, Shell phase, FetchStrategy.RuntimeShell, two-pass fulfilled-first navigation lookup). This commit is a refinement of that architecture: when vinext implements it, the shell vary path must distinguish root params from non-root params, not treat all params uniformly.

Concretely, whatever vinext builds to mirror the segment cache needs to:

  1. Propagate an IsRootLayoutOrAbove (or equivalent) hint from the server's flight router state, marking every segment at or above the root layout, not just the root layout segment itself. The hint must be set in both createFlightRouterStateFromLoaderTree and createRouteTreePrefetch, threaded from the parent slice so sliced renders don't re-mark a sub-layout as the root.

  2. Store isRootParam on each param node of the vary path, set when the corresponding segment carries the IsRootLayoutOrAbove hint. Only path param nodes; structural and search param nodes leave it unset.

  3. Key the shell vary path on root params: in the equivalent of getShellSegmentVaryPath, keep the concrete value of nodes where isRootParam === true and replace every other param node with Fallback. Both the "spawn on request" path (when fulfilling a RuntimeShell entry) and the "lookup on shell read" path must use this same keying.

  4. Cache the precomputed shell vary path on the route tree node (shellVaryPath on RouteTree) and reuse it through tree-clone operations (deprecated_createOptimisticRouteTree, page-vs-layout copies). Otherwise every shell request re-walks the vary path.

  5. Not special-case root params on the server side as "always omit from the shell." The shell render must honor root params, since they may legitimately appear above the root layout. The static-prerender treatment already does this (root params are always included during fallback shell generation), and the runtime shell now does too.

This is a small, contained refinement on top of #1614, but it has a user-visible consequence: a locale-routed app (/[locale]/...) will route every navigation to the wrong locale's shell if root params aren't keyed correctly. Worth porting at the same time as the underlying segment-cache architecture.

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    nextjs-trackingTracking issue for a Next.js canary change relevant to vinext

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions