UNPKG

nuqs

Version:

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

477 lines (466 loc) 13 kB
import * as React from 'react'; // src/cache.ts // src/errors.ts var 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.", 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://err.47ng.com/NUQS-${code}`; } // 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/cache.ts var $input = Symbol("Input"); function createSearchParamsCache(parsers, { urlKeys = {} } = {}) { const load = createLoader(parsers, { urlKeys }); const getCache = React.cache(() => ({ searchParams: {} })); function parseSync(searchParams) { 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); c[$input] = searchParams; return Object.freeze(c.searchParams); } function parse(searchParams) { if (searchParams instanceof Promise) { return searchParams.then(parseSync); } return parseSync(searchParams); } 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; } // src/debug.ts var debugEnabled = isDebugEnabled(); function warn(message, ...args) { if (!debugEnabled) { return; } console.warn(message, ...args); } function isDebugEnabled() { try { if (typeof localStorage === "undefined") { return false; } const test = "nuqs-localStorage-test"; localStorage.setItem(test, test); const isStorageAvailable = localStorage.getItem(test) === test; localStorage.removeItem(test); if (!isStorageAvailable) { return false; } } catch (error2) { console.error( "[nuqs]: debug mode is disabled (localStorage unavailable).", error2 ); return false; } const debug = localStorage.getItem("debug") ?? ""; return debug.includes("nuqs"); } // 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; } } // 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/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)); } var 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)); } } // 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) ]; } } export { createLoader, createParser, createSearchParamsCache, createSerializer, parseAsArrayOf, parseAsBoolean, parseAsFloat, parseAsHex, parseAsIndex, parseAsInteger, parseAsIsoDate, parseAsIsoDateTime, parseAsJson, parseAsNumberLiteral, parseAsString, parseAsStringEnum, parseAsStringLiteral, parseAsTimestamp };