From 3286fc2db47938f71b40a4fead9cb2530ef85d79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Best?= Date: Fri, 7 Feb 2025 11:42:02 +0100 Subject: [PATCH 01/14] ref: Reorganise lib code - Move shared code into src/lib, leaving only features at the top level - Move the throttle queue into a class to help with testing & multi-storage support later - Add stubs for debounce queue --- packages/nuqs/src/adapters/custom.ts | 2 +- packages/nuqs/src/adapters/lib/context.ts | 2 +- .../nuqs/src/adapters/lib/patch-history.ts | 4 +- .../nuqs/src/adapters/lib/react-router.ts | 2 +- packages/nuqs/src/adapters/next/impl.app.ts | 2 +- packages/nuqs/src/adapters/next/impl.pages.ts | 2 +- packages/nuqs/src/adapters/next/shared.ts | 2 +- packages/nuqs/src/adapters/react.ts | 6 +- packages/nuqs/src/adapters/testing.ts | 6 +- packages/nuqs/src/cache.ts | 2 +- .../compose.test.ts} | 12 +- packages/nuqs/src/lib/compose.ts | 16 ++ packages/nuqs/src/{ => lib}/debug.test.ts | 0 packages/nuqs/src/{ => lib}/debug.ts | 0 packages/nuqs/src/{ => lib}/errors.ts | 0 packages/nuqs/src/lib/queues/debounce.test.ts | 44 ++++ packages/nuqs/src/lib/queues/debounce.ts | 37 ++++ packages/nuqs/src/lib/queues/throttle.ts | 193 +++++++++++++++++ packages/nuqs/src/lib/safe-parse.ts | 21 ++ packages/nuqs/src/{ => lib}/sync.test.tsx | 4 +- packages/nuqs/src/{ => lib}/sync.ts | 0 packages/nuqs/src/lib/timeout.test.ts | 51 +++++ packages/nuqs/src/lib/timeout.ts | 4 + .../nuqs/src/{ => lib}/url-encoding.test.ts | 0 packages/nuqs/src/{ => lib}/url-encoding.ts | 0 packages/nuqs/src/lib/with-resolvers.test.ts | 33 +++ packages/nuqs/src/lib/with-resolvers.ts | 20 ++ packages/nuqs/src/parsers.ts | 2 +- packages/nuqs/src/serializer.ts | 2 +- packages/nuqs/src/update-queue.ts | 200 ------------------ packages/nuqs/src/useQueryState.ts | 35 ++- packages/nuqs/src/useQueryStates.ts | 35 ++- packages/nuqs/src/utils.ts | 40 ---- 33 files changed, 476 insertions(+), 303 deletions(-) rename packages/nuqs/src/{update-queue.test.ts => lib/compose.test.ts} (79%) create mode 100644 packages/nuqs/src/lib/compose.ts rename packages/nuqs/src/{ => lib}/debug.test.ts (100%) rename packages/nuqs/src/{ => lib}/debug.ts (100%) rename packages/nuqs/src/{ => lib}/errors.ts (100%) create mode 100644 packages/nuqs/src/lib/queues/debounce.test.ts create mode 100644 packages/nuqs/src/lib/queues/debounce.ts create mode 100644 packages/nuqs/src/lib/queues/throttle.ts create mode 100644 packages/nuqs/src/lib/safe-parse.ts rename packages/nuqs/src/{ => lib}/sync.test.tsx (97%) rename packages/nuqs/src/{ => lib}/sync.ts (100%) create mode 100644 packages/nuqs/src/lib/timeout.test.ts create mode 100644 packages/nuqs/src/lib/timeout.ts rename packages/nuqs/src/{ => lib}/url-encoding.test.ts (100%) rename packages/nuqs/src/{ => lib}/url-encoding.ts (100%) create mode 100644 packages/nuqs/src/lib/with-resolvers.test.ts create mode 100644 packages/nuqs/src/lib/with-resolvers.ts delete mode 100644 packages/nuqs/src/update-queue.ts delete mode 100644 packages/nuqs/src/utils.ts diff --git a/packages/nuqs/src/adapters/custom.ts b/packages/nuqs/src/adapters/custom.ts index d7b50b82b..0aa170d39 100644 --- a/packages/nuqs/src/adapters/custom.ts +++ b/packages/nuqs/src/adapters/custom.ts @@ -1,4 +1,4 @@ -export { renderQueryString } from '../url-encoding' +export { renderQueryString } from '../lib/url-encoding' export { createAdapterProvider as unstable_createAdapterProvider, type AdapterContext as unstable_AdapterContext diff --git a/packages/nuqs/src/adapters/lib/context.ts b/packages/nuqs/src/adapters/lib/context.ts index 034fa773a..4061fbb9f 100644 --- a/packages/nuqs/src/adapters/lib/context.ts +++ b/packages/nuqs/src/adapters/lib/context.ts @@ -1,5 +1,5 @@ import { createContext, createElement, useContext, type ReactNode } from 'react' -import { error } from '../../errors' +import { error } from '../../lib/errors' import type { UseAdapterHook } from './defs' export type AdapterContext = { diff --git a/packages/nuqs/src/adapters/lib/patch-history.ts b/packages/nuqs/src/adapters/lib/patch-history.ts index 71033a68f..fea927d21 100644 --- a/packages/nuqs/src/adapters/lib/patch-history.ts +++ b/packages/nuqs/src/adapters/lib/patch-history.ts @@ -1,6 +1,6 @@ import type { Emitter } from 'mitt' -import { debug } from '../../debug' -import { error } from '../../errors' +import { debug } from '../../lib/debug' +import { error } from '../../lib/errors' export type SearchParamsSyncEmitter = Emitter<{ update: URLSearchParams }> diff --git a/packages/nuqs/src/adapters/lib/react-router.ts b/packages/nuqs/src/adapters/lib/react-router.ts index efa2c188b..a2c37e389 100644 --- a/packages/nuqs/src/adapters/lib/react-router.ts +++ b/packages/nuqs/src/adapters/lib/react-router.ts @@ -1,6 +1,6 @@ import mitt from 'mitt' import { startTransition, useCallback, useEffect, useState } from 'react' -import { renderQueryString } from '../../url-encoding' +import { renderQueryString } from '../../lib/url-encoding' import { createAdapterProvider } from './context' import type { AdapterInterface, AdapterOptions } from './defs' import { diff --git a/packages/nuqs/src/adapters/next/impl.app.ts b/packages/nuqs/src/adapters/next/impl.app.ts index 8aea3b0b4..1bb320333 100644 --- a/packages/nuqs/src/adapters/next/impl.app.ts +++ b/packages/nuqs/src/adapters/next/impl.app.ts @@ -1,6 +1,6 @@ import { useRouter, useSearchParams } from 'next/navigation' import { startTransition, useCallback, useOptimistic } from 'react' -import { debug } from '../../debug' +import { debug } from '../../lib/debug' import type { AdapterInterface, UpdateUrlFunction } from '../lib/defs' import { renderURL } from './shared' diff --git a/packages/nuqs/src/adapters/next/impl.pages.ts b/packages/nuqs/src/adapters/next/impl.pages.ts index 6b98eaa96..89203aae9 100644 --- a/packages/nuqs/src/adapters/next/impl.pages.ts +++ b/packages/nuqs/src/adapters/next/impl.pages.ts @@ -1,7 +1,7 @@ import { useSearchParams } from 'next/navigation.js' import type { NextRouter } from 'next/router' import { useCallback } from 'react' -import { debug } from '../../debug' +import { debug } from '../../lib/debug' import { createAdapterProvider } from '../lib/context' import type { AdapterInterface, UpdateUrlFunction } from '../lib/defs' import { renderURL } from './shared' diff --git a/packages/nuqs/src/adapters/next/shared.ts b/packages/nuqs/src/adapters/next/shared.ts index 495aa7fbb..c1088a106 100644 --- a/packages/nuqs/src/adapters/next/shared.ts +++ b/packages/nuqs/src/adapters/next/shared.ts @@ -1,4 +1,4 @@ -import { renderQueryString } from '../../url-encoding' +import { renderQueryString } from '../../lib/url-encoding' export function renderURL(base: string, search: URLSearchParams) { const hashlessBase = base.split('#')[0] ?? '' diff --git a/packages/nuqs/src/adapters/react.ts b/packages/nuqs/src/adapters/react.ts index 5c3f7437a..fb0aaa25c 100644 --- a/packages/nuqs/src/adapters/react.ts +++ b/packages/nuqs/src/adapters/react.ts @@ -1,8 +1,8 @@ import mitt from 'mitt' import { useEffect, useState } from 'react' -import { renderQueryString } from '../url-encoding' +import { renderQueryString } from '../lib/url-encoding' import { createAdapterProvider } from './lib/context' -import type { AdapterOptions } from './lib/defs' +import type { AdapterInterface, AdapterOptions } from './lib/defs' import { patchHistory, type SearchParamsSyncEmitter } from './lib/patch-history' const emitter: SearchParamsSyncEmitter = mitt() @@ -19,7 +19,7 @@ function updateUrl(search: URLSearchParams, options: AdapterOptions) { } } -function useNuqsReactAdapter() { +function useNuqsReactAdapter(): AdapterInterface { const [searchParams, setSearchParams] = useState(() => { if (typeof location === 'undefined') { return new URLSearchParams() diff --git a/packages/nuqs/src/adapters/testing.ts b/packages/nuqs/src/adapters/testing.ts index 7dfc10306..dd6c6278a 100644 --- a/packages/nuqs/src/adapters/testing.ts +++ b/packages/nuqs/src/adapters/testing.ts @@ -1,6 +1,6 @@ import { createElement, type ReactNode } from 'react' -import { resetQueue } from '../update-queue' -import { renderQueryString } from '../url-encoding' +import { globalThrottleQueue } from '../lib/queues/throttle' +import { renderQueryString } from '../lib/url-encoding' import { context } from './lib/context' import type { AdapterInterface, AdapterOptions } from './lib/defs' @@ -25,7 +25,7 @@ export function NuqsTestingAdapter({ ...props }: TestingAdapterProps) { if (resetUrlUpdateQueueOnMount) { - resetQueue() + globalThrottleQueue.reset() } const useAdapter = (): AdapterInterface => ({ searchParams: new URLSearchParams(props.searchParams), diff --git a/packages/nuqs/src/cache.ts b/packages/nuqs/src/cache.ts index 7cc2a1306..79ae5ddea 100644 --- a/packages/nuqs/src/cache.ts +++ b/packages/nuqs/src/cache.ts @@ -1,7 +1,7 @@ // @ts-ignore import { cache } from 'react' import type { SearchParams, UrlKeys } from './defs' -import { error } from './errors' +import { error } from './lib/errors' import { createLoader } from './loader' import type { inferParserType, ParserMap } from './parsers' diff --git a/packages/nuqs/src/update-queue.test.ts b/packages/nuqs/src/lib/compose.test.ts similarity index 79% rename from packages/nuqs/src/update-queue.test.ts rename to packages/nuqs/src/lib/compose.test.ts index d634281a4..248125b61 100644 --- a/packages/nuqs/src/update-queue.test.ts +++ b/packages/nuqs/src/lib/compose.test.ts @@ -1,13 +1,13 @@ -import { describe, expect, test, vi } from 'vitest' -import { compose } from './update-queue' +import { describe, expect, it, vi } from 'vitest' +import { compose } from './compose' -describe('update-queue/compose', () => { - test('empty array', () => { +describe('queues: compose', () => { + it('handles an empty array', () => { const final = vi.fn() compose([], final) expect(final).toHaveBeenCalledOnce() }) - test('one item', () => { + it('handles one item, calling it before the final', () => { const a = vi .fn() .mockImplementation(x => x()) @@ -20,7 +20,7 @@ describe('update-queue/compose', () => { final.mock.invocationCallOrder[0]! ) }) - test('several items', () => { + it('composes several items, calling them in order', () => { const a = vi.fn().mockImplementation(x => x()) const b = vi.fn().mockImplementation(x => x()) const c = vi.fn().mockImplementation(x => x()) diff --git a/packages/nuqs/src/lib/compose.ts b/packages/nuqs/src/lib/compose.ts new file mode 100644 index 000000000..c1583e166 --- /dev/null +++ b/packages/nuqs/src/lib/compose.ts @@ -0,0 +1,16 @@ +export function compose( + fns: React.TransitionStartFunction[], + final: () => void +) { + const recursiveCompose = (index: number) => { + if (index === fns.length) { + return final() + } + const fn = fns[index] + if (!fn) { + throw new Error('Invalid transition function') + } + fn(() => recursiveCompose(index + 1)) + } + recursiveCompose(0) +} diff --git a/packages/nuqs/src/debug.test.ts b/packages/nuqs/src/lib/debug.test.ts similarity index 100% rename from packages/nuqs/src/debug.test.ts rename to packages/nuqs/src/lib/debug.test.ts diff --git a/packages/nuqs/src/debug.ts b/packages/nuqs/src/lib/debug.ts similarity index 100% rename from packages/nuqs/src/debug.ts rename to packages/nuqs/src/lib/debug.ts diff --git a/packages/nuqs/src/errors.ts b/packages/nuqs/src/lib/errors.ts similarity index 100% rename from packages/nuqs/src/errors.ts rename to packages/nuqs/src/lib/errors.ts diff --git a/packages/nuqs/src/lib/queues/debounce.test.ts b/packages/nuqs/src/lib/queues/debounce.test.ts new file mode 100644 index 000000000..c3dc90cd0 --- /dev/null +++ b/packages/nuqs/src/lib/queues/debounce.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it, vi } from 'vitest' +import { DebouncedPromiseQueue } from './debounce' + +describe('queues: DebouncedPromiseQueue', () => { + it('creates a queue for a given key', () => { + vi.useFakeTimers() + const spy = vi.fn() + const queue = new DebouncedPromiseQueue('key', spy) + queue.push('value', 100) + vi.advanceTimersToNextTimer() + expect(spy).toHaveBeenCalledExactlyOnceWith('key', 'value') + }) + it('debounces the queue', () => { + vi.useFakeTimers() + const spy = vi.fn() + const queue = new DebouncedPromiseQueue('key', spy) + queue.push('a', 100) + queue.push('b', 100) + queue.push('c', 100) + vi.advanceTimersToNextTimer() + expect(spy).toHaveBeenCalledExactlyOnceWith('key', 'c') + }) + it('returns a stable promise to the next time the callback is called', async () => { + vi.useFakeTimers() + const queue = new DebouncedPromiseQueue('key', () => 'output') + const p1 = queue.push('value', 100) + const p2 = queue.push('value', 100) + expect(p1).toBe(p2) + vi.advanceTimersToNextTimer() + await expect(p1).resolves.toBe('output') + }) + it('returns a new Promise once the callback is called', async () => { + vi.useFakeTimers() + let count = 0 + const queue = new DebouncedPromiseQueue('key', () => count++) + const p1 = queue.push('value', 100) + vi.advanceTimersToNextTimer() + await expect(p1).resolves.toBe(0) + const p2 = queue.push('value', 100) + expect(p2).not.toBe(p1) + vi.advanceTimersToNextTimer() + await expect(p2).resolves.toBe(1) + }) +}) diff --git a/packages/nuqs/src/lib/queues/debounce.ts b/packages/nuqs/src/lib/queues/debounce.ts new file mode 100644 index 000000000..328ef5a83 --- /dev/null +++ b/packages/nuqs/src/lib/queues/debounce.ts @@ -0,0 +1,37 @@ +import { timeout } from '../timeout' +import { withResolvers } from '../with-resolvers' + +export class DebouncedPromiseQueue { + key: string + callback: (key: string, value: ValueType) => OutputType + resolvers = withResolvers() + controller = new AbortController() + + constructor( + key: string, + callback: (key: string, value: ValueType) => OutputType + ) { + this.key = key + this.callback = callback + } + + public push(value: ValueType, timeMs: number) { + this.controller.abort() + this.controller = new AbortController() + timeout( + () => { + try { + const output = this.callback(this.key, value) + this.resolvers.resolve(output) + } catch (error) { + this.resolvers.reject(error) + } finally { + this.resolvers = withResolvers() + } + }, + timeMs, + this.controller.signal + ) + return this.resolvers.promise + } +} diff --git a/packages/nuqs/src/lib/queues/throttle.ts b/packages/nuqs/src/lib/queues/throttle.ts new file mode 100644 index 000000000..a4a506ea7 --- /dev/null +++ b/packages/nuqs/src/lib/queues/throttle.ts @@ -0,0 +1,193 @@ +// 50ms between calls to the history API seems to satisfy Chrome and Firefox. +// Safari remains annoying with at most 100 calls in 30 seconds. + +import type { AdapterInterface, AdapterOptions } from '../../adapters/lib/defs' +import type { Options } from '../../defs' +import { compose } from '../compose' +import { debug } from '../debug' +import { error } from '../errors' +import { withResolvers, type Resolvers } from '../with-resolvers' + +// edit: Safari 17 now allows 100 calls per 10 seconds, a bit better. +export function getDefaultThrottle() { + if (typeof window === 'undefined') return 50 + // https://stackoverflow.com/questions/7944460/detect-safari-browser + // @ts-expect-error + const isSafari = Boolean(window.GestureEvent) + if (!isSafari) { + return 50 + } + try { + const match = navigator.userAgent?.match(/version\/([\d\.]+) safari/i) + return parseFloat(match![1]!) >= 17 ? 120 : 320 + } catch { + return 320 + } +} + +export const FLUSH_RATE_LIMIT_MS = getDefaultThrottle() + +// -- + +type UpdateMap = Map +type TransitionSet = Set +export type UpdateQueueAdapterContext = Pick< + AdapterInterface, + 'updateUrl' | 'getSearchParamsSnapshot' | 'rateLimitFactor' +> + +export type UpdateQueuePushArgs = { + key: string + query: string | null + options: AdapterOptions & Pick + throttleMs: number +} + +function getSearchParamsSnapshotFromLocation() { + return new URLSearchParams(location.search) +} + +export class ThrottledQueue { + updateMap: UpdateMap = new Map() + options: Required = { + history: 'replace', + scroll: false, + shallow: true + } + throttleMs = 0 + transitions: TransitionSet = new Set() + resolvers: Resolvers | null = null + lastFlushedAt = 0 + + public push({ key, query, options, throttleMs }: UpdateQueuePushArgs) { + debug('[nuqs queue] Enqueueing %s=%s %O', key, query, options) + // Enqueue update + this.updateMap.set(key, query) + if (options.history === 'push') { + this.options.history = 'push' + } + if (options.scroll) { + this.options.scroll = true + } + if (options.shallow === false) { + this.options.shallow = false + } + if (options.startTransition) { + this.transitions.add(options.startTransition) + } + this.throttleMs = Math.max( + throttleMs ?? FLUSH_RATE_LIMIT_MS, + Number.isFinite(this.throttleMs) ? this.throttleMs : 0 + ) + } + + public getQueuedQuery(key: string): string | null | undefined { + return this.updateMap.get(key) + } + + public flush({ + getSearchParamsSnapshot = getSearchParamsSnapshotFromLocation, + rateLimitFactor = 1, + ...adapter + }: UpdateQueueAdapterContext): Promise { + if (!Number.isFinite(this.throttleMs)) { + debug('[nuqs queue] Skipping flush due to throttleMs=Infinity') + return Promise.resolve(getSearchParamsSnapshot()) + } + if (this.resolvers) { + // Flush already scheduled + return this.resolvers.promise + } + this.resolvers = withResolvers() + const flushNow = () => { + this.lastFlushedAt = performance.now() + const [search, error] = this.applyPendingUpdates({ + ...adapter, + getSearchParamsSnapshot + }) + if (error === null) { + this.resolvers!.resolve(search) + } else { + this.resolvers!.reject(search) + } + this.resolvers = null + } + // We run the logic on the next event loop tick to allow + // multiple query updates to batch in the same event loop tick + // and possibly set their own throttleMs value. + const runOnNextTick = () => { + const now = performance.now() + const timeSinceLastFlush = now - this.lastFlushedAt + const throttleMs = this.throttleMs + const flushInMs = + rateLimitFactor * + 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 this.resolvers.promise + } + + public reset() { + this.updateMap.clear() + this.transitions.clear() + this.options.history = 'replace' + this.options.scroll = false + this.options.shallow = true + this.throttleMs = FLUSH_RATE_LIMIT_MS + } + + // -- + + private applyPendingUpdates( + adapter: Required> + ): [URLSearchParams, null | unknown] { + const { updateUrl, getSearchParamsSnapshot } = adapter + const search = getSearchParamsSnapshot() + if (this.updateMap.size === 0) { + return [search, null] + } + // Work on a copy and clear the queue immediately + const items = Array.from(this.updateMap.entries()) + const options = { ...this.options } + const transitions = Array.from(this.transitions) + // Restore defaults + this.reset() + debug('[nuqs queue] Flushing queue %O with options %O', items, options) + for (const [key, value] of items) { + if (value === null) { + search.delete(key) + } else { + search.set(key, value) + } + } + try { + compose(transitions, () => { + updateUrl(search, { + history: options.history, + scroll: options.scroll, + shallow: options.shallow + }) + }) + return [search, null] + } catch (err) { + // This may fail due to rate-limiting of history methods, + // for example Safari only allows 100 updates in a 30s window. + console.error(error(429), items.map(([key]) => key).join(), err) + return [search, err] + } + } +} + +export const globalThrottleQueue = new ThrottledQueue() diff --git a/packages/nuqs/src/lib/safe-parse.ts b/packages/nuqs/src/lib/safe-parse.ts new file mode 100644 index 000000000..0e7d51254 --- /dev/null +++ b/packages/nuqs/src/lib/safe-parse.ts @@ -0,0 +1,21 @@ +import type { Parser } from '../parsers' +import { warn } from './debug' + +export function safeParse( + parser: Parser['parse'], + value: string, + key?: string +) { + try { + return parser(value) + } catch (error) { + warn( + '[nuqs] Error while parsing value `%s`: %O' + + (key ? ' (for key `%s`)' : ''), + value, + error, + key + ) + return null + } +} diff --git a/packages/nuqs/src/sync.test.tsx b/packages/nuqs/src/lib/sync.test.tsx similarity index 97% rename from packages/nuqs/src/sync.test.tsx rename to packages/nuqs/src/lib/sync.test.tsx index d0e332110..a70e1a54e 100644 --- a/packages/nuqs/src/sync.test.tsx +++ b/packages/nuqs/src/lib/sync.test.tsx @@ -2,8 +2,8 @@ import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import React from 'react' import { describe, expect, it } from 'vitest' -import { withNuqsTestingAdapter } from './adapters/testing' -import { parseAsInteger, useQueryState, useQueryStates } from './index' +import { withNuqsTestingAdapter } from '../adapters/testing' +import { parseAsInteger, useQueryState, useQueryStates } from '../index' type TestComponentProps = { testId: string diff --git a/packages/nuqs/src/sync.ts b/packages/nuqs/src/lib/sync.ts similarity index 100% rename from packages/nuqs/src/sync.ts rename to packages/nuqs/src/lib/sync.ts diff --git a/packages/nuqs/src/lib/timeout.test.ts b/packages/nuqs/src/lib/timeout.test.ts new file mode 100644 index 000000000..9e4eb2257 --- /dev/null +++ b/packages/nuqs/src/lib/timeout.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it, vi } from 'vitest' +import { timeout } from './timeout' + +describe('utils: timeout', () => { + it('should resolve after the timeout if no signal is triggered', () => { + vi.useFakeTimers() + const spy = vi.fn() + const controller = new AbortController() + timeout(() => spy(), 100, controller.signal) + vi.advanceTimersToNextTimer() + expect(spy).toHaveBeenCalledOnce() + }) + it('should abort the timeout if the signal is triggered', () => { + vi.useFakeTimers() + const spy = vi.fn() + const controller = new AbortController() + timeout(() => spy(), 100, controller.signal) + controller.abort() + vi.advanceTimersToNextTimer() + expect(spy).not.toHaveBeenCalled() + }) + it('does not throw when aborting after timeout', () => { + vi.useFakeTimers() + const spy = vi.fn() + const controller = new AbortController() + timeout(() => spy(), 100, controller.signal) + vi.advanceTimersToNextTimer() + expect(() => controller.abort()).not.toThrow() + expect(spy).toHaveBeenCalledOnce() + }) + it('reuses the same signal to abort multiple timeouts', () => { + vi.useFakeTimers() + const spy = vi.fn() + const controller = new AbortController() + timeout(() => spy(), 100, controller.signal) + timeout(() => spy(), 100, controller.signal) + controller.abort() + vi.advanceTimersToNextTimer() + expect(spy).not.toHaveBeenCalled() + }) + it('aborts when using a signal already used before', () => { + vi.useFakeTimers() + const spy = vi.fn() + const controller = new AbortController() + timeout(() => spy(), 100, controller.signal) + controller.abort() + timeout(() => spy(), 100, controller.signal) + vi.advanceTimersToNextTimer() + expect(spy).toHaveBeenCalledOnce() + }) +}) diff --git a/packages/nuqs/src/lib/timeout.ts b/packages/nuqs/src/lib/timeout.ts new file mode 100644 index 000000000..3f42dbb44 --- /dev/null +++ b/packages/nuqs/src/lib/timeout.ts @@ -0,0 +1,4 @@ +export function timeout(callback: () => void, ms: number, signal: AbortSignal) { + const id = setTimeout(callback, ms) + signal.addEventListener('abort', () => clearTimeout(id)) +} diff --git a/packages/nuqs/src/url-encoding.test.ts b/packages/nuqs/src/lib/url-encoding.test.ts similarity index 100% rename from packages/nuqs/src/url-encoding.test.ts rename to packages/nuqs/src/lib/url-encoding.test.ts diff --git a/packages/nuqs/src/url-encoding.ts b/packages/nuqs/src/lib/url-encoding.ts similarity index 100% rename from packages/nuqs/src/url-encoding.ts rename to packages/nuqs/src/lib/url-encoding.ts diff --git a/packages/nuqs/src/lib/with-resolvers.test.ts b/packages/nuqs/src/lib/with-resolvers.test.ts new file mode 100644 index 000000000..d5ae51158 --- /dev/null +++ b/packages/nuqs/src/lib/with-resolvers.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import { withResolvers } from './with-resolvers' + +describe('utils: withResolvers', () => { + it('supports built-in Promise.withResolvers', async () => { + expect('withResolvers' in Promise).toBe(true) + const resolving = withResolvers() + expect(resolving.promise).toBeInstanceOf(Promise) + expect(resolving.resolve).toBeInstanceOf(Function) + expect(resolving.reject).toBeInstanceOf(Function) + resolving.resolve('foo') + await expect(resolving.promise).resolves.toBe('foo') + const rejecting = withResolvers() + rejecting.reject('bar') + await expect(rejecting.promise).rejects.toBe('bar') + }) + it('polyfills when support is not available', async () => { + if ('withResolvers' in Promise) { + // @ts-expect-error + delete Promise.withResolvers + } + expect('withResolvers' in Promise).toBe(false) + const resolving = withResolvers() + expect(resolving.promise).toBeInstanceOf(Promise) + expect(resolving.resolve).toBeInstanceOf(Function) + expect(resolving.reject).toBeInstanceOf(Function) + resolving.resolve('foo') + await expect(resolving.promise).resolves.toBe('foo') + const rejecting = withResolvers() + rejecting.reject('bar') + await expect(rejecting.promise).rejects.toBe('bar') + }) +}) diff --git a/packages/nuqs/src/lib/with-resolvers.ts b/packages/nuqs/src/lib/with-resolvers.ts new file mode 100644 index 000000000..fcb88da4d --- /dev/null +++ b/packages/nuqs/src/lib/with-resolvers.ts @@ -0,0 +1,20 @@ +export type Resolvers = { + promise: Promise + resolve: (value: T | PromiseLike) => void + reject: (reason?: any) => void +} + +export function withResolvers(): Resolvers { + const P = Promise + if ('withResolvers' in Promise) { + return Promise.withResolvers() + } + // todo: Remove this once Promise.withResolvers is Baseline GA (September 2026) + let resolve: (value: T | PromiseLike) => void = () => {} + let reject: () => void = () => {} + const promise = new P((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} diff --git a/packages/nuqs/src/parsers.ts b/packages/nuqs/src/parsers.ts index bde81495b..6844de0d6 100644 --- a/packages/nuqs/src/parsers.ts +++ b/packages/nuqs/src/parsers.ts @@ -1,5 +1,5 @@ import type { Options } from './defs' -import { safeParse } from './utils' +import { safeParse } from './lib/safe-parse' type Require = Pick, Keys> & Omit diff --git a/packages/nuqs/src/serializer.ts b/packages/nuqs/src/serializer.ts index 9ea77f4f9..994bf1b35 100644 --- a/packages/nuqs/src/serializer.ts +++ b/packages/nuqs/src/serializer.ts @@ -1,6 +1,6 @@ import type { Nullable, Options, UrlKeys } from './defs' +import { renderQueryString } from './lib/url-encoding' import type { inferParserType, ParserMap } from './parsers' -import { renderQueryString } from './url-encoding' type Base = string | URLSearchParams | URL diff --git a/packages/nuqs/src/update-queue.ts b/packages/nuqs/src/update-queue.ts deleted file mode 100644 index 5971362a3..000000000 --- a/packages/nuqs/src/update-queue.ts +++ /dev/null @@ -1,200 +0,0 @@ -import type { AdapterInterface } from './adapters/lib/defs' -import { debug } from './debug' -import type { Options } from './defs' -import { error } from './errors' -import { getDefaultThrottle } from './utils' - -export const FLUSH_RATE_LIMIT_MS = getDefaultThrottle() - -type UpdateMap = Map -const updateQueue: UpdateMap = new Map() -const queueOptions: Required< - Omit -> = { - history: 'replace', - scroll: false, - shallow: true, - throttleMs: FLUSH_RATE_LIMIT_MS -} -const transitionsQueue: Set = new Set() - -let lastFlushTimestamp = 0 -let flushPromiseCache: Promise | null = null - -export function getQueuedValue(key: string) { - return updateQueue.get(key) -} - -export function resetQueue() { - updateQueue.clear() - transitionsQueue.clear() - queueOptions.history = 'replace' - queueOptions.scroll = false - queueOptions.shallow = true - queueOptions.throttleMs = FLUSH_RATE_LIMIT_MS -} - -export function enqueueQueryStringUpdate( - key: string, - value: Value | null, - serialize: (value: Value) => string, - options: Pick< - Options, - 'history' | 'scroll' | 'shallow' | 'startTransition' | 'throttleMs' - > -) { - const serializedOrNull = value === null ? null : serialize(value) - debug('[nuqs queue] Enqueueing %s=%s %O', key, serializedOrNull, options) - updateQueue.set(key, serializedOrNull) - // Any item can override an option for the whole batch of updates - if (options.history === 'push') { - queueOptions.history = 'push' - } - if (options.scroll) { - queueOptions.scroll = true - } - if (options.shallow === false) { - queueOptions.shallow = false - } - if (options.startTransition) { - transitionsQueue.add(options.startTransition) - } - queueOptions.throttleMs = Math.max( - options.throttleMs ?? FLUSH_RATE_LIMIT_MS, - Number.isFinite(queueOptions.throttleMs) ? queueOptions.throttleMs : 0 - ) - return serializedOrNull -} - -function getSearchParamsSnapshotFromLocation() { - return new URLSearchParams(location.search) -} - -/** - * Eventually flush the update queue to the URL query string. - * - * This takes care of throttling to avoid hitting browsers limits - * on calls to the history pushState/replaceState APIs, and defers - * the call so that individual query state updates can be batched - * when running in the same event loop tick. - * - * @returns a Promise to the URLSearchParams that have been applied. - */ -export function scheduleFlushToURL({ - getSearchParamsSnapshot = getSearchParamsSnapshotFromLocation, - updateUrl, - rateLimitFactor = 1 -}: Pick< - AdapterInterface, - 'updateUrl' | 'getSearchParamsSnapshot' | 'rateLimitFactor' ->) { - if (flushPromiseCache === null) { - flushPromiseCache = new Promise((resolve, reject) => { - if (!Number.isFinite(queueOptions.throttleMs)) { - debug('[nuqs queue] Skipping flush due to throttleMs=Infinity') - resolve(getSearchParamsSnapshot()) - // Let the promise be returned before clearing the cached value - setTimeout(() => { - flushPromiseCache = null - }, 0) - return - } - function flushNow() { - lastFlushTimestamp = performance.now() - const [search, error] = flushUpdateQueue({ - updateUrl, - getSearchParamsSnapshot - }) - if (error === null) { - resolve(search) - } else { - reject(search) - } - flushPromiseCache = null - } - // 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 = - rateLimitFactor * - 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({ - updateUrl, - getSearchParamsSnapshot -}: Pick, 'updateUrl' | 'getSearchParamsSnapshot'>): [ - URLSearchParams, - null | unknown -] { - const search = getSearchParamsSnapshot() - if (updateQueue.size === 0) { - return [search, null] - } - // Work on a copy and clear the queue immediately - const items = Array.from(updateQueue.entries()) - const options = { ...queueOptions } - const transitions = Array.from(transitionsQueue) - // Restore defaults - resetQueue() - debug('[nuqs queue] Flushing queue %O with options %O', items, options) - for (const [key, value] of items) { - if (value === null) { - search.delete(key) - } else { - search.set(key, value) - } - } - try { - compose(transitions, () => { - updateUrl(search, { - history: options.history, - scroll: options.scroll, - shallow: options.shallow - }) - }) - return [search, null] - } catch (err) { - // This may fail due to rate-limiting of history methods, - // for example Safari only allows 100 updates in a 30s window. - console.error(error(429), items.map(([key]) => key).join(), err) - return [search, err] - } -} - -export function compose( - fns: React.TransitionStartFunction[], - final: () => void -) { - const recursiveCompose = (index: number) => { - if (index === fns.length) { - return final() - } - const fn = fns[index] - if (!fn) { - throw new Error('Invalid transition function') - } - fn(() => recursiveCompose(index + 1)) - } - recursiveCompose(0) -} diff --git a/packages/nuqs/src/useQueryState.ts b/packages/nuqs/src/useQueryState.ts index 06f11ad9a..e88595b62 100644 --- a/packages/nuqs/src/useQueryState.ts +++ b/packages/nuqs/src/useQueryState.ts @@ -1,16 +1,11 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { useAdapter } from './adapters/lib/context' -import { debug } from './debug' import type { Options } from './defs' +import { debug } from './lib/debug' +import { FLUSH_RATE_LIMIT_MS, globalThrottleQueue } from './lib/queues/throttle' +import { safeParse } from './lib/safe-parse' +import { emitter, type CrossHookSyncPayload } from './lib/sync' import type { Parser } from './parsers' -import { emitter, type CrossHookSyncPayload } from './sync' -import { - FLUSH_RATE_LIMIT_MS, - enqueueQueryStringUpdate, - getQueuedValue, - scheduleFlushToURL -} from './update-queue' -import { safeParse } from './utils' export interface UseQueryStateOptions extends Parser, Options {} @@ -226,7 +221,7 @@ export function useQueryState( const initialSearchParams = adapter.searchParams const queryRef = useRef(initialSearchParams?.get(key) ?? null) const [internalState, setInternalState] = useState(() => { - const queuedQuery = getQueuedValue(key) + const queuedQuery = globalThrottleQueue.getQueuedQuery(key) const query = queuedQuery === undefined ? (initialSearchParams?.get(key) ?? null) @@ -282,17 +277,21 @@ export function useQueryState( ) { newValue = null } - const query = enqueueQueryStringUpdate(key, newValue, serialize, { - // Call-level options take precedence over hook declaration options. - history: options.history ?? history, - shallow: options.shallow ?? shallow, - scroll: options.scroll ?? scroll, - throttleMs: options.throttleMs ?? throttleMs, - startTransition: options.startTransition ?? startTransition + const query = newValue === null ? null : serialize(newValue) + globalThrottleQueue.push({ + key, + query, + options: { + history: options.history ?? history, + shallow: options.shallow ?? shallow, + scroll: options.scroll ?? scroll, + startTransition: options.startTransition ?? startTransition + }, + throttleMs: options.throttleMs ?? throttleMs }) // Sync all hooks state (including this one) emitter.emit(key, { state: newValue, query }) - return scheduleFlushToURL(adapter) + return globalThrottleQueue.flush(adapter) }, [ key, diff --git a/packages/nuqs/src/useQueryStates.ts b/packages/nuqs/src/useQueryStates.ts index 112f944f9..35446ef98 100644 --- a/packages/nuqs/src/useQueryStates.ts +++ b/packages/nuqs/src/useQueryStates.ts @@ -1,16 +1,11 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useAdapter } from './adapters/lib/context' -import { debug } from './debug' import type { Nullable, Options, UrlKeys } from './defs' +import { debug } from './lib/debug' +import { FLUSH_RATE_LIMIT_MS, globalThrottleQueue } from './lib/queues/throttle' +import { safeParse } from './lib/safe-parse' +import { emitter, type CrossHookSyncPayload } from './lib/sync' import type { Parser } from './parsers' -import { emitter, type CrossHookSyncPayload } from './sync' -import { - enqueueQueryStringUpdate, - FLUSH_RATE_LIMIT_MS, - getQueuedValue, - scheduleFlushToURL -} from './update-queue' -import { safeParse } from './utils' type KeyMapValue = Parser & Options & { @@ -231,27 +226,27 @@ export function useQueryStates( ) { value = null } - const query = enqueueQueryStringUpdate( - urlKey, - value, - parser.serialize ?? String, - { + const query = + value === null ? null : (parser.serialize ?? String)(value) + globalThrottleQueue.push({ + key: urlKey, + query, + options: { // Call-level options take precedence over individual parser options // which take precedence over global options history: callOptions.history ?? parser.history ?? history, shallow: callOptions.shallow ?? parser.shallow ?? shallow, scroll: callOptions.scroll ?? parser.scroll ?? scroll, - throttleMs: - callOptions.throttleMs ?? parser.throttleMs ?? throttleMs, startTransition: callOptions.startTransition ?? parser.startTransition ?? startTransition - } - ) + }, + throttleMs: callOptions.throttleMs ?? parser.throttleMs ?? throttleMs + }) emitter.emit(urlKey, { state: value, query }) } - return scheduleFlushToURL(adapter) + return globalThrottleQueue.flush(adapter) }, [ stateKeys, @@ -291,7 +286,7 @@ function parseMap( const state = Object.keys(keyMap).reduce((out, stateKey) => { const urlKey = urlKeys?.[stateKey] ?? stateKey const { parse } = keyMap[stateKey]! - const queuedQuery = getQueuedValue(urlKey) + const queuedQuery = globalThrottleQueue.getQueuedQuery(urlKey) const query = queuedQuery === undefined ? (searchParams?.get(urlKey) ?? null) diff --git a/packages/nuqs/src/utils.ts b/packages/nuqs/src/utils.ts deleted file mode 100644 index c19797ee1..000000000 --- a/packages/nuqs/src/utils.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { warn } from './debug' -import type { Parser } from './parsers' - -export function safeParse( - parser: Parser['parse'], - value: string, - key?: string -) { - try { - return parser(value) - } catch (error) { - warn( - '[nuqs] Error while parsing value `%s`: %O' + - (key ? ' (for key `%s`)' : ''), - value, - error, - key - ) - return null - } -} - -// 50ms between calls to the history API seems to satisfy Chrome and Firefox. -// Safari remains annoying with at most 100 calls in 30 seconds. -// edit: Safari 17 now allows 100 calls per 10 seconds, a bit better. -export function getDefaultThrottle() { - if (typeof window === 'undefined') return 50 - // https://stackoverflow.com/questions/7944460/detect-safari-browser - // @ts-expect-error - const isSafari = Boolean(window.GestureEvent) - if (!isSafari) { - return 50 - } - try { - const match = navigator.userAgent?.match(/version\/([\d\.]+) safari/i) - return parseFloat(match![1]!) >= 17 ? 120 : 320 - } catch { - return 320 - } -} From 7c766cf2c657a13208b92b5de4817fd6ec0962aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Best?= Date: Sat, 8 Feb 2025 07:59:11 +0100 Subject: [PATCH 02/14] feat: Add limitUrlUpdates option --- packages/nuqs/src/defs.ts | 21 +++++++++- packages/nuqs/src/lib/queues/throttle.ts | 9 ++-- packages/nuqs/src/useQueryState.ts | 41 +++++++++++------- packages/nuqs/src/useQueryStates.ts | 53 +++++++++++++++--------- 4 files changed, 86 insertions(+), 38 deletions(-) diff --git a/packages/nuqs/src/defs.ts b/packages/nuqs/src/defs.ts index db7872037..e545685ba 100644 --- a/packages/nuqs/src/defs.ts +++ b/packages/nuqs/src/defs.ts @@ -34,13 +34,32 @@ export type Options = { * 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. + * and defaults to 50ms. Safari requires a higher value of around 120ms. * * Note: the value will be limited to a minimum of 50ms, anything lower * will not have any effect. + * @deprecated use limitUrlUpdates: { 'method': 'throttle', timeMs: number } */ throttleMs?: number + /** + * Limit the rate of URL updates to prevent spamming the browser history, + * and the server if `shallow: false`. + * + * This is to alleviate rate-limiting of the Web History API in browsers, + * and defaults to 50ms. Safari requires a higher value of around 120ms. + * + * Note: the value will be limited to a minimum of 50ms, anything lower + * will not have any effect. + * + * If both `throttleMs` and `limitUrlUpdates` are set, `limitUrlUpdates` will + * take precedence. + */ + limitUrlUpdates?: { + method: 'debounce' | 'throttle' + timeMs: number + } + /** * In RSC frameworks, opt-in to observing Server Component loading states when * doing non-shallow updates by passing a `startTransition` from the diff --git a/packages/nuqs/src/lib/queues/throttle.ts b/packages/nuqs/src/lib/queues/throttle.ts index a4a506ea7..b4ce5a3f7 100644 --- a/packages/nuqs/src/lib/queues/throttle.ts +++ b/packages/nuqs/src/lib/queues/throttle.ts @@ -25,7 +25,10 @@ export function getDefaultThrottle() { } } -export const FLUSH_RATE_LIMIT_MS = getDefaultThrottle() +export const defaultRateLimit: NonNullable = { + method: 'throttle', + timeMs: getDefaultThrottle() +} // -- @@ -76,7 +79,7 @@ export class ThrottledQueue { this.transitions.add(options.startTransition) } this.throttleMs = Math.max( - throttleMs ?? FLUSH_RATE_LIMIT_MS, + throttleMs ?? defaultRateLimit.timeMs, Number.isFinite(this.throttleMs) ? this.throttleMs : 0 ) } @@ -145,7 +148,7 @@ export class ThrottledQueue { this.options.history = 'replace' this.options.scroll = false this.options.shallow = true - this.throttleMs = FLUSH_RATE_LIMIT_MS + this.throttleMs = defaultRateLimit.timeMs } // -- diff --git a/packages/nuqs/src/useQueryState.ts b/packages/nuqs/src/useQueryState.ts index e88595b62..841d803a0 100644 --- a/packages/nuqs/src/useQueryState.ts +++ b/packages/nuqs/src/useQueryState.ts @@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { useAdapter } from './adapters/lib/context' import type { Options } from './defs' import { debug } from './lib/debug' -import { FLUSH_RATE_LIMIT_MS, globalThrottleQueue } from './lib/queues/throttle' +import { defaultRateLimit, globalThrottleQueue } from './lib/queues/throttle' import { safeParse } from './lib/safe-parse' import { emitter, type CrossHookSyncPayload } from './lib/sync' import type { Parser } from './parsers' @@ -196,7 +196,8 @@ export function useQueryState( history = 'replace', shallow = true, scroll = false, - throttleMs = FLUSH_RATE_LIMIT_MS, + throttleMs = defaultRateLimit.timeMs, + limitUrlUpdates, parse = x => x as unknown as T, serialize = String, eq = (a, b) => a === b, @@ -209,7 +210,7 @@ export function useQueryState( history: 'replace', scroll: false, shallow: true, - throttleMs: FLUSH_RATE_LIMIT_MS, + throttleMs: defaultRateLimit.timeMs, parse: x => x as unknown as T, serialize: String, eq: (a, b) => a === b, @@ -278,20 +279,28 @@ export function useQueryState( newValue = null } const query = newValue === null ? null : serialize(newValue) - globalThrottleQueue.push({ - key, - query, - options: { - history: options.history ?? history, - shallow: options.shallow ?? shallow, - scroll: options.scroll ?? scroll, - startTransition: options.startTransition ?? startTransition - }, - throttleMs: options.throttleMs ?? throttleMs - }) // Sync all hooks state (including this one) emitter.emit(key, { state: newValue, query }) - return globalThrottleQueue.flush(adapter) + if (limitUrlUpdates?.method === 'debounce') { + // todo: implement debounce + } else { + globalThrottleQueue.push({ + key, + query, + options: { + history: options.history ?? history, + shallow: options.shallow ?? shallow, + scroll: options.scroll ?? scroll, + startTransition: options.startTransition ?? startTransition + }, + throttleMs: + options.limitUrlUpdates?.timeMs ?? + limitUrlUpdates?.timeMs ?? + options.throttleMs ?? + throttleMs + }) + return globalThrottleQueue.flush(adapter) + } }, [ key, @@ -299,6 +308,8 @@ export function useQueryState( shallow, scroll, throttleMs, + limitUrlUpdates?.method, + limitUrlUpdates?.timeMs, startTransition, adapter.updateUrl, adapter.getSearchParamsSnapshot, diff --git a/packages/nuqs/src/useQueryStates.ts b/packages/nuqs/src/useQueryStates.ts index 35446ef98..fef163ac1 100644 --- a/packages/nuqs/src/useQueryStates.ts +++ b/packages/nuqs/src/useQueryStates.ts @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useAdapter } from './adapters/lib/context' import type { Nullable, Options, UrlKeys } from './defs' import { debug } from './lib/debug' -import { FLUSH_RATE_LIMIT_MS, globalThrottleQueue } from './lib/queues/throttle' +import { defaultRateLimit, globalThrottleQueue } from './lib/queues/throttle' import { safeParse } from './lib/safe-parse' import { emitter, type CrossHookSyncPayload } from './lib/sync' import type { Parser } from './parsers' @@ -63,7 +63,8 @@ export function useQueryStates( history = 'replace', scroll = false, shallow = true, - throttleMs = FLUSH_RATE_LIMIT_MS, + throttleMs = defaultRateLimit.timeMs, + limitUrlUpdates, clearOnDefault = true, startTransition, urlKeys = defaultUrlKeys @@ -210,6 +211,7 @@ export function useQueryStates( ) ?? nullMap) : (stateUpdater ?? nullMap) debug('[nuq+ `%s`] setState: %O', stateKeys, newState) + let returnedPromise: Promise | undefined = undefined for (let [stateKey, value] of Object.entries(newState)) { const parser = keyMap[stateKey] const urlKey = resolvedUrlKeys[stateKey]! @@ -228,25 +230,36 @@ export function useQueryStates( } const query = value === null ? null : (parser.serialize ?? String)(value) - globalThrottleQueue.push({ - key: urlKey, - query, - options: { - // Call-level options take precedence over individual parser options - // which take precedence over global options - history: callOptions.history ?? parser.history ?? history, - shallow: callOptions.shallow ?? parser.shallow ?? shallow, - scroll: callOptions.scroll ?? parser.scroll ?? scroll, - startTransition: - callOptions.startTransition ?? - parser.startTransition ?? - startTransition - }, - throttleMs: callOptions.throttleMs ?? parser.throttleMs ?? throttleMs - }) emitter.emit(urlKey, { state: value, query }) + if (limitUrlUpdates?.method === 'debounce') { + // todo: implement debounce + } else { + globalThrottleQueue.push({ + key: urlKey, + query, + options: { + // Call-level options take precedence over individual parser options + // which take precedence over global options + history: callOptions.history ?? parser.history ?? history, + shallow: callOptions.shallow ?? parser.shallow ?? shallow, + scroll: callOptions.scroll ?? parser.scroll ?? scroll, + startTransition: + callOptions.startTransition ?? + parser.startTransition ?? + startTransition + }, + throttleMs: + callOptions?.limitUrlUpdates?.timeMs ?? + parser?.limitUrlUpdates?.timeMs ?? + limitUrlUpdates?.timeMs ?? + callOptions.throttleMs ?? + parser.throttleMs ?? + throttleMs + }) + } } - return globalThrottleQueue.flush(adapter) + returnedPromise ??= globalThrottleQueue.flush(adapter) + return returnedPromise }, [ stateKeys, @@ -254,6 +267,8 @@ export function useQueryStates( shallow, scroll, throttleMs, + limitUrlUpdates?.method, + limitUrlUpdates?.timeMs, startTransition, resolvedUrlKeys, adapter.updateUrl, From e3c5ea45935cc2aa1e0a10f29410f74797b9562a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Best?= Date: Sun, 9 Feb 2025 13:34:03 +0100 Subject: [PATCH 03/14] feat: Implement debouncing logic --- .../e2e/next/src/app/app/debounce/client.tsx | 70 ++++++++++++++++ .../e2e/next/src/app/app/debounce/page.tsx | 23 ++++++ .../src/app/app/debounce/search-params.ts | 19 +++++ packages/nuqs/src/lib/queues/debounce.test.ts | 16 ++-- packages/nuqs/src/lib/queues/debounce.ts | 76 ++++++++++++++--- packages/nuqs/src/lib/queues/throttle.ts | 11 ++- packages/nuqs/src/useQueryState.ts | 51 +++++++----- packages/nuqs/src/useQueryStates.ts | 81 ++++++++++++------- 8 files changed, 278 insertions(+), 69 deletions(-) create mode 100644 packages/e2e/next/src/app/app/debounce/client.tsx create mode 100644 packages/e2e/next/src/app/app/debounce/page.tsx create mode 100644 packages/e2e/next/src/app/app/debounce/search-params.ts diff --git a/packages/e2e/next/src/app/app/debounce/client.tsx b/packages/e2e/next/src/app/app/debounce/client.tsx new file mode 100644 index 000000000..08cbb4ccf --- /dev/null +++ b/packages/e2e/next/src/app/app/debounce/client.tsx @@ -0,0 +1,70 @@ +'use client' + +import { parseAsInteger, useQueryState, useQueryStates } from 'nuqs' +import { searchParams, urlKeys } from './search-params' + +export function Client() { + const [timeMs, setTimeMs] = useQueryState( + 'debounceTime', + parseAsInteger.withDefault(100).withOptions({ + limitUrlUpdates: { + method: 'throttle', + timeMs: 200 + } + }) + ) + const [{ search, pageIndex }, setSearchParams] = useQueryStates( + searchParams, + { + shallow: false, + urlKeys + } + ) + return ( + <> + + setSearchParams( + { search: e.target.value }, + { + limitUrlUpdates: { + method: e.target.value === '' ? 'throttle' : 'debounce', + timeMs: e.target.value === '' ? 50 : timeMs + } + } + ) + } + /> + + +
+ +
+ + ) +} diff --git a/packages/e2e/next/src/app/app/debounce/page.tsx b/packages/e2e/next/src/app/app/debounce/page.tsx new file mode 100644 index 000000000..7840df1a6 --- /dev/null +++ b/packages/e2e/next/src/app/app/debounce/page.tsx @@ -0,0 +1,23 @@ +import { type SearchParams } from 'nuqs' +import { Suspense } from 'react' +import { Client } from './client' +import { loadSearchParams } from './search-params' + +type PageProps = { + searchParams: Promise +} + +export default async function Page({ searchParams }: PageProps) { + const { search, pageIndex } = await loadSearchParams(searchParams) + return ( + <> +

Server

+

Search: {search}

+

Page Index: {pageIndex}

+

Client

+ + + + + ) +} diff --git a/packages/e2e/next/src/app/app/debounce/search-params.ts b/packages/e2e/next/src/app/app/debounce/search-params.ts new file mode 100644 index 000000000..e6e665f76 --- /dev/null +++ b/packages/e2e/next/src/app/app/debounce/search-params.ts @@ -0,0 +1,19 @@ +import { + createLoader, + parseAsInteger, + parseAsString, + UrlKeys +} from 'nuqs/server' + +export const searchParams = { + search: parseAsString.withDefault('').withOptions({ + limitUrlUpdates: { method: 'debounce', timeMs: 2000 } + }), + pageIndex: parseAsInteger.withDefault(0) +} +export const urlKeys: UrlKeys = { + search: 'q', + pageIndex: 'page' +} + +export const loadSearchParams = createLoader(searchParams, { urlKeys }) diff --git a/packages/nuqs/src/lib/queues/debounce.test.ts b/packages/nuqs/src/lib/queues/debounce.test.ts index c3dc90cd0..9a69f2514 100644 --- a/packages/nuqs/src/lib/queues/debounce.test.ts +++ b/packages/nuqs/src/lib/queues/debounce.test.ts @@ -4,25 +4,25 @@ import { DebouncedPromiseQueue } from './debounce' describe('queues: DebouncedPromiseQueue', () => { it('creates a queue for a given key', () => { vi.useFakeTimers() - const spy = vi.fn() - const queue = new DebouncedPromiseQueue('key', spy) + const spy = vi.fn().mockResolvedValue('output') + const queue = new DebouncedPromiseQueue(spy) queue.push('value', 100) vi.advanceTimersToNextTimer() - expect(spy).toHaveBeenCalledExactlyOnceWith('key', 'value') + expect(spy).toHaveBeenCalledExactlyOnceWith('value') }) it('debounces the queue', () => { vi.useFakeTimers() - const spy = vi.fn() - const queue = new DebouncedPromiseQueue('key', spy) + const spy = vi.fn().mockResolvedValue('output') + const queue = new DebouncedPromiseQueue(spy) queue.push('a', 100) queue.push('b', 100) queue.push('c', 100) vi.advanceTimersToNextTimer() - expect(spy).toHaveBeenCalledExactlyOnceWith('key', 'c') + expect(spy).toHaveBeenCalledExactlyOnceWith('c') }) it('returns a stable promise to the next time the callback is called', async () => { vi.useFakeTimers() - const queue = new DebouncedPromiseQueue('key', () => 'output') + const queue = new DebouncedPromiseQueue(() => Promise.resolve('output')) const p1 = queue.push('value', 100) const p2 = queue.push('value', 100) expect(p1).toBe(p2) @@ -32,7 +32,7 @@ describe('queues: DebouncedPromiseQueue', () => { it('returns a new Promise once the callback is called', async () => { vi.useFakeTimers() let count = 0 - const queue = new DebouncedPromiseQueue('key', () => count++) + const queue = new DebouncedPromiseQueue(() => Promise.resolve(count++)) const p1 = queue.push('value', 100) vi.advanceTimersToNextTimer() await expect(p1).resolves.toBe(0) diff --git a/packages/nuqs/src/lib/queues/debounce.ts b/packages/nuqs/src/lib/queues/debounce.ts index 328ef5a83..2f881847e 100644 --- a/packages/nuqs/src/lib/queues/debounce.ts +++ b/packages/nuqs/src/lib/queues/debounce.ts @@ -1,32 +1,37 @@ import { timeout } from '../timeout' import { withResolvers } from '../with-resolvers' +import { + globalThrottleQueue, + type UpdateQueueAdapterContext, + type UpdateQueuePushArgs +} from './throttle' export class DebouncedPromiseQueue { - key: string - callback: (key: string, value: ValueType) => OutputType + callback: (value: ValueType) => Promise resolvers = withResolvers() controller = new AbortController() + queuedValue: ValueType | undefined = undefined - constructor( - key: string, - callback: (key: string, value: ValueType) => OutputType - ) { - this.key = key + constructor(callback: (value: ValueType) => Promise) { this.callback = callback } public push(value: ValueType, timeMs: number) { + this.queuedValue = value this.controller.abort() this.controller = new AbortController() timeout( () => { try { - const output = this.callback(this.key, value) - this.resolvers.resolve(output) + this.callback(value) + .then(output => this.resolvers.resolve(output)) + .catch(error => this.resolvers.reject(error)) + .finally(() => { + // todo: Should we clear the queued value here? + this.resolvers = withResolvers() + }) } catch (error) { this.resolvers.reject(error) - } finally { - this.resolvers = withResolvers() } }, timeMs, @@ -34,4 +39,53 @@ export class DebouncedPromiseQueue { ) return this.resolvers.promise } + + public get queued() { + return this.queuedValue + } +} + +type DebouncedUpdateQueue = DebouncedPromiseQueue< + Omit, + URLSearchParams +> + +export class DebounceController { + queues: Map = new Map() + + public push( + update: Omit, + timeMs: number, + adapter: UpdateQueueAdapterContext + ): Promise { + if (!this.queues.has(update.key)) { + const queue = new DebouncedPromiseQueue< + Omit, + URLSearchParams + >(update => { + globalThrottleQueue.push(update) + return globalThrottleQueue.flush(adapter) + // todo: Figure out cleanup strategy + // .finally(() => { + // this.queues.delete(update.key) + // }) + }) + this.queues.set(update.key, queue) + } + const queue = this.queues.get(update.key)! + return queue.push(update, timeMs) + } + + public getQueuedQuery(key: string) { + // The debounced queued values are more likely to be up-to-date + // than any updates pending in the throttle queue, which comes last + // in the update chain. + const debouncedQueued = this.queues.get(key)?.queued?.query + if (debouncedQueued !== undefined) { + return debouncedQueued + } + return globalThrottleQueue.getQueuedQuery(key) + } } + +export const debounceController = new DebounceController() diff --git a/packages/nuqs/src/lib/queues/throttle.ts b/packages/nuqs/src/lib/queues/throttle.ts index b4ce5a3f7..5c302eadf 100644 --- a/packages/nuqs/src/lib/queues/throttle.ts +++ b/packages/nuqs/src/lib/queues/throttle.ts @@ -43,7 +43,7 @@ export type UpdateQueuePushArgs = { key: string query: string | null options: AdapterOptions & Pick - throttleMs: number + throttleMs?: number } function getSearchParamsSnapshotFromLocation() { @@ -62,7 +62,12 @@ export class ThrottledQueue { resolvers: Resolvers | null = null lastFlushedAt = 0 - public push({ key, query, options, throttleMs }: UpdateQueuePushArgs) { + public push({ + key, + query, + options, + throttleMs = defaultRateLimit.timeMs + }: UpdateQueuePushArgs) { debug('[nuqs queue] Enqueueing %s=%s %O', key, query, options) // Enqueue update this.updateMap.set(key, query) @@ -79,7 +84,7 @@ export class ThrottledQueue { this.transitions.add(options.startTransition) } this.throttleMs = Math.max( - throttleMs ?? defaultRateLimit.timeMs, + throttleMs, Number.isFinite(this.throttleMs) ? this.throttleMs : 0 ) } diff --git a/packages/nuqs/src/useQueryState.ts b/packages/nuqs/src/useQueryState.ts index 841d803a0..d5b797d3e 100644 --- a/packages/nuqs/src/useQueryState.ts +++ b/packages/nuqs/src/useQueryState.ts @@ -2,7 +2,12 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { useAdapter } from './adapters/lib/context' import type { Options } from './defs' import { debug } from './lib/debug' -import { defaultRateLimit, globalThrottleQueue } from './lib/queues/throttle' +import { debounceController } from './lib/queues/debounce' +import { + defaultRateLimit, + globalThrottleQueue, + type UpdateQueuePushArgs +} from './lib/queues/throttle' import { safeParse } from './lib/safe-parse' import { emitter, type CrossHookSyncPayload } from './lib/sync' import type { Parser } from './parsers' @@ -222,7 +227,7 @@ export function useQueryState( const initialSearchParams = adapter.searchParams const queryRef = useRef(initialSearchParams?.get(key) ?? null) const [internalState, setInternalState] = useState(() => { - const queuedQuery = globalThrottleQueue.getQueuedQuery(key) + const queuedQuery = debounceController.getQueuedQuery(key) const query = queuedQuery === undefined ? (initialSearchParams?.get(key) ?? null) @@ -281,24 +286,32 @@ export function useQueryState( const query = newValue === null ? null : serialize(newValue) // Sync all hooks state (including this one) emitter.emit(key, { state: newValue, query }) - if (limitUrlUpdates?.method === 'debounce') { - // todo: implement debounce + const update: UpdateQueuePushArgs = { + key, + query, + options: { + history: options.history ?? history, + shallow: options.shallow ?? shallow, + scroll: options.scroll ?? scroll, + startTransition: options.startTransition ?? startTransition + } + } + if ( + options.limitUrlUpdates?.method === 'debounce' || + limitUrlUpdates?.method === 'debounce' + ) { + const timeMs = + options.limitUrlUpdates?.timeMs ?? + limitUrlUpdates?.timeMs ?? + defaultRateLimit.timeMs + return debounceController.push(update, timeMs, adapter) } else { - globalThrottleQueue.push({ - key, - query, - options: { - history: options.history ?? history, - shallow: options.shallow ?? shallow, - scroll: options.scroll ?? scroll, - startTransition: options.startTransition ?? startTransition - }, - throttleMs: - options.limitUrlUpdates?.timeMs ?? - limitUrlUpdates?.timeMs ?? - options.throttleMs ?? - throttleMs - }) + update.throttleMs = + options.limitUrlUpdates?.timeMs ?? + limitUrlUpdates?.timeMs ?? + options.throttleMs ?? + throttleMs + globalThrottleQueue.push(update) return globalThrottleQueue.flush(adapter) } }, diff --git a/packages/nuqs/src/useQueryStates.ts b/packages/nuqs/src/useQueryStates.ts index fef163ac1..4e845e9cd 100644 --- a/packages/nuqs/src/useQueryStates.ts +++ b/packages/nuqs/src/useQueryStates.ts @@ -2,7 +2,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useAdapter } from './adapters/lib/context' import type { Nullable, Options, UrlKeys } from './defs' import { debug } from './lib/debug' -import { defaultRateLimit, globalThrottleQueue } from './lib/queues/throttle' +import { debounceController } from './lib/queues/debounce' +import { + defaultRateLimit, + globalThrottleQueue, + type UpdateQueuePushArgs +} from './lib/queues/throttle' import { safeParse } from './lib/safe-parse' import { emitter, type CrossHookSyncPayload } from './lib/sync' import type { Parser } from './parsers' @@ -212,6 +217,7 @@ export function useQueryStates( : (stateUpdater ?? nullMap) debug('[nuq+ `%s`] setState: %O', stateKeys, newState) let returnedPromise: Promise | undefined = undefined + let maxDebounceTime = 0 for (let [stateKey, value] of Object.entries(newState)) { const parser = keyMap[stateKey] const urlKey = resolvedUrlKeys[stateKey]! @@ -231,35 +237,54 @@ export function useQueryStates( const query = value === null ? null : (parser.serialize ?? String)(value) emitter.emit(urlKey, { state: value, query }) - if (limitUrlUpdates?.method === 'debounce') { - // todo: implement debounce + const update: UpdateQueuePushArgs = { + key: urlKey, + query, + options: { + // Call-level options take precedence over individual parser options + // which take precedence over global options + history: callOptions.history ?? parser.history ?? history, + shallow: callOptions.shallow ?? parser.shallow ?? shallow, + scroll: callOptions.scroll ?? parser.scroll ?? scroll, + startTransition: + callOptions.startTransition ?? + parser.startTransition ?? + startTransition + } + } + if ( + callOptions?.limitUrlUpdates?.method === 'debounce' || + limitUrlUpdates?.method === 'debounce' || + parser.limitUrlUpdates?.method === 'debounce' + ) { + const timeMs = + callOptions?.limitUrlUpdates?.timeMs ?? + limitUrlUpdates?.timeMs ?? + parser.limitUrlUpdates?.timeMs ?? + defaultRateLimit.timeMs + const debouncedPromise = debounceController.push( + update, + timeMs, + adapter + ) + if (maxDebounceTime < timeMs) { + // The largest debounce is likely to be the last URL update: + returnedPromise = debouncedPromise + maxDebounceTime = timeMs + } } else { - globalThrottleQueue.push({ - key: urlKey, - query, - options: { - // Call-level options take precedence over individual parser options - // which take precedence over global options - history: callOptions.history ?? parser.history ?? history, - shallow: callOptions.shallow ?? parser.shallow ?? shallow, - scroll: callOptions.scroll ?? parser.scroll ?? scroll, - startTransition: - callOptions.startTransition ?? - parser.startTransition ?? - startTransition - }, - throttleMs: - callOptions?.limitUrlUpdates?.timeMs ?? - parser?.limitUrlUpdates?.timeMs ?? - limitUrlUpdates?.timeMs ?? - callOptions.throttleMs ?? - parser.throttleMs ?? - throttleMs - }) + update.throttleMs = + callOptions?.limitUrlUpdates?.timeMs ?? + parser?.limitUrlUpdates?.timeMs ?? + limitUrlUpdates?.timeMs ?? + callOptions.throttleMs ?? + parser.throttleMs ?? + throttleMs + globalThrottleQueue.push(update) } } - returnedPromise ??= globalThrottleQueue.flush(adapter) - return returnedPromise + const globalPromise = globalThrottleQueue.flush(adapter) + return returnedPromise ?? globalPromise }, [ stateKeys, @@ -301,7 +326,7 @@ function parseMap( const state = Object.keys(keyMap).reduce((out, stateKey) => { const urlKey = urlKeys?.[stateKey] ?? stateKey const { parse } = keyMap[stateKey]! - const queuedQuery = globalThrottleQueue.getQueuedQuery(urlKey) + const queuedQuery = debounceController.getQueuedQuery(urlKey) const query = queuedQuery === undefined ? (searchParams?.get(urlKey) ?? null) From e09bd3ed8ad795c69e6a481230a46b8063a1128e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Best?= Date: Mon, 10 Feb 2025 10:11:55 +0100 Subject: [PATCH 04/14] chore: Identify adapters in debug logs --- packages/nuqs/src/adapters/lib/react-router.ts | 2 ++ packages/nuqs/src/adapters/next/impl.app.ts | 2 +- packages/nuqs/src/adapters/next/impl.pages.ts | 2 +- packages/nuqs/src/adapters/react.ts | 2 ++ 4 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/nuqs/src/adapters/lib/react-router.ts b/packages/nuqs/src/adapters/lib/react-router.ts index a2c37e389..4cc17d722 100644 --- a/packages/nuqs/src/adapters/lib/react-router.ts +++ b/packages/nuqs/src/adapters/lib/react-router.ts @@ -1,5 +1,6 @@ import mitt from 'mitt' import { startTransition, useCallback, useEffect, useState } from 'react' +import { debug } from '../../lib/debug' import { renderQueryString } from '../../lib/url-encoding' import { createAdapterProvider } from './context' import type { AdapterInterface, AdapterOptions } from './defs' @@ -47,6 +48,7 @@ export function createReactRouterBasedAdapter({ }) const url = new URL(location.href) url.search = renderQueryString(search) + debug(`[nuqs ${adapter}] Updating url: %s`, url) // First, update the URL locally without triggering a network request, // this allows keeping a reactive URL if the network is slow. const updateMethod = diff --git a/packages/nuqs/src/adapters/next/impl.app.ts b/packages/nuqs/src/adapters/next/impl.app.ts index 1bb320333..f67953fb4 100644 --- a/packages/nuqs/src/adapters/next/impl.app.ts +++ b/packages/nuqs/src/adapters/next/impl.app.ts @@ -16,7 +16,7 @@ export function useNuqsNextAppRouterAdapter(): AdapterInterface { setOptimisticSearchParams(search) } const url = renderURL(location.origin + location.pathname, search) - debug('[nuqs queue (app)] Updating url: %s', url) + debug('[nuqs next/app] Updating url: %s', url) // First, update the URL locally without triggering a network request, // this allows keeping a reactive URL if the network is slow. const updateMethod = diff --git a/packages/nuqs/src/adapters/next/impl.pages.ts b/packages/nuqs/src/adapters/next/impl.pages.ts index 89203aae9..cae6c4cf6 100644 --- a/packages/nuqs/src/adapters/next/impl.pages.ts +++ b/packages/nuqs/src/adapters/next/impl.pages.ts @@ -33,7 +33,7 @@ export function useNuqsNextPagesRouterAdapter(): AdapterInterface { // passing an asPath, causing issues in dynamic routes in the pages router. const nextRouter = window.next?.router! const url = renderURL(nextRouter.state.asPath.split('?')[0] ?? '', search) - debug('[nuqs queue (pages)] Updating url: %s', url) + debug('[nuqs next/pages] Updating url: %s', url) const method = options.history === 'push' ? nextRouter.push : nextRouter.replace method.call(nextRouter, url, url, { diff --git a/packages/nuqs/src/adapters/react.ts b/packages/nuqs/src/adapters/react.ts index fb0aaa25c..5bb90b0fc 100644 --- a/packages/nuqs/src/adapters/react.ts +++ b/packages/nuqs/src/adapters/react.ts @@ -1,5 +1,6 @@ import mitt from 'mitt' import { useEffect, useState } from 'react' +import { debug } from '../lib/debug' import { renderQueryString } from '../lib/url-encoding' import { createAdapterProvider } from './lib/context' import type { AdapterInterface, AdapterOptions } from './lib/defs' @@ -10,6 +11,7 @@ const emitter: SearchParamsSyncEmitter = mitt() function updateUrl(search: URLSearchParams, options: AdapterOptions) { const url = new URL(location.href) url.search = renderQueryString(search) + debug('[nuqs react] Updating url: %s', url) const method = options.history === 'push' ? history.pushState : history.replaceState method.call(history, history.state, '', url) From ddd59a4c93a6c85e59dc76b390381488335042ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Best?= Date: Mon, 10 Feb 2025 10:50:27 +0100 Subject: [PATCH 05/14] fix: Clear queued value after use --- packages/nuqs/src/lib/queues/debounce.test.ts | 161 +++++++++++++++++- packages/nuqs/src/lib/queues/debounce.ts | 33 ++-- packages/nuqs/src/lib/queues/throttle.ts | 15 +- packages/nuqs/src/useQueryStates.ts | 5 +- 4 files changed, 191 insertions(+), 23 deletions(-) diff --git a/packages/nuqs/src/lib/queues/debounce.test.ts b/packages/nuqs/src/lib/queues/debounce.test.ts index 9a69f2514..58d44ace8 100644 --- a/packages/nuqs/src/lib/queues/debounce.test.ts +++ b/packages/nuqs/src/lib/queues/debounce.test.ts @@ -1,8 +1,15 @@ +import { setTimeout } from 'node:timers/promises' import { describe, expect, it, vi } from 'vitest' -import { DebouncedPromiseQueue } from './debounce' +import type { UpdateUrlFunction } from '../../adapters/lib/defs' +import { DebounceController, DebouncedPromiseQueue } from './debounce' +import { ThrottledQueue, type UpdateQueueAdapterContext } from './throttle' -describe('queues: DebouncedPromiseQueue', () => { - it('creates a queue for a given key', () => { +async function passThrough(value: T): Promise { + return value +} + +describe('debounce: DebouncedPromiseQueue', () => { + it('calls the callback after the timer expired', () => { vi.useFakeTimers() const spy = vi.fn().mockResolvedValue('output') const queue = new DebouncedPromiseQueue(spy) @@ -22,12 +29,12 @@ describe('queues: DebouncedPromiseQueue', () => { }) it('returns a stable promise to the next time the callback is called', async () => { vi.useFakeTimers() - const queue = new DebouncedPromiseQueue(() => Promise.resolve('output')) - const p1 = queue.push('value', 100) - const p2 = queue.push('value', 100) + const queue = new DebouncedPromiseQueue(passThrough) + const p1 = queue.push('a', 100) + const p2 = queue.push('b', 100) expect(p1).toBe(p2) vi.advanceTimersToNextTimer() - await expect(p1).resolves.toBe('output') + await expect(p1).resolves.toBe('b') }) it('returns a new Promise once the callback is called', async () => { vi.useFakeTimers() @@ -41,4 +48,144 @@ describe('queues: DebouncedPromiseQueue', () => { vi.advanceTimersToNextTimer() await expect(p2).resolves.toBe(1) }) + it('keeps a record of the last queued value', async () => { + vi.useFakeTimers() + const queue = new DebouncedPromiseQueue(passThrough) + const p = queue.push('a', 100) + expect(queue.queuedValue).toBe('a') + vi.advanceTimersToNextTimer() + await expect(p).resolves.toBe('a') + expect(queue.queuedValue).toBeUndefined() + }) + it('clears the queued value when the callback returns its promise (not when it resolves)', () => { + vi.useFakeTimers() + const queue = new DebouncedPromiseQueue(async input => { + await setTimeout(100) + return input + }) + queue.push('a', 100) + vi.advanceTimersByTime(100) + expect(queue.queuedValue).toBeUndefined() + }) + it('clears the queued value when the callback throws an error synchronously', async () => { + vi.useFakeTimers() + const queue = new DebouncedPromiseQueue(() => { + throw new Error('error') + }) + const p = queue.push('a', 100) + vi.advanceTimersToNextTimer() + expect(queue.queuedValue).toBeUndefined() + await expect(p).rejects.toThrowError('error') + }) + it('clears the queued value when the callback rejects', async () => { + vi.useFakeTimers() + const queue = new DebouncedPromiseQueue(() => + Promise.reject(new Error('error')) + ) + const p = queue.push('a', 100) + vi.advanceTimersToNextTimer() + expect(queue.queuedValue).toBeUndefined() + await expect(p).rejects.toThrowError('error') + }) +}) + +describe.only('debounce: DebounceController', () => { + it('schedules an update and calls the adapter with it', async () => { + vi.useFakeTimers() + const fakeAdapter: UpdateQueueAdapterContext = { + updateUrl: vi.fn(), + getSearchParamsSnapshot() { + return new URLSearchParams() + } + } + const controller = new DebounceController() + const promise = controller.push( + { + key: 'key', + query: 'value', + options: {} + }, + 100, + fakeAdapter + ) + const queue = controller.queues.get('key') + expect(queue).toBeInstanceOf(DebouncedPromiseQueue) + vi.runAllTimers() + await expect(promise).resolves.toEqual(new URLSearchParams('?key=value')) + expect(fakeAdapter.updateUrl).toHaveBeenCalledExactlyOnceWith( + new URLSearchParams('?key=value'), + { + history: 'replace', + scroll: false, + shallow: true + } + ) + }) + it('isolates debounce queues per key', async () => { + vi.useFakeTimers() + const fakeAdapter: UpdateQueueAdapterContext = { + updateUrl: vi.fn(), + getSearchParamsSnapshot() { + return new URLSearchParams() + } + } + const controller = new DebounceController() + const promise1 = controller.push( + { + key: 'a', + query: 'a', + options: {} + }, + 100, + fakeAdapter + ) + const promise2 = controller.push( + { + key: 'b', + query: 'b', + options: {} + }, + 200, + fakeAdapter + ) + expect(promise1).not.toBe(promise2) + vi.runAllTimers() + await expect(promise1).resolves.toEqual(new URLSearchParams('?a=a')) + // Our snapshot always returns an empty search params object, so there is no + // merging of keys here. + await expect(promise2).resolves.toEqual(new URLSearchParams('?b=b')) + expect(fakeAdapter.updateUrl).toHaveBeenCalledTimes(2) + }) + it('keeps a record of pending updates', async () => { + vi.useFakeTimers() + const fakeAdapter: UpdateQueueAdapterContext = { + updateUrl: vi.fn(), + getSearchParamsSnapshot() { + return new URLSearchParams() + } + } + const controller = new DebounceController() + controller.push( + { + key: 'key', + query: 'value', + options: {} + }, + 100, + fakeAdapter + ) + expect(controller.getQueuedQuery('key')).toEqual('value') + vi.runAllTimers() + expect(controller.getQueuedQuery('key')).toBeUndefined() + }) + it('falls back to the throttle queue pending values if nothing is debounced', () => { + const throttleQueue = new ThrottledQueue() + throttleQueue.push({ + key: 'key', + query: 'value', + options: {} + }) + const controller = new DebounceController(throttleQueue) + expect(controller.getQueuedQuery('key')).toEqual('value') + }) }) diff --git a/packages/nuqs/src/lib/queues/debounce.ts b/packages/nuqs/src/lib/queues/debounce.ts index 2f881847e..85c2e575f 100644 --- a/packages/nuqs/src/lib/queues/debounce.ts +++ b/packages/nuqs/src/lib/queues/debounce.ts @@ -1,7 +1,9 @@ +import { debug } from '../debug' import { timeout } from '../timeout' import { withResolvers } from '../with-resolvers' import { globalThrottleQueue, + ThrottledQueue, type UpdateQueueAdapterContext, type UpdateQueuePushArgs } from './throttle' @@ -23,14 +25,18 @@ export class DebouncedPromiseQueue { timeout( () => { try { - this.callback(value) - .then(output => this.resolvers.resolve(output)) + debug('[nuqs queue] Flushing debounce queue', value) + const p = this.callback(value) + debug('[nuqs queue] Reset debounced queue %O', this.queuedValue) + this.queuedValue = undefined + p.then(output => this.resolvers.resolve(output)) .catch(error => this.resolvers.reject(error)) .finally(() => { - // todo: Should we clear the queued value here? + // Reset Promise for next use this.resolvers = withResolvers() }) } catch (error) { + this.queuedValue = undefined this.resolvers.reject(error) } }, @@ -39,20 +45,23 @@ export class DebouncedPromiseQueue { ) return this.resolvers.promise } - - public get queued() { - return this.queuedValue - } } +// -- + type DebouncedUpdateQueue = DebouncedPromiseQueue< Omit, URLSearchParams > export class DebounceController { + throttleQueue: ThrottledQueue queues: Map = new Map() + constructor(throttleQueue: ThrottledQueue = new ThrottledQueue()) { + this.throttleQueue = throttleQueue + } + public push( update: Omit, timeMs: number, @@ -63,8 +72,8 @@ export class DebounceController { Omit, URLSearchParams >(update => { - globalThrottleQueue.push(update) - return globalThrottleQueue.flush(adapter) + this.throttleQueue.push(update) + return this.throttleQueue.flush(adapter) // todo: Figure out cleanup strategy // .finally(() => { // this.queues.delete(update.key) @@ -80,12 +89,12 @@ export class DebounceController { // The debounced queued values are more likely to be up-to-date // than any updates pending in the throttle queue, which comes last // in the update chain. - const debouncedQueued = this.queues.get(key)?.queued?.query + const debouncedQueued = this.queues.get(key)?.queuedValue?.query if (debouncedQueued !== undefined) { return debouncedQueued } - return globalThrottleQueue.getQueuedQuery(key) + return this.throttleQueue.getQueuedQuery(key) } } -export const debounceController = new DebounceController() +export const debounceController = new DebounceController(globalThrottleQueue) diff --git a/packages/nuqs/src/lib/queues/throttle.ts b/packages/nuqs/src/lib/queues/throttle.ts index 5c302eadf..678aa7f1e 100644 --- a/packages/nuqs/src/lib/queues/throttle.ts +++ b/packages/nuqs/src/lib/queues/throttle.ts @@ -106,6 +106,10 @@ export class ThrottledQueue { // Flush already scheduled return this.resolvers.promise } + if (this.updateMap.size === 0) { + // Nothing to flush + return Promise.resolve(getSearchParamsSnapshot()) + } this.resolvers = withResolvers() const flushNow = () => { this.lastFlushedAt = performance.now() @@ -131,9 +135,10 @@ export class ThrottledQueue { rateLimitFactor * Math.max(0, Math.min(throttleMs, throttleMs - timeSinceLastFlush)) debug( - '[nuqs queue] Scheduling flush in %f ms. Throttled at %f ms', + `[nuqs queue] Scheduling flush in %f ms. Throttled at %f ms (x%f)`, flushInMs, - throttleMs + throttleMs, + rateLimitFactor ) if (flushInMs === 0) { // Since we're already in the "next tick" from queued updates, @@ -172,7 +177,11 @@ export class ThrottledQueue { const transitions = Array.from(this.transitions) // Restore defaults this.reset() - debug('[nuqs queue] Flushing queue %O with options %O', items, options) + debug( + '[nuqs queue] Flushing throttle queue %O with options %O', + items, + options + ) for (const [key, value] of items) { if (value === null) { search.delete(key) diff --git a/packages/nuqs/src/useQueryStates.ts b/packages/nuqs/src/useQueryStates.ts index 4e845e9cd..bc099f950 100644 --- a/packages/nuqs/src/useQueryStates.ts +++ b/packages/nuqs/src/useQueryStates.ts @@ -268,7 +268,8 @@ export function useQueryStates( adapter ) if (maxDebounceTime < timeMs) { - // The largest debounce is likely to be the last URL update: + // The largest debounce is likely to be the last URL update, + // so we keep that Promise to return it. returnedPromise = debouncedPromise maxDebounceTime = timeMs } @@ -283,6 +284,8 @@ export function useQueryStates( globalThrottleQueue.push(update) } } + // We need to flush the throttle queue, but we may have a pending + // debounced update that will resolve afterwards. const globalPromise = globalThrottleQueue.flush(adapter) return returnedPromise ?? globalPromise }, From 9510f62651313e2b00dec993067c0f0b1620de80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Best?= Date: Mon, 10 Feb 2025 13:25:33 +0100 Subject: [PATCH 06/14] chore: Cleanup & testing --- packages/nuqs/src/lib/queues/debounce.test.ts | 16 ++++- packages/nuqs/src/lib/queues/debounce.ts | 36 +++++----- packages/nuqs/src/lib/queues/throttle.test.ts | 65 +++++++++++++++++++ packages/nuqs/src/lib/queues/throttle.ts | 12 ++-- packages/nuqs/src/lib/timeout.ts | 15 ++++- 5 files changed, 118 insertions(+), 26 deletions(-) create mode 100644 packages/nuqs/src/lib/queues/throttle.test.ts diff --git a/packages/nuqs/src/lib/queues/debounce.test.ts b/packages/nuqs/src/lib/queues/debounce.test.ts index 58d44ace8..777c7de21 100644 --- a/packages/nuqs/src/lib/queues/debounce.test.ts +++ b/packages/nuqs/src/lib/queues/debounce.test.ts @@ -87,9 +87,23 @@ describe('debounce: DebouncedPromiseQueue', () => { expect(queue.queuedValue).toBeUndefined() await expect(p).rejects.toThrowError('error') }) + it('returns a new Promise when an update is pushed while the callback is pending', async () => { + vi.useFakeTimers() + const queue = new DebouncedPromiseQueue(async input => { + await setTimeout(100) + return input + }) + const p1 = queue.push('a', 100) + vi.advanceTimersByTime(150) // 100ms debounce + half the callback settle time + const p2 = queue.push('b', 100) + expect(p1).not.toBe(p2) + vi.advanceTimersToNextTimer() + await expect(p1).resolves.toBe('a') + await expect(p2).resolves.toBe('b') + }) }) -describe.only('debounce: DebounceController', () => { +describe('debounce: DebounceController', () => { it('schedules an update and calls the adapter with it', async () => { vi.useFakeTimers() const fakeAdapter: UpdateQueueAdapterContext = { diff --git a/packages/nuqs/src/lib/queues/debounce.ts b/packages/nuqs/src/lib/queues/debounce.ts index 85c2e575f..69422b9e7 100644 --- a/packages/nuqs/src/lib/queues/debounce.ts +++ b/packages/nuqs/src/lib/queues/debounce.ts @@ -18,26 +18,28 @@ export class DebouncedPromiseQueue { this.callback = callback } - public push(value: ValueType, timeMs: number) { + push(value: ValueType, timeMs: number) { this.queuedValue = value this.controller.abort() this.controller = new AbortController() timeout( () => { + // Keep the resolvers in a separate variable to reset the queue + // while the callback is pending, so that the next push can be + // assigned to a new Promise (and not dropped). + const outputResolvers = this.resolvers try { debug('[nuqs queue] Flushing debounce queue', value) - const p = this.callback(value) + const callbackPromise = this.callback(value) debug('[nuqs queue] Reset debounced queue %O', this.queuedValue) this.queuedValue = undefined - p.then(output => this.resolvers.resolve(output)) - .catch(error => this.resolvers.reject(error)) - .finally(() => { - // Reset Promise for next use - this.resolvers = withResolvers() - }) + this.resolvers = withResolvers() + callbackPromise + .then(output => outputResolvers.resolve(output)) + .catch(error => outputResolvers.reject(error)) } catch (error) { this.queuedValue = undefined - this.resolvers.reject(error) + outputResolvers.reject(error) } }, timeMs, @@ -62,7 +64,7 @@ export class DebounceController { this.throttleQueue = throttleQueue } - public push( + push( update: Omit, timeMs: number, adapter: UpdateQueueAdapterContext @@ -73,11 +75,13 @@ export class DebounceController { URLSearchParams >(update => { this.throttleQueue.push(update) - return this.throttleQueue.flush(adapter) - // todo: Figure out cleanup strategy - // .finally(() => { - // this.queues.delete(update.key) - // }) + return this.throttleQueue.flush(adapter).finally(() => { + const queuedValue = this.queues.get(update.key)?.queuedValue + if (queuedValue === undefined) { + // Cleanup empty queues + this.queues.delete(update.key) + } + }) }) this.queues.set(update.key, queue) } @@ -85,7 +89,7 @@ export class DebounceController { return queue.push(update, timeMs) } - public getQueuedQuery(key: string) { + getQueuedQuery(key: string) { // The debounced queued values are more likely to be up-to-date // than any updates pending in the throttle queue, which comes last // in the update chain. diff --git a/packages/nuqs/src/lib/queues/throttle.test.ts b/packages/nuqs/src/lib/queues/throttle.test.ts new file mode 100644 index 000000000..0700a881a --- /dev/null +++ b/packages/nuqs/src/lib/queues/throttle.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest' +import { ThrottledQueue } from './throttle' + +describe('throttle: ThrottleQueue value queueing', () => { + it('should enqueue key & values', () => { + const queue = new ThrottledQueue() + queue.push({ key: 'key', query: 'value', options: {} }) + expect(queue.getQueuedQuery('key')).toEqual('value') + }) + it('should replace more recent values with the same key', () => { + const queue = new ThrottledQueue() + queue.push({ key: 'key', query: 'a', options: {} }) + queue.push({ key: 'key', query: 'b', options: {} }) + expect(queue.getQueuedQuery('key')).toEqual('b') + }) + it('should enqueue multiple keys', () => { + const queue = new ThrottledQueue() + queue.push({ key: 'key1', query: 'a', options: {} }) + queue.push({ key: 'key2', query: 'b', options: {} }) + expect(queue.getQueuedQuery('key1')).toEqual('a') + expect(queue.getQueuedQuery('key2')).toEqual('b') + }) + it('should enqueue null values (to clear a key from the URL)', () => { + const queue = new ThrottledQueue() + queue.push({ key: 'key', query: 'a', options: {} }) + queue.push({ key: 'key', query: null, options: {} }) + expect(queue.getQueuedQuery('key')).toBeNull() + }) + it('should return an undefined queued value if no push occurred', () => { + const queue = new ThrottledQueue() + expect(queue.getQueuedQuery('key')).toBeUndefined() + }) +}) + +describe('throttle: ThrottleQueue option combination logic', () => { + it('should resolve with the default options', () => { + const queue = new ThrottledQueue() + expect(queue.options).toEqual({ + history: 'replace', + scroll: false, + shallow: true + }) + }) + it('should combine history options (push takes precedence)', () => { + const queue = new ThrottledQueue() + queue.push({ key: 'a', query: null, options: { history: 'replace' } }) + queue.push({ key: 'b', query: null, options: { history: 'push' } }) + queue.push({ key: 'c', query: null, options: { history: 'replace' } }) + expect(queue.options.history).toEqual('push') + }) + it('should combine scroll options (true takes precedence)', () => { + const queue = new ThrottledQueue() + queue.push({ key: 'a', query: null, options: { scroll: false } }) + queue.push({ key: 'b', query: null, options: { scroll: true } }) + queue.push({ key: 'c', query: null, options: { scroll: false } }) + expect(queue.options.scroll).toEqual(true) + }) + it('should combine shallow options (false takes precedence)', () => { + const queue = new ThrottledQueue() + queue.push({ key: 'a', query: null, options: { shallow: true } }) + queue.push({ key: 'b', query: null, options: { shallow: false } }) + queue.push({ key: 'c', query: null, options: { shallow: true } }) + expect(queue.options.shallow).toEqual(false) + }) +}) diff --git a/packages/nuqs/src/lib/queues/throttle.ts b/packages/nuqs/src/lib/queues/throttle.ts index 678aa7f1e..19591c358 100644 --- a/packages/nuqs/src/lib/queues/throttle.ts +++ b/packages/nuqs/src/lib/queues/throttle.ts @@ -62,7 +62,7 @@ export class ThrottledQueue { resolvers: Resolvers | null = null lastFlushedAt = 0 - public push({ + push({ key, query, options, @@ -89,11 +89,11 @@ export class ThrottledQueue { ) } - public getQueuedQuery(key: string): string | null | undefined { + getQueuedQuery(key: string): string | null | undefined { return this.updateMap.get(key) } - public flush({ + flush({ getSearchParamsSnapshot = getSearchParamsSnapshotFromLocation, rateLimitFactor = 1, ...adapter @@ -152,7 +152,7 @@ export class ThrottledQueue { return this.resolvers.promise } - public reset() { + reset() { this.updateMap.clear() this.transitions.clear() this.options.history = 'replace' @@ -161,9 +161,7 @@ export class ThrottledQueue { this.throttleMs = defaultRateLimit.timeMs } - // -- - - private applyPendingUpdates( + applyPendingUpdates( adapter: Required> ): [URLSearchParams, null | unknown] { const { updateUrl, getSearchParamsSnapshot } = adapter diff --git a/packages/nuqs/src/lib/timeout.ts b/packages/nuqs/src/lib/timeout.ts index 3f42dbb44..9ae3dd36f 100644 --- a/packages/nuqs/src/lib/timeout.ts +++ b/packages/nuqs/src/lib/timeout.ts @@ -1,4 +1,15 @@ +// Source: +// https://www.bennadel.com/blog/4195-using-abortcontroller-to-debounce-settimeout-calls-in-javascript.htm + export function timeout(callback: () => void, ms: number, signal: AbortSignal) { - const id = setTimeout(callback, ms) - signal.addEventListener('abort', () => clearTimeout(id)) + function onTick() { + callback() + signal.removeEventListener('abort', onAbort) + } + const id = setTimeout(onTick, ms) + function onAbort() { + clearTimeout(id) + signal.removeEventListener('abort', onAbort) + } + signal.addEventListener('abort', onAbort) } From 4bf8e8dc90045794434a76f1e0c2aefbcee4f278 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Best?= Date: Mon, 10 Feb 2025 14:13:00 +0100 Subject: [PATCH 07/14] feat: Expose shorthand helpers for limitUrlUpdates --- .../e2e/next/src/app/app/debounce/client.tsx | 41 +++++++++---------- .../src/app/app/debounce/search-params.ts | 4 +- packages/nuqs/src/defs.ts | 8 ++-- packages/nuqs/src/index.server.ts | 5 +++ packages/nuqs/src/index.ts | 5 +++ packages/nuqs/src/lib/queues/rate-limiting.ts | 28 +++++++++++++ packages/nuqs/src/lib/queues/throttle.ts | 25 +---------- packages/nuqs/src/useQueryState.ts | 2 +- packages/nuqs/src/useQueryStates.ts | 2 +- 9 files changed, 65 insertions(+), 55 deletions(-) create mode 100644 packages/nuqs/src/lib/queues/rate-limiting.ts diff --git a/packages/e2e/next/src/app/app/debounce/client.tsx b/packages/e2e/next/src/app/app/debounce/client.tsx index 08cbb4ccf..45c5d3d9f 100644 --- a/packages/e2e/next/src/app/app/debounce/client.tsx +++ b/packages/e2e/next/src/app/app/debounce/client.tsx @@ -1,16 +1,20 @@ 'use client' -import { parseAsInteger, useQueryState, useQueryStates } from 'nuqs' +import { + debounce, + parseAsInteger, + throttle, + useQueryState, + useQueryStates +} from 'nuqs' import { searchParams, urlKeys } from './search-params' export function Client() { const [timeMs, setTimeMs] = useQueryState( 'debounceTime', parseAsInteger.withDefault(100).withOptions({ - limitUrlUpdates: { - method: 'throttle', - timeMs: 200 - } + // No real need to throttle this one, but it showcases usage: + limitUrlUpdates: throttle(200) }) ) const [{ search, pageIndex }, setSearchParams] = useQueryStates( @@ -28,30 +32,23 @@ export function Client() { setSearchParams( { search: e.target.value }, { - limitUrlUpdates: { - method: e.target.value === '' ? 'throttle' : 'debounce', - timeMs: e.target.value === '' ? 50 : timeMs - } + // Instant update when clearing the input, otherwise debounce + limitUrlUpdates: + e.target.value === '' ? undefined : debounce(timeMs) } ) } + onKeyDown={e => { + if (e.key === 'Enter') { + // Send the search immediately when pressing Enter + setSearchParams({ search: e.currentTarget.value }) + } + }} /> - +