Inconsistent useRouter behavior compared to the Next.js Router
illandril opened this issue · comments
If any of the router
properties are accessed in a delayed fashion, if the route changes between the useRouter
call and accessing the properties, then the original route's data is returned when using next-router-mock
.
If the Next.js Router is used, the updated route's data is returned.
Example:
const router = useRouter()
useEffect(() => {
const onRouteChange = (url) => {
console.log('routeChangeComplete', router.query, router.asPath, url)
}
router.events.on('routeChangeComplete', onRouteChange)
return () => {
router.events.off('routeChangeComplete', onRouteChange)
}
}, [router])
const routeChangeA = useCallback(() => {
router.push('/path?x=A')
})
const routeChangeB = useCallback(() => {
router.push('/path?x=B')
})
Start at /path
, then trigger routeChangeA
and then routeChangeB
. When using the Next.js Router, you'll get...
'routeChangeComplete' { x: 'A' } '/path?x=A' '/path?x=A'
'routeChangeComplete' { x: 'B' } '/path?x=B' '/path?x=B'
... but with next-router-mock
you'll get...
'routeChangeComplete' {} '/path' '/path?x=A'
'routeChangeComplete' { x: 'A' } '/path?x=A' '/path?x=B'
I believe this is due to Next's async behavior.
You can import from next-router-mock/async
to ensure the async behavior is replicated. See: https://github.com/scottrippey/next-router-mock#sync-vs-async
Please let me know if this works for you!
Nope - the behavior is the same for both sync and async.
I think the issue is because you're creating a snapshot of the router: https://github.com/scottrippey/next-router-mock/blob/d786607dbdc04819d37f4d9eaa32bec41c2a544e/src/useMemoryRouter.tsx#L15C1-L15C86
The snapshot is intentional, because that does seem to be how Next behaves. If you've got a closure around the router
and you try to access the .query
etc, it'll give you a snapshotted (stale) value.
Here's a CodeSandbox to demonstrate:
Having said that, you're still observing a behavior that is different between next/router
and next-router-mock
, and I'd like to figure out what's going on.
Do you think you could create a CodeSandbox that demonstrates the Next behavior that you're seeing?
I forked your CodeSandbox and added a routeChangeComplete values
section that demonstrates the behavior we're relying on: https://codesandbox.io/p/sandbox/holy-platform-kk7qfj
Thanks, now I see what's happening.
In Next, it appears that the routeChangeComplete
event is triggering after React has re-rendered. Here's the order of events that I'm seeing:
- Initial render
useEffect
adds therouteChangeComplete
event- User event triggers the
router.replace(...)
call - The component is re-rendered:
- The
useRouter
hook returns a new snapshot of therouter
- This changes the
useEffect
dependencies, causing it to unsubscribe and resubscribe therouteChangeComplete
event (with a new closure around the updatedrouter
- The
routeChangeComplete
event is triggered, and successfully logs the updatedrouter
values
So here's where next-router-mock/async
is behaving differently. The async version adds a setTimeout(0)
before the routeChangeComplete
event, in an effort to allow React to re-render.
It's possible/probable that this isn't good enough to allow React to rerender. I wonder if a longer timeout would help?
But also, it's possible that your test isn't allowing React to rerender. Are you using React Testing Library? If so, it can be tricky to properly use act
to trigger a rerender.
For example,
await act(() => {
userEvent.click(container.getByText("Button 1"));
userEvent.click(container.getByText("Button 2"));
});
This would trigger 2 router.push
but it would only rerender once. You'd need to change that to:
await act(() => {
userEvent.click(container.getByText("Button 1"));
});
await act(() => {
userEvent.click(container.getByText("Button 2"));
});
I think it might be helpful, to move forward, if you could post some of your testing code.
There's also one more solution for you to consider. In Next, you can also use the "singleton router" for this kind of situation. The singleton router does not use snapshots, so reading the .query
will always return the latest values. For example:
import router from 'next/router';
function ExampleComponent() {
useEffect(() => {
function onRouteChange() {
// These values will always read the most up-to-date values:
console.log(router.asPath, router.query);
}
router.on('routeChangeComplete', onRouteChange);
return () => router.off('routeChangeComplete', onRouteChange);
}, [] ); // Notice, no dependencies
}
This is a much better way to subscribe to router values, because you only need useRouter
if you need the values during render. If you're only accessing router values during an event handler, then it's better to read them from the singleton.