UNPKG

nuqs

Version:

Type-safe search params state manager for React - Like useState, but stored in the URL query string

360 lines (350 loc) 11.9 kB
import { c as debug, s as error } from "./context-C4spomkL.js"; import { useCallback, useRef, useSyncExternalStore } from "react"; //#region src/lib/queues/rate-limiting.ts function getDefaultThrottle() { if (typeof window === "undefined") return 50; if (!Boolean(window.GestureEvent)) return 50; try { const match = navigator.userAgent?.match(/version\/([\d\.]+) safari/i); return parseFloat(match[1]) >= 17 ? 120 : 320; } catch { return 320; } } function throttle(timeMs) { return { method: "throttle", timeMs }; } function debounce(timeMs) { return { method: "debounce", timeMs }; } const defaultRateLimit = throttle(getDefaultThrottle()); //#endregion //#region src/lib/search-params.ts function isAbsentFromUrl(query) { return query === null || Array.isArray(query) && query.length === 0; } function write(serialized, key, searchParams) { if (typeof serialized === "string") searchParams.set(key, serialized); else { searchParams.delete(key); for (const v of serialized) searchParams.append(key, v); if (!searchParams.has(key)) searchParams.set(key, ""); } return searchParams; } //#endregion //#region src/lib/emitter.ts function createEmitter() { const all = /* @__PURE__ */ new Map(); return { on(type, handler) { const handlers = all.get(type) || []; handlers.push(handler); all.set(type, handlers); return () => this.off(type, handler); }, off(type, handler) { const handlers = all.get(type); if (handlers) all.set(type, handlers.filter((h) => h !== handler)); }, emit(type, event) { all.get(type)?.forEach((handler) => handler(event)); } }; } //#endregion //#region src/lib/timeout.ts function timeout(callback, ms, signal) { function onTick() { callback(); signal.removeEventListener("abort", onAbort); } const id = setTimeout(onTick, ms); function onAbort() { clearTimeout(id); signal.removeEventListener("abort", onAbort); } signal.addEventListener("abort", onAbort); } //#endregion //#region src/lib/with-resolvers.ts function withResolvers() { const P = Promise; if (Promise.hasOwnProperty("withResolvers")) return Promise.withResolvers(); let resolve = () => {}; let reject = () => {}; return { promise: new P((res, rej) => { resolve = res; reject = rej; }), resolve, reject }; } //#endregion //#region src/lib/compose.ts function compose(fns, final) { let next = final; for (let i = fns.length - 1; i >= 0; i--) { const fn = fns[i]; if (!fn) continue; const prev = next; next = () => fn(prev); } next(); } //#endregion //#region src/lib/queues/throttle.ts function getSearchParamsSnapshotFromLocation() { return new URLSearchParams(location.search); } var ThrottledQueue = class { updateMap = /* @__PURE__ */ new Map(); options = { history: "replace", scroll: false, shallow: true }; timeMs = defaultRateLimit.timeMs; transitions = /* @__PURE__ */ new Set(); resolvers = null; controller = null; lastFlushedAt = 0; resetQueueOnNextPush = false; push({ key, query, options }, timeMs = defaultRateLimit.timeMs) { if (this.resetQueueOnNextPush) { this.reset(); this.resetQueueOnNextPush = false; } debug("[nuqs gtq] Enqueueing %s=%s %O", key, query, options); 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); if (!Number.isFinite(this.timeMs) || timeMs > this.timeMs) this.timeMs = timeMs; } getQueuedQuery(key) { return this.updateMap.get(key); } getPendingPromise({ getSearchParamsSnapshot = getSearchParamsSnapshotFromLocation }) { return this.resolvers?.promise ?? Promise.resolve(getSearchParamsSnapshot()); } flush({ getSearchParamsSnapshot = getSearchParamsSnapshotFromLocation, rateLimitFactor = 1, ...adapter }, processUrlSearchParams) { this.controller ??= new AbortController(); if (!Number.isFinite(this.timeMs)) { debug("[nuqs gtq] Skipping flush due to throttleMs=Infinity"); return Promise.resolve(getSearchParamsSnapshot()); } if (this.resolvers) return this.resolvers.promise; this.resolvers = withResolvers(); const flushNow = () => { this.lastFlushedAt = performance.now(); const [search, error] = this.applyPendingUpdates({ ...adapter, autoResetQueueOnUpdate: adapter.autoResetQueueOnUpdate ?? true, getSearchParamsSnapshot }, processUrlSearchParams); if (error === null) { this.resolvers.resolve(search); this.resetQueueOnNextPush = true; } else this.resolvers.reject(search); this.resolvers = null; }; const runOnNextTick = () => { const timeSinceLastFlush = performance.now() - this.lastFlushedAt; const timeMs = this.timeMs; const flushInMs = rateLimitFactor * Math.max(0, timeMs - timeSinceLastFlush); debug(`[nuqs gtq] Scheduling flush in %f ms. Throttled at %f ms (x%f)`, flushInMs, timeMs, rateLimitFactor); if (flushInMs === 0) flushNow(); else timeout(flushNow, flushInMs, this.controller.signal); }; timeout(runOnNextTick, 0, this.controller.signal); return this.resolvers.promise; } abort() { this.controller?.abort(); this.controller = new AbortController(); this.resolvers?.resolve(new URLSearchParams()); this.resolvers = null; return this.reset(); } reset() { const queuedKeys = Array.from(this.updateMap.keys()); debug("[nuqs gtq] Resetting queue %s", JSON.stringify(Object.fromEntries(this.updateMap))); this.updateMap.clear(); this.transitions.clear(); this.options = { history: "replace", scroll: false, shallow: true }; this.timeMs = defaultRateLimit.timeMs; return queuedKeys; } applyPendingUpdates(adapter, processUrlSearchParams) { const { updateUrl, getSearchParamsSnapshot } = adapter; let search = getSearchParamsSnapshot(); debug(`[nuqs gtq] Applying %d pending update(s) on top of %s`, this.updateMap.size, search.toString()); if (this.updateMap.size === 0) return [search, null]; const items = Array.from(this.updateMap.entries()); const options = { ...this.options }; const transitions = Array.from(this.transitions); if (adapter.autoResetQueueOnUpdate) this.reset(); debug("[nuqs gtq] Flushing queue %O with options %O", items, options); for (const [key, value] of items) if (value === null) search.delete(key); else search = write(value, key, search); if (processUrlSearchParams) search = processUrlSearchParams(search); try { compose(transitions, () => { updateUrl(search, options); }); return [search, null]; } catch (err) { console.error(error(429), items.map(([key]) => key).join(), err); return [search, err]; } } }; const globalThrottleQueue = new ThrottledQueue(); //#endregion //#region src/lib/queues/useSyncExternalStores.ts /** * Like `useSyncExternalStore`, but for subscribing to multiple keys. * * Each key becomes the key of the returned object, * and the value is the result of calling `getKeySnapshot` with that key. * * @param keys - A list of keys to subscribe to. * @param subscribeKey - A function that takes a key and a callback, * subscribes to an external store using that key (calling the callback when * state changes occur), and returns a function to unsubscribe from that key. * @param getKeySnapshot - A function that takes a key and returns the snapshot for that key. * It will be called on the server and on the client, so it needs to handle both * environments. */ function useSyncExternalStores(keys, subscribeKey, getKeySnapshot) { const snapshot = useCallback(() => { const record = Object.fromEntries(keys.map((key) => [key, getKeySnapshot(key)])); return [JSON.stringify(record), record]; }, [keys.join(","), getKeySnapshot]); const cacheRef = useRef(null); if (cacheRef.current === null) cacheRef.current = snapshot(); return useSyncExternalStore(useCallback((callback) => { const off = keys.map((key) => subscribeKey(key, callback)); return () => off.forEach((unsubscribe) => unsubscribe()); }, [keys.join(","), subscribeKey]), () => { const [cacheKey, record] = snapshot(); if (cacheRef.current[0] === cacheKey) return cacheRef.current[1]; cacheRef.current = [cacheKey, record]; return record; }, () => cacheRef.current[1]); } //#endregion //#region src/lib/queues/debounce.ts var DebouncedPromiseQueue = class { callback; resolvers = withResolvers(); controller = new AbortController(); queuedValue = void 0; constructor(callback) { this.callback = callback; } abort() { this.controller.abort(); this.queuedValue = void 0; } push(value, timeMs) { this.queuedValue = value; this.controller.abort(); this.controller = new AbortController(); timeout(() => { const outputResolvers = this.resolvers; try { debug("[nuqs dq] Flushing debounce queue", value); const callbackPromise = this.callback(value); debug("[nuqs dq] Reset debounce queue %O", this.queuedValue); this.queuedValue = void 0; this.resolvers = withResolvers(); callbackPromise.then((output) => outputResolvers.resolve(output)).catch((error) => outputResolvers.reject(error)); } catch (error) { this.queuedValue = void 0; outputResolvers.reject(error); } }, timeMs, this.controller.signal); return this.resolvers.promise; } }; var DebounceController = class { throttleQueue; queues = /* @__PURE__ */ new Map(); queuedQuerySync = createEmitter(); constructor(throttleQueue = new ThrottledQueue()) { this.throttleQueue = throttleQueue; } useQueuedQueries(keys) { return useSyncExternalStores(keys, (key, callback) => this.queuedQuerySync.on(key, callback), (key) => this.getQueuedQuery(key)); } push(update, timeMs, adapter, processUrlSearchParams) { if (!Number.isFinite(timeMs)) { const getSnapshot = adapter.getSearchParamsSnapshot ?? getSearchParamsSnapshotFromLocation; return Promise.resolve(getSnapshot()); } const key = update.key; if (!this.queues.has(key)) { debug("[nuqs dqc] Creating debounce queue for `%s`", key); const queue = new DebouncedPromiseQueue((update) => { this.throttleQueue.push(update); return this.throttleQueue.flush(adapter, processUrlSearchParams).finally(() => { if (this.queues.get(update.key)?.queuedValue === void 0) { debug("[nuqs dqc] Cleaning up empty queue for `%s`", update.key); this.queues.delete(update.key); } this.queuedQuerySync.emit(update.key); }); }); this.queues.set(key, queue); } debug("[nuqs dqc] Enqueueing debounce update %O", update); const promise = this.queues.get(key).push(update, timeMs); this.queuedQuerySync.emit(key); return promise; } abort(key) { const queue = this.queues.get(key); if (!queue) return (passThrough) => passThrough; debug("[nuqs dqc] Aborting debounce queue %s=%s", key, queue.queuedValue?.query); this.queues.delete(key); queue.abort(); this.queuedQuerySync.emit(key); return (promise) => { promise.then(queue.resolvers.resolve, queue.resolvers.reject); return promise; }; } abortAll() { for (const [key, queue] of this.queues.entries()) { debug("[nuqs dqc] Aborting debounce queue %s=%s", key, queue.queuedValue?.query); queue.abort(); queue.resolvers.resolve(new URLSearchParams()); this.queuedQuerySync.emit(key); } this.queues.clear(); } getQueuedQuery(key) { const debouncedQueued = this.queues.get(key)?.queuedValue?.query; if (debouncedQueued !== void 0) return debouncedQueued; return this.throttleQueue.getQueuedQuery(key); } }; const debounceController = new DebounceController(globalThrottleQueue); //#endregion export { write as a, throttle as c, isAbsentFromUrl as i, globalThrottleQueue as n, debounce as o, createEmitter as r, defaultRateLimit as s, debounceController as t }; //# sourceMappingURL=debounce-PSGthE_7.js.map