Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Configurable throttling #374

Merged
merged 11 commits into from
Nov 8, 2023
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion packages/next-usequerystate/src/debug.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
const enabled =
(typeof window === 'object' &&
(typeof localStorage === 'object' &&
localStorage.getItem('debug')?.includes('next-usequerystate')) ??
false

Expand Down
11 changes: 11 additions & 0 deletions packages/next-usequerystate/src/defs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> = {
Expand Down
13 changes: 13 additions & 0 deletions packages/next-usequerystate/src/sync.ts
Original file line number Diff line number Diff line change
@@ -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__'
Expand Down Expand Up @@ -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.`
//
Expand Down
112 changes: 70 additions & 42 deletions packages/next-usequerystate/src/update-queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string | null>
const updateQueue: UpdateMap = new Map()
const queueOptions: Required<Options> = {
history: 'replace',
scroll: false,
shallow: true
shallow: true,
throttleMs: FLUSH_RATE_LIMIT_MS
}

let lastFlushTimestamp = 0
Expand All @@ -37,9 +38,13 @@ export function enqueueQueryStringUpdate<Value>(
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
}

Expand All @@ -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<URLSearchParams>((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) {
Expand All @@ -72,67 +79,88 @@ 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
}
// Work on a copy and clear the queue immediately
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)
} else {
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) {
Expand Down
22 changes: 13 additions & 9 deletions packages/next-usequerystate/src/useQueryState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> extends Parser<T>, Options {}
Expand Down Expand Up @@ -199,13 +200,15 @@ export function useQueryState<T = string>(
history = 'replace',
shallow = true,
scroll = false,
throttleMs = FLUSH_RATE_LIMIT_MS,
parse = x => x as unknown as T,
serialize = String,
defaultValue = undefined
}: Partial<UseQueryStateOptions<T>> & { defaultValue?: T } = {
history: 'replace',
scroll: false,
shallow: true,
throttleMs: FLUSH_RATE_LIMIT_MS,
parse: x => x as unknown as T,
serialize: String,
defaultValue: undefined
Expand All @@ -215,13 +218,13 @@ export function useQueryState<T = string>(
// Not reactive, but available on the server and on page load
const initialSearchParams = useSearchParams()
const [internalState, setInternalState] = React.useState<T | null>(() => {
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)
})
Expand All @@ -230,7 +233,7 @@ export function useQueryState<T = string>(
'[nuqs `%s`] render - state: %O, iSP: %s',
key,
internalState,
initialSearchParams
initialSearchParams?.get(key) ?? null
)

// Sync all hooks together & with external URL changes
Expand Down Expand Up @@ -268,11 +271,12 @@ export function useQueryState<T = string>(
// 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]
}
Expand Down
19 changes: 11 additions & 8 deletions packages/next-usequerystate/src/useQueryStates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Type> = Parser<Type> & {
Expand Down Expand Up @@ -59,7 +60,8 @@ export function useQueryStates<KeyMap extends UseQueryStatesKeysMap>(
{
history = 'replace',
scroll = false,
shallow = true
shallow = true,
throttleMs = FLUSH_RATE_LIMIT_MS
}: Partial<UseQueryStatesOptions> = {}
): UseQueryStatesReturn<KeyMap> {
type V = Values<KeyMap>
Expand All @@ -68,12 +70,12 @@ export function useQueryStates<KeyMap extends UseQueryStatesKeysMap>(
// Not reactive, but available on the server and on page load
const initialSearchParams = useSearchParams()
const [internalState, setInternalState] = React.useState<V>(() => {
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(
Expand Down Expand Up @@ -148,10 +150,11 @@ export function useQueryStates<KeyMap extends UseQueryStatesKeysMap>(
// 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]
)
Expand All @@ -167,7 +170,7 @@ function parseMap<KeyMap extends UseQueryStatesKeysMap>(
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
Expand Down
Loading
Loading