Skip to content

fix(router): settled actions no longer count as concurrent — the consolidation refetch bypassed revalidate gates - #707

Merged
ivogt merged 1 commit into
mainfrom
fix/action-settle-window-consolidation
Jul 6, 2026
Merged

ivogt merged 1 commit into
mainfrom
fix/action-settle-window-consolidation

Conversation

@ivogt

@ivogt ivogt commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

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 flaky is-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

GET /is-action?_rsc_partial=true&_rsc_segments=M0L0,M0L0L0,M0L0L0.@sidebar,M0L0L0L0

— a consolidation refetch whose _rsc_segments omits the route and both loader segment ids. Server-side, a loader id missing from _rsc_segments is treated as new-segment and runs unconditionally (src/router/segment-resolution/revalidation.ts:150), bypassing the revalidate gate. The decoy action's own response was correct (its diff excluded the target loader); the phantom GET did the damage.

The chain: doSettle() keeps a completed action in inflightActions for 100ms (the useAction read window). startAction() computed hadConcurrent = inflightActions.size > 0, so a second action starting inside that window latched hadAnyConcurrentActions — for two strictly sequential actions. Since every action records its diff into the shared revalidated-segments set, classifyActionOutcome then returned consolidation-needed for 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.

loader(ExpensiveReportLoader, () => [
  revalidate(({ isAction }) => isAction(refreshReport)),
]),
// User clicks two buttons in quick succession (<100ms apart, sequential):
await addNote();      // matches nothing → report loader must hold
await toggleFilter(); // matches nothing → report loader must hold

Before: the second action classified as consolidation-needed; the consolidation GET re-ran ExpensiveReportLoader (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

// actions.ts
"use server";
export async function refreshReport(): Promise<void> { /* invalidate source */ }
export async function addNote(text: string): Promise<void> { /* insert */ }

// urls/dashboard.tsx
export const dashboard = urls(({ path, loader, revalidate }) => [
  path("/", () => <Dashboard />, { name: "dashboard" }, () => [
    // 30s analytics query — must re-run ONLY when refreshReport fires
    loader(ReportLoader, () => [
      revalidate(({ isAction }) => isAction(refreshReport)),
    ]),
    loader(NotesLoader), // default: re-runs on any action
  ]),
]);

A user adds two notes quickly. Before this fix the second addNote triggered a consolidation refetch that re-ran the 30s ReportLoader. After it, ReportLoader runs only when refreshReport fires.

Semantics

Scenario Before After
Action B starts while A is fetching/streaming (genuine overlap) concurrent → consolidation after last unchanged
Action B starts after A's complete(), A's Flight stream still draining EOF concurrent → consolidation, gates bypassed normal — B is strictly ordered behind A
Action B starts inside A's 100ms doSettle window (A completed) concurrent → consolidation, gates bypassed normal
Action B starts after A fully settled (map empty) normal unchanged
Consolidation refetch after genuine overlap re-runs gated loaders yes (by design — definitive state) unchanged (see Notes)

Code change

One predicate in packages/rangojs-router/src/browser/event-controller.ts (startAction):

- const hadConcurrent = inflightActions.size > 0;
+ const hadConcurrent = [...inflightActions.values()].some(
+   (a) => !a.completed,
+ );

completed is set by complete()/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 (not phase !== "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 in docs/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

  • New unit tests, proven red before the fix:
    • 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)
    • red run: Tests 2 failed | 71 skipped (73) (expect(second.hadConcurrentActions).toBe(false) → received true); green with fix: 73 passed (73)
    • plus action starting while another is still fetching stays concurrent pinning the preserved overlap semantics
  • Router unit suite: 326 files, 5055 passed
  • pnpm run test:unit:all (all packages, unit + RSC)
  • e2e is-action dev + 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)
  • e2e location-state concurrent-action subset (concurrent|consolidation|action-ls) dev + production: 12/12 passed — genuine-overlap consolidation intact
  • pnpm run typecheck, pnpm run lint, pnpm run format

Notes

  • The consolidation refetch for genuinely overlapping actions still re-runs gated loaders as 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.
  • The shared concurrentRevalidatedSegments set still accumulates across tight sequential chains (it only clears when inflightActions empties). 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.
  • No route changes (*.gen.ts untouched), no export/file changes, so no internal-reference-doc updates are required.

…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.
@pkg-pr-new

pkg-pr-new Bot commented Jul 6, 2026

Copy link
Copy Markdown

Open in StackBlitz

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

commit: 174f272

@ivogt
ivogt merged commit caa43c7 into main Jul 6, 2026
9 checks passed
@ivogt
ivogt deleted the fix/action-settle-window-consolidation branch July 6, 2026 17:56
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.

flaky e2e: is-action (production) 'target action re-runs the loader; decoy action does not' — 2x in one day on unrelated PRs

1 participant