fix: SSR loader cache isolation and form-submission action race#220
Merged
Conversation
Without a navigation entry, loader results were cached under the literal "ssr" key in the module-level cache, so a long-running SSR server (or a multi-page SSG build) served the first render's loader data to every subsequent render and never re-executed loaders. Loader results for renders without a location entry are now stored in a per-Router-instance cache that is garbage-collected with the instance, while still memoizing results within a single render lifecycle. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012B1oYTQh1Q7hhBgirpGiek
…plete For intercepted form submissions, the entry commits (and currententrychange fires) before the intercept handler runs the action. The Router's transition render would execute loaders eagerly with actionResult: undefined, and because the handler's post-action loader re-execution produced no new snapshot reference, the revalidated data was never rendered — the UI kept the pre-action loader results, and loader side effects ran twice. The adapter now creates a pending deferred for intercepted form submissions and attaches it to the entry-change notification; the Router waits on it inside an async transition (keeping the old UI and isPending up) before reading the snapshot and rendering. Loaders therefore execute exactly once, with the action result, and the committed render always shows post-action data. The deferred is released on action errors and superseding navigations so renders never hang. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012B1oYTQh1Q7hhBgirpGiek
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012B1oYTQh1Q7hhBgirpGiek
uhyo
force-pushed
the
claude/library-audit-sxpugz
branch
from
July 11, 2026 05:07
f71056d to
52a58e8
Compare
…waiting Review feedback on #220: replace the manual deferred with Promise.withResolvers (lib bumped with ES2024.Promise), and read the committed snapshot before the async transition so addTransitionType is called synchronously — it is not supported after an await. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012B1oYTQh1Q7hhBgirpGiek
uhyo
commented
Jul 11, 2026
| // currententrychange fires. | ||
| if (isFormSubmission) { | ||
| let resolve!: () => void; | ||
| const promise = new Promise<void>((r) => { |
Owner
Author
There was a problem hiding this comment.
Use Promise.withResolvers here.
| await wait; | ||
| const newSnapshot = adapter.getSnapshot(); | ||
| if (newSnapshot) { | ||
| // Note: addTransitionType after an await requires async |
Owner
Author
There was a problem hiding this comment.
This comment is correct, but there is no known plan of supporting transition type addition. So I'd rather write code that works currently.
uhyo
commented
Jul 11, 2026
| await wait; | ||
| // Re-read the snapshot: a superseding navigation may have | ||
| // resolved the wait, in which case this renders the newest entry. | ||
| setLocationEntry(adapter.getSnapshot()); |
Owner
Author
There was a problem hiding this comment.
This setLocationEntry call is after the await, so won't be included in the transition. Please include it. This can be achieved by a nested startTransition call.
Review feedback on #220: state updates after the first await in an async transition are not part of the transition unless wrapped in a nested startTransition call. Without it, the post-action update was synchronous, showing Suspense fallbacks instead of keeping the old UI up. Adds a regression test using an async loader with use() behind a Suspense boundary, which fails without the nested call. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012B1oYTQh1Q7hhBgirpGiek
uhyo
commented
Jul 11, 2026
| "module": "nodenext", | ||
| "moduleResolution": "nodenext", | ||
| "lib": ["ES2022", "DOM", "DOM.Iterable"], | ||
| "lib": ["ES2022", "ES2024.Promise", "DOM", "DOM.Iterable"], |
Owner
Author
There was a problem hiding this comment.
Let's simply bump ES2022. EESNext is fine.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012B1oYTQh1Q7hhBgirpGiek
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.
Fixes the two high-severity bugs found in the library audit (the remaining findings were filed as issues #204–#219).
1. SSR/SSG loader cache leaked data across renders
Without a navigation entry, loader results were cached under the literal
"ssr"key in the module-level cache, so a long-running SSR server (or a multi-page SSG build) served the first render's loader data to every subsequent render — rendering/athen/bin one process showed/a's data on the/bpage, and/b's loader never ran.Fix:
executeLoadersgained an optionalcacheparameter. When the Router has no real location entry (SSR, or Navigation API unavailable), it passes a per-instance cache (created withuseState, garbage-collected with the instance) instead of the shared module-level map. Each SSR request/SSG page now executes loaders fresh, while results remain memoized within a single render lifecycle. Client-side behavior with real entry keys is unchanged.2. Form submissions rendered pre-action loader data
For intercepted form submissions, the entry commits (and
currententrychangefires) before the intercept handler runs the action. The Router's transition render executed loaders eagerly withactionResult: undefined, and because the handler's post-action re-execution produced no new snapshot reference, the revalidated data was never rendered: the UI kept the pre-action results, and loader side effects ran twice. (Only visible with actions slower than a microtask, which is why existing adapter-level tests didn't catch it.)Fix: the adapter creates a pending deferred when it intercepts a form submission and attaches it to the entry-change notification as
wait; the Router waits on it inside an async transition (old UI andisPendingstay up) before reading the snapshot and rendering. Loaders execute exactly once, with the action result, and the committed render always shows post-action data. The deferred is released on action errors and superseding navigations so a waiting render can never hang.Testing
ssrLoaderCache.test.tsx(cross-instance isolation, per-request re-execution, within-instance memoization) andactionRevalidation.test.tsx(slow action, immediate action, action error — driven through<Router>with the browser's real event ordering: navigate → commit → handler → navigatesuccess).funstack-router-docsSSG build fails in my container on every commit includingmaster(Node 22 lacks globalURLPattern) — unrelated to this change.Also includes a small docs addition to the Form Actions page describing the (now-guaranteed) pending behavior during submissions.
🤖 Generated with Claude Code
https://claude.ai/code/session_012B1oYTQh1Q7hhBgirpGiek