diff --git a/packages/nuqs/src/useQueryStates.test.tsx b/packages/nuqs/src/useQueryStates.test.tsx index 6168a3ee3..c0bbcde43 100644 --- a/packages/nuqs/src/useQueryStates.test.tsx +++ b/packages/nuqs/src/useQueryStates.test.tsx @@ -1,6 +1,11 @@ import { act, render, renderHook, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import React, { createElement, useState, type ReactNode } from 'react' +import React, { + createElement, + useState, + type ReactNode, + useEffect +} from 'react' import { describe, expect, it, vi } from 'vitest' import { NullDetector, @@ -201,6 +206,48 @@ describe('useQueryStates: referential equality', () => { }) }) +describe('useQueryStates: rendering & bail-out', () => { + it('should bail out of rendering the same component when setting to the same value', async () => { + let renderCount = 0 + function TestComponent() { + const [{ test }, setSearchParams] = useQueryStates({ + test: parseAsString + }) + useEffect(() => { + renderCount++ + }) + return ( + <> + +
value: {test}
+ + ) + } + const user = userEvent.setup() + const onUrlUpdate = vi.fn() + render(, { + wrapper: withNuqsTestingAdapter({ + onUrlUpdate, + searchParams: '?test=init' + }) + }) + await expect(screen.findByText('value: init')).resolves.toBeInTheDocument() + expect(renderCount).toBe(1) + expect(onUrlUpdate).toHaveBeenCalledTimes(0) + + await user.click(screen.getByRole('button', { name: 'Start' })) + + expect(renderCount).toBe(1) // same render count as before + expect(onUrlUpdate).toHaveBeenCalledTimes(1) // url update is still called + }) +}) + describe('useQueryStates: urlKeys remapping', () => { it('uses the object key names by default', async () => { const onUrlUpdate = vi.fn() diff --git a/packages/nuqs/src/useQueryStates.ts b/packages/nuqs/src/useQueryStates.ts index 569ed58e6..7734f7b02 100644 --- a/packages/nuqs/src/useQueryStates.ts +++ b/packages/nuqs/src/useQueryStates.ts @@ -194,36 +194,49 @@ export function useQueryStates( // Sync all hooks together & with external URL changes useEffect(() => { - function updateInternalState(state: V) { - debug('[nuq+ %s `%s`] updateInternalState %O', hookId, stateKeys, state) - stateRef.current = state - setInternalState(state) - } const handlers = Object.keys(keyMap).reduce( (handlers, stateKey) => { handlers[stateKey as keyof KeyMap] = ({ state, query }: CrossHookSyncPayload) => { - const { defaultValue } = keyMap[stateKey]! - const urlKey = resolvedUrlKeys[stateKey]! - // Note: cannot mutate in-place, the object ref must change - // for the subsequent setState to pick it up. - stateRef.current = { - ...stateRef.current, - [stateKey as keyof KeyMap]: state ?? defaultValue ?? null - } - queryRef.current[urlKey] = query - debug( - '[nuq+ %s `%s`] Cross-hook key sync %s: %O (default: %O). Resolved: %O', - hookId, - stateKeys, - urlKey, - state, - defaultValue, - stateRef.current - ) - updateInternalState(stateRef.current) + setInternalState(currentState => { + const { defaultValue } = keyMap[stateKey]! + const urlKey = resolvedUrlKeys[stateKey]! + const nextValue = state ?? defaultValue ?? null + const currentValue = currentState[stateKey] ?? defaultValue ?? null + + if (Object.is(currentValue, nextValue)) { + debug( + '[nuq+ %s `%s`] Cross-hook key sync %s: %O (default: %O). no change, skipping, resolved: %O', + hookId, + stateKeys, + urlKey, + state, + defaultValue, + stateRef.current + ) + // bail out by returning the current state + return currentState + } + // Note: cannot mutate in-place, the object ref must change + // for the subsequent setState to pick it up. + stateRef.current = { + ...stateRef.current, + [stateKey as keyof KeyMap]: nextValue + } + queryRef.current[urlKey] = query + debug( + '[nuq+ %s `%s`] Cross-hook key sync %s: %O (default: %O). updateInternalState, resolved: %O', + hookId, + stateKeys, + urlKey, + state, + defaultValue, + stateRef.current + ) + return stateRef.current + }) } return handlers },