nuqs
Version:
Type-safe search params state manager for Next.js - Like React.useState, but stored in the URL query string
531 lines (526 loc) • 17.7 kB
JavaScript
import { getDefaultThrottle, error, debug, parseAsString, parseAsInteger, parseAsFloat, parseAsBoolean, parseAsTimestamp, parseAsIsoDateTime, parseAsStringEnum, parseAsJson, parseAsArrayOf, safeParse, renderQueryString } from './chunk-IYCODKYJ.js';
export { createParser, createSerializer, parseAsArrayOf, parseAsBoolean, parseAsFloat, parseAsHex, parseAsInteger, parseAsIsoDateTime, parseAsJson, parseAsNumberLiteral, parseAsString, parseAsStringEnum, parseAsStringLiteral, parseAsTimestamp } from './chunk-IYCODKYJ.js';
import Mitt from 'mitt';
import { useRouter, useSearchParams } from 'next/navigation.js';
import React from 'react';
// src/deprecated.ts
var queryTypes = {
/**
* @deprecated use `parseAsString` instead.
*/
string: parseAsString,
/**
* @deprecated use `parseAsInteger` instead.
*/
integer: parseAsInteger,
/**
* @deprecated use `parseAsFloat` instead.
*/
float: parseAsFloat,
/**
* @deprecated use `parseAsBoolean` instead.
*/
boolean: parseAsBoolean,
/**
* @deprecated use `parseAsTimestamp` instead.
*/
timestamp: parseAsTimestamp,
/**
* @deprecated use `parseAsIsoDateTime` instead.
*/
isoDateTime: parseAsIsoDateTime,
/**
* @deprecated use `parseAsStringEnum` instead.
*/
stringEnum: parseAsStringEnum,
/**
* @deprecated use `parseAsJson` instead.
*/
json: parseAsJson,
/**
* @deprecated use `parseAsArrayOf` instead.
*/
array: parseAsArrayOf
};
// 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.shallow = false;
}
queueOptions.throttleMs = Math.max(
options.throttleMs ?? FLUSH_RATE_LIMIT_MS,
Number.isFinite(queueOptions.throttleMs) ? queueOptions.throttleMs : 0
);
return serializedOrNull;
}
function getQueuedValue(key) {
return updateQueue.get(key) ?? null;
}
function scheduleFlushToURL(router) {
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(router);
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 = 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(router) {
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 {
const nextRouter = window.next?.router;
const isPagesRouter = typeof nextRouter?.state?.asPath === "string";
if (isPagesRouter) {
const url = renderURL(nextRouter.state.asPath.split("?")[0] ?? "", search);
debug("[nuqs queue (pages)] Updating url: %s", url);
const method = options.history === "push" ? nextRouter.push : nextRouter.replace;
method.call(nextRouter, url, url, {
scroll: options.scroll,
shallow: options.shallow
});
} else {
const url = renderURL(location.origin + location.pathname, search);
debug("[nuqs queue (app)] Updating url: %s", url);
const updateMethod = options.history === "push" ? history.pushState : history.replaceState;
const state = (window.next?.version ?? "") >= "14.1.0" ? null : history.state;
updateMethod.call(
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) {
compose(transitions, () => {
router.replace(url, {
scroll: false
});
});
}
}
return [search, null];
} catch (err) {
console.error(error(429), items.map(([key]) => key).join(), err);
return [search, err];
}
}
function renderURL(base, search) {
const hashlessBase = base.split("#")[0] ?? "";
const query = renderQueryString(search);
const hash = location.hash;
return hashlessBase + query + hash;
}
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/sync.ts
var SYNC_EVENT_KEY = Symbol("__nuqs__SYNC__");
var NOSYNC_MARKER = "__nuqs__NO_SYNC__";
var NOTIFY_EVENT_KEY = Symbol("__nuqs__NOTIFY__");
var emitter = Mitt();
function subscribeToQueryUpdates(callback) {
emitter.on(NOTIFY_EVENT_KEY, callback);
return () => emitter.off(NOTIFY_EVENT_KEY, callback);
}
if (typeof history === "object") {
patchHistory();
}
function patchHistory() {
const version = "1.19.3";
const patched = history.__nuqs_patched;
if (patched) {
if (patched !== version) {
console.error(error(409), patched, version);
}
return;
}
debug("[nuqs] Patching history with %s", version);
for (const method of ["pushState", "replaceState"]) {
const original = history[method].bind(history);
history[method] = function nuqs_patchedHistory(state, title, url) {
if (!url) {
debug("[nuqs] history.%s(null) (%s) %O", method, title, state);
return original(state, title, url);
}
const source = title === NOSYNC_MARKER ? "internal" : "external";
const search = new URL(url, location.origin).searchParams;
debug("[nuqs] history.%s(%s) (%s) %O", method, url, source, 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);
}
}
setTimeout(() => {
debug(
"[nuqs] External history.%s call: triggering sync with %s",
method,
search
);
emitter.emit(SYNC_EVENT_KEY, search);
emitter.emit(NOTIFY_EVENT_KEY, { search, source });
}, 0);
} else {
setTimeout(() => {
emitter.emit(NOTIFY_EVENT_KEY, { search, source });
}, 0);
}
return original(state, title === NOSYNC_MARKER ? "" : title, url);
};
}
Object.defineProperty(history, "__nuqs_patched", {
value: version,
writable: false,
enumerable: false,
configurable: false
});
}
function useQueryState(key, {
history: history2 = "replace",
shallow = true,
scroll = false,
throttleMs = FLUSH_RATE_LIMIT_MS,
parse = (x) => x,
serialize = String,
eq = (a, b) => a === b,
defaultValue = void 0,
clearOnDefault = false,
startTransition
} = {
history: "replace",
scroll: false,
shallow: true,
throttleMs: FLUSH_RATE_LIMIT_MS,
parse: (x) => x,
serialize: String,
eq: (a, b) => a === b,
clearOnDefault: false,
defaultValue: void 0
}) {
const router = useRouter();
const initialSearchParams = useSearchParams();
const queryRef = React.useRef(null);
const [internalState, setInternalState] = React.useState(() => {
const queueValue = getQueuedValue(key);
const urlValue = initialSearchParams?.get(key) ?? null;
const value = queueValue ?? urlValue;
queryRef.current = value;
return value === null ? null : safeParse(parse, value, key);
});
const stateRef = React.useRef(internalState);
debug(
"[nuqs `%s`] render - state: %O, iSP: %s",
key,
internalState,
initialSearchParams?.get(key) ?? null
);
React.useEffect(() => {
if (window.next?.version !== "14.0.3") {
return;
}
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]);
React.useInsertionEffect(() => {
function updateInternalState({ state, query }) {
debug("[nuqs `%s`] updateInternalState %O", key, state);
stateRef.current = state;
queryRef.current = query;
setInternalState(state);
}
function syncFromURL(search) {
const query = search.get(key);
if (query === queryRef.current) {
return;
}
const state = query === null ? null : safeParse(parse, query, key);
debug("[nuqs `%s`] syncFromURL %O", key, state);
updateInternalState({ state, query });
}
debug("[nuqs `%s`] subscribing to sync", key);
emitter.on(SYNC_EVENT_KEY, syncFromURL);
emitter.on(key, updateInternalState);
return () => {
debug("[nuqs `%s`] unsubscribing from sync", key);
emitter.off(SYNC_EVENT_KEY, syncFromURL);
emitter.off(key, updateInternalState);
};
}, [key]);
const update = React.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 ?? history2,
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(router);
},
[key, history2, shallow, scroll, throttleMs, startTransition]
);
return [internalState ?? defaultValue ?? null, update];
}
function isUpdaterFunction(stateUpdater) {
return typeof stateUpdater === "function";
}
function useQueryStates(keyMap, {
history: history2 = "replace",
scroll = false,
shallow = true,
throttleMs = FLUSH_RATE_LIMIT_MS,
clearOnDefault = false,
startTransition
} = {}) {
const keys = Object.keys(keyMap).join(",");
const router = useRouter();
const initialSearchParams = useSearchParams();
const queryRef = React.useRef({});
const [internalState, setInternalState] = React.useState(() => {
const source = initialSearchParams ?? new URLSearchParams();
queryRef.current = Object.fromEntries(source.entries());
return parseMap(keyMap, source);
});
const stateRef = React.useRef(internalState);
debug(
"[nuq+ `%s`] render - state: %O, iSP: %s",
keys,
internalState,
initialSearchParams
);
React.useEffect(() => {
if (window.next?.version !== "14.0.3") {
return;
}
const state = parseMap(
keyMap,
initialSearchParams,
queryRef.current,
stateRef.current
);
setInternalState(state);
}, [
Object.keys(keyMap).map((key) => initialSearchParams?.get(key)).join("&"),
keys
]);
React.useInsertionEffect(() => {
function updateInternalState(state) {
debug("[nuq+ `%s`] updateInternalState %O", keys, state);
stateRef.current = state;
setInternalState(state);
}
function syncFromURL(search) {
const state = parseMap(keyMap, search, queryRef.current, stateRef.current);
debug("[nuq+ `%s`] syncFromURL %O", keys, state);
updateInternalState(state);
}
const handlers = Object.keys(keyMap).reduce(
(handlers2, key) => {
handlers2[key] = ({ state, query }) => {
const { defaultValue } = keyMap[key];
stateRef.current = {
...stateRef.current,
[key]: state ?? defaultValue ?? null
};
queryRef.current[key] = query;
debug(
"[nuq+ `%s`] Cross-hook key sync %s: %O (default: %O). Resolved: %O",
keys,
key,
state,
defaultValue,
stateRef.current
);
updateInternalState(stateRef.current);
};
return handlers2;
},
{}
);
emitter.on(SYNC_EVENT_KEY, syncFromURL);
for (const key of Object.keys(keyMap)) {
debug("[nuq+ `%s`] Subscribing to sync for `%s`", keys, key);
emitter.on(key, handlers[key]);
}
return () => {
emitter.off(SYNC_EVENT_KEY, syncFromURL);
for (const key of Object.keys(keyMap)) {
debug("[nuq+ `%s`] Unsubscribing to sync for `%s`", keys, key);
emitter.off(key, handlers[key]);
}
};
}, [keyMap]);
const update = React.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", keys, newState);
for (let [key, value] of Object.entries(newState)) {
const parser = keyMap[key];
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[key] = enqueueQueryStringUpdate(
key,
value,
parser.serialize ?? String,
{
// Call-level options take precedence over individual parser options
// which take precedence over global options
history: callOptions.history ?? parser.history ?? history2,
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(key, {
state: value,
query: queryRef.current[key] ?? null
});
}
return scheduleFlushToURL(router);
},
[keyMap, history2, shallow, scroll, throttleMs, startTransition]
);
return [internalState, update];
}
function parseMap(keyMap, searchParams, cachedQuery, cachedState) {
return Object.keys(keyMap).reduce((obj, key) => {
const { defaultValue, parse } = keyMap[key];
const urlQuery = searchParams?.get(key) ?? null;
const queueQuery = getQueuedValue(key);
const query = queueQuery ?? urlQuery;
if (cachedQuery && cachedState && cachedQuery[key] === query) {
obj[key] = cachedState[key] ?? defaultValue ?? null;
return obj;
}
const value = query === null ? null : safeParse(parse, query, key);
obj[key] = value ?? defaultValue ?? null;
if (cachedQuery) {
cachedQuery[key] = query;
}
return obj;
}, {});
}
export { queryTypes, subscribeToQueryUpdates, useQueryState, useQueryStates };