fix(router): reject stale same-url server action commits#1100
Conversation
Same-URL server action payloads could commit visible router state after newer visible work had already won. URL equality and activeNavigationId alone did not distinguish two different visible worlds at the same href. The lifecycle gate now compares a pending operation's started visibleCommitVersion against the current router state's visibleCommitVersion at approval time. Stale results keep returning their action value, but they no longer mutate visible state or trigger stale hard-navigation recovery. Tests cover out-of-order same-URL server actions and the visible-version stale path taking priority over root-boundary hard navigation.
commit: |
|
@codex review |
Server action POST handling captured the router state before fetch but the same-URL commit helper previously rebased the pending operation when the response was decoded. If a newer visible commit landed while the POST was in flight, the old action could still look current. Thread the initiation state through same-URL action commit handling so startedVisibleCommitVersion reflects the world that submitted the action. Approval still compares against the live router state after payload decoding, so stale results keep their action value without mutating visible state. Add a hostile race test where the action response is processed after a newer same-URL visible commit.
|
/bigbonk review |
There was a problem hiding this comment.
This is a clean, well-scoped implementation of the #726-CORE-04 stale-action rejection gate. The approach is correct: stamp startedVisibleCommitVersion at operation creation time, re-read the live visibleCommitVersion at approval time, and skip if they diverge. A few observations and one minor nit below — none blocking.
What works well:
- The visible-version check in
resolvePendingNavigationCommitDispositionis ordered before theshouldHardNavigatecheck, which is the right call. A stale action whose root layout happens to differ from the current one should be silently dropped, not trigger a hard reload. The "treats a visible commit version mismatch as stale before root-boundary decisions" test explicitly validates this. - The
getCurrentStateForApprovalcallback pattern inresolveAndClassifyNavigationCommitcleanly separates the "state at initiation" (used forcreatePendingNavigationCommitto stampstartedVisibleCommitVersion) from the "state at approval" (re-read afterawait). This mirrors whatrenderNavigationPayloadalready does at line 421 of the controller. - The return-value contract is correctly preserved: stale actions still resolve their
returnValueeven when the UI commit is rejected. Both tests validate this. - The
actionInitiationStateparameter threading fromapp-browser-entry.tsthrough to the controller is correct —currentStateis captured beforefetch()and before the RSC decodeawait, which is exactly when the action's "world view" should be frozen.
Test coverage is solid. The two new controller tests cover the two important orderings: (1) concurrent actions where the older payload resolves last (deferred promise pattern), and (2) sequential actions where the caller explicitly passes a stale actionInitiationState. The existing tests are updated to pass the new required fields without changing their semantics.
One minor nit inline.
| currentState: AppRouterState; | ||
| navigationSnapshot: ClientNavigationRenderSnapshot; | ||
| nextElements: Promise<AppElements>; | ||
| getCurrentStateForApproval?: () => AppRouterState; |
There was a problem hiding this comment.
Nit: The ? makes this optional, which means callers that don't pass it silently fall back to using the stale captured currentState for approval. That's fine for renderNavigationPayload (which calls approvePendingNavigationCommit directly and never goes through this function), but if a future caller forgets to pass it for an async path, the version gate becomes ineffective without any signal.
Consider making it required for async callers or at least adding a doc comment that explains the fallback semantics — something like "If omitted, approval uses the captured currentState, which is only safe for synchronous commit paths."
|
Review posted. Here's a summary of what I found: Approved — this is a well-scoped, correct implementation of the #726-CORE-04 stale-action rejection gate. Key observations:
One minor nit posted inline: the optional |
What this changes
Implements #726-CORE-04 from #726 by rejecting stale same-URL server-action visible commits. A server action that started from an older visible router state can still return its action value, but it can no longer overwrite newer visible state once another commit has advanced the lifecycle version.
Why
Issue #726 calls out that
activeNavigationIdis not enough for same-URL refresh and server-action races. Same URL does not mean same visible world. Before this change, two same-URL server actions resolving out of order could let the older RSC payload commit after the newer one had already updated the route.That violated the lifecycle spine invariant from #726: no stale operation commits visible state, and no older server-action result overwrites newer visible state.
Approach
startedVisibleCommitVersionwith the current router statevisibleCommitVersion.Non-goals: this does not model discarded revalidation, redirect lifecycle, cache reuse authority, or the broader NavigationPlanner work from issue #726.
Validation
vp test run tests/app-browser-entry.test.tsvp check packages/vinext/src/server/app-browser-navigation-controller.ts packages/vinext/src/server/app-browser-state.ts packages/vinext/src/server/app-browser-visible-commit.ts tests/app-browser-entry.test.tsvp run vinext#buildvp run vinext#buildcompleted with the existing virtual-module external warnings forvirtual:vinext-rsc-entry,virtual:vite-rsc/client-references, andprivate-next-instrumentation-client.Review note
Bonk: please read issue #726 to see the big picture before reviewing this PR. This is specifically the #726-CORE-04 slice, not the full lifecycle, planner, cache, or compatibility architecture.