Repository navigation
Description
Problem
A PPR shell inside its stale-while-revalidate window is considered a document HIT, but the same shell snapshot is explicitly rejected for partial navigation. The partial request then reruns route handlers and recreates top-level handle promises that were already baked into the shell snapshot.
This breaks the intuitive PPR contract: top-level handler data and handles are baked, documents advertise a shell HIT, yet an SPA navigation to the same URL can pay the full handler latency and produce different values. Adding an explicit cache() masks the behavior but should not be required to preserve baked PPR handler/handle output during the declared SWR window.
This is separate from #772, where transition({ when }) disables replay even for a fresh shell. Here there is no transition, loader, intercept, nonce, or handler-live hole. The only replay gate is shell staleness.
Concrete /test fixture
The route has only a handler and handles. It deliberately makes the timing visible:
export const TestHandle = createHandle(data => data);
path(
"/test/:id",
async ctx => {
const data = new Promise<string>(resolve =>
setTimeout(() => resolve("resolved data " + new Date()), 5000),
);
ctx.use(TestHandle)({ yo: "yo" });
ctx.use(TestHandle)(data.then(d => ({ asd: d })));
ctx.use(Meta)(
data.then(async d => {
await new Promise(resolve => setTimeout(resolve, 2000));
return { title: `Test with ID${ctx.params.id}: ${d}` };
}),
);
return (
<div>
Test with ID {ctx.params.id}
<Link to="/test/1">Go to test/1</Link>
<Link to="/test/2">Go to test/2</Link>
<Suspense fallback={<div>Loading {ctx.params.id}...</div>}>
<Test data={data} handle={TestHandle} />
</Suspense>
</div>
);
},
{ ppr: { ttl: 300, swr: 86400 } },
);The client component consumes the same handler promise:
"use client";
function Test({ data, handle }) {
const value = use(data);
return <div>{value}: {JSON.stringify(useHandle(handle))}</div>;
}Top-level pushed handle promises are documented shell material: capture awaits them and stores resolved values. The shell KV entry confirms that behavior. Its canonical segment snapshot contains the resolved component data and resolved handles:
TestHandle -> { asd: "resolved data Sat Jul 11 2026 10:27:43 GMT..." }
Meta -> { title: "Test with ID1: resolved data Sat Jul 11 2026 10:27:43 GMT..." }
The shell has one valid segment snapshot record, transitionWhen: false, and handlerLiveHoles: false.
Reproduction
- Load
/testas a document. - Navigate
/test->/test/1->/test/2->/test/1. - Warm
/test/1through a document request and confirm a fresh partial replay control. - Let the 300-second shell TTL expire while remaining inside the 86400-second SWR window.
- Confirm a document request still returns
x-rango-shell: HITand schedules background recapture. - Before recapture completes, navigate partially from
/test/2to/test/1.
A shorter TTL can be used in an e2e fixture to avoid waiting five minutes.
Measurements
Fresh shell control
Server-Timing: pipeline-cache-hit
TTFB: 0.039s
stream total: 0.041s
handler spans: absent
The baked component and handles replay immediately. This confirms that cache() is not necessary when the PPR snapshot is fresh.
Stale shell inside SWR
Server-Timing: pipeline-cache-miss
TTFB: 0.035s
stream total: 7.028s
Wire timing:
+0.035s partial payload shell and pending handle references
+5.000s fresh `data`, TestHandle `{ asd }` value
+7.000s fresh Meta title
The emitted timestamps are from the new request, proving that the handler promises were recreated rather than the resolved captured values being replayed.
Root cause
matchPartialWithPprReplay() discards stale runtime entries before checking whether their segment snapshot is replayable:
let snapshot = cached?.shouldRevalidate
? undefined
: replayableShellSnapshot(cached?.entry, ctx.version);The package documentation states that only fresh shells replay and that partial requests cannot recapture HTML or claim SWR ownership. The inability to recapture does not require rejecting the stale snapshot, however: a passive partial read can serve the already-authorized stale generation without claiming revalidation, bounded by the existing hard expiry. That is the same stale generation the document path is willing to serve as x-rango-shell: HIT.
Desired behavior
During the shell SWR window, partial navigation should passively replay the stale canonical segment snapshot and its baked handles while continuing to run loaders fresh. It must not claim revalidation ownership or pretend that a partial request recaptured HTML. A later document request can perform the existing background shell recapture; hard expiry still falls open to normal rendering.
If stale replay is intentionally declined, the document and navigation status surfaces must clearly distinguish fresh HIT, stale HIT, and partial BYPASS so consumers do not infer baked-handler reuse from x-rango-shell: HIT. The dedicated observability requirement is also tracked in #772.
Acceptance criteria
- A fresh PPR partial replay remains unchanged: handlers do not run, baked handles replay, loaders run fresh.
- A stale-but-within-SWR shell snapshot is eligible for passive partial replay.
- Top-level baked handle promises replay as their captured resolved values; the
/test/:idstale navigation does not wait five or seven seconds. - Partial replay does not claim SWR revalidation ownership and does not trigger HTML recapture.
- A document request can still recapture the stale shell in the background.
- A hard-expired, invalid-version, corrupt, or missing snapshot still falls open.
- Response observability distinguishes fresh replay, stale replay, and bypass with a bounded reason.
- E2E coverage runs in both development and production modes.
Activity