Skip to content

test: add e2e reproduction for #1358#1359

Open
franky47 wants to merge 16 commits intonextfrom
test/repro-1358
Open

test: add e2e reproduction for #1358#1359
franky47 wants to merge 16 commits intonextfrom
test/repro-1358

Conversation

@franky47
Copy link
Copy Markdown
Member

@franky47 franky47 commented Mar 12, 2026

Summary

Fix cross-route state bleeding with setState during render for React Router & TanStack Router.

  • Refactor to a cleaner implementation
  • Split TanStack Router in separate PR?
  • Test against the warning in dev, not just the incorrect state update

Closes #1358.

Cross-route state bleeding when navigating between routes that both use
useQueryState with different defaults and setState during render.
@vercel
Copy link
Copy Markdown

vercel bot commented Mar 12, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
nuqs Ready Ready Preview, Comment Mar 24, 2026 4:11pm

Request Review

Bot added 2 commits March 12, 2026 19:40
The setState-during-render pattern crashes the React Router v7
production server, taking down 87 other tests. Using useEffect
still reproduces the core state bleeding issue.
Wire up the cross-route state bleeding reproduction into Next.js
(app + pages), Remix, React Router v6, and TanStack Router.
The point of the reproduction is to crash — the fix needs to be in
nuqs core, not in user code.
@franky47
Copy link
Copy Markdown
Member Author

franky47 commented Mar 12, 2026

Analysis of #1358 root cause

The bug flow

When Route A (default AAA) navigates to Route B (default BBB), here's what happens:

  1. Route A renders, useQueryState('mode') returns nullif (!mode) { setMode('default') } fires
  2. The update function in useQueryStates.ts:306 does: emitter.emit(urlKey, { state: value, query }) — this is the cross-hook sync emitter (global singleton in lib/sync.ts)
  3. Then it pushes to globalThrottleQueue and calls .flush(), which schedules the actual URL update via setTimeout(0) (next tick)
  4. User clicks link → React Router navigation starts
  5. The queued flush firesapplyPendingUpdates() in throttle.ts:178 calls getSearchParamsSnapshot() which reads new URLSearchParams(location.search), applies the pending mode=default, and calls adapter.updateUrl() which does history.replaceState() with mode=default in the URL
  6. Route B mounts — its useOptimisticSearchParams() initializes from location.search (line 116 in react-router.ts), which now includes mode=default from Route A's flush
  7. Route B's useQueryState('filter', parseAsString.withDefault('BBB')) — the filter key is not in the URL, but mode=default is, showing state from Route A leaked into Route B's URL

Root cause: autoResetQueueOnUpdate: false

The React Router adapter (adapters/lib/react-router.ts:94) sets autoResetQueueOnUpdate: false. This means the globalThrottleQueue is never cleared between route navigations for the React Router adapter.

Compare with the Next.js App Router adapter (adapters/next/impl.app.ts:103) which sets autoResetQueueOnUpdate: true — this was the fix for the identical issue #1156 ("On navigation, Next.js renders the target page with the source URL state"), landed in PR #1320.

From the PR #1320 description:

"the issue seemed to be that the queued queries were not cleared after a nuqs state update, until after the navigation transition had completed (which caused this pre-rendering of the destination page with the old queued queries, taking precedence over the correct values returned by useSearchParams)"

Two additional contributing factors

1. location.search vs React Router state — The adapter reads window.location.search directly (lines 106, 116, 125) rather than using React Router's internal location state. React Router may have already transitioned its internal state to Route B's URL, but window.location.search still reflects Route A's pending flush, creating a window where old state bleeds into new components.

2. Global singleton queues — Both globalThrottleQueue (throttle.ts:221) and debounceController (debounce.ts:172) are module-level singletons. There's no scoping by route or component lifecycle. When Route A queues an update and navigation happens before the flush, the pending update persists and can execute in Route B's context.

Related prior art

Issue Framework Root cause Fix
#1156 Next.js App Router Queued queries not cleared after navigation Set autoResetQueueOnUpdate: true (PR #1320)
#947 React Router / Remix Search params updated before navigation completed Wrap state updates in startTransition() (PR #1316)
#1293 React Router Cross-page state bleeding on back/forward Same startTransition fix
#524 Next.js Pages Both source and target pages re-rendered during navigation Pages router keeps both mounted during transition
#1245 React Router v6 <Navigate /> updates search params during first render, never syncs Open — same pattern as #1358
#1099 Next.js / React Router State temporarily flashes old value Queue reset timing fix

Suggested investigation path

The most direct avenue is whether setting autoResetQueueOnUpdate: true in the React Router adapter (like was done for Next.js App Router in PR #1320) resolves #1358 without breaking the existing flush-after-navigate tests. The flush-after-navigate tests specifically exercise debounce/throttle queue behavior across navigations, so they'd be the canary for regressions.

The cross-hook sync emitter firing synchronously during render (line 306) is the deeper issue causing the React warning, but addressing the queue persistence would at least prevent the state bleeding.

Add typeof window check to prevent the state updater from running
on the server where location is not available.
- Also set filter value during render (not just mode), matching the
  original CodeSandbox reproduction where both setMode and setValue
  are called in the render body
- Use .first() on locators for Next.js cache-components compatibility
  where Activity keeps both pages in the DOM
- Wait for URL stability (mode param in URL) before asserting, since
  the render-time effect updates the URL after DOM mount
- Use expectSearch and toHaveURL predicates to wait for the effect
Same fix as PR #1320 applied for the Next.js app router adapter.
Clears the globalThrottleQueue after URL updates so that pending
state from the previous route doesn't leak into the next route.
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Mar 12, 2026

pnpm add https://pkg.pr.new/nuqs@1359

commit: fbb514f

Setting it to true broke 8 additional tests (flush-after-navigate
and others) while not fixing the back-navigation state leak.
The fix needs a different approach.
@franky47 franky47 added this to the 🪵 Backlog milestone Mar 13, 2026
@franky47 franky47 added the adapters/react-router Uses the React Router adapter label Mar 13, 2026
@franky47 franky47 added the adapters/tanstack-router Uses the TanStack Router adapter label Mar 17, 2026
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds an end-to-end reproduction for nuqs issue #1358 (cross-route query state bleeding when setState occurs during render), and introduces queue/adapter changes intended to prevent stale queued updates from leaking across route transitions (notably on popstate/back navigation).

Changes:

  • Add shared Playwright e2e spec + per-framework route wiring for repro #1358 across React Router v6/v7, Remix, TanStack Router, and Next.js.
  • Introduce a frozen mode on the global throttle queue and new “silent” queue reset behavior to avoid render-triggered cross-route sync during popstate.
  • Update React Router and TanStack Router adapters to freeze/reset queues on route transitions to mitigate state bleeding.

Reviewed changes

Copilot reviewed 27 out of 27 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
packages/nuqs/src/lib/queues/throttle.ts Adds frozen flag to drop queue pushes during cross-route navigation windows.
packages/nuqs/src/lib/queues/reset.ts Adjusts queue reset behavior and adds silentResetQueues() to avoid queuedQuerySync emissions.
packages/nuqs/src/lib/queues/debounce.ts Removes queuedQuerySync emission from abortAll() (now handled by resetQueues()).
packages/nuqs/src/adapters/tanstack-router.ts Adds pathname-change handling that freezes/resets queues and strips carried-over search params.
packages/nuqs/src/adapters/lib/react-router.ts Adds popstate-driven pathname-change handling to freeze/reset queue on back/forward nav.
packages/nuqs/src/adapters/lib/patch-history.ts Tracks popstate via a module flag; switches popstate handler to silentResetQueues().
packages/e2e/tanstack-router/src/routes/repro-1358.a.tsx Adds TanStack Router route A for repro.
packages/e2e/tanstack-router/src/routes/repro-1358.b.tsx Adds TanStack Router route B for repro.
packages/e2e/tanstack-router/specs/shared/repro-1358.spec.ts Wires shared repro spec into TanStack Router e2e.
packages/e2e/shared/specs/repro-1358.tsx Adds shared repro components for Route A/Route B using useQueryState.
packages/e2e/shared/specs/repro-1358.spec.ts Adds shared Playwright tests for derived values + leak checks (A→B and back).
packages/e2e/remix/app/routes/repro-1358.a.tsx Adds Remix route A for repro.
packages/e2e/remix/app/routes/repro-1358.b.tsx Adds Remix route B for repro.
packages/e2e/remix/specs/shared/repro-1358.spec.ts Wires shared repro spec into Remix e2e.
packages/e2e/react-router/v7/app/routes/repro-1358.a.tsx Adds React Router v7 route A for repro.
packages/e2e/react-router/v7/app/routes/repro-1358.b.tsx Adds React Router v7 route B for repro.
packages/e2e/react-router/v7/app/routes.ts Registers v7 repro routes in route config.
packages/e2e/react-router/v7/specs/shared/repro-1358.spec.ts Wires shared repro spec into React Router v7 e2e.
packages/e2e/react-router/v6/src/routes/repro-1358.a.tsx Adds React Router v6 route A for repro.
packages/e2e/react-router/v6/src/routes/repro-1358.b.tsx Adds React Router v6 route B for repro.
packages/e2e/react-router/v6/src/react-router.tsx Registers v6 repro routes in router config.
packages/e2e/react-router/v6/specs/shared/repro-1358.spec.ts Wires shared repro spec into React Router v6 e2e.
packages/e2e/next/src/app/app/repro-1358/a/page.tsx Adds Next.js app router route A for repro.
packages/e2e/next/src/app/app/repro-1358/b/page.tsx Adds Next.js app router route B for repro.
packages/e2e/next/src/pages/pages/repro-1358/a.tsx Adds Next.js pages router route A for repro.
packages/e2e/next/src/pages/pages/repro-1358/b.tsx Adds Next.js pages router route B for repro.
packages/e2e/next/specs/shared/repro-1358.spec.ts Wires shared repro spec into Next.js (app + pages) e2e.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

You can also share your feedback on Copilot code review. Take the survey.

Comment thread packages/nuqs/src/adapters/lib/react-router.ts Outdated
Comment thread packages/nuqs/src/adapters/tanstack-router.ts
Comment thread packages/nuqs/src/adapters/lib/patch-history.ts Outdated
Comment thread packages/nuqs/src/lib/queues/throttle.ts
@franky47 franky47 marked this pull request as ready for review March 17, 2026 14:54
React's concurrent rendering can reset transition state in the outgoing
route during popstate-driven navigation, causing render-time setState
calls to fire with stale values and push them into the shared queue on
the wrong pathname.

The previous approach used a `frozen` flag on the ThrottledQueue to
block these pushes, but that leaked queue internals into the adapter
layer.

This replaces `frozen` with three scoped guards: the outgoing route
can't consume the incoming route's pending popstate data
(pathname-scoped lookup), stale queue flushes are dropped
(mountedPathname check in updateUrl), and a deferred reset catches any
queue repopulation between the synchronous reset and the async flush
(popstate-only microtask to avoid breaking v6's synchronous forward
navigation).
The TSR adapter needed frozen back on the ThrottleQueue because TanStack
Router's <Link> carries search params in the router state (not just the
URL), and the isFreshMount → unfreeze → refreeze → setTimeout pattern is
the only reliable way to handle the timing mismatch between
useRouterState.search and location.search. The react-router adapters
don't need frozen since they use  patchHistory + mountedPathnameRef +
microtask instead.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

adapters/react-router Uses the React Router adapter adapters/tanstack-router Uses the TanStack Router adapter

Projects

None yet

Development

Successfully merging this pull request may close these issues.

update another component while rendering a different component & unexpected query state

2 participants