Skip to content

Commit

Permalink
fix: Patch history before navigation occurs
Browse files Browse the repository at this point in the history
This introduces an import side-effect, but it makes sure
we catch navigation events before the hooks are mounted.

Switching to a useInsertionEffect would not help here,
since Next.js already made the call to the history API
at the time where the hook first mounts.

In dev mode, there is enough time to patch history before
navigation is triggered, probably due to page build times,
but not in production, where the first ever navigation event
would not hydrate properly.
  • Loading branch information
franky47 committed Sep 12, 2023
1 parent d87c29d commit f31bead
Show file tree
Hide file tree
Showing 5 changed files with 33 additions and 41 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
"useQueryState.gif"
],
"type": "module",
"sideEffects": false,
"sideEffects": true,
"main": "dist/index.cjs",
"module": "dist/index.js",
"types": "dist/index.d.ts",
Expand Down
2 changes: 1 addition & 1 deletion src/lib/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client'

export * from './defs'
export type { HistoryOptions, Options } from './defs'
export * from './deprecated'
export * from './parsers'
export { subscribeToQueryUpdates } from './sync'
Expand Down
64 changes: 29 additions & 35 deletions src/lib/sync.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import Mitt from 'mitt'
import React from 'react'

export const SYNC_EVENT_KEY = Symbol('__nextUseQueryState__SYNC__')
export const NOSYNC_MARKER = '__nextUseQueryState__NO_SYNC__'
Expand All @@ -22,40 +21,35 @@ export function subscribeToQueryUpdates(

let patched = false

export function usePatchedHistory() {
React.useEffect(() => {
if (patched) {
return
}
// console.debug('Patching history')
for (const method of ['pushState', 'replaceState'] as const) {
const original = window.history[method].bind(window.history)
window.history[method] = function nextUseQueryState_patchedHistory(
state: any,
title: string,
url?: string | URL | null
) {
// console.debug(`history.${method}(${url}) ${title} %O`, state)
// If someone else than our hooks have updated the URL,
// send out a signal for them to sync their internal state.
if (title !== NOSYNC_MARKER && url) {
const search = new URL(url, location.origin).searchParams
// console.debug(`Triggering sync with ${search.toString()}`)
// Here we're delaying application to next tick to avoid:
// `Warning: useInsertionEffect must not schedule updates.`
//
// Because the emitter runs in sync, this would trigger
// each hook's setInternalState updates, so we schedule
// those after the current batch of events.
// Because we don't know if the history method would
// have been applied by then, we're also sending the
// parsed query string to the hooks so they don't need
// to rely on the URL being up to date.
setTimeout(() => emitter.emit(SYNC_EVENT_KEY, search), 0)
}
return original(state, title === NOSYNC_MARKER ? '' : title, url)
if (!patched && typeof window === 'object') {
// console.debug('Patching history')
for (const method of ['pushState', 'replaceState'] as const) {
const original = window.history[method].bind(window.history)
window.history[method] = function nextUseQueryState_patchedHistory(
state: any,
title: string,
url?: string | URL | null
) {
// console.debug(`history.${method}(${url}) ${title} %O`, state)
// If someone else than our hooks have updated the URL,
// send out a signal for them to sync their internal state.
if (title !== NOSYNC_MARKER && url) {
const search = new URL(url, location.origin).searchParams
// console.debug(`Triggering sync with ${search.toString()}`)
// Here we're delaying application to next tick to avoid:
// `Warning: useInsertionEffect must not schedule updates.`
//
// Because the emitter runs in sync, this would trigger
// each hook's setInternalState updates, so we schedule
// those after the current batch of events.
// Because we don't know if the history method would
// have been applied by then, we're also sending the
// parsed query string to the hooks so they don't need
// to rely on the URL being up to date.
setTimeout(() => emitter.emit(SYNC_EVENT_KEY, search), 0)
}
return original(state, title === NOSYNC_MARKER ? '' : title, url)
}
patched = true
}, [])
}
patched = true
}
3 changes: 1 addition & 2 deletions src/lib/useQueryState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { useRouter, useSearchParams } from 'next/navigation'
import React from 'react'
import type { Options } from './defs'
import type { Parser } from './parsers'
import { SYNC_EVENT_KEY, emitter, usePatchedHistory } from './sync'
import { SYNC_EVENT_KEY, emitter } from './sync'
import { enqueueQueryStringUpdate, flushToURL } from './update-queue'

export interface UseQueryStateOptions<T> extends Parser<T>, Options {}
Expand Down Expand Up @@ -206,7 +206,6 @@ export function useQueryState<T = string>(
defaultValue: undefined
}
) {
usePatchedHistory()
const router = useRouter()
// Not reactive, but available on the server and on page load
const initialSearchParams = useSearchParams()
Expand Down
3 changes: 1 addition & 2 deletions src/lib/useQueryStates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
import React from 'react'
import type { Nullable, Options } from './defs'
import type { Parser } from './parsers'
import { SYNC_EVENT_KEY, emitter, usePatchedHistory } from './sync'
import { SYNC_EVENT_KEY, emitter } from './sync'
import { enqueueQueryStringUpdate, flushToURL } from './update-queue'

type KeyMapValue<Type> = Parser<Type> & {
Expand Down Expand Up @@ -58,7 +58,6 @@ export function useQueryStates<KeyMap extends UseQueryStatesKeysMap>(
}: Partial<UseQueryStatesOptions> = {}
): UseQueryStatesReturn<KeyMap> {
type V = Values<KeyMap>
usePatchedHistory()
const router = useRouter()
// Not reactive, but available on the server and on page load
const initialSearchParams = useSearchParams()
Expand Down

0 comments on commit f31bead

Please sign in to comment.