cloudflare / cloudflare/vinext
App Router App Shell: extract shell from static prerender response via server-sent byte offset
- Dominant language
- TypeScript
- Stars
- 8.8k
- Forks
- 406
- Avg merge
- 2d 6h
- Merged PRs (30d)
- 120
Description
## Next.js Change
**Commit:** [`2aea494`](https://github.com/vercel/next.js/commit/2aea494ef1c7ffb8879dd260bc2ae3368ec685af)
**PR:** [#94095 — Extract App Shell from static prefetches](https://github.com/vercel/next.js/pull/94095)
## What changed
Adds the next slice of the App Shells workstream: extracting a reusable App Shell from the **byte prefix** of a more concrete prerender response, using a byte offset the server emits inline with the Flight stream.
Previously, an App Shell prefetch always required a dedicated runtime render (#1427). With this change, when a route is fully or partially statically prerendered, the server can return the full static response and include a hint that says "the first N bytes of this stream are also a valid reusable App Shell." The client tees the response body, decodes the byte prefix as a separate Flight payload, and writes it into the segment cache at the shell vary path. No extra round trip, no extra server execution.
### Mechanism (from the diff)
- The Flight response payload gains a new `a` field — a thenable that resolves to either a `number` (byte offset of the shell within the response) or `null` (the entire response IS the shell, nothing to extract).
- `processFetch` in `packages/next/src/client/components/router-reducer/fetch-server-response.ts` now does **two** `tee()` calls on the response body, producing three readers: the main Flight decoder, a `staticBodyClone` for static-stage extraction, and a new `shellBodyClone` for shell-stage extraction.
- `decodeStaticStage` is renamed to `decodeStageUntilBoundary` and generalized — it takes a resolved byte length and buffers/truncates the cloned stream into a Flight payload. Both the static-stage and shell-stage extractors use it.
- New `resolveShellStageData()` helper: returns `null` when the shell is the main response (caller reuses `flightResponse`); otherwise returns a decoded shell payload from the byte prefix.
- `fetchSegmentPrefetchesUsingDynamicRequest` in `segment-cache/cache.ts` now branches on whether a shell was extracted:
- If this is a `FetchStrategy.RuntimeShell` prefetch and a shell was extracted, **fulfill pending entries with the shell** and additionally upsert the full concrete response into the cache at `FetchStrategy.PPR`.
- If this is _not_ a shell prefetch but a shell was extracted, fulfill with the full response and upsert the shell at `FetchStrategy.RuntimeShell`. Both variants end up in the cache.
- `writeStaticStageResponseIntoCache` is renamed to `writePrerenderResponseIntoCache` and now takes an explicit `FetchStrategy` (`PPR` or `RuntimeShell`) rather than deriving it from `isResponsePartial`.
- Scheduler change (`segment-cache/scheduler.ts`): when the prefetch task is in `PrefetchPhase.Shell`, every new segment is treated as runtime-prefetch-eligible regardless of `HasRuntimePrefetch` hints, since the Shell is reusable across all params by definition.
- The `createInitialRouterState` path is updated to use the new helpers and leaves a `TODO` noting that initial-HTML shell extraction is intentionally deferred until Cached Navigations and App Shells are reconciled.
### Scope notes from the PR
- **In scope:** shell extraction from a full static prerender response.
- **Out of scope (deferred to follow-up PRs):**
- Shell extraction from **per-segment prefetch responses** (generated in a separate build phase, additional complexity).
- Shell extraction from a **navigation response** (the Cached Navigations feature). The "static stage" boundary may not make sense to track separately from the App Shell in the new model.
### Practical upshot
A fully statically prerendered page with no dynamic holes can now have its App Shell fetched by the client **without any runtime server execution** — the server returns the cached static response and the client extracts the reusable shell from the byte prefix.
## Impact on vinext
vinext does not yet implement the App Shells workstream end-to-end. The pieces tracked so far:
- **#1427** — server-side handler for `NEXT_ROUTER_PREFETCH_HEADER: '3'` (param/searchParam suspension, render-time App Shell)
- **#1614** — client-side scheduler `PrefetchPhase.Shell`, shell vary path, fulfilled-first navigation lookup
- **#1405** — `experimental.appShells` config flag plumbing
This issue tracks a distinct piece of the puzzle: emitting and consuming the **shell byte offset** on prerendered responses so that fully-static pages can serve App Shells "for free."
To match Next.js behavior, vinext would need to:
1. **Server side** (`entries/app-rsc-entry.ts`, App Router production server):
- When prerendering a route, identify the byte boundary in the RSC stream at which the response transitions from shell-reusable content to param-dependent content.
- Emit the byte offset as the `a` field on the Flight response (a thenable that resolves to a number for "shell is a prefix of the response" or `null` for "shell == full response").
- For routes that are entirely static and entirely shell-eligible, emit `a` as `null`.
- For routes that have no shell concept (or shell extraction disabled), omit `a` entirely.
2. **Client side** (`shims/next-navigation.ts` and any segment-cache shim work):
- Implement the two-`tee()` body clone pattern in the prefetch fetch path.
- Port `resolveShellStageData` semantics.
- Branch on `FetchStrategy.RuntimeShell` to decide which payload (shell vs. full) fulfills pending segment entries, and upsert both into the cache.
- Update the cache write helper to take an explicit `FetchStrategy` instead of deriving from `isResponsePartial`.
- Update the scheduler so `PrefetchPhase.Shell` treats every new segment as runtime-prefetch-eligible.
3. **Compat surface:**
- The `a` field is new on the Flight response shape. Any vinext code that constructs or consumes `NavigationFlightResponse` / `InitialRSCPayload` shapes (e.g., custom RSC entry helpers under `entries/`) will need to remain compatible — either pass `a: undefined` (no shell info) or implement the full mechanism.
- Rename impact: `decodeStaticStage` → `decodeStageUntilBoundary` and `writeStaticStageResponseIntoCache` → `writePrerenderResponseIntoCache`. If we ever vendored or referenced these names directly, update them.
### Open questions
- vinext currently produces RSC streams via the `@vitejs/plugin-rsc` flow; how do we surface the "shell byte boundary" from the underlying React render? This likely requires either (a) two separate renders (one shell, one full) and concatenation with a recorded offset, or (b) hooking into the Flight serializer to mark the transition point.
- For Cloudflare Workers prod, where prerendered responses are stored in KV via the cache handler — do we also store the byte offset alongside the body? The simplest model is to write `{ body, shellByteOffset }` into the cache value.
- Dev mode: does this kick in at all, or do we treat dev as "always do a runtime shell render" (current behavior under #1427) to avoid the build-time-only optimization complexity?
### Dependencies
This is downstream of:
- #1427 (server-side App Shell render branch) — needed so the server understands what a shell IS
- #1614 (client scheduler / vary path) — needed so the client knows when to look up a shell entry
- #1405 (`experimental.appShells` flag) — gates the whole feature
This is **not** a prerequisite for the basic App Shells feature to function. It is a performance optimization that eliminates a runtime render for fully-static pages. Recommend implementing the base feature first and adding this extraction path as a follow-up.
## Related
- #1427 — server-side `NEXT_ROUTER_PREFETCH_HEADER: '3'` handler
- #1614 — client-side App Shell prefetching (scheduler, vary path)
- #1405 — `experimental.appShells` config flag
- #860 — `experimental.prefetchInlining` default flip
Contributor guide
Assessment
This issue has not been assessed yet.