Skip to content

Extract App Shell from static prefetches#63

Open
hbrooks wants to merge 3 commits into
canaryfrom
demo/pr-94095
Open

Extract App Shell from static prefetches#63
hbrooks wants to merge 3 commits into
canaryfrom
demo/pr-94095

Conversation

@hbrooks
Copy link
Copy Markdown

@hbrooks hbrooks commented May 28, 2026

Originally PR vercel#94095 in vercel/next.js by @acdlite

acdlite and others added 3 commits May 25, 2026 03:09
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.
Adds support for extracting an App Shell from a more concrete
prerender response. The server sends down a byte offset that
represents the subset of the stream that corresponds to the
reusable App Shell.

This does _not_ yet implement shell extraction for per-segment
prefetch responses. Implementing this adds an additional layer of
complexity, because those responses are generated during a separate
phase of the build process. We do intend to implement this, but
it's a non-essential optimization that can come later.

This also does not yet implement shell extraction from a navigation
response (the Cached Navigations feature), though both features are
based on essentially the same mechanism. I'm deferring this to a
subsequent PR because some of the existing implementation needs to
be rethought in light of the new App Shells based model; for
example, the "static stage" boundary might not make sense to track
separately from the App Shell.

The main practical upshot of the PR is that if you have a fully
statically prerendered page with no dynamic holes, that page's App
Shell can now be fetched by the client without incurring any
runtime server execution cost: the server will return the full
static page, and the client will extract the App Shell from that
concrete response.
Squashed server-only diff from:
  - vercel#94044  Streaming prerender
  - vercel#93801  Rewinding app shells

This commit is a placeholder on the stack so we can develop and test the
client-side rewinding integration that lands in the commit above. It gets
regenerated from Janka's latest each time we re-sync; do not edit by hand.

For now this uses only the fallback-stage branch as source because its
app-render.tsx version supersedes streaming-prerender's (the smaller
streaming-prerender files are byte-identical to fallback-stage's).
Replaces the forceOmitParams hanging-promise mechanism from canary with
the abort-before-params approach Janka builds on.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants