diff --git a/README.md b/README.md index 33b2662c4..1299b98b3 100644 --- a/README.md +++ b/README.md @@ -272,6 +272,35 @@ const [state, setState] = useQueryState('foo', { scroll: true }) setState('bar', { scroll: true }) ``` +### Throttling URL updates + +Because of browsers rate-limiting the History API, internal updates to the +URL are queued and throttled to a default of 50ms, which seems to satisfy +most browsers even when sending high-frequency query updates, like binding +to a text input or a slider. + +Safari's rate limits are much higher and would require a throttle of around 340ms. +If you end up needing a longer time between updates, you can specify it in the +options: + +```ts +useQueryState('foo', { + // Send updates to the server maximum once every second + shallow: false, + throttleMs: 1000 +}) + +// You can also pass the option on calls to setState: +setState('bar', { throttleMs: 1000 }) +``` + +> Note: the state returned by the hook is always updated instantly, to keep UI responsive. +> Only changes to the URL, and server requests when using `shallow: false`, are throttled. + +If multiple hooks set different throttle values on the same event loop tick, +the highest value will be used. Also, values lower than 50ms will be ignored, +to avoid rate-limiting issues. [Read more](https://francoisbest.com/posts/2023/storing-react-state-in-the-url-with-nextjs#batching--throttling). + ## Configuring parsers, default value & options You can use a builder pattern to facilitate specifying all of those things: diff --git a/packages/next-usequerystate/src/debug.ts b/packages/next-usequerystate/src/debug.ts index 7338fef9a..472b48f4e 100644 --- a/packages/next-usequerystate/src/debug.ts +++ b/packages/next-usequerystate/src/debug.ts @@ -1,5 +1,5 @@ const enabled = - (typeof window === 'object' && + (typeof localStorage === 'object' && localStorage.getItem('debug')?.includes('next-usequerystate')) ?? false diff --git a/packages/next-usequerystate/src/defs.ts b/packages/next-usequerystate/src/defs.ts index 888335001..d06d5d57f 100644 --- a/packages/next-usequerystate/src/defs.ts +++ b/packages/next-usequerystate/src/defs.ts @@ -30,6 +30,17 @@ export type Options = { * the updated querystring. */ shallow?: boolean + + /** + * Maximum amount of time (ms) to wait between updates of the URL query string. + * + * This is to alleviate rate-limiting of the Web History API in browsers, + * and defaults to 50ms. Safari requires a much higher value of around 340ms. + * + * Note: the value will be limited to a minimum of 50ms, anything lower + * will not have any effect. + */ + throttleMs?: number } export type Nullable = { diff --git a/packages/next-usequerystate/src/sync.ts b/packages/next-usequerystate/src/sync.ts index 124b7269c..9568f61b5 100644 --- a/packages/next-usequerystate/src/sync.ts +++ b/packages/next-usequerystate/src/sync.ts @@ -1,5 +1,6 @@ import Mitt from 'mitt' import { debug } from './debug' +import { getQueuedValue } from './update-queue' export const SYNC_EVENT_KEY = Symbol('__nextUseQueryState__SYNC__') export const NOSYNC_MARKER = '__nextUseQueryState__NO_SYNC__' @@ -72,6 +73,18 @@ function patchHistory() { // If someone else than our hooks have updated the URL, // send out a signal for them to sync their internal state. if (source === 'external') { + for (const [key, value] of search.entries()) { + const queueValue = getQueuedValue(key) + if (queueValue !== null && queueValue !== value) { + debug( + '[nuqs] Overwrite detected for key: %s, Server: %s, queue: %s', + key, + value, + queueValue + ) + search.set(key, queueValue) + } + } // Here we're delaying application to next tick to avoid: // `Warning: useInsertionEffect must not schedule updates.` // diff --git a/packages/next-usequerystate/src/update-queue.ts b/packages/next-usequerystate/src/update-queue.ts index bd7c5bf15..fd75a4d3f 100644 --- a/packages/next-usequerystate/src/update-queue.ts +++ b/packages/next-usequerystate/src/update-queue.ts @@ -5,14 +5,15 @@ import { renderQueryString } from './url-encoding' // 50ms between calls to the history API seems to satisfy Chrome and Firefox. // Safari remains annoying with at most 100 calls in 30 seconds. #wontfix -const FLUSH_RATE_LIMIT_MS = 50 +export const FLUSH_RATE_LIMIT_MS = 50 type UpdateMap = Map const updateQueue: UpdateMap = new Map() const queueOptions: Required = { history: 'replace', scroll: false, - shallow: true + shallow: true, + throttleMs: FLUSH_RATE_LIMIT_MS } let lastFlushTimestamp = 0 @@ -37,9 +38,13 @@ export function enqueueQueryStringUpdate( if (options.shallow === false) { queueOptions.shallow = false } + queueOptions.throttleMs = Math.max( + options.throttleMs ?? FLUSH_RATE_LIMIT_MS, + Number.isFinite(queueOptions.throttleMs) ? queueOptions.throttleMs : 0 + ) } -export function getInitialStateFromQueue(key: string) { +export function getQueuedValue(key: string) { return updateQueue.get(key) ?? null } @@ -53,17 +58,19 @@ export function getInitialStateFromQueue(key: string) { * * @returns a Promise to the URLSearchParams that have been applied. */ -export function flushToURL(router: Router) { +export function scheduleFlushToURL(router: Router) { if (flushPromiseCache === null) { flushPromiseCache = new Promise((resolve, reject) => { - const now = performance.now() - const timeSinceLastFlush = now - lastFlushTimestamp - const flushInMs = Math.max( - 0, - Math.min(FLUSH_RATE_LIMIT_MS, FLUSH_RATE_LIMIT_MS - timeSinceLastFlush) - ) - debug('[nuqs queue] Scheduling flush in %f ms', flushInMs) - setTimeout(() => { + if (!Number.isFinite(queueOptions.throttleMs)) { + debug('[nuqs queue] Skipping flush due to throttleMs=Infinity') + resolve(new URLSearchParams(location.search)) + // Let the promise be returned before clearing the cached value + setTimeout(() => { + flushPromiseCache = null + }, 0) + return + } + function flushNow() { lastFlushTimestamp = performance.now() const search = flushUpdateQueue(router) if (!search) { @@ -72,14 +79,38 @@ export function flushToURL(router: Router) { resolve(search) } flushPromiseCache = null - }, flushInMs) + } + // We run the logic on the next event loop tick to allow + // multiple query updates to set their own throttleMs value. + function runOnNextTick() { + const now = performance.now() + const timeSinceLastFlush = now - lastFlushTimestamp + const throttleMs = queueOptions.throttleMs + const flushInMs = Math.max( + 0, + Math.min(throttleMs, throttleMs - timeSinceLastFlush) + ) + debug( + '[nuqs queue] Scheduling flush in %f ms. Throttled at %f ms', + flushInMs, + throttleMs + ) + if (flushInMs === 0) { + // Since we're already in the "next tick" from queued updates, + // no need to do setTimeout(0) here. + flushNow() + } else { + setTimeout(flushNow, flushInMs) + } + } + setTimeout(runOnNextTick, 0) }) } return flushPromiseCache } function flushUpdateQueue(router: Router) { - const search = new URLSearchParams(window.location.search) + const search = new URLSearchParams(location.search) if (updateQueue.size === 0) { return search } @@ -87,11 +118,12 @@ function flushUpdateQueue(router: Router) { const items = Array.from(updateQueue.entries()) const options = { ...queueOptions } // Restore defaults + updateQueue.clear() queueOptions.history = 'replace' queueOptions.scroll = false queueOptions.shallow = true - updateQueue.clear() - debug('[nuqs queue] Flushing queue %O', items) + queueOptions.throttleMs = FLUSH_RATE_LIMIT_MS + debug('[nuqs queue] Flushing queue %O with options %O', items, options) for (const [key, value] of items) { if (value === null) { search.delete(key) @@ -99,40 +131,36 @@ function flushUpdateQueue(router: Router) { search.set(key, value) } } - const query = renderQueryString(search) - const path = window.location.pathname - const hash = window.location.hash - + const path = location.pathname + const hash = location.hash // If the querystring is empty, add the pathname to clear it out, // otherwise using a relative URL works just fine. // todo: Does it when using the router with `shallow: false` on dynamic paths? const url = query ? `?${query}${hash}` : `${path}${hash}` debug('[nuqs queue] Updating url: %s', url) try { - if (options.shallow) { - const updateUrl = - options.history === 'push' - ? window.history.pushState - : window.history.replaceState - updateUrl.call( - window.history, - window.history.state, - // Our own updates have a marker to prevent syncing - // when the URL changes (we've already sync'd them up - // via `emitter.emit(key, newValue)` above, without - // going through the parsers). - NOSYNC_MARKER, - url - ) - if (options.scroll) { - window.scrollTo(0, 0) - } - } else { + // First, update the URL locally without triggering a network request, + // this allows keeping a reactive URL if the network is slow. + const updateMethod = + options.history === 'push' ? history.pushState : history.replaceState + updateMethod.call( + history, + history.state, + // Our own updates have a marker to prevent syncing + // when the URL changes (we've already sync'd them up + // via `emitter.emit(key, newValue)` above, without + // going through the parsers). + NOSYNC_MARKER, + url + ) + if (options.scroll) { + window.scrollTo(0, 0) + } + if (!options.shallow) { // Call the Next.js router to perform a network request - const updateUrl = - options.history === 'push' ? router.push : router.replace - updateUrl.call(router, url, { scroll: options.scroll }) + // and re-render server components. + router.replace(url, { scroll: false }) } return search } catch (error) { diff --git a/packages/next-usequerystate/src/useQueryState.ts b/packages/next-usequerystate/src/useQueryState.ts index 43c437e42..d6e85b909 100644 --- a/packages/next-usequerystate/src/useQueryState.ts +++ b/packages/next-usequerystate/src/useQueryState.ts @@ -5,9 +5,10 @@ import type { Options } from './defs' import type { Parser } from './parsers' import { SYNC_EVENT_KEY, emitter } from './sync' import { + FLUSH_RATE_LIMIT_MS, enqueueQueryStringUpdate, - flushToURL, - getInitialStateFromQueue + getQueuedValue, + scheduleFlushToURL } from './update-queue' export interface UseQueryStateOptions extends Parser, Options {} @@ -199,6 +200,7 @@ export function useQueryState( history = 'replace', shallow = true, scroll = false, + throttleMs = FLUSH_RATE_LIMIT_MS, parse = x => x as unknown as T, serialize = String, defaultValue = undefined @@ -206,6 +208,7 @@ export function useQueryState( history: 'replace', scroll: false, shallow: true, + throttleMs: FLUSH_RATE_LIMIT_MS, parse: x => x as unknown as T, serialize: String, defaultValue: undefined @@ -215,13 +218,13 @@ export function useQueryState( // Not reactive, but available on the server and on page load const initialSearchParams = useSearchParams() const [internalState, setInternalState] = React.useState(() => { - const queueValue = getInitialStateFromQueue(key) + const queueValue = getQueuedValue(key) const urlValue = - typeof window !== 'object' + typeof location !== 'object' ? // SSR initialSearchParams?.get(key) ?? null : // Components mounted after page load must use the current URL value - new URLSearchParams(window.location.search).get(key) ?? null + new URLSearchParams(location.search).get(key) ?? null const value = queueValue ?? urlValue return value === null ? null : parse(value) }) @@ -230,7 +233,7 @@ export function useQueryState( '[nuqs `%s`] render - state: %O, iSP: %s', key, internalState, - initialSearchParams + initialSearchParams?.get(key) ?? null ) // Sync all hooks together & with external URL changes @@ -268,11 +271,12 @@ export function useQueryState( // Call-level options take precedence over hook declaration options. history: options.history ?? history, shallow: options.shallow ?? shallow, - scroll: options.scroll ?? scroll + scroll: options.scroll ?? scroll, + throttleMs: options.throttleMs ?? throttleMs }) - return flushToURL(router) + return scheduleFlushToURL(router) }, - [key, history, shallow, scroll] + [key, history, shallow, scroll, throttleMs] ) return [internalState ?? defaultValue ?? null, update] } diff --git a/packages/next-usequerystate/src/useQueryStates.ts b/packages/next-usequerystate/src/useQueryStates.ts index 852ad8c4b..d4726bbbd 100644 --- a/packages/next-usequerystate/src/useQueryStates.ts +++ b/packages/next-usequerystate/src/useQueryStates.ts @@ -9,9 +9,10 @@ import type { Nullable, Options } from './defs' import type { Parser } from './parsers' import { SYNC_EVENT_KEY, emitter } from './sync' import { + FLUSH_RATE_LIMIT_MS, enqueueQueryStringUpdate, - flushToURL, - getInitialStateFromQueue + getQueuedValue, + scheduleFlushToURL } from './update-queue' type KeyMapValue = Parser & { @@ -59,7 +60,8 @@ export function useQueryStates( { history = 'replace', scroll = false, - shallow = true + shallow = true, + throttleMs = FLUSH_RATE_LIMIT_MS }: Partial = {} ): UseQueryStatesReturn { type V = Values @@ -68,12 +70,12 @@ export function useQueryStates( // Not reactive, but available on the server and on page load const initialSearchParams = useSearchParams() const [internalState, setInternalState] = React.useState(() => { - if (typeof window !== 'object') { + if (typeof location !== 'object') { // SSR return parseMap(keyMap, initialSearchParams ?? new URLSearchParams()) } // Components mounted after page load must use the current URL value - return parseMap(keyMap, new URLSearchParams(window.location.search)) + return parseMap(keyMap, new URLSearchParams(location.search)) }) const stateRef = React.useRef(internalState) debug( @@ -148,10 +150,11 @@ export function useQueryStates( // Call-level options take precedence over hook declaration options. history: options.history ?? history, shallow: options.shallow ?? shallow, - scroll: options.scroll ?? scroll + scroll: options.scroll ?? scroll, + throttleMs: options.throttleMs ?? throttleMs }) } - return flushToURL(router) + return scheduleFlushToURL(router) }, [keyMap, history, shallow, scroll] ) @@ -167,7 +170,7 @@ function parseMap( return Object.keys(keyMap).reduce((obj, key) => { const { defaultValue, parse } = keyMap[key]! const urlQuery = searchParams?.get(key) ?? null - const queueQuery = getInitialStateFromQueue(key) + const queueQuery = getQueuedValue(key) const query = queueQuery ?? urlQuery const value = query === null ? null : parse(query) obj[key as keyof KeyMap] = value ?? defaultValue ?? null diff --git a/packages/playground/src/app/demos/throttling/client.tsx b/packages/playground/src/app/demos/throttling/client.tsx new file mode 100644 index 000000000..1893bb020 --- /dev/null +++ b/packages/playground/src/app/demos/throttling/client.tsx @@ -0,0 +1,102 @@ +'use client' + +import { useQueryState } from 'next-usequerystate' +import { useRouter } from 'next/navigation' +import React from 'react' +import { delayParser, queryParser } from './parsers' + +const autoFillMessage = `Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec a diam lectus. Sed sit amet ipsum mauris. Maecenas congue ligula ac quam viverra nec consectetur ante hendrerit. Donec et mollis dolor.` + +export function Client() { + const [serverDelay, setServerDelay] = useQueryState('serverDelay', { + ...delayParser, + shallow: false + }) + const [clientDelay, setClientDelay] = useQueryState( + 'clientDelay', + delayParser + ) + const [q, setQ] = useQueryState('q', { + ...queryParser, + throttleMs: clientDelay, + shallow: false + }) + const router = useRouter() + + const timeoutRef = React.useRef() + const [index, setIndex] = React.useState(0) + + React.useEffect(() => { + if (index === 0) { + return + } + setQ(autoFillMessage.slice(0, index)) + clearTimeout(timeoutRef.current) + if (index === autoFillMessage.length) { + return + } + timeoutRef.current = window.setTimeout(() => { + setIndex(i => Math.min(i + 1, autoFillMessage.length)) + }, 80) + }, [index]) + + return ( + <> +

Client

+
+ + +
+
+ + +
+
+
+ + setQ(e.target.value)} + placeholder="Search" + /> + {timeoutRef.current ? ( + + ) : ( + + )} +

Client state: {q || empty}

+
+ + ) +} diff --git a/packages/playground/src/app/demos/throttling/page.tsx b/packages/playground/src/app/demos/throttling/page.tsx new file mode 100644 index 000000000..7f77f0a2b --- /dev/null +++ b/packages/playground/src/app/demos/throttling/page.tsx @@ -0,0 +1,43 @@ +import { setTimeout } from 'node:timers/promises' +import { Suspense } from 'react' +import { Client } from './client' +import { delayParser, queryParser } from './parsers' + +type PageParams = { + searchParams: { + q?: string | string[] + serverDelay?: string | string[] + } +} + +export default async function ThottlingDemoPage({ searchParams }: PageParams) { + const serverDelay = delayParser.parseServerSide(searchParams.serverDelay) + const query = queryParser.parseServerSide(searchParams.q) + await setTimeout(serverDelay) + console.debug('Server query: %s', query) + return ( + <> +

Throttling

+

+ Play with the various delays, and try throttling your network connection + in devtools. +

+

+ When the client is faster to update the URL than the network is to + re-render the server components, the server may hang under the waterfall + of heavy load. +

+

Server

+

Server delay: {serverDelay} ms

+

Server query: {query}

+ + + +

+ + Source on GitHub + +

+ + ) +} diff --git a/packages/playground/src/app/demos/throttling/parsers.ts b/packages/playground/src/app/demos/throttling/parsers.ts new file mode 100644 index 000000000..cbf955c66 --- /dev/null +++ b/packages/playground/src/app/demos/throttling/parsers.ts @@ -0,0 +1,4 @@ +import { parseAsInteger, parseAsString } from 'next-usequerystate/parsers' + +export const delayParser = parseAsInteger.withDefault(0) +export const queryParser = parseAsString.withDefault('') diff --git a/packages/playground/src/app/layout.tsx b/packages/playground/src/app/layout.tsx index ae0c1bf52..32d264b3a 100644 --- a/packages/playground/src/app/layout.tsx +++ b/packages/playground/src/app/layout.tsx @@ -1,3 +1,4 @@ +import dynamic from 'next/dynamic' import React, { Suspense } from 'react' import { HydrationMarker } from '../components/hydration-marker' @@ -7,6 +8,18 @@ export const metadata = { 'useQueryState hook for Next.js - Like React.useState, but stored in the URL query string' } +const DebugControlsSkeleton = () => ( + + + + +) + +const DebugControl = dynamic(() => import('../components/debug-control'), { + ssr: false, + loading: DebugControlsSkeleton +}) + export default function RootLayout({ children }: { @@ -27,6 +40,10 @@ export default function RootLayout({ How it works + {' • '} + }> + +
{children} diff --git a/packages/playground/src/app/page.tsx b/packages/playground/src/app/page.tsx index 4f6c25975..3a38b557a 100644 --- a/packages/playground/src/app/page.tsx +++ b/packages/playground/src/app/page.tsx @@ -13,6 +13,7 @@ const demos = [ 'app/pretty-urls', 'app/server-side-parsing', 'app/subscribeToQueryUpdates', + 'app/throttling', 'app/repro-359', 'app/repro-376', // Pages router demos diff --git a/packages/playground/src/components/debug-control.tsx b/packages/playground/src/components/debug-control.tsx new file mode 100644 index 000000000..d5641866c --- /dev/null +++ b/packages/playground/src/components/debug-control.tsx @@ -0,0 +1,34 @@ +'use client' + +import React from 'react' + +export default function DebugControl() { + const [checked, setChecked] = React.useState(() => { + if (typeof localStorage === 'undefined') { + return false + } + return ( + localStorage.getItem('debug')?.includes('next-usequerystate') ?? false + ) + }) + const update = React.useCallback(() => { + setChecked(c => { + const checked = !c + if (typeof localStorage !== 'undefined') { + if (checked) { + localStorage.setItem('debug', 'next-usequerystate') + } else { + localStorage.removeItem('debug') + } + } + return checked + }) + }, []) + + return ( + + + + + ) +}