nuqs
Version:
Type-safe search params state manager for React - Like useState, but stored in the URL query string
632 lines (625 loc) • 19 kB
JavaScript
'use client';
import { safeParse, FLUSH_RATE_LIMIT_MS, getQueuedValue, enqueueQueryStringUpdate, scheduleFlushToURL } from './chunk-6YKAEXDW.js';
import { useAdapter, debug, renderQueryString } from './chunk-5WWTJYGR.js';
import { useRef, useState, useEffect, useCallback, useMemo } from 'react';
import Mitt from 'mitt';
// src/loader.ts
function createLoader(parsers, { urlKeys = {} } = {}) {
function loadSearchParams(input) {
if (input instanceof Promise) {
return input.then((i) => loadSearchParams(i));
}
const searchParams = extractSearchParams(input);
const result = {};
for (const [key, parser] of Object.entries(parsers)) {
const urlKey = urlKeys[key] ?? key;
const value = searchParams.get(urlKey);
result[key] = parser.parseServerSide(value ?? void 0);
}
return result;
}
return loadSearchParams;
}
function extractSearchParams(input) {
try {
if (input instanceof Request) {
if (input.url) {
return new URL(input.url).searchParams;
} else {
return new URLSearchParams();
}
}
if (input instanceof URL) {
return input.searchParams;
}
if (input instanceof URLSearchParams) {
return input;
}
if (typeof input === "object") {
const entries = Object.entries(input);
const searchParams = new URLSearchParams();
for (const [key, value] of entries) {
if (Array.isArray(value)) {
for (const v of value) {
searchParams.append(key, v);
}
} else if (value !== void 0) {
searchParams.set(key, value);
}
}
return searchParams;
}
if (typeof input === "string") {
if ("canParse" in URL && URL.canParse(input)) {
return new URL(input).searchParams;
}
return new URLSearchParams(input);
}
} catch (e) {
return new URLSearchParams();
}
return new URLSearchParams();
}
// 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 parseAsIndex = createParser({
parse: (v) => {
const int = parseAsInteger.parse(v);
if (int === null) {
return null;
}
return int - 1;
},
serialize: (v) => parseAsInteger.serialize(v + 1)
});
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"
});
function compareDates(a, b) {
return a.valueOf() === b.valueOf();
}
var parseAsTimestamp = createParser({
parse: (v) => {
const ms = parseInt(v);
if (Number.isNaN(ms)) {
return null;
}
return new Date(ms);
},
serialize: (v) => v.valueOf().toString(),
eq: compareDates
});
var parseAsIsoDateTime = createParser({
parse: (v) => {
const date = new Date(v);
if (Number.isNaN(date.valueOf())) {
return null;
}
return date;
},
serialize: (v) => v.toISOString(),
eq: compareDates
});
var parseAsIsoDate = createParser({
parse: (v) => {
const date = new Date(v.slice(0, 10));
if (Number.isNaN(date.valueOf())) {
return null;
}
return date;
},
serialize: (v) => v.toISOString().slice(0, 10),
eq: compareDates
});
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, {
clearOnDefault = true,
urlKeys = {}
} = {}) {
function serialize(arg1BaseOrValues, arg2values = {}) {
const [base, search] = isBase(arg1BaseOrValues) ? splitBase(arg1BaseOrValues) : ["", new URLSearchParams()];
const values = isBase(arg1BaseOrValues) ? arg2values : arg1BaseOrValues;
if (values === null) {
for (const key in parsers) {
const urlKey = urlKeys[key] ?? key;
search.delete(urlKey);
}
return base + renderQueryString(search);
}
for (const key in parsers) {
const parser = parsers[key];
const value = values[key];
if (!parser || value === void 0) {
continue;
}
const urlKey = urlKeys[key] ?? key;
const isMatchingDefault = parser.defaultValue !== void 0 && (parser.eq ?? ((a, b) => a === b))(value, parser.defaultValue);
if (value === null || (parser.clearOnDefault ?? clearOnDefault ?? true) && isMatchingDefault) {
search.delete(urlKey);
} else {
search.set(urlKey, 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.join("?"))];
} else if (base instanceof URLSearchParams) {
return ["", new URLSearchParams(base)];
} else {
return [
base.origin + base.pathname,
new URLSearchParams(base.searchParams)
];
}
}
var emitter = Mitt();
// 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 adapter = useAdapter();
const initialSearchParams = adapter.searchParams;
const queryRef = useRef(initialSearchParams?.get(key) ?? null);
const [internalState, setInternalState] = useState(() => {
const queuedQuery = getQueuedValue(key);
const query = queuedQuery === void 0 ? initialSearchParams?.get(key) ?? null : queuedQuery;
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]);
useEffect(() => {
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;
}
const query = 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 });
return scheduleFlushToURL(adapter);
},
[
key,
history,
shallow,
scroll,
throttleMs,
startTransition,
adapter.updateUrl,
adapter.getSearchParamsSnapshot,
adapter.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, JSON.stringify(urlKeys)]
);
const adapter = useAdapter();
const initialSearchParams = adapter.searchParams;
const queryRef = useRef({});
const defaultValues = useMemo(
() => Object.fromEntries(
Object.keys(keyMap).map((key) => [key, keyMap[key].defaultValue ?? null])
),
[
Object.values(keyMap).map(({ defaultValue }) => defaultValue).join(",")
]
);
const [internalState, setInternalState] = useState(() => {
const source = initialSearchParams ?? new URLSearchParams();
return parseMap(keyMap, urlKeys, source).state;
});
const stateRef = useRef(internalState);
debug(
"[nuq+ `%s`] render - state: %O, iSP: %s",
stateKeys,
internalState,
initialSearchParams
);
if (Object.keys(queryRef.current).join("&") !== Object.values(resolvedUrlKeys).join("&")) {
const { state, hasChanged } = parseMap(
keyMap,
urlKeys,
initialSearchParams,
queryRef.current,
stateRef.current
);
if (hasChanged) {
stateRef.current = state;
setInternalState(state);
}
queryRef.current = Object.fromEntries(
Object.values(resolvedUrlKeys).map((urlKey) => [
urlKey,
initialSearchParams?.get(urlKey) ?? null
])
);
}
useEffect(() => {
const { state, hasChanged } = parseMap(
keyMap,
urlKeys,
initialSearchParams,
queryRef.current,
stateRef.current
);
if (hasChanged) {
stateRef.current = state;
setInternalState(state);
}
}, [
Object.values(resolvedUrlKeys).map((key) => `${key}=${initialSearchParams?.get(key)}`).join("&")
]);
useEffect(() => {
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]);
}
};
}, [stateKeys, resolvedUrlKeys]);
const update = useCallback(
(stateUpdater, callOptions = {}) => {
const nullMap = Object.fromEntries(
Object.keys(keyMap).map((key) => [key, null])
);
const newState = typeof stateUpdater === "function" ? stateUpdater(
applyDefaultValues(stateRef.current, defaultValues)
) ?? nullMap : stateUpdater ?? nullMap;
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;
}
const query = 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 });
}
return scheduleFlushToURL(adapter);
},
[
stateKeys,
history,
shallow,
scroll,
throttleMs,
startTransition,
resolvedUrlKeys,
adapter.updateUrl,
adapter.getSearchParamsSnapshot,
adapter.rateLimitFactor,
defaultValues
]
);
const outputState = useMemo(
() => applyDefaultValues(internalState, defaultValues),
[internalState, defaultValues]
);
return [outputState, update];
}
function parseMap(keyMap, urlKeys, searchParams, cachedQuery, cachedState) {
let hasChanged = false;
const state = Object.keys(keyMap).reduce((out, stateKey) => {
const urlKey = urlKeys?.[stateKey] ?? stateKey;
const { parse } = keyMap[stateKey];
const queuedQuery = getQueuedValue(urlKey);
const query = queuedQuery === void 0 ? searchParams?.get(urlKey) ?? null : queuedQuery;
if (cachedQuery && cachedState && (cachedQuery[urlKey] ?? null) === query) {
out[stateKey] = cachedState[stateKey] ?? null;
return out;
}
hasChanged = true;
const value = query === null ? null : safeParse(parse, query, stateKey);
out[stateKey] = value ?? null;
if (cachedQuery) {
cachedQuery[urlKey] = query;
}
return out;
}, {});
if (!hasChanged) {
const keyMapKeys = Object.keys(keyMap);
const cachedStateKeys = Object.keys(cachedState ?? {});
hasChanged = keyMapKeys.length !== cachedStateKeys.length || keyMapKeys.some((key) => !cachedStateKeys.includes(key));
}
return { state, hasChanged };
}
function applyDefaultValues(state, defaults) {
return Object.fromEntries(
Object.keys(state).map((key) => [key, state[key] ?? defaults[key] ?? null])
);
}
export { createLoader, createParser, createSerializer, parseAsArrayOf, parseAsBoolean, parseAsFloat, parseAsHex, parseAsIndex, parseAsInteger, parseAsIsoDate, parseAsIsoDateTime, parseAsJson, parseAsNumberLiteral, parseAsString, parseAsStringEnum, parseAsStringLiteral, parseAsTimestamp, useQueryState, useQueryStates };