Prefetch App Shells on the client (#93999)
Gated behind `experimental.appShells`. Clicking a link to a route the
user has never specifically prefetched now renders the route's App Shell
instantly; the per-link concrete request continues in the background and
streams in the param-specific content.
### Motivation
Today, prefetching a parameterized route like `/chat/[id]` requires a
concrete `id`. The framework caches a separate prefetch entry per link,
so the cost of prefetching scales with the number of visible links — not
with the number of routes. On a page with high link cardinality (a feed,
a search-results page, a chat list) it is impractical to prefetch every
concrete URL up front. If a per-link prefetch for `/chat/123` is still
in flight when the user clicks the link, the navigation blocks until
that prefetch completes; there's no cached generic shell to fall back
to. The cost of a prefetch miss is a full blocking navigation.
The property we want: once a user has visited a Next.js app, every
subsequent navigation should transition to _something_ instantly — at
minimum an App Shell — regardless of whether per-link prefetches for the
concrete destination have completed. This matters most under adverse
conditions (slow networks, offline, high-cardinality routes), but the
guarantee is unconditional.
App Shells make that property hold. A shell is a per-_route_ resource,
not a per-link one — the number of shells in flight at any time scales
with filesystem routes, which is bounded and small. Aggressive App Shell
prefetching is affordable in a way that aggressive per-link runtime
prefetching is not.
### Mechanism
A new `Shell` phase sits in the prefetch scheduler between the existing
`RouteTree` and `Speculative` (formerly `Segments`) phases. Shell-phase
tasks issue an App Shell request and write the response under a
param-independent vary path. Concurrent shell tasks for sibling links to
the same route dedupe at this keypath, so the cache holds at most one
shell entry per route.
The headline property: if N links on a page resolve to the same route
under different params, they share _one_ App Shell request collectively.
Once it lands, every one of those navigations can render an instant
shell, regardless of whether the param-specific concrete prefetch has
completed.
The Speculative phase is mostly unchanged — it still issues the per-link
concrete prefetches that fill in param-specific content over time.
Routes that are fully static (no runtime data anywhere in their tree)
skip the Shell phase entirely, since their existing static prefetches
are already shell-like in shape.
### Navigation-time cache lookup
A small change in how the cache is read at navigation time is what makes
the instant-shell guarantee actually hold. The cache normally returns
the _most-specific_ matching entry — the right semantics for prefetch
dedup, but the wrong semantics for navigation. If a fulfilled shell
entry coexists with an in-flight Pending entry for a more-specific
keypath that the navigation also matches, the most-specific entry is the
empty Pending one, and the navigation would block on it instead of
rendering the shell.
Navigation now does a two-pass lookup: first prefer Fulfilled entries
anywhere along the vary path (so a less-specific shell beats a
more-specific Pending), then fall back to the regular behavior if
nothing fulfilled is found. Prefetch reads keep the original semantics,
since they need to see in-flight entries to dedupe.
### Scope
The shape of the prefetch request and the response interpretation differ
between static (`PPR`) and runtime (`PPRRuntime`) prefetches. Only the
runtime path is covered here. The static path uses a different strategy
— rewinding a single response into a shell prefix and a concrete suffix,
rather than issuing a separate shell-only request — and will be added in
a future PR alongside the server-side byte-offset machinery it depends
on.
<!-- NEXT_JS_LLM_PR -->