`useQueryStates` subscriber permanently stuck on a stale value after overlapping `shallow: false` updates when server responses are slow (still on 2.9.1)
- Vorherrschende Sprache
- TypeScript
- Sterne
- 10.8k
- Forks
- 294
- Ø Merge
- 1 T. 16 Std.
- Gemergte PRs (30 T.)
- 21
Beschreibung
## 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 #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).
Beitragsleitfaden
Bewertung
Dieses Issue wurde noch nicht bewertet.