Skip to content

fix: SSR loader cache isolation and form-submission action race#220

Merged
uhyo merged 6 commits into
masterfrom
claude/library-audit-sxpugz
Jul 11, 2026
Merged

fix: SSR loader cache isolation and form-submission action race#220
uhyo merged 6 commits into
masterfrom
claude/library-audit-sxpugz

Conversation

@uhyo

@uhyo uhyo commented Jul 11, 2026

Copy link
Copy Markdown
Owner

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 /a then /b in one process showed /a's data on the /b page, and /b's loader never ran.

Fix: executeLoaders gained an optional cache parameter. When the Router has no real location entry (SSR, or Navigation API unavailable), it passes a per-instance cache (created with useState, 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 currententrychange fires) before the intercept handler runs the action. The Router's transition render executed loaders eagerly with actionResult: 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 and isPending stay 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

  • 6 new regression tests: ssrLoaderCache.test.tsx (cross-instance isolation, per-request re-execution, within-instance memoization) and actionRevalidation.test.tsx (slow action, immediate action, action error — driven through <Router> with the browser's real event ordering: navigate → commit → handler → navigatesuccess).
  • Full suite: 281 tests + type tests pass; lint, format, and the router package build are clean.
  • Note: funstack-router-docs SSG build fails in my container on every commit including master (Node 22 lacks global URLPattern) — 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

claude added 3 commits July 11, 2026 05:07
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
@uhyo
uhyo force-pushed the claude/library-audit-sxpugz branch from f71056d to 52a58e8 Compare July 11, 2026 05:07
…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
// currententrychange fires.
if (isFormSubmission) {
let resolve!: () => void;
const promise = new Promise<void>((r) => {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use Promise.withResolvers here.

Comment thread packages/router/src/Router/index.tsx Outdated
await wait;
const newSnapshot = adapter.getSnapshot();
if (newSnapshot) {
// Note: addTransitionType after an await requires async

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment is correct, but there is no known plan of supporting transition type addition. So I'd rather write code that works currently.

Comment thread packages/router/src/Router/index.tsx Outdated
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());

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Comment thread packages/router/tsconfig.json Outdated
"module": "nodenext",
"moduleResolution": "nodenext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"lib": ["ES2022", "ES2024.Promise", "DOM", "DOM.Iterable"],

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
@uhyo
uhyo merged commit f37e261 into master Jul 11, 2026
1 check passed
@uhyo
uhyo deleted the claude/library-audit-sxpugz branch July 11, 2026 07:00
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.

2 participants