nuqs-svelte
Version:
Svelte adaptation of the `nuqs` library for managing URL query strings as state.
167 lines (166 loc) • 7.64 kB
JavaScript
// 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]));
}