Skip to content

Commit

Permalink
feat: Add throttling option
Browse files Browse the repository at this point in the history
See discussion #373.
  • Loading branch information
franky47 committed Oct 24, 2023
1 parent 575a5cf commit 06ece2c
Show file tree
Hide file tree
Showing 6 changed files with 149 additions and 25 deletions.
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
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
59 changes: 44 additions & 15 deletions packages/next-usequerystate/src/update-queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,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 Down Expand Up @@ -43,6 +44,10 @@ export function enqueueQueryStringUpdate<Value>(
if (options.shallow === false) {
queueOptions.shallow = false
}
queueOptions.throttleMs = Math.max(
options.throttleMs ?? FLUSH_RATE_LIMIT_MS,
queueOptions.throttleMs
)
}

export function getInitialStateFromQueue(key: string) {
Expand All @@ -62,16 +67,7 @@ export function getInitialStateFromQueue(key: string) {
export function flushToURL(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__ &&
performance.mark(`[nuqs queue] Scheduling flush in ${flushInMs} ms`) &&
console.debug('[nuqs queue] Scheduling flush in %f ms', flushInMs)
setTimeout(() => {
function flushNow() {
lastFlushTimestamp = performance.now()
const search = flushUpdateQueue(router)
if (!search) {
Expand All @@ -80,7 +76,35 @@ 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__ &&
performance.mark(
`[nuqs queue] Scheduling flush in ${flushInMs} ms. Throttled at ${throttleMs} ms`
) &&
console.debug(
`[nuqs queue] Scheduling flush in %f ms. Throttle: %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
Expand All @@ -95,13 +119,18 @@ function flushUpdateQueue(router: Router) {
const items = Array.from(updateQueue.entries())
const options = { ...queueOptions }
// Restore defaults
updateQueue.clear()
queueOptions.history = 'replace'
queueOptions.scroll = false
queueOptions.shallow = true
updateQueue.clear()
queueOptions.throttleMs = FLUSH_RATE_LIMIT_MS
__DEBUG__ &&
performance.mark('[nuqs queue] Flushing queue') &&
console.debug('[nuqs queue] Flushing queue %O', items)
console.debug(
'[nuqs queue] Flushing queue %O with options %O',
items,
options
)

for (const [key, value] of items) {
if (value === null) {
Expand Down
14 changes: 6 additions & 8 deletions packages/next-usequerystate/src/useQueryState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ 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
Expand Down Expand Up @@ -198,13 +199,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 Down Expand Up @@ -278,17 +281,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)
},
[
key,
history,
shallow,
scroll
// internalState, defaultValue
]
[key, history, shallow, scroll, throttleMs]
)
return [internalState ?? defaultValue ?? null, update]
}
Expand Down
7 changes: 5 additions & 2 deletions packages/next-usequerystate/src/useQueryStates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ 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
Expand Down Expand Up @@ -58,7 +59,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 Down Expand Up @@ -208,7 +210,8 @@ 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)
Expand Down
54 changes: 54 additions & 0 deletions packages/playground/src/app/demos/throttling/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
'use client'

import { parseAsInteger, useQueryState } from 'src/nuqs'

export default function ThottlingDemoPage() {
const [a, setA] = useQueryState('a', parseAsInteger.withDefault(0))
const [b, setB] = useQueryState('b', parseAsInteger.withDefault(0))

return (
<>
<h1>Throttled counters</h1>
<p>Note: URL state updated are intentionally slowed down</p>
<nav style={{ display: 'flex', gap: '4px' }}>
<button
style={{ padding: '2px 12px' }}
onClick={() => {
console.debug('decrement')
setA(x => x - 1, { throttleMs: 1000 })
setB(x => x - 1, { throttleMs: 2000 })
}}
>
-
</button>
<button
style={{ padding: '2px 12px' }}
onClick={() => {
console.debug('increment')
setA(x => x + 1, { throttleMs: 2000 })
setB(x => x + 1, { throttleMs: 1000 })
}}
>
+
</button>
<button
style={{ padding: '2px 6px' }}
onClick={() => {
console.debug('clear')
setB(null)
setA(null, { throttleMs: 1000 })
}}
>
Reset
</button>
</nav>
<p>A: {a}</p>
<p>B: {b}</p>
<p>
<a href="https://github.com/47ng/next-usequerystate/blob/next/src/app/demos/thottling/page.tsx">
Source on GitHub
</a>
</p>
</>
)
}

0 comments on commit 06ece2c

Please sign in to comment.