Skip to content

fix(router): invoke parallel slot revalidate() fns when slot missing from clientSegmentIds - #482

Merged
ivogt merged 1 commit into
mainfrom
fix/parallel-revalidate-fns-skipped
Apr 26, 2026
Merged

ivogt merged 1 commit into
mainfrom
fix/parallel-revalidate-fns-skipped

Conversation

@ivogt

@ivogt ivogt commented Apr 26, 2026

Copy link
Copy Markdown
Collaborator

Summary

When a parallel slot's id wasn't present in the request's _rsc_segments
set (the client's known-segments list), the resolver short-circuited with a
static decision and never invoked the user-defined revalidate() functions
attached to that slot
. For shared / parent-chain parallels (e.g. an
@panel that lives above the route segment) the static default is false,
so the slot rendered as component: null. The slot also failed to push into
matchedIds, so the response listed @panel in diff but not in matched
— which lets the client prune the slot, perpetuating the staleness on the
next request.

The bug surfaced reliably after a server action: actions can return a sparse
matched set that doesn't include a parent-chain parallel, the client
prunes it, and the next navigation arrives with the slot missing from
_rsc_segments. From then on, the user's revalidate() was dead code.

Before vs. after

// Definition — typical "click an email, intercept opens panel" pattern
parallel(
  { "@panel": EmailPanel },
  () => [
    revalidate(({ method, defaultShouldRevalidate, fromRouteName, toRouteName }) => {
      // We want @panel to refresh whenever we cross between mailbox folders,
      // even though the parent layout is shared.
      if (method === "POST") return false;
      if (fromRouteName !== toRouteName) return true;
      return { defaultShouldRevalidate };
    }),
    revalidate(() => {
      console.log("[panel] revalidate hook fired");
      return null;
    }),
  ],
);

// Sequence: initial nav → mark-as-read action → click a different email
//
// BEFORE FIX:
//   second nav request:
//     ?_rsc_segments=...,M0L0L0I0L0,M0L0L0I0L0R1   (no @panel)
//   server short-circuits: shouldResolve = belongsToRoute || isNewParent = false
//   neither revalidate() fn runs; response has
//     "component": null    for @panel
//     "matched":  [..., M0L0L0I0L0, M0L0L0I0L0R2]   (no @panel)
//     "diff":     [M0L0L0I0L0.@panel, M0L0L0I0L0R2]
//   client prunes @panel; staleness persists
//
// AFTER FIX:
//   both revalidate() fns are called with the static decision as the seed
//   `defaultShouldRevalidate`; the panel can opt back in via soft-true and
//   re-renders cleanly. matchedIds always lists the slot.

revalidate() callbacks attached to a parallel slot now have the same
contract as those on any other entry: if you defined them, they get
called
on every revalidation evaluation (with the one explicit exception
called out below).

Semantics after the fix

Case (parallel slot under non-route entry) Calls user revalidate() fns? Default seed
Slot id IS in clientSegmentIds yes (unchanged) type-derived (existing heuristic)
Slot missing, client set non-empty, parent unknown OR belongsToRoute yes (was: no) true (new-segment)
Slot missing, client set non-empty, parent known parent-chain yes (was: no) false (skip-parent-chain)
clientSegmentIds.size === 0 (full refetch) no — bypassed by design forced true (load-bearing for first render)
Hard false returned by user fn (any non-full-refetch case) short-circuits as documented n/a

Full-refetch is the one case where user fns are deliberately bypassed: the
client has nothing cached, so a false return would leave the slot blank
with no fallback. This is unchanged from prior behavior and pinned by a new
test.

The same fix is applied to resolveOrphanLayoutWithRevalidation's parallel
branch.

Sibling audit

I checked the other "skip user fns when segment not in clientSegmentIds"
branches:

Location Default Verdict
resolveLoadersWithRevalidation (loader new on client) true left as-is — useLoader() would return undefined if a user soft-false'd
resolveEntryHandlerWithRevalidation (layout/route new on client) true left as-is — segment renders
resolveOrphanLayoutWithRevalidation (orphan layout entry new on client) true left as-is — segment renders
resolveParallelSegmentsWithRevalidation (parallel slot, this PR) could be false fixed
resolveOrphanLayoutWithRevalidation (orphan parallel slot, this PR) true fixed for contract consistency

Loader/layout/route new-segment paths default to true so the segment
renders. Their user-fn bypass is load-bearing for first-render correctness
(returning false would leave the segment without data) and is left
untouched.

What changed

packages/rangojs-router/src/router/revalidation.ts

Added defaultOverride?: { value: boolean; reason: string } to
EvaluateRevalidationOptions. When set, evaluateRevalidation uses it as the
seed defaultShouldRevalidate (and trace reason) instead of deriving from
segment type / params / method. User fns still run and the soft chain
behaves identically.

if (defaultOverride) {
  defaultShouldRevalidate = defaultOverride.value;
  defaultReason = defaultOverride.reason;
} else if (request.method === "POST") {
  ...
}

packages/rangojs-router/src/router/segment-resolution/revalidation.ts

resolveParallelSegmentsWithRevalidation and the orphan-layout parallel
branch now always go through evaluateRevalidation (instead of returning
early with a static decision), passing defaultOverride derived from
client-knowledge so the seed is correct:

let shouldResolve: boolean;
if (isFullRefetch) {
  // Client has nothing cached — slot MUST render. User revalidate fns are
  // bypassed because returning false would leave the segment blank with no
  // client-side fallback.
  traceFullRefetchedParallelSlot(parallelId, belongsToRoute);
  shouldResolve = true;
} else {
  let defaultOverride: { value: boolean; reason: string } | undefined;
  if (!clientSegmentIds.has(parallelId)) {
    const value = belongsToRoute || isNewParent;
    defaultOverride = {
      value,
      reason: value ? "new-segment" : "skip-parent-chain",
    };
  }
  shouldResolve = await evaluateRevalidation({
    ...,
    defaultOverride,
  });
}

matchedIds.push(parallelId) is now unconditional (was guarded by the same
4-condition check that excluded parent-chain parallels). The slot is
unconditionally appended to segments, so excluding it from matchedIds
caused the diff/matched mismatch the client used as a prune signal.

Also dropped an unused loaderPromises: Map<string, Promise<any>> parameter
on resolveOrphanLayoutWithRevalidation (TS6133, surfaced by the recompile)
and updated the two internal call sites and three test files that passed it.

Test plan

  • New regression test parallel-revalidate-fns-invocation.test.ts — 9 cases pinning the contract: slot known to client; full refetch with hard-false (must still resolve, fns bypassed); isNewParent; belongsToRoute; the user's exact bug (parent-chain, slot missing, soft-true); hard-false short-circuit when slot missing; orphan path equivalents. 6 of 9 cases failed against the pre-fix code.
  • Full unit suite — pnpm --filter @rangojs/router run test:unit — 161 files / 3046 tests pass / 3 todo (was 3045, +1 from the new full-refetch hard-false case).
  • pnpm --filter @rangojs/router exec tsc --noEmit clean.
  • pnpm -w run lint — 0 warnings / 0 errors on 1191 files.
  • pnpm -w run format clean on all changed files.
  • Playwright e2e — not run locally; CI will exercise it.

Notes

  • The matchedIds.push change is a behavior change for the wire format:
    parent-chain parallels under a known parent now appear in the response's
    matched array even when their revalidate() returned soft-false. This
    matches the existing invariant that everything pushed to segments is
    also pushed to matched. If any client logic was relying on the previous
    diff/matched skew to mean "ignore this slot," that would be a latent bug
    surfaced by this fix.
  • evaluateRevalidation's new defaultOverride is a single optional
    object, not two parallel optional fields, to make the pairing
    type-enforced (the prior shape allowed setting one without the other and
    silently fell back to a "override" reason string).
  • Loader / layout / route "new on client" branches were intentionally not
    changed — their user-fn bypass is load-bearing (a user revalidate(() => false) on a brand-new loader would yield useLoader() returning
    undefined). That asymmetry vs. parallels is intentional and called out in
    the test file's audit comment.

…from clientSegmentIds

Parallel slots whose `revalidate()` functions should decide whether to refetch
were silently skipped on every navigation where the slot id wasn't present in
the request's `_rsc_segments` set. The resolver short-circuited with a static
`belongsToRoute || isNewParent` decision, bypassing user fns entirely. For
parent-chain parallels (the typical "shared sidebar" / "intercept slot"
pattern) the static default is `false`, so the slot rendered as `component:
null` and was returned in the response's `diff` but not in `matched` — which
let the client prune it, perpetuating the staleness on the next request.

This commonly surfaced after a server action: actions can return a sparse
matched-list that doesn't include the parallel slot, the client prunes it,
and the next nav arrives with the slot missing from `_rsc_segments`.

Fix: in `resolveParallelSegmentsWithRevalidation` and the orphan-layout
counterpart, always call `evaluateRevalidation` so user-defined revalidate
fns are honored, seeding the soft chain with the static decision via a new
`defaultOverride: { value, reason }` option. Full-refetch (empty
`clientSegmentIds`) is still forced to render — user fns are bypassed there
because returning false would leave the slot blank with no client-side
fallback. `matchedIds.push(parallelId)` is now unconditional to match the
unconditional `segments.push`, removing the diff/matched mismatch.

Sibling audit (loaders, layout/route entry, orphan-layout entry) confirmed
their analogous short-circuits default to `true` and are load-bearing for
first-render correctness — left untouched.
@pkg-pr-new

pkg-pr-new Bot commented Apr 26, 2026

Copy link
Copy Markdown

Open in StackBlitz

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

commit: 5a9ab3d

@ivogt
ivogt merged commit e0fef49 into main Apr 26, 2026
25 checks passed
@ivogt
ivogt deleted the fix/parallel-revalidate-fns-skipped branch April 26, 2026 04:57
ivogt added a commit that referenced this pull request Apr 26, 2026
…ng nav (#482 follow-up) (#483)

* test(router): e2e for parallel slot revalidate() after action + sibling nav (#482 follow-up)

Adds Playwright coverage for the user-reported scenario PR #482 fixed at the
unit level: parent-chain @panel parallel slot, server action that opts out
of POST revalidation via revalidate() returning false, sibling-route
navigation immediately after.

Pre-fix observable symptom: the action's revalidation phase pruned the
panel from the client's matched set (matchedIds.push was guarded by a
4-condition check that excluded parent-chain parallels), and the next nav
arrived without @panel in _rsc_segments. The resolver short-circuited and
never invoked the panel's revalidate() fn — the count from the panel's
loader did not change, and in some cases the slot rendered as
component:null.

Fixture (test-app/src/urls/parallel-reval-after-action.tsx) sets up the
exact shape from the bug report: parent layout with @panel, panel has
loader(ParallelRevalLoader) + revalidate({method}) => method !== "POST",
two sibling pages, and reuses the existing RevalidateButton + triggerRevalidation
no-op action.

Test (e2e/parallel-reval-after-action.test.ts) walks the sequence in both
dev and production modes (per the global "ALL e2e tests must cover both
modes" mandate):

  1. nav to page A → assert panel mounted, capture count
  2. click action → panel must stay mounted, count unchanged (POST opt-out)
  3. nav to page B → panel re-renders, count strictly greater
  4. nav back to A → panel re-renders again, count strictly greater
  5. (second test) repeat the action+nav cycle twice to pin that the fix
     survives multiple round-trips, not just the first

Both tests pass on main; they would fail (count stays equal, or panel
disappears) if either part of the PR #482 fix is reverted.

* test(router): name parallel-reval-after-action include + regenerate gen file

Reviewer flagged that the new test-app routes (revalAfterActionPageA/B)
were not appearing in router.named-routes.gen.ts, which AGENTS.md mandates
must stay in sync with route definitions. The original include() was
unnamed, matching the sibling parallel-loader-reval pattern, so the rango
plugin filtered the route names out of the gen file.

Adding `{ name: "parallelRevalAfterAction" }` to the include() and
rebuilding registers them as `parallelRevalAfterAction.revalAfterActionPageA`
and `parallelRevalAfterAction.revalAfterActionPageB`. The Playwright tests
still pass in dev and production (they reference the routes by URL string,
not by name lookup, so the rename is transparent to them).
ivogt added a commit that referenced this pull request Jun 14, 2026
…r via policy args (#568) (#578)

resolveOrphanLayoutWithRevalidation inlined ~130 lines of parallel-slot
resolution that duplicated resolveParallelSegmentsWithRevalidation. The two
diverged in exactly two ways; both are now explicit args on the shared helper,
preserving each path BYTE-FOR-BYTE:

  - parentChainDefault: "type-derived" (main) seeds an unknown parent-chain
    slot with belongsToRoute || isNewParent; "force-render" (orphan) seeds
    unconditional true -- orphan parallels always belong to the route (the #482
    blank-parent-chain-slot guard). Divergence (A): INTENTIONAL, carried, not
    normalized.
  - loaderOrder: "after" (main) emits a slot's loaders after the slot segment;
    "before" (orphan) emits them first. Divergence (B): cosmetic emission order
    (the client reconciler is order-insensitive), carried exactly.

The orphan loop becomes a single call; the orphan layout's own loader/inherited/
handler resolution is untouched.

Coverage-first: the existing parallel-revalidate-fns suite never exercised the
divergent case (all orphan tests used belongsToRoute=true, where the policies
coincide). Added pinning tests, written + verified green BEFORE the refactor:
  - (A) belongsToRoute=false, slot not in client, parent in client, zero
    revalidate fns -> main SKIPS the slot (component null), orphan FORCE-RENDERS
    it. Non-vacuous: normalizing the orphan policy turns the orphan test red.
  - (B) loaderOrder flips slot-vs-loader emission order; orphan chooses "before".

Behavior-preserving: semantic matrix 61/61 dev+prod (no row changes), parallel
e2e 65/65 dev+prod, adversarially verified byte-identical for both paths.
ivogt added a commit that referenced this pull request Jul 25, 2026
…an unrendered parallel slot (#814)

A parallel() slot carrying revalidate(() => false) never appeared when the route
owning it was reached by soft navigation, while a direct load of the same URL
rendered it fine. Reported against tests/vite-rsc-demo: land on
/shop/product/:slug, click through to /shop, and the category sidebar stays
blank until a full reload. Whether the slot showed up depended on the order the
user happened to navigate in.

The slot id is absent from the partial request's client-segment set because the
product page never rendered it. resolveParallelSegmentsWithRevalidation seeds
that case "new-segment" (render it), then passes the seed to
evaluateRevalidation, where the user's hard `false` overrode it — emitting
component:null for a slot the client had nothing cached to fall back on.

The three sibling resolvers (loaders, layout/route entries, orphan layouts)
never had this bug because they express the same invariant as an unconditional
early return that never consults user predicates. The parallel path cannot copy
that shape: #482 deliberately made it consult predicates for an unknown slot so
a "skip-parent-chain" seed could still be raised, and because evaluateRevalidation
re-throws a Response from a predicate as control flow, an early return would
silently drop revalidate(() => { throw redirect(...) }).

So the seed is floored rather than short-circuited. defaultOverride gains an
optional `floor`; evaluateRevalidation applies it at all three exits through a
new local decide(), so predicates still run and may only raise the decision.
Applying it inside evaluateRevalidation rather than clamping the returned
boolean at the call site keeps the revalidation trace to one entry per segment —
an outer clamp pushed a second, contradicting entry, and flushRevalidationTrace
buckets by finalShouldRevalidate with no dedup by segmentId, so the slot was
counted as both revalidated and skipped and `total` was inflated.

revalidate() now means "render once, then never refetch" rather than "sometimes
never appear at all". Once the slot is on the client the seed is unfloored and a
`false` is honored exactly as before, as are a `true` and a thrown Response in
every case.

Regression tests proven red before green: parallel-revalidate-fns-invocation.test.ts
gains six tests covering the floored seeds (belongsToRoute, isNewParent, orphan
force-render, and soft {defaultShouldRevalidate: false}, which exits by a
different path than a hard false) plus the two cases that must NOT floor —
client already has the slot, and the skip-parent-chain seed — without which
"floor to true" could widen into "parallel slots always render".
revalidation-trace-pipeline.test.ts gains two tests pinning one trace entry per
decision, verified to fail against the double-push shape. Dev and production e2e
in three apps: the router test-app and cloudflare-basic (workerd) via a reduced
fixture, and vite-rsc-demo driving the reported /shop routes with no new fixture;
each has a direct-load control so a broken fixture is distinguishable from a
broken soft nav.

The client-knowledge seed invariant, previously implicit in five differently
shaped code blocks with no name, is now stated in docs/internal/execution-model.md
with a table of which resolver uses which shape and why the parallel path
diverges. skills/parallel/SKILL.md covers the consumer half.

Not done: a general floor adopted by the sibling resolvers. Each would have to
start calling evaluateRevalidation unconditionally, invoking user predicates on
paths that never have — reversing #482's deliberate sibling audit. No userland
test through @rangojs/router/testing either: dispatch is RSC-free by design and
rejects partial requests to component routes, and renderRoute is client-only, so
no primitive can reach a partial component-route render.

Also carries an unrelated test fix. default-prefetch-none's intersection test
asserted one /assets/runtime-*.js fetch, but #809 folded the prefetch runtime
into the eager router chunk precisely so that fetch would stop happening, making
the production assertion unsatisfiable. #809 updated build-test-app.setup.ts and
the manual-chunks unit tests but not this assertion, and its merge-time run
skipped cloudflare-basic-e2e via path filtering, so it landed unnoticed; main's
scheduled runs had been red on it since 2026-07-24. The count is mode-dependent —
dev still serves browser/prefetch/runtime.ts as its own unbundled module — so it
is flipped to a mode-aware check rather than deleted, turning a stale expectation
into a guard for the invariant #809 established.
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.

1 participant