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
JavaScript
'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 };