Skip to content

PPR/SWR: stale shell snapshots are rejected on partial navigation, rerunning baked handlers and handles #773

Closed

Description

@ivogt

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

  1. Load /test as a document.
  2. Navigate /test -> /test/1 -> /test/2 -> /test/1.
  3. Warm /test/1 through a document request and confirm a fresh partial replay control.
  4. Let the 300-second shell TTL expire while remaining inside the 86400-second SWR window.
  5. Confirm a document request still returns x-rango-shell: HIT and schedules background recapture.
  6. Before recapture completes, navigate partially from /test/2 to /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/:id stale 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions