Repository navigation
fix(router): settled actions no longer count as concurrent — the consolidation refetch bypassed revalidate gates - #707
Merged
Conversation
…olidation refetch bypassed revalidate gates (closes #675) startAction counted a completed action still inside its 100ms doSettle window (or draining its Flight stream EOF) as concurrent, latching hadAnyConcurrentActions for back-to-back sequential actions. The last action then classified as consolidation-needed, and the consolidation refetch omits every concurrently-revalidated segment id from _rsc_segments — so the server re-ran gated loaders as "new-segment", bypassing revalidate(({ isAction }) => ...) on a plain GET. Root-caused from the Playwright trace of CI run 28716490345: a phantom GET /is-action?_rsc_partial=true&_rsc_segments=<layouts only> fired 8ms after the decoy action and re-ran both probe loaders, producing the flaky is-action (production) failures. Concurrency now requires a genuinely in-flight entry (!completed); overlapping actions still latch and consolidate. New unit tests pin the settle-window and stream-drain cases (proven red without the fix) and that fetching overlap stays concurrent. is-action e2e dev+production passes at --repeat-each=5; the concurrent location-state suite passes in both modes.
commit: |
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.
Closes #675.
Problem
A loader gated with
revalidate(({ isAction }) => isAction(targetAction))re-ran on actions that did not match the gate. Whenever a consumer fired two server actions back-to-back — the second starting within ~100ms of the first one settling — the router issued a partial-update GET that re-ran every loader on the route, gates ignored. Consumer-observable: spurious loader executions (extra DB queries, bumped counters, invalidated derived state) after a non-matching action, on a plain GET with no action context at all. In CI this surfaced as the flakyis-action (production)e2e (#675): all in-run retries failed with the target-gated counter one higher than expected.Root cause, proven from the Playwright trace of failed run 28716490345: 8ms after the decoy action POST, the client issued
— a consolidation refetch whose
_rsc_segmentsomits the route and both loader segment ids. Server-side, a loader id missing from_rsc_segmentsis treated asnew-segmentand runs unconditionally (src/router/segment-resolution/revalidation.ts:150), bypassing the revalidate gate. The decoy action's own response was correct (itsdiffexcluded the target loader); the phantom GET did the damage.The chain:
doSettle()keeps a completed action ininflightActionsfor 100ms (theuseActionread window).startAction()computedhadConcurrent = inflightActions.size > 0, so a second action starting inside that window latchedhadAnyConcurrentActions— for two strictly sequential actions. Since every action records itsdiffinto the shared revalidated-segments set,classifyActionOutcomethen returnedconsolidation-neededfor the second action and the bridge refetched with the revalidated ids stripped.Before vs. after
Consumers write nothing differently — the fix restores the documented gate contract.
Before: the second action classified as
consolidation-needed; the consolidation GET re-ranExpensiveReportLoader(and every other loader on the route) with no action context.After: the second action classifies as
normal; the gate decides, the loader holds.End-to-end example
A user adds two notes quickly. Before this fix the second
addNotetriggered a consolidation refetch that re-ran the 30sReportLoader. After it,ReportLoaderruns only whenrefreshReportfires.Semantics
complete(), A's Flight stream still draining EOFdoSettlewindow (A completed)Code change
One predicate in
packages/rangojs-router/src/browser/event-controller.ts(startAction):completedis set bycomplete()/fail()— the action's response was fully processed and applied, so a request dispatched after that point is strictly ordered behind it server-side; there is no skipped render or order uncertainty for consolidation to repair.completed(notphase !== "settling") also covers the completed-but-stream-EOF-still-draining window. Fixing the trigger here beats alternatives: gating the consolidation refetch would break repair duty for genuine overlap (see the keep row indocs/design/rango-state-cookie.md), and the server cannot apply revalidate gates to a segment id it was never sent.Tests:
packages/rangojs-router/src/__tests__/event-controller.test.ts(+3 in the "concurrent actions" describe).Test plan
action starting during a completed action's settle window is not concurrent (#675)action starting after complete() but before stream end is not concurrent (#675)Tests 2 failed | 71 skipped (73)(expect(second.hadConcurrentActions).toBe(false)→ receivedtrue); green with fix:73 passed (73)action starting while another is still fetching stays concurrentpinning the preserved overlap semanticspnpm run test:unit:all(all packages, unit + RSC)is-actiondev + production,--repeat-each=5: 10/10 passed (the flaky test from flaky e2e: is-action (production) 'target action re-runs the loader; decoy action does not' — 2x in one day on unrelated PRs #675, both modes)location-stateconcurrent-action subset (concurrent|consolidation|action-ls) dev + production: 12/12 passed — genuine-overlap consolidation intactpnpm run typecheck,pnpm run lint,pnpm run formatNotes
new-segment— deliberate: after interleaved actions the client needs the definitive server state, and the gate's action context no longer exists. This fix narrows when consolidation triggers, not what it does.concurrentRevalidatedSegmentsset still accumulates across tight sequential chains (it only clears wheninflightActionsempties). With the trigger fixed this is unreachable for pure sequential flows; after a real overlap it can only over-refetch. Left as is; deferred follow-up if it ever matters.*.gen.tsuntouched), no export/file changes, so no internal-reference-doc updates are required.