Skip to content

useQueryStates subscriber permanently stuck on a stale value after overlapping shallow: false updates when server responses are slow (still on 2.9.1) #1498

Description

@LaserGods

Context

What's your version of nuqs?

"nuqs": "^2.9.1"

What framework are you using?

  • ✅ Next.js (app router)
  • ❌ Next.js (pages router)
  • ❌ React SPA (no router)
  • ❌ Remix
  • ❌ React Router

Which version of your framework are you using?

"next": "16.2.10",
"react": "19.2.7",
"react-dom": "19.2.7"

(cacheComponents: true is enabled, Turbopack in dev)

Description

When several useQueryStates hooks issue shallow: false updates in a short burst (an interactive "write storm"), and each resulting RSC round-trip is slow (roughly ≥500ms — a dev server, a cold serverless function, or a slow network), one multi-key useQueryStates subscriber can re-base onto a stale committed state and then return the stale value forever — while useSearchParams(), window.location.search, and sibling nuqs subscribers (including single-key ones reading the same param) all report the correct value. Only a hard reload (or a later unrelated navigation) recovers it.

v2.9.1 clearly improved this area (thank you!): a related manifestation I had pinned on 2.9.0 — a remove-then-re-add of an array item being swallowed because the writing component's own useQueryStates snapshot was stale at event time — is fixed, A/B-verified 2.9.0 vs 2.9.1 with the same scripted reproduction steps. And when server responses are fast, the divergence I still see always heals within milliseconds. But under slow responses, the sticky case remains on 2.9.1.

The table below is a render-by-render probe from the affected subscriber (a component reading an 18-parser useQueryStates map; city is parseAsArrayOf(parseAsString).withDefault([]) with shallow: false). Columns: what the nuqs hook returned vs useSearchParams().get("city") vs new URLSearchParams(location.search).get("city") at the same render:

t (ms) nuqs useSearchParams() location.search phase
16874–16895 0505320 — — optimistic, after setQuery
17736 0505320 → "" same burst — 0505320 history flush landed; nuqs flips back
18089–18093 "" → 0505320 0505320 0505320 RSC commit; briefly heals
18110 → ∞ "" stuck 0505320 0505320 after a later debounced write from another hook

The write burst that triggers it (all shallow: false, from three different components):

  1. setQuery({ city: [...], mapQ: null, page: 0 }) on an 18-parser map — default 50ms throttle
  2. (previously: a separate mapQ clear on limitUrlUpdates: debounce(400) — called synchronously with chore(deps-dev): Bump next from 9.3.6 to 9.4.0 #1 but flushing as its own update)
  3. ~1–2s later (after a map camera animation): a 4-key bounds write on limitUrlUpdates: debounce(500) from a third hook

With each RSC round-trip taking 1–3s, updates overlap in flight. The stuck state consistently sets in around update #3. Reducing the number of flushes helps the odds but does not remove the race.

Environment differential (same code, same interaction):

environment RSC latency result
next dev 1–3s stuck, 2/2 runs
next build + next start, same machine fast diverges, heals in ~12ms, 1/1
Vercel deployment (cached RSC responses) tens of ms never observed

So this hides completely in fast environments — which I suspect is why it survives: any repro needs artificially slow server responses.

Expected: after the transitions settle, every subscriber converges on the committed URL's values.

Actual: one subscriber can keep returning the pre-update value indefinitely while the URL and useSearchParams() are correct.

Workaround I shipped: affected read-only consumers derive their values from window.location.search (with useSearchParams subscribed as a commit-time re-render trigger), and parse via createLoader, keeping useQueryStates for writes only.

Reproduction

Our reproduction is a private app, so I can't link it — but the setup is small, and the load-bearing ingredients are clear; Working on a minimal shareable reproduction based on the recipe above — will update this issue when it's ready.:

  1. App-router page reading searchParams, with an artificial delay in the RSC render to simulate slow responses: await new Promise((r) => setTimeout(r, 800)) — this is the key ingredient; without it the bug heals invisibly.
  2. Three client components:
    • A: useQueryStates with many keys (I have 18; includes city: parseAsArrayOf(parseAsString).withDefault([])), { shallow: false }, rendering the value of city — the victim subscriber.
    • B: a button whose handler calls A's-shaped setQuery({ city: ["x"], page: 0 }) from its own hook instance, and ~1.5s later (in my app, this comes from a map camera animation's moveend event; a setTimeout reproduces it). triggers C.
    • C: useQueryStates over 4 numeric keys with { shallow: false, limitUrlUpdates: debounce(500) }, writing new values.
  3. Click B. Watch A: the value appears optimistically, flips back when the history flush/commit lands, may briefly heal, then sticks at the old value while the address bar and a useSearchParams() probe show the new one.

Timing-dependent, so not 100% per-click in a minimal setup — in my app, it's ~every run with ≥ 500ms RSC latency; repeating the interaction a few times should catch it.

Possibly related: #1469, #1470 (the v2.9.1 fixes — they resolved the event-time-swallow manifestation for me, but not this one).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions