Skip to content

fix(ppr): reevaluate transition gates on replay - #776

Merged
ivogt merged 5 commits into
mainfrom
feat/replay-safe-transition-when
Jul 11, 2026
Merged

ivogt merged 5 commits into
mainfrom
feat/replay-safe-transition-when

Conversation

@ivogt

@ivogt ivogt commented Jul 11, 2026

Copy link
Copy Markdown
Collaborator

Problem

A consumer can combine ppr with transition({ when }), but a warmed partial navigation currently rejects the captured PPR segment snapshot solely because the shell records that a conditional transition exists. The route handler then reruns even though only the request-specific transition decision changed. On dispatcher-style routes this turns a replay hit into the full CMS/catalog handler path.

Closes #772.

Before vs. after

Before, this valid route captures a shell but partial navigations bypass its segment replay:

path("/*", Dispatcher, { ppr: { ttl: 300, swr: 86400 } }, () => [
  transition({
    when: ({ currentUrl, nextUrl }) =>
      currentUrl?.pathname.split("/")[1] === nextUrl.pathname.split("/")[1],
  }),
]);
// Warm partial navigation: Dispatcher runs again.

After, consumer code is unchanged. The router reuses the captured segment record and evaluates only the server-side predicate for the current request:

path("/*", Dispatcher, { ppr: { ttl: 300, swr: 86400 } }, () => [
  transition({
    when: ({ currentUrl, nextUrl }) =>
      currentUrl?.pathname.split("/")[1] === nextUrl.pathname.split("/")[1],
  }),
]);
// Warm partial navigation: captured handlers replay; `when` runs fresh.

Consumers who do not use ppr or transition({ when }) see no behavior change.

End-to-end example

import { urls } from "@rangojs/router";

export const storefrontUrls = urls(({ path, loading, transition }) => [
  path(
    "/shop/:category/:slug",
    ProductDispatcher,
    { name: "product", ppr: { ttl: 300, swr: 3600 } },
    () => [
      transition({
        when: ({ currentParams, nextParams }) =>
          currentParams?.category === nextParams.category,
      }),
      loading(<ProductSkeleton />),
    ],
  ),
]);

Navigating within one category keeps the transition hold. Navigating across categories drops it and streams ProductSkeleton, while both paths can reuse the warmed PPR handler segments and continue running loaders fresh.

Semantics

Route/path Predicate timing Cache/replay behavior
Ordinary fresh resolution After route handlers Existing behavior; handler-set context is visible
Ordinary cache()/prerender hit Not rerun Existing stored decision is replayed
PPR fresh resolution After middleware, before cache lookup and route handlers URL, params, action metadata, env, and middleware context are visible
PPR runtime/prerender/shell replay Rerun on every match Captured handler segments replay; keep/drop is projected onto response-local segments
Intercept path Existing intercept resolution Existing fall-open behavior is unchanged
Throwing predicate Reported as rendering Transition is dropped conservatively

Code changes

  • src/router/transition-when.ts owns shared context construction and manifest-based PPR predicate evaluation, including route, orphan-layout, and parallel-slot segment IDs.
  • src/router/match-handlers.ts evaluates PPR predicates after route middleware has established context but before the segment pipeline performs cache lookup.
  • src/router/match-middleware/cache-lookup.ts retains predicate-owning replay segments so a fresh keep/drop decision reaches the client even when the component otherwise needs no update.
  • src/rsc/transition-gate.ts applies PPR decisions copy-on-write, preserving reusable cache, prerender, and shell records. Ordinary post-handler gating remains in place.
  • src/testing/run-transition-when.ts adds { ppr: true } so consumers can test the production pre-handler timing through @rangojs/router/testing.
  • PPR/view-transition skills, API types, execution-model docs, and internal source maps now describe the split timing.

The predicate is evaluated from the already matched manifest rather than forcing normal segment resolution, because rerunning handlers would defeat the PPR replay contract this fixes.

Test plan

  • transition-when.test.ts: PPR pre-handler context, keep/drop results, route/orphan/parallel IDs, throwing behavior, and reusable-record immutability through the public testing primitive.
  • Semantic matrix [PPR4]: conditional predicates rerun while PPR handlers replay, in dev and production (71 rows passed).
  • Router test app: document HIT execution counters, partial replay HIT status, and fresh drop decision, in dev and production (6 focused cases passed).
  • cloudflare-basic: the same document, partial replay, and fresh drop contracts on workerd, in dev and production (6 focused cases passed).
  • pnpm run typecheck
  • pnpm run test:unit:all
  • pnpm run lint
  • pnpm run format

Notes

@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@776

commit: 0a8b020

ivogt added 2 commits July 11, 2026 16:13
…sition-when

# Conflicts:
#	packages/rangojs-router/docs/design/ppr-shell-resume.md
#	packages/rangojs-router/docs/internal/execution-model.md
#	packages/rangojs-router/e2e/shell-cache.test.ts
#	packages/rangojs-router/skills/ppr/SKILL.md
#	tests/cloudflare-basic/e2e/ppr-shell.test.ts
@ivogt
ivogt merged commit 5bf6777 into main Jul 11, 2026
36 checks passed
@ivogt
ivogt deleted the feat/replay-safe-transition-when branch July 11, 2026 13:55
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 navigation replay: transition({ when }) disables the cached segment fast path

1 participant