cloudflare / cloudflare/vinext

App Router: client-side App Shell prefetching (segment cache scheduler, shell vary-path, fulfilled-first navigation lookup)

Open
#1,614 0 comments 0 reactions 0 assignees View on GitHub
nextjs-tracking
Dominant language
TypeScript
Stars
8.8k
Forks
406
Avg merge
2d 6h
Merged PRs (30d)
120

Description

## Next.js Change

**Commit:** [`1eaa37e`](https://github.com/vercel/next.js/commit/1eaa37ec842a6be552f5375fc55c83eb737dc80b)
**PR:** [#93999 — Prefetch App Shells on the client](https://github.com/vercel/next.js/pull/93999)

## What changed

Adds the **client-side** counterpart to the server-side App Shell prefetch handler tracked in #1427. Gated behind `experimental.appShells`. Clicking a link to a route the user has never specifically prefetched now renders the route's App Shell instantly while the per-link concrete request streams in the param-specific content in the background.

### Mechanism (from the diff)

A new `Shell` phase is inserted into the prefetch scheduler between the existing `RouteTree` and `Speculative` (formerly `Segments`) phases:

```ts
// packages/next/src/client/components/segment-cache/scheduler.ts
const enum PrefetchPhase {
RouteTree = 2,
Shell = 1,
Speculative = 0,
}
```

Phase transitions are gated on `process.env.__NEXT_APP_SHELLS`:

```ts
task.phase = process.env.__NEXT_APP_SHELLS
? PrefetchPhase.Shell
: PrefetchPhase.Speculative
```

Shell-phase tasks issue an App Shell request using a new `FetchStrategy.RuntimeShell` and write the response under a **param-independent vary path** — every param node (path params, search params) is replaced with `Fallback`:

```ts
// packages/next/src/client/components/segment-cache/vary-path.ts
function getShellSegmentVaryPath(original: VaryPath): SegmentVaryPath {
const clone: VaryPath = {
id: original.id,
value: original.id === null ? original.value : Fallback,
parent:
original.parent === null
? null
: getShellSegmentVaryPath(original.parent),
}
return clone as SegmentVaryPath
}
```

Sibling links pointing to the same route under different params dedupe at this keypath, so the segment cache holds **one** shell entry per route regardless of how many concrete links are visible on the page.

### Navigation-time cache lookup

A two-pass lookup change in `cache-map.ts` is what makes the instant-shell guarantee hold. The cache normally returns the most-specific matching entry — correct for prefetch dedup, wrong for navigation:

- If a fulfilled shell entry coexists with a `Pending` entry for a more-specific keypath that the navigation also matches, the navigation would otherwise block on the pending entry.
- Navigation now does a two-pass lookup: prefer `Fulfilled` entries anywhere along the vary path first, fall back to the regular most-specific behavior if nothing fulfilled is found.
- Prefetch reads keep the original semantics, since they need to see in-flight entries to dedupe.

### Scope of the upstream PR

- Only the **runtime** (`PPRRuntime` / `RuntimeShell`) prefetch path is covered. The static (`PPR`) path will be handled in a future PR using a "rewinding" strategy on the existing static prefetch response, alongside server-side byte-offset machinery.
- Routes that are fully static skip the Shell phase entirely — their existing static prefetches are already shell-like in shape.
- The Shell phase only does work on routes that use the PPR strategy (Cache Components routes). Other strategies fall through to Speculative as a no-op.

### 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.

## Impact on vinext

vinext does not implement a segment-cache prefetch scheduler with `RouteTree`/`Segments` phases today. It also does not implement the segment-level vary-path cache that Next.js uses to dedupe concrete prefetches across sibling links. Search of `packages/vinext/src` for `segment-cache`, `scheduler`, `vary-path`, `app-shell`, etc. returns only:

- `server/app-rsc-render-mode.ts` — a `"prefetch-loading-shell"` render mode (server-side, unrelated to this client work)
- `check.ts` — entry for `experimental.appShells` in the config compat matrix
- `shims/url-safety.ts` — a comment pointer into Next.js `segment-cache/navigation.ts`

For vinext to honor `experimental.appShells` end-to-end, the client runtime needs:

1. **A phased prefetch scheduler** with at minimum `RouteTree` → `Shell` → `Speculative` ordering, gated on the `__NEXT_APP_SHELLS` build flag.
2. **A shell-keyed vary path** — when a shell prefetch is scheduled, key the response cache entry at a param-independent path so concurrent sibling links to the same route dedupe onto a single in-flight shell request.
3. **A `FetchStrategy.RuntimeShell` request shape** that the client issues with `NEXT_ROUTER_PREFETCH_HEADER: '3'` (this is the request shape that #1427 tracks on the server side).
4. **Two-pass navigation-time cache lookup** — at navigation time, prefer any `Fulfilled` entry along the vary path before falling back to the most-specific match, so a ready shell beats an in-flight concrete prefetch.
5. **No-op handling** for fully-static routes and for routes whose `FetchStrategy` is not `PPRRuntime` — the Shell phase should fall through to Speculative without issuing any request.

This is a large workstream that is only meaningful once vinext has the underlying segment-cache architecture; it is the client half of the App Shells feature.

## Files changed in this commit

- `client/components/segment-cache/scheduler.ts` — +157/-81 (phase enum, transitions, RuntimeShell strategy gating)
- `client/components/segment-cache/cache.ts` — +84/-20 (shell entry storage and lookup)
- `client/components/segment-cache/cache-map.ts` — +54/-18 (two-pass fulfilled-first navigation lookup)
- `client/components/segment-cache/vary-path.ts` — +32 (`getShellSegmentVaryPath`)
- `client/components/segment-cache/bfcache.ts` — +12/-3
- `client/components/segment-cache/types.ts` — +4/-3 (`FetchStrategy.RuntimeShell`)
- `client/components/router-reducer/{fetch-server-response,ppr-navigations}.ts` — small adjustments
- New e2e: `test/e2e/app-dir/segment-cache/prefetch-app-shell/**`

## Related

- #1427 — Server-side handler for App Shell prefetches (`NEXT_ROUTER_PREFETCH_HEADER: '3'`) — this issue is the client-side counterpart
- #1425 — Sparkle experimental flag defaults (varyParams, optimisticRouting, cachedNavigations)
- #860 — `experimental.prefetchInlining` default flip

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.