47ng / 47ng/nuqs

react router: `history: 'push'` write with `shallow: false` does not advance the history index

Abierto
#1,563 2 comentarios 1 reacción 0 asignados Ver en GitHub
adapters/react-router bug feature/time-safety
Lenguaje dominante
TypeScript
Estrellas
10.8k
Forks
294
Merge medio
1 d 16 h
PR fusionados (30 d)
21

Descripción

## Context

What's your version of `nuqs`?

```
"nuqs": "^2.9.6",
```

What framework are you using?

- ❌ Next.js (app router)
- ❌ Next.js (pages router)
- ❌ React SPA (no router)
- ❌ Remix
- ✅ React Router
- ❌ Other (please specify)

Which version of your framework are you using?

```
"react": "19.0.0",
"react-dom": "19.0.0",
"react-router": "^6.30.2",
"react-router-dom": "^6.30.2",
```

## Description

(This is my first time posting here, and I am not sure about your AI disclosure policy. I used Opus to help me prepare the reproduction and the description below. I have verified the bug manually before submission.)

A `useQueryState` write with `{ history: 'push', shallow: false }` adds a browser history
entry and then tells react-router it was a replace.

[`packages/nuqs/src/adapters/lib/react-router.ts#L66-L89`](https://github.com/47ng/nuqs/blob/aff877ca959e3c4484718965e3d1a0d20542a611/packages/nuqs/src/adapters/lib/react-router.ts#L66-L89):

```ts
const updateMethod =
options.history === 'push' ? history.pushState : history.replaceState
setQueueResetMutex(options.shallow ? 1 : 2)
updateMethod.call(
history,
history.state, // Maintain the history state
historyUpdateMarker,
url
)
let navigationSettled: Promise | undefined
if (options.shallow === false) {
const maybePromise = navigate(
{
// Somehow passing the full URL object here strips the search params
// when accessing the request.url in loaders.
hash: url.hash,
search: url.search
},
{
replace: true,
preventScrollReset: true,
state: history.state?.usr
}
)
```

`updateMethod.call` (L69) really pushes; `navigate(..., { replace: true })` (L77-L85) then
tells the router nothing was pushed. Both cannot be true, and the router acts on the
second. Passing `history.state` through (L71) also gives the new entry the `idx` of the
entry below it — react-router's own index, which it writes on every push and reads back to
size a traversal.

[`packages/router/history.ts#L623-L645`](https://github.com/remix-run/react-router/blob/72973b6493d27014aa76a23a95e3ca186616c4fd/packages/router/history.ts#L623-L645)
(`@remix-run/router@1.23.3`, the version `react-router-dom@6.30.x` depends on):

```ts
function getIndex(): number {
let state = globalHistory.state || { idx: null };
return state.idx;
}

function handlePop() {
action = Action.Pop;
let nextIndex = getIndex();
let delta = nextIndex == null ? null : nextIndex - index; // 0 after a nuqs push
...
}

function push(to: To, state?: any) {
...
index = getIndex() + 1; // what nuqs skips
```

After one such write the router's index is permanently one behind the browser stack, and a
genuine back reports `delta: 0` instead of `-1`. react-router warns about exactly this
desync — "navigating outside the router via `window.history.pushState`" — in
[`packages/router/router.ts#L1038-L1046`](https://github.com/remix-run/react-router/blob/72973b6493d27014aa76a23a95e3ca186616c4fd/packages/router/router.ts#L1038-L1046),
except here the adapter is what navigated.

**Expected:** the router's index still matches the browser stack, as it does after
`navigate(to)`. Either let react-router perform the push
(`navigate(..., { replace: false })`) or write a state with `idx + 1`. The reproduction's
control step shows `navigate()` producing `idx: 1` where the nuqs write produces `idx: 0`.

`shallow: true` is not a workaround: react-router is then never told at all and its
location goes stale. Neither setting leaves the router consistent with the browser.

### Downstream effect on Ionic

`@ionic/react-router` builds its own `LocationHistory` from react-router's action stream.
Told "replace", it drops the entry for the current page (`LocationHistory._replace` pops,
then adds) while both entries still exist in the browser. The next back therefore arrives
at a pathname it does not expect, and it force-navigates to what it believes came before.

My app keeps its open-modal stack in a query parameter, pushed so that back closes the
modal. Closing one instead sends the user a page backwards, in some flows several pages.
Measured across a single modal open: `history.length` 16 → 17, `history.state.idx`
unchanged at 10. Ionic's recovery is aggressive, but it is reacting to being told a push
was a replace.

## Reproduction

Example: Steps to reproduce the behavior:

```sh
git clone -b nuqs-push-history-idx https://github.com/ptmkenny/ionic-react-router-6-test
cd ionic-react-router-6-test
npm install
npm run dev
```

Open and click the four buttons in order. Each click appends a row
to the table.

Guía de contribución

Abrir la guía de contribución

Evaluación

Este issue todavía no se ha evaluado.

Recibe los nuevos issues en tu correo

Un resumen breve de issues de GitHub para principiantes.