You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
react router: history: 'push' write with shallow: false does not advance the history index #1563
(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.
constupdateMethod=options.history==='push' ? history.pushState : history.replaceStatesetQueueResetMutex(options.shallow ? 1 : 2)updateMethod.call(history,history.state,// Maintain the history statehistoryUpdateMarker,url)letnavigationSettled: Promise<void>|undefinedif(options.shallow===false){constmaybePromise=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.
functiongetIndex(): number{letstate=globalHistory.state||{idx: null};returnstate.idx;}functionhandlePop(){action=Action.Pop;letnextIndex=getIndex();letdelta=nextIndex==null ? null : nextIndex-index;// 0 after a nuqs push
...
}functionpush(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,
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:
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 http://localhost:5173/ and click the four buttons in order. Each click appends a row
to the table.
Thanks, the use of AI for investigative work is fine (I also feed that into my agent so the extra context is appreciated).
The initial reason for having a History API replace/push before the router API call was to have an optimistic URL update when an async loader takes its time, so that the URL update doesn't lag until the loader resolves. But I didn't know about that internal .idx property on the state so I see how that might break it.
I'm gonna see what our options are (dropping that optimistic update breaks a few other behaviours), and will report back.
Activity
franky47 commentedon Aug 23, 2026
Thanks, the use of AI for investigative work is fine (I also feed that into my agent so the extra context is appreciated).
The initial reason for having a History API replace/push before the router API call was to have an optimistic URL update when an async loader takes its time, so that the URL update doesn't lag until the loader resolves. But I didn't know about that internal .idx property on the state so I see how that might break it.
I'm gonna see what our options are (dropping that optimistic update breaks a few other behaviours), and will report back.
franky47 commentedon Aug 23, 2026
I have a possible fix in #1564, could you give the preview build a try please?