Repository navigation
fix(router): invoke parallel slot revalidate() fns when slot missing from clientSegmentIds - #482
Merged
Merged
Conversation
…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.
commit: |
Merged
6 tasks done
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).
This was referenced Jun 13, 2026
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.
7 tasks done
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
When a parallel slot's id wasn't present in the request's
_rsc_segmentsset (the client's known-segments list), the resolver short-circuited with a
static decision and never invoked the user-defined
revalidate()functionsattached to that slot. For shared / parent-chain parallels (e.g. an
@panelthat lives above the route segment) the static default isfalse,so the slot rendered as
component: null. The slot also failed to push intomatchedIds, so the response listed@panelindiffbut not inmatched— 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
matchedset that doesn't include a parent-chain parallel, the clientprunes it, and the next navigation arrives with the slot missing from
_rsc_segments. From then on, the user'srevalidate()was dead code.Before vs. after
revalidate()callbacks attached to a parallel slot now have the samecontract 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
revalidate()fns?clientSegmentIdsbelongsToRoutetrue(new-segment)false(skip-parent-chain)clientSegmentIds.size === 0(full refetch)true(load-bearing for first render)falsereturned by user fn (any non-full-refetch case)Full-refetch is the one case where user fns are deliberately bypassed: the
client has nothing cached, so a
falsereturn would leave the slot blankwith no fallback. This is unchanged from prior behavior and pinned by a new
test.
The same fix is applied to
resolveOrphanLayoutWithRevalidation's parallelbranch.
Sibling audit
I checked the other "skip user fns when segment not in clientSegmentIds"
branches:
resolveLoadersWithRevalidation(loader new on client)trueuseLoader()would return undefined if a user soft-false'dresolveEntryHandlerWithRevalidation(layout/route new on client)trueresolveOrphanLayoutWithRevalidation(orphan layout entry new on client)trueresolveParallelSegmentsWithRevalidation(parallel slot, this PR)falseresolveOrphanLayoutWithRevalidation(orphan parallel slot, this PR)trueLoader/layout/route new-segment paths default to
trueso the segmentrenders. Their user-fn bypass is load-bearing for first-render correctness
(returning
falsewould leave the segment without data) and is leftuntouched.
What changed
packages/rangojs-router/src/router/revalidation.tsAdded
defaultOverride?: { value: boolean; reason: string }toEvaluateRevalidationOptions. When set, evaluateRevalidation uses it as theseed
defaultShouldRevalidate(and tracereason) instead of deriving fromsegment type / params / method. User fns still run and the soft chain
behaves identically.
packages/rangojs-router/src/router/segment-resolution/revalidation.tsresolveParallelSegmentsWithRevalidationand the orphan-layout parallelbranch now always go through
evaluateRevalidation(instead of returningearly with a static decision), passing
defaultOverridederived fromclient-knowledge so the seed is correct:
matchedIds.push(parallelId)is now unconditional (was guarded by the same4-condition check that excluded parent-chain parallels). The slot is
unconditionally appended to
segments, so excluding it frommatchedIdscaused the diff/matched mismatch the client used as a prune signal.
Also dropped an unused
loaderPromises: Map<string, Promise<any>>parameteron
resolveOrphanLayoutWithRevalidation(TS6133, surfaced by the recompile)and updated the two internal call sites and three test files that passed it.
Test plan
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-falseshort-circuit when slot missing; orphan path equivalents. 6 of 9 cases failed against the pre-fix code.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 --noEmitclean.pnpm -w run lint— 0 warnings / 0 errors on 1191 files.pnpm -w run formatclean on all changed files.Notes
matchedIds.pushchange is a behavior change for the wire format:parent-chain parallels under a known parent now appear in the response's
matchedarray even when theirrevalidate()returned soft-false. Thismatches the existing invariant that everything pushed to
segmentsisalso pushed to
matched. If any client logic was relying on the previousdiff/matched skew to mean "ignore this slot," that would be a latent bug
surfaced by this fix.
evaluateRevalidation's newdefaultOverrideis a single optionalobject, 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).changed — their user-fn bypass is load-bearing (a user
revalidate(() => false)on a brand-new loader would yielduseLoader()returningundefined). That asymmetry vs. parallels is intentional and called out in
the test file's audit comment.