47ng/nuqs

Resets to initial state when there is Link with prefetch

Arctomachine opened this issue · 11 comments

Reproduction code: https://github.com/Arctomachine/usequerystate-bug

Only works in production (start) mode, not in dev

  1. pnpm install
  2. pnpm run build
  3. pnpm run start
  4. Got to index page /
  5. Click checkbox - page should flicker and reset to default value
  6. Click it again - should work as expected
  7. Move cursor over any link in grid - it should reset again

To "fix" it go to ./app/Wrapper.tsx, at line 33 change prefetch={true} to prefetch={false}

Thanks for the reproduction. It looks like prefetch calls history.replaceState, which throws things off track, thinking it's an actual navigation. And since prefetch is a production-only feature, it explains why it works fine in development.

I'll see if that's a recent behaviour in the newest canaries, otherwise we might have to use a different strategy to differentiate external navigation from prefetches.

I have yet to trace the full path taken from dispatching the ACTION_PREFETCH action in Next.js to the call to the history API that causes the problem, but it looks like Next.js is keeping an internal record of the URL it's "supposed" to be at, rather than using the actual location object.

A proof of this is using { shallow: false } for the query filter, which uses the Next.js router rather than a call to the history API. This solves both the checkbox blinking and the prefetch resets, because the router was told about the intent to have a querystring in the URL. Obviously, this is not a satisfying solution to our problem.

I'm not sure if the Next.js team would consider that a bug, but there may be other libraries interfacing with the history API that have a similar issue (eg: alternative routers).

I can confirm this behaviour started to appear in 14.0.2-canary.7, specifically due to this PR: vercel/next.js#56497.

Note: 14.0.2 has been released with the PR that broke shallow URL updates when combined with prefetch links. next-usequerystate is unfortunately not compatible with this release (and any subsequent until I have identified the root cause and they patched it on their end, PR coming).

@franky47 Could you tell me if you plan to maintain this repo for a long period of time? I plan to use it, but I don't want libraries that will "die" in a few months. Would really appreciate if you could give me some feedback about your plans with this repo :)

Thanks! :)

I'm actively using it across many production projects, it's not going anywhere. Ideally, I'd like to get some of the complexity pruned out by merging it into Next.js core (shallow routing in app router).

That being said, it's also dependent on external factors, like a stable revenue stream, which is not the case at the moment. Sponsors are welcome to help with maintenance.

Found the root cause of the issue. Let's follow the N whys:

The main effect is, as described by @Arctomachine: Query updates get reset when hovering (or mounting) a prefetch Link.

  1. Why: on prefetch, history.replaceState is called with the internal canonicalUrl in the app router's HistoryUpdater component, which is not aware of the shallow URL update. (source)
  2. Why: the HistoryUpdater re-ran its useInsertionEffect.
  3. Why: the sync parameter function changed
  4. Why: it was recreated because the reducer state had changed (source)

The impacting PR did not change how the prefetch reducer behaves. It did not change the app router state when running in 14.0.2-canary.6 and below, and actually still doesn't change the internal state in subsequent releases.

What changed however, is that the reducer state is now based on Promises. For each action, a new Promise is created. Those are referencially different, even if the state they resolve to is the same. The sync function is being rebuilt at every action dispatched, because it's essentially a different reference.

Solution: drop this sync function entirely, as its purpose was to synchronize the Redux devtools store somehow, but I doubt it still serves its purpose with Promises.

Going to run this by the Next.js team, I'll update this comment with the PR number when it's out. Edit: vercel/next.js#58297

Notes: the sync (along with the whole Redux devtools code) was introduced in vercel/next.js#39866

Note: also keep an eye on these:

@Arctomachine Could you please try the following combination, and let me know if you are still experiencing this issue?

  • next@14.0.3-canary.6
  • next-usequerystate@1.12.0-beta.1
  • Setting the experimental flag windowHistorySupport to true

It works now, tested on project where it first appeared - no problems so far.

Thanks a lot for your feedback, I have released 1.12.0. Fingers crossed that PR on the Next.js repo lands on 12.0.3 🤞

next@14.0.3-canary.8 has been released with the prefetch fix. The experimental flag is not needed anymore from there on (although its use is supported).

I'll publish next-usequerystate@1.12.1 with the updated semver range when next@12.0.3 lands.