UNPKG

nuqs

Version:

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

654 lines (646 loc) 19.4 kB
'use client'; import { debug, warn } from './chunk-3RCMAOX7.js'; import { useAdapter, renderQueryString, error } from './chunk-OMREKXUP.js'; import { useRef, useState, useEffect, useInsertionEffect, useCallback, useMemo } from 'react'; import Mitt from 'mitt'; // src/utils.ts function safeParse(parser, value, key) { try { return parser(value); } catch (error2) { warn( "[nuqs] Error while parsing value `%s`: %O" + (key ? " (for key `%s`)" : ""), value, error2, key ); return null; } } function getDefaultThrottle() { if (typeof window === "undefined") return 50; 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; } } // src/parsers.ts function createParser(parser) { function parseServerSideNullable(value) { if (typeof value === "undefined") { return null; } let str = ""; if (Array.isArray(value)) { if (value[0] === void 0) { return null; } str = value[0]; } if (typeof value === "string") { str = value; } return safeParse(parser.parse, str); } return { eq: (a, b) => a === b, ...parser, parseServerSide: parseServerSideNullable, withDefault(defaultValue) { return { ...this, defaultValue, parseServerSide(value) { return parseServerSideNullable(value) ?? defaultValue; } }; }, withOptions(options) { return { ...this, ...options }; } }; } var parseAsString = createParser({ parse: (v) => v, serialize: (v) => `${v}` }); var parseAsInteger = createParser({ parse: (v) => { const int = parseInt(v); if (Number.isNaN(int)) { return null; } return int; }, serialize: (v) => Math.round(v).toFixed() }); var parseAsHex = createParser({ parse: (v) => { const int = parseInt(v, 16); if (Number.isNaN(int)) { return null; } return int; }, serialize: (v) => { const hex = Math.round(v).toString(16); return hex.padStart(hex.length + hex.length % 2, "0"); } }); var parseAsFloat = createParser({ parse: (v) => { const float = parseFloat(v); if (Number.isNaN(float)) { return null; } return float; }, serialize: (v) => v.toString() }); var parseAsBoolean = createParser({ parse: (v) => v === "true", serialize: (v) => v ? "true" : "false" }); var parseAsTimestamp = createParser({ parse: (v) => { const ms = parseInt(v); if (Number.isNaN(ms)) { return null; } return new Date(ms); }, serialize: (v) => v.valueOf().toString() }); var parseAsIsoDateTime = createParser({ parse: (v) => { const date = new Date(v); if (Number.isNaN(date.valueOf())) { return null; } return date; }, serialize: (v) => v.toISOString() }); function parseAsStringEnum(validValues) { return createParser({ parse: (query) => { const asEnum = query; if (validValues.includes(asEnum)) { return asEnum; } return null; }, serialize: (value) => value.toString() }); } function parseAsStringLiteral(validValues) { return createParser({ parse: (query) => { const asConst = query; if (validValues.includes(asConst)) { return asConst; } return null; }, serialize: (value) => value.toString() }); } function parseAsNumberLiteral(validValues) { return createParser({ parse: (query) => { const asConst = parseFloat(query); if (validValues.includes(asConst)) { return asConst; } return null; }, serialize: (value) => value.toString() }); } function parseAsJson(runtimeParser) { return createParser({ parse: (query) => { try { const obj = JSON.parse(query); return runtimeParser(obj); } catch { return null; } }, serialize: (value) => JSON.stringify(value), eq(a, b) { return a === b || JSON.stringify(a) === JSON.stringify(b); } }); } function parseAsArrayOf(itemParser, separator = ",") { const itemEq = itemParser.eq ?? ((a, b) => a === b); const encodedSeparator = encodeURIComponent(separator); return createParser({ parse: (query) => { if (query === "") { return []; } return query.split(separator).map( (item, index) => safeParse( itemParser.parse, item.replaceAll(encodedSeparator, separator), `[${index}]` ) ).filter((value) => value !== null && value !== void 0); }, serialize: (values) => values.map((value) => { const str = itemParser.serialize ? itemParser.serialize(value) : String(value); return str.replaceAll(separator, encodedSeparator); }).join(separator), eq(a, b) { if (a === b) { return true; } if (a.length !== b.length) { return false; } return a.every((value, index) => itemEq(value, b[index])); } }); } // src/serializer.ts function createSerializer(parsers) { function serialize(baseOrValues, values = {}) { const [base, search] = isBase(baseOrValues) ? splitBase(baseOrValues) : ["", new URLSearchParams()]; const vals = isBase(baseOrValues) ? values : baseOrValues; if (vals === null) { for (const key in parsers) { search.delete(key); } return base + renderQueryString(search); } for (const key in parsers) { const parser = parsers[key]; const value = vals[key]; if (!parser || value === void 0) { continue; } const isMatchingDefault = parser.defaultValue !== void 0 && (parser.eq ?? ((a, b) => a === b))(value, parser.defaultValue); if (value === null || (parser.clearOnDefault ?? true) && isMatchingDefault) { search.delete(key); } else { search.set(key, parser.serialize(value)); } } return base + renderQueryString(search); } return serialize; } function isBase(base) { return typeof base === "string" || base instanceof URLSearchParams || base instanceof URL; } function splitBase(base) { if (typeof base === "string") { const [path = "", search] = base.split("?"); return [path, new URLSearchParams(search)]; } else if (base instanceof URLSearchParams) { return ["", new URLSearchParams(base)]; } else { return [ base.origin + base.pathname, new URLSearchParams(base.searchParams) ]; } } var emitter = Mitt(); // src/update-queue.ts var FLUSH_RATE_LIMIT_MS = getDefaultThrottle(); var updateQueue = /* @__PURE__ */ new Map(); var queueOptions = { history: "replace", scroll: false, shallow: true, throttleMs: FLUSH_RATE_LIMIT_MS }; var transitionsQueue = /* @__PURE__ */ new Set(); var lastFlushTimestamp = 0; var flushPromiseCache = null; function enqueueQueryStringUpdate(key, value, serialize, options) { const serializedOrNull = value === null ? null : serialize(value); debug("[nuqs queue] Enqueueing %s=%s %O", key, serializedOrNull, options); updateQueue.set(key, serializedOrNull); 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 scheduleFlushToURL(updateUrl, rateLimitFactor) { if (flushPromiseCache === null) { flushPromiseCache = new Promise((resolve, reject) => { if (!Number.isFinite(queueOptions.throttleMs)) { debug("[nuqs queue] Skipping flush due to throttleMs=Infinity"); resolve(new URLSearchParams(location.search)); setTimeout(() => { flushPromiseCache = null; }, 0); return; } function flushNow() { lastFlushTimestamp = performance.now(); const [search, error2] = flushUpdateQueue(updateUrl); if (error2 === null) { resolve(search); } else { reject(search); } flushPromiseCache = null; } 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) { flushNow(); } else { setTimeout(flushNow, flushInMs); } } setTimeout(runOnNextTick, 0); }); } return flushPromiseCache; } function flushUpdateQueue(updateUrl) { const search = new URLSearchParams(location.search); if (updateQueue.size === 0) { return [search, null]; } const items = Array.from(updateQueue.entries()); const options = { ...queueOptions }; const transitions = Array.from(transitionsQueue); updateQueue.clear(); transitionsQueue.clear(); queueOptions.history = "replace"; queueOptions.scroll = false; queueOptions.shallow = true; 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); } } try { compose(transitions, () => { updateUrl(search, { history: options.history, scroll: options.scroll, shallow: options.shallow }); }); return [search, null]; } catch (err) { console.error(error(429), items.map(([key]) => key).join(), err); return [search, err]; } } function compose(fns, final) { const recursiveCompose = (index) => { if (index === fns.length) { return final(); } const fn = fns[index]; if (!fn) { throw new Error("Invalid transition function"); } fn(() => recursiveCompose(index + 1)); }; recursiveCompose(0); } // src/useQueryState.ts function useQueryState(key, { history = "replace", shallow = true, scroll = false, throttleMs = FLUSH_RATE_LIMIT_MS, parse = (x) => x, serialize = String, eq = (a, b) => a === b, defaultValue = void 0, clearOnDefault = true, startTransition } = { history: "replace", scroll: false, shallow: true, throttleMs: FLUSH_RATE_LIMIT_MS, parse: (x) => x, serialize: String, eq: (a, b) => a === b, clearOnDefault: true, defaultValue: void 0 }) { const { searchParams: initialSearchParams, updateUrl, rateLimitFactor = 1 } = useAdapter(); const queryRef = useRef(initialSearchParams?.get(key) ?? null); const [internalState, setInternalState] = useState(() => { const query = initialSearchParams?.get(key) ?? null; return query === null ? null : safeParse(parse, query, key); }); const stateRef = useRef(internalState); debug( "[nuqs `%s`] render - state: %O, iSP: %s", key, internalState, initialSearchParams?.get(key) ?? null ); useEffect(() => { const query = initialSearchParams.get(key) ?? null; if (query === queryRef.current) { return; } const state = query === null ? null : safeParse(parse, query, key); debug("[nuqs `%s`] syncFromUseSearchParams %O", key, state); stateRef.current = state; queryRef.current = query; setInternalState(state); }, [initialSearchParams?.get(key), key]); useInsertionEffect(() => { function updateInternalState({ state, query }) { debug("[nuqs `%s`] updateInternalState %O", key, state); stateRef.current = state; queryRef.current = query; setInternalState(state); } debug("[nuqs `%s`] subscribing to sync", key); emitter.on(key, updateInternalState); return () => { debug("[nuqs `%s`] unsubscribing from sync", key); emitter.off(key, updateInternalState); }; }, [key]); const update = useCallback( (stateUpdater, options = {}) => { let newValue = isUpdaterFunction(stateUpdater) ? stateUpdater(stateRef.current ?? defaultValue ?? null) : stateUpdater; if ((options.clearOnDefault ?? clearOnDefault) && newValue !== null && defaultValue !== void 0 && eq(newValue, defaultValue)) { newValue = null; } queryRef.current = 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 }); emitter.emit(key, { state: newValue, query: queryRef.current }); return scheduleFlushToURL(updateUrl, rateLimitFactor); }, [ key, history, shallow, scroll, throttleMs, startTransition, updateUrl, rateLimitFactor ] ); return [internalState ?? defaultValue ?? null, update]; } function isUpdaterFunction(stateUpdater) { return typeof stateUpdater === "function"; } var defaultUrlKeys = {}; function useQueryStates(keyMap, { history = "replace", scroll = false, shallow = true, throttleMs = FLUSH_RATE_LIMIT_MS, clearOnDefault = true, startTransition, urlKeys = defaultUrlKeys } = {}) { const stateKeys = Object.keys(keyMap).join(","); const resolvedUrlKeys = useMemo( () => Object.fromEntries( Object.keys(keyMap).map((key) => [key, urlKeys[key] ?? key]) ), [stateKeys, urlKeys] ); const { searchParams: initialSearchParams, updateUrl, rateLimitFactor = 1 } = useAdapter(); const queryRef = useRef({}); if (Object.keys(queryRef.current).length !== Object.keys(keyMap).length) { queryRef.current = Object.fromEntries(initialSearchParams?.entries() ?? []); } const [internalState, setInternalState] = useState(() => { const source = initialSearchParams ?? new URLSearchParams(); return parseMap(keyMap, urlKeys, source); }); const stateRef = useRef(internalState); debug( "[nuq+ `%s`] render - state: %O, iSP: %s", stateKeys, internalState, initialSearchParams ); useEffect(() => { const state = parseMap( keyMap, urlKeys, initialSearchParams, queryRef.current, stateRef.current ); setInternalState(state); }, [ Object.keys(resolvedUrlKeys).map((key) => initialSearchParams?.get(key)).join("&") ]); useInsertionEffect(() => { function updateInternalState(state) { debug("[nuq+ `%s`] updateInternalState %O", stateKeys, state); stateRef.current = state; setInternalState(state); } const handlers = Object.keys(keyMap).reduce( (handlers2, stateKey) => { handlers2[stateKey] = ({ state, query }) => { const { defaultValue } = keyMap[stateKey]; const urlKey = resolvedUrlKeys[stateKey]; stateRef.current = { ...stateRef.current, [stateKey]: state ?? defaultValue ?? null }; queryRef.current[urlKey] = query; debug( "[nuq+ `%s`] Cross-hook key sync %s: %O (default: %O). Resolved: %O", stateKeys, urlKey, state, defaultValue, stateRef.current ); updateInternalState(stateRef.current); }; return handlers2; }, {} ); for (const stateKey of Object.keys(keyMap)) { const urlKey = resolvedUrlKeys[stateKey]; debug("[nuq+ `%s`] Subscribing to sync for `%s`", stateKeys, urlKey); emitter.on(urlKey, handlers[stateKey]); } return () => { for (const stateKey of Object.keys(keyMap)) { const urlKey = resolvedUrlKeys[stateKey]; debug("[nuq+ `%s`] Unsubscribing to sync for `%s`", stateKeys, urlKey); emitter.off(urlKey, handlers[stateKey]); } }; }, [keyMap, resolvedUrlKeys]); const update = useCallback( (stateUpdater, callOptions = {}) => { const newState = typeof stateUpdater === "function" ? stateUpdater(stateRef.current) : stateUpdater === null ? Object.fromEntries( Object.keys(keyMap).map((key) => [key, null]) ) : stateUpdater; debug("[nuq+ `%s`] setState: %O", stateKeys, newState); for (let [stateKey, value] of Object.entries(newState)) { const parser = keyMap[stateKey]; const urlKey = resolvedUrlKeys[stateKey]; if (!parser) { continue; } if ((callOptions.clearOnDefault ?? parser.clearOnDefault ?? clearOnDefault) && value !== null && parser.defaultValue !== void 0 && (parser.eq ?? ((a, b) => a === b))(value, parser.defaultValue)) { value = null; } queryRef.current[urlKey] = enqueueQueryStringUpdate( urlKey, value, parser.serialize ?? String, { // 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 } ); emitter.emit(urlKey, { state: value, query: queryRef.current[urlKey] ?? null }); } return scheduleFlushToURL(updateUrl, rateLimitFactor); }, [ keyMap, history, shallow, scroll, throttleMs, startTransition, resolvedUrlKeys, updateUrl, rateLimitFactor ] ); return [internalState, update]; } function parseMap(keyMap, urlKeys, searchParams, cachedQuery, cachedState) { return Object.keys(keyMap).reduce((obj, stateKey) => { const urlKey = urlKeys?.[stateKey] ?? stateKey; const { defaultValue, parse } = keyMap[stateKey]; const query = searchParams?.get(urlKey) ?? null; if (cachedQuery && cachedState && cachedQuery[urlKey] === query) { obj[stateKey] = cachedState[stateKey] ?? defaultValue ?? null; return obj; } const value = query === null ? null : safeParse(parse, query, stateKey); obj[stateKey] = value ?? defaultValue ?? null; if (cachedQuery) { cachedQuery[urlKey] = query; } return obj; }, {}); } export { createParser, createSerializer, parseAsArrayOf, parseAsBoolean, parseAsFloat, parseAsHex, parseAsInteger, parseAsIsoDateTime, parseAsJson, parseAsNumberLiteral, parseAsString, parseAsStringEnum, parseAsStringLiteral, parseAsTimestamp, useQueryState, useQueryStates };