Skip to content

PPR navigation replay: transition({ when }) disables the cached segment fast path #772

Closed

Description

@ivogt

Problem

A PPR route with transition({ when }) captures and serves its HTML shell normally, but every partial RSC navigation to that route falls open to fresh segment resolution even when the shell contains a valid canonical segment snapshot.

This makes conditional view-transition behavior and the PPR navigation fast path mutually exclusive. The exclusion is especially costly for catch-all storefront routes where handler resolution includes CMS/catalog dispatch.

Reproduction

Environment:

  • @rangojs/router 0.1.1
  • Cloudflare preset with CFCacheStore and KV
  • Catch-all page route with ppr and a source-dependent transition gate
path(
  "/*",
  Dispatcher,
  { name: "dispatcher", ppr: { ttl: 300, swr: 86400 } },
  () => [
    transition({
      when: ({ currentUrl, nextUrl }) => {
        if (currentUrl?.pathname === nextUrl?.pathname) return true;
        const from = currentUrl?.pathname.split("/") ?? [];
        const to = nextUrl?.pathname.split("/") ?? [];
        return from[1] === to[1];
      },
    }),
  ],
);
  1. Request a concrete URL until the document shell is warm.
  2. Confirm the document response has x-rango-shell: HIT.
  3. Navigate to the same URL through a normal partial RSC request.
  4. Inspect Server-Timing.

Observed on /surprisingly-eligible:

  • Document request: x-rango-shell: HIT, ppr-shell-read;desc="hit".
  • Stored shell: 7 snapshot records, including a non-empty segment record, plus transitionWhen: true.
  • Partial request: pipeline-cache-miss; dispatcher/layout handlers execute again; approximately 449 ms locally.
  • Control route with PPR but no conditional transition: pipeline-cache-hit; approximately 2 ms locally.

The missing x-rango-shell header on the partial is expected and is not the bug: shell status is document-only. The pipeline-cache-miss despite a usable captured segment record is the problem.

Root cause

replayableShellSnapshot() rejects the complete snapshot whenever entry.transitionWhen is truthy:

if (
  !entry ||
  !isValidShellHit(entry, buildVersion) ||
  entry.handlerLiveHoles ||
  entry.transitionWhen
) {
  return undefined;
}

Capture persists only the boolean fact that a conditional transition exists. The predicate itself cannot cross the serialized shell boundary, and a segment-cache hit would otherwise skip the fresh resolution that collects the predicate. The current correctness fallback therefore discards the entire segment replay optimization.

This behavior is documented in the PPR skill, but it is a substantial and silent performance cliff.

Desired behavior

A conditional transition should be evaluated fresh against the current navigation source while the canonical PPR segment record is still reused. The transition decision is request-specific; the captured route tree is not.

Possible direction:

  1. Resolve or collect only the transition({ when }) predicates from the matched manifest for the current request.
  2. Seed the implicit document scope from the shell snapshot as usual.
  3. Run matchPartial() against the seeded segment record.
  4. Apply the freshly evaluated transition gate to the replayed segments before serialization.

If that split is not feasible, expose a first-class diagnostic explaining that transition({ when }) disabled navigation replay. This overlaps the observability goal in #721, but the main request here is to remove the exclusion rather than only report it.

Acceptance criteria

  • A warmed PPR route with transition({ when }) reuses its captured canonical segment record on partial navigation.
  • The predicate runs for every partial request and receives the correct currentUrl, nextUrl, params, route names, and action metadata.
  • Both keep and drop outcomes preserve current transition semantics.
  • Loaders remain fresh; item/response/loader snapshot values are not replayed.
  • Intercepts and other existing fall-open cases remain unchanged.
  • Server-Timing or a dedicated debug signal makes the navigation replay visible.
  • E2E coverage exercises both development and production modes.

Observability gap

There is currently no first-class response signal showing whether a partial navigation reused the PPR segment snapshot. x-rango-shell is intentionally document-only, so it is absent from every partial response regardless of whether replay HIT or fell open. This made the failure above look identical to a successful replay until the generic performance timings and KV entry were inspected manually.

pipeline-cache-hit / pipeline-cache-miss is not an adequate public PPR signal:

  • it is a generic segment-pipeline metric rather than a PPR-navigation status;
  • a miss does not identify why replay was bypassed (transitionWhen, handlerLiveHoles, stale shell, missing snapshot, nonce, unsupported store, etc.);
  • consumers should not need to inspect private KV envelopes or router source to determine whether the optimization engaged.

Add a dedicated partial-navigation signal. For example:

Server-Timing: ppr-navigation-replay;desc="hit"
X-Rango-PPR-Replay: HIT

On bypass/miss, the debug form should include a bounded reason such as transition-when, handler-live-holes, stale, no-snapshot, nonce, or unsupported-store. Reusing x-rango-shell may be misleading because partial navigation does not serve HTML shell bytes; a distinct header/timing name is preferable, but the critical requirement is that HIT versus bypass is directly observable from the response.

Additional acceptance criteria:

  • A partial response directly reports whether PPR segment replay was HIT, MISS, or BYPASS.
  • A debug/development response reports the bounded bypass reason.
  • The signal distinguishes the confirmed control case (/test: replay HIT) from the conditional-transition case (/surprisingly-eligible: bypass due to transitionWhen) without KV inspection.
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

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions