UNPKG

nuqs-svelte

Version:

Svelte adaptation of the `nuqs` library for managing URL query strings as state.

167 lines (166 loc) 7.64 kB
// reactive values are required to be declared as let in Svelte /* eslint-disable prefer-const */ import { useAdapter } from "./adapters/index.svelte"; import { debug } from "./debug"; import { emitter } from "./sync"; import { enqueueQueryStringUpdate, FLUSH_RATE_LIMIT_MS, getQueuedValue, scheduleFlushToURL, } from "./update-queue"; import { safeParse } from "./utils"; // Ensure referential consistency for the default value of urlKeys // by hoisting it out of the function scope. // Otherwise useEffect loops go brrrr const defaultUrlKeys = {}; /** * Synchronise multiple query string arguments to Svelte state * * @param keys - An object describing the keys to synchronise and how to * serialise and parse them. * Use `parseAs(String|Integer|Float|...)` for quick shorthands. * * @param options - Optional history mode, shallow routing and scroll restoration options. */ export function useQueryStates(keyMap, { history = "replace", scroll = false, shallow = true, throttleMs = FLUSH_RATE_LIMIT_MS, clearOnDefault = true, urlKeys = defaultUrlKeys, } = {}) { const adapter = useAdapter(); const stateKeys = Object.keys(keyMap).join(","); const resolvedUrlKeys = Object.fromEntries(Object.keys(keyMap).map((key) => [key, urlKeys[key] ?? key])); let initialSearchParams = $derived(adapter.searchParams()); let defaultValues = $derived(Object.fromEntries(Object.keys(keyMap).map((key) => [key, keyMap[key].defaultValue ?? null]))); let internalState = $derived(parseMap(keyMap, urlKeys, initialSearchParams).state); let queryRef = $state({}); let stateRef = $derived(internalState); $effect(() => { if (Object.keys(queryRef).join("&") !== Object.values(resolvedUrlKeys).join("&")) { const { state, hasChanged } = parseMap(keyMap, urlKeys, initialSearchParams, queryRef, stateRef); if (hasChanged) { stateRef = state; internalState = state; } queryRef = Object.fromEntries(Object.values(resolvedUrlKeys).map((urlKey) => [ urlKey, initialSearchParams.get(urlKey) ?? null, ])); } }); $effect(() => { const { state, hasChanged } = parseMap(keyMap, urlKeys, initialSearchParams, queryRef, stateRef); if (hasChanged) { stateRef = state; internalState = state; } }); // Sync all hooks together & with external URL changes $effect(() => { const updateInternalState = (state) => { debug("[nuq+ `%s`] updateInternalState %O", stateKeys, state); stateRef = state; internalState = state; }; const handlers = Object.keys(keyMap).reduce((handlers, stateKey) => { handlers[stateKey] = ({ state, query }) => { const { defaultValue } = keyMap[stateKey]; const urlKey = resolvedUrlKeys[stateKey]; // Note: cannot mutate in-place, the object ref must change // for the subsequent setState to pick it up. stateRef = { ...stateRef, [stateKey]: state ?? defaultValue ?? null, }; queryRef[urlKey] = query; debug("[nuq+ `%s`] Cross-hook key sync %s: %O (default: %O). Resolved: %O", stateKeys, urlKey, state, defaultValue, stateRef); updateInternalState(stateRef); }; return handlers; }, {}); 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]); } }; }); const update = (stateUpdater, callOptions = {}) => { const nullMap = Object.fromEntries(Object.keys(keyMap).map((key) => [key, null])); const newState = typeof stateUpdater === "function" ? (stateUpdater(applyDefaultValues(stateRef, 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 !== undefined && (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, }); emitter.emit(urlKey, { state: value, query }); } return scheduleFlushToURL(adapter); }; const outputState = $derived(applyDefaultValues(stateRef, defaultValues)); return { ...Object.fromEntries(Object.keys(keyMap).map((key) => [ key, { get current() { return outputState[key]; }, set current(value) { update((prev) => ({ ...prev, [key]: value })); }, }, ])), set: 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 === undefined ? (searchParams?.get(urlKey) ?? null) : queuedQuery; if (cachedQuery && cachedState && (cachedQuery[urlKey] ?? null) === query) { // Cache hit out[stateKey] = cachedState[stateKey] ?? null; return out; } // Cache miss hasChanged = true; const value = query === null ? null : safeParse(parse, query, stateKey); out[stateKey] = value ?? null; if (cachedQuery) { cachedQuery[urlKey] = query; } return out; }, {}); if (!hasChanged) { // check that keyMap keys have not changed 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])); }