Skip to content

fix(router): replay stale PPR shells during navigation - #775

Merged
ivogt merged 3 commits into
mainfrom
fix/ppr-stale-partial-replay
Jul 11, 2026
Merged

ivogt merged 3 commits into
mainfrom
fix/ppr-stale-partial-replay

Conversation

@ivogt

@ivogt ivogt commented Jul 11, 2026

Copy link
Copy Markdown
Collaborator

Problem

A consumer can declare a PPR route whose handler creates delayed component data and top-level handle promises, including an async Meta value derived from the same promise. During the SWR window, document requests served that captured shell as x-rango-shell: HIT, but partial navigation rejected the same snapshot and reran the handler. Consumers saw the full handler and Meta latency again and could receive values different from the shell generation that the document path advertised as a hit.

Before vs. after

Before, this route needed an unrelated explicit data cache to avoid rerunning baked work during stale partial navigation:

path("/test/:id", handler, {
  ppr: { ttl: 300, swr: 86400 },
});
// Stale partial navigation reran handler promises and async Meta.

After, the route declaration stays unchanged:

path("/test/:id", handler, {
  ppr: { ttl: 300, swr: 86400 },
});
// Stale partial navigation passively replays the captured generation.

No migration or consumer opt-in is required.

End-to-end example

export const TestHandle = createHandle<{ yo?: string; asd?: string }>();

path(
  "/test/:id",
  ctx => {
    const data = new Promise<string>(resolve =>
      setTimeout(() => resolve(`resolved-${ctx.params.id}`), 1500),
    );

    const push = ctx.use(TestHandle);
    push({ yo: `yo-${ctx.params.id}` });
    push(data.then(value => ({ asd: value })));
    ctx.use(Meta)(
      data.then(async value => {
        await new Promise(resolve => setTimeout(resolve, 500));
        return { title: `Test ${ctx.params.id}: ${value}` };
      }),
    );

    return (
      <Suspense fallback={<div>Loading...</div>}>
        <Test data={data} handle={TestHandle} />
      </Suspense>
    );
  },
  { ppr: { ttl: 300, swr: 86400 } },
);

The e2e fixtures warm /1, navigate /2 -> /1 -> /2, age /1 into SWR, perform a stale document HIT that starts background recapture, and immediately navigate partially /2 -> /1. They assert that the component promise, both top-level handles, and async Meta title remain from the captured generation without waiting for the handler delay.

Semantics

Shell state Partial navigation Observability Revalidation behavior
Fresh replayable snapshot Replay baked handlers and handles HIT; freshness=fresh No claim or capture
Stale snapshot inside SWR Passively replay the stale generation HIT; freshness=stale No claim or capture
Missing or hard-expired snapshot Fall open to normal matching BYPASS; reason=no-entry No partial capture
Invalid version or corrupt snapshot Fall open to normal matching Bounded bypass reason No partial capture
Handler-live holes or conditional transition Fall open to normal matching Bounded bypass reason Existing behavior unchanged
Loader segments Resolve fresh Included in normal partial result Existing behavior unchanged
Stale document request Serve stale HTML shell x-rango-shell: HIT Existing background recapture remains

Code changes

  • Allow runtime stale snapshots to seed partial matching while preserving the passive claimRevalidation: false store read.
  • Report fresh replay, stale replay, and bounded bypass reasons through x-rango-ppr-replay and ppr:navigation-replay telemetry.
  • Track whether matching actually consumed a seeded segment before reporting a replay hit.
  • Export parser and assertion helpers through @rangojs/router/testing and @rangojs/router/testing/e2e.
  • Add the concrete delayed-promise, top-level-handle, and async-Meta fixture to the router test app and Cloudflare consumer app.
  • Update PPR behavior, telemetry, testing, skill, and internal-reference documentation.

Test plan

  • Red-before-green: restoring the old stale guard makes stale SWR navigation replays the captured handler promise, top-level handles, and Meta fail on all three attempts: expected HIT; freshness=stale, received BYPASS; reason=no-entry.
  • Router test app concrete scenario in dev and production: 2 passed.
  • Cloudflare-basic concrete scenario in dev and production: 2 passed.
  • Internal replay, passive ownership, bypass, and status parser tests: 64 passed.
  • Public testing primitive dogfood in e2e/mini: 4 focused tests passed.
  • Semantic matrix: 69 passed across dev and production.
  • pnpm run typecheck
  • pnpm run test:unit:all
  • pnpm run lint
  • pnpm run format
  • E2E bucketing guard

Notes

Partial navigation remains unable to capture HTML or own SWR regeneration. A later document request retains responsibility for background recapture. Build-manifest entries past their build TTL remain a bounded bypass because passive runtime SWR authorization is not available for that source.

Closes #773

ivogt added 2 commits July 11, 2026 15:17
…-replay

# Conflicts:
#	packages/rangojs-router/docs/internal/execution-model.md
#	packages/rangojs-router/docs/internal/feature-map.md
#	packages/rangojs-router/docs/testing.md
#	packages/rangojs-router/e2e/shell-cache.test.ts
#	packages/rangojs-router/skills/ppr/SKILL.md
#	packages/rangojs-router/src/rsc/rsc-rendering.ts
#	packages/rangojs-router/src/testing/__tests__/shell-status.test.ts
#	packages/rangojs-router/src/testing/e2e/index.ts
#	packages/rangojs-router/src/testing/index.ts
#	packages/rangojs-router/src/testing/shell-status.ts
#	tests/cloudflare-basic/e2e/ppr-shell.test.ts
@pkg-pr-new

pkg-pr-new Bot commented Jul 11, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@rangojs/router@775

commit: 9c66cf7

@ivogt
ivogt merged commit 08e3dcc into main Jul 11, 2026
36 checks passed
@ivogt
ivogt deleted the fix/ppr-stale-partial-replay branch July 11, 2026 13:35
@ivogt ivogt mentioned this pull request Jul 12, 2026
9 tasks done
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.

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

1 participant