UNPKG

nuqs

Version:

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

581 lines (569 loc) 18.8 kB
import * as React from "react"; //#region src/lib/errors.ts const errors = { 303: "Multiple adapter contexts detected. This might happen in monorepos.", 404: "nuqs requires an adapter to work with your framework.", 409: "Multiple versions of the library are loaded. This may lead to unexpected behavior. Currently using `%s`, but `%s` (via the %s adapter) was about to load on top.", 414: "Max safe URL length exceeded. Some browsers may not be able to accept this URL. Consider limiting the amount of state stored in the URL.", 422: "Invalid options combination: `limitUrlUpdates: debounce` should be used in SSR scenarios, with `shallow: false`", 429: "URL update rate-limited by the browser. Consider increasing `throttleMs` for key(s) `%s`. %O", 500: "Empty search params cache. Search params can't be accessed in Layouts.", 501: "Search params cache already populated. Have you called `parse` twice?" }; function error(code) { return `[nuqs] ${errors[code]} See https://nuqs.dev/NUQS-${code}`; } //#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/loader.ts function createLoader(parsers, { urlKeys = {} } = {}) { function loadSearchParams(input, { strict = false } = {}) { if (input instanceof Promise) return input.then((i) => loadSearchParams(i, { strict })); const searchParams = extractSearchParams(input); const result = {}; for (const [key, parser] of Object.entries(parsers)) { const urlKey = urlKeys[key] ?? key; const query = parser.type === "multi" ? searchParams.getAll(urlKey) : searchParams.get(urlKey); if (isAbsentFromUrl(query)) { result[key] = parser.defaultValue ?? null; continue; } let parsedValue; try { parsedValue = parser.parse(query); } catch (error) { if (strict) throw new Error(`[nuqs] Error while parsing query \`${query}\` for key \`${key}\`: ${error}`); parsedValue = null; } if (strict && query && parsedValue === null) throw new Error(`[nuqs] Failed to parse query \`${query}\` for key \`${key}\` (got null)`); result[key] = parsedValue ?? parser.defaultValue ?? null; } return result; } return loadSearchParams; } function extractSearchParams(input) { try { if (input instanceof Request) return input.url ? new URL(input.url).searchParams : new URLSearchParams(); if (input instanceof URL) return input.searchParams; if (input instanceof URLSearchParams) return input; if (typeof input === "object") { const searchParams = new URLSearchParams(); for (const [key, value] of Object.entries(input)) 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 (URL.hasOwnProperty("canParse") && URL.canParse(input)) return new URL(input).searchParams; return new URLSearchParams(input); } } catch {} return new URLSearchParams(); } //#endregion //#region src/cache.ts const $input = Symbol("Input"); function createSearchParamsCache(parsers, { urlKeys = {} } = {}) { const load = createLoader(parsers, { urlKeys }); const getCache = React.cache(() => ({ searchParams: {} })); function parseSync(searchParams, loaderOptions) { const c = getCache(); if (Object.isFrozen(c.searchParams)) { if (c[$input] && compareSearchParams(searchParams, c[$input])) return all(); throw new Error(error(501)); } c.searchParams = load(searchParams, loaderOptions); c[$input] = searchParams; return Object.freeze(c.searchParams); } function parse(searchParams, loaderOptions = {}) { if (searchParams instanceof Promise) return searchParams.then((searchParams) => parseSync(searchParams, loaderOptions)); return parseSync(searchParams, loaderOptions); } function all() { const { searchParams } = getCache(); if (Object.keys(searchParams).length === 0) throw new Error(error(500)); return searchParams; } function get(key) { const { searchParams } = getCache(); const entry = searchParams[key]; if (typeof entry === "undefined") throw new Error(error(500) + ` in get(${String(key)})`); return entry; } return { parse, get, all }; } function compareSearchParams(a, b) { if (a === b) return true; if (Object.keys(a).length !== Object.keys(b).length) return false; for (const key in a) if (a[key] !== b[key]) return false; return true; } //#endregion //#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/debug.ts const debugEnabled = isDebugEnabled(); function warn(message, ...args) { if (!debugEnabled) return; console.warn(message, ...args); } function isDebugEnabled() { if (typeof window === "undefined") return (process.env.DEBUG || "").includes("nuqs"); try { const test = "nuqs-localStorage-test"; if (typeof localStorage === "undefined") return false; localStorage.setItem(test, test); const isStorageAvailable = localStorage.getItem(test) === test; localStorage.removeItem(test); return isStorageAvailable && (localStorage.getItem("debug") || "").includes("nuqs"); } catch { return false; } } //#endregion //#region src/lib/safe-parse.ts function safeParse(parser, value, key) { try { return parser(value); } catch (error) { warn("[nuqs] Error while parsing value `%s`: %O" + (key ? " (for key `%s`)" : ""), value, error, key); return null; } } //#endregion //#region src/parsers.ts /** * Wrap a set of parse/serialize functions into a builder pattern parser * you can pass to one of the hooks, making its default value type safe. */ 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 { type: "single", eq: (a, b) => a === b, ...parser, parseServerSide: parseServerSideNullable, withDefault(defaultValue) { return { ...this, defaultValue, parseServerSide(value) { return parseServerSideNullable(value) ?? defaultValue; } }; }, withOptions(options) { return { ...this, ...options }; } }; } function createMultiParser(parser) { function parseServerSideNullable(value) { if (typeof value === "undefined") return null; return safeParse(parser.parse, Array.isArray(value) ? value : [value]); } return { type: "multi", eq: (a, b) => a === b, ...parser, parseServerSide: parseServerSideNullable, withDefault(defaultValue) { return { ...this, defaultValue, parseServerSide(value) { return parseServerSideNullable(value) ?? defaultValue; } }; }, withOptions(options) { return { ...this, ...options }; } }; } const parseAsString = createParser({ parse: (v) => v, serialize: String }); const parseAsInteger = createParser({ parse: (v) => { const int = parseInt(v); return int == int ? int : null; }, serialize: (v) => "" + Math.round(v) }); const parseAsIndex = createParser({ parse: (v) => { const int = parseInt(v); return int == int ? int - 1 : null; }, serialize: (v) => "" + Math.round(v + 1) }); const parseAsHex = createParser({ parse: (v) => { const int = parseInt(v, 16); return int == int ? int : null; }, serialize: (v) => { const hex = Math.round(v).toString(16); return (hex.length & 1 ? "0" : "") + hex; } }); const parseAsFloat = createParser({ parse: (v) => { const float = parseFloat(v); return float == float ? float : null; }, serialize: String }); const parseAsBoolean = createParser({ parse: (v) => v.toLowerCase() === "true", serialize: String }); function compareDates(a, b) { return a.valueOf() === b.valueOf(); } /** * Querystring encoded as the number of milliseconds since epoch, * and returned as a Date object. */ const parseAsTimestamp = createParser({ parse: (v) => { const ms = parseInt(v); return ms == ms ? new Date(ms) : null; }, serialize: (v) => "" + v.valueOf(), eq: compareDates }); /** * Querystring encoded as an ISO-8601 string (UTC), * and returned as a Date object. */ const parseAsIsoDateTime = createParser({ parse: (v) => { const date = new Date(v); return date.valueOf() == date.valueOf() ? date : null; }, serialize: (v) => v.toISOString(), eq: compareDates }); /** * Querystring encoded as an ISO-8601 string (UTC) * without the time zone offset, and returned as * a Date object. * * The Date is parsed without the time zone offset, * making it at 00:00:00 UTC. */ const parseAsIsoDate = createParser({ parse: (v) => { const date = new Date(v.slice(0, 10)); return date.valueOf() == date.valueOf() ? date : null; }, serialize: (v) => v.toISOString().slice(0, 10), eq: compareDates }); /** * String-based enums provide better type-safety for known sets of values. * You will need to pass the parseAsStringEnum function a list of your enum values * in order to validate the query string. Anything else will return `null`, * or your default value if specified. * * Example: * ```ts * enum Direction { * up = 'UP', * down = 'DOWN', * left = 'LEFT', * right = 'RIGHT' * } * * const [direction, setDirection] = useQueryState( * 'direction', * parseAsStringEnum<Direction>(Object.values(Direction)) // pass a list of allowed values * .withDefault(Direction.up) * ) * ``` * * Note: the query string value will be the value of the enum, not its name * (example above: `direction=UP`). * * @param validValues The values you want to accept */ function parseAsStringEnum(validValues) { return parseAsStringLiteral(validValues); } /** * String-based literals provide better type-safety for known sets of values. * You will need to pass the parseAsStringLiteral function a list of your string values * in order to validate the query string. Anything else will return `null`, * or your default value if specified. * * Example: * ```ts * const colors = ["red", "green", "blue"] as const * * const [color, setColor] = useQueryState( * 'color', * parseAsStringLiteral(colors) // pass a readonly list of allowed values * .withDefault("red") * ) * ``` * * @param validValues The values you want to accept */ function parseAsStringLiteral(validValues) { return createParser({ parse: (query) => { const asConst = query; return validValues.includes(asConst) ? asConst : null; }, serialize: String }); } /** * Number-based literals provide better type-safety for known sets of values. * You will need to pass the parseAsNumberLiteral function a list of your number values * in order to validate the query string. Anything else will return `null`, * or your default value if specified. * * Example: * ```ts * const diceSides = [1, 2, 3, 4, 5, 6] as const * * const [side, setSide] = useQueryState( * 'side', * parseAsNumberLiteral(diceSides) // pass a readonly list of allowed values * .withDefault(4) * ) * ``` * * @param validValues The values you want to accept */ function parseAsNumberLiteral(validValues) { return createParser({ parse: (query) => { const asConst = parseFloat(query); if (validValues.includes(asConst)) return asConst; return null; }, serialize: String }); } /** * Encode any object shape into the querystring value as JSON. * Note: you may want to use `useQueryStates` for finer control over * multiple related query keys. * * @param runtimeParser Runtime parser (eg: Zod schema or Standard Schema) to validate after JSON.parse */ function parseAsJson(validator) { return createParser({ parse: (query) => { try { const obj = JSON.parse(query); if ("~standard" in validator) { const result = validator["~standard"].validate(obj); if (result instanceof Promise) throw new Error("[nuqs] Only synchronous Standard Schemas are supported in parseAsJson."); return result.issues ? null : result.value; } return validator(obj); } catch { return null; } }, serialize: (value) => JSON.stringify(value), eq(a, b) { return a === b || JSON.stringify(a) === JSON.stringify(b); } }); } /** * A comma-separated list of items. * Items are URI-encoded for safety, so they may not look nice in the URL. * * @param itemParser Parser for each individual item in the array * @param separator The character to use to separate items (default ',') */ 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) => { return (itemParser.serialize ? itemParser.serialize(value) : String(value)).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])); } }); } function parseAsNativeArrayOf(itemParser) { const itemEq = itemParser.eq ?? ((a, b) => a === b); return createMultiParser({ parse: (query) => { const parsed = query.map((item, index) => safeParse(itemParser.parse, item, `[${index}]`)).filter((value) => value !== null && value !== void 0); return parsed.length === 0 ? null : parsed; }, serialize: (values) => { return (Array.isArray(values) ? values : [values]).flatMap((value) => { const serialized = itemParser.serialize?.(value) ?? String(value); return typeof serialized === "string" ? [serialized] : [...serialized]; }); }, eq(a, b) { if (a === b) return true; if (a.length !== b.length) return false; return a.every((value, index) => itemEq(value, b[index])); } }).withDefault([]); } //#endregion //#region src/lib/url-encoding.ts function renderQueryString(search) { if (search.size === 0) return ""; const query = []; for (const [key, value] of search.entries()) { const safeKey = key.replace(/#/g, "%23").replace(/&/g, "%26").replace(/\+/g, "%2B").replace(/=/g, "%3D").replace(/\?/g, "%3F"); query.push(`${safeKey}=${encodeQueryValue(value)}`); } const queryString = "?" + query.join("&"); warnIfURLIsTooLong(queryString); return queryString; } function encodeQueryValue(input) { return input.replace(/%/g, "%25").replace(/\+/g, "%2B").replace(/ /g, "+").replace(/#/g, "%23").replace(/&/g, "%26").replace(/"/g, "%22").replace(/'/g, "%27").replace(/`/g, "%60").replace(/</g, "%3C").replace(/>/g, "%3E").replace(/[\x00-\x1F]/g, (char) => encodeURIComponent(char)); } const URL_MAX_LENGTH = 2e3; function warnIfURLIsTooLong(queryString) { if (process.env.NODE_ENV === "production") return; if (typeof location === "undefined") return; const url = new URL(location.href); url.search = queryString; if (url.href.length > URL_MAX_LENGTH) console.warn(error(414)); } //#endregion //#region src/serializer.ts function createSerializer(parsers, { clearOnDefault = true, urlKeys = {}, processUrlSearchParams } = {}) { function serialize(arg1BaseOrValues, arg2values = {}) { let [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); } if (processUrlSearchParams) search = processUrlSearchParams(search); 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 && value !== null && (parser.eq ?? ((a, b) => a === b))(value, parser.defaultValue); if (value === null || (parser.clearOnDefault ?? clearOnDefault ?? true) && isMatchingDefault) search.delete(urlKey); else search = write(parser.serialize(value), urlKey, search); } if (processUrlSearchParams) search = processUrlSearchParams(search); 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)]; } //#endregion //#region src/standard-schema.ts function createStandardSchemaV1(parsers, { urlKeys, partialOutput = false } = {}) { const serialize = createSerializer(parsers, { urlKeys }); const load = createLoader(parsers, { urlKeys }); return { "~standard": { version: 1, vendor: "nuqs", validate(input) { try { const value = load(serialize(input), { strict: true }); if (partialOutput) { for (const key in value) if (!(key in input)) delete value[key]; } return { value }; } catch (error) { return { issues: [{ message: error instanceof Error ? error.message : String(error) }] }; } } } }; } //#endregion export { createLoader, createMultiParser, createParser, createSearchParamsCache, createSerializer, createStandardSchemaV1, debounce, defaultRateLimit, parseAsArrayOf, parseAsBoolean, parseAsFloat, parseAsHex, parseAsIndex, parseAsInteger, parseAsIsoDate, parseAsIsoDateTime, parseAsJson, parseAsNativeArrayOf, parseAsNumberLiteral, parseAsString, parseAsStringEnum, parseAsStringLiteral, parseAsTimestamp, throttle }; //# sourceMappingURL=server.js.map