1278 lines
56 KiB
JavaScript
1278 lines
56 KiB
JavaScript
import { SvelteURLSearchParams } from "svelte/reactivity";
|
|
import { dequal } from "dequal/lite";
|
|
import * as lzString from "lz-string";
|
|
import { browser, building } from "$app/environment";
|
|
import { goto } from "$app/navigation";
|
|
import { page } from "$app/state";
|
|
import { IsMounted } from "../is-mounted/is-mounted.svelte.js";
|
|
import { BROWSER } from "esm-env";
|
|
import { untrack } from "svelte";
|
|
/**
|
|
* Extract and pre-process values from URLSearchParams
|
|
*
|
|
* This is a shared utility used by both validateSearchParams and the SearchParams class
|
|
* to ensure consistent conversion of URL string values to JavaScript types.
|
|
*
|
|
* @param searchParams The URLSearchParams object to extract values from
|
|
* @param numberFields Optional set of field names that should be treated as numbers
|
|
* @returns An object with processed parameter values
|
|
* @internal
|
|
*/
|
|
function extractParamValues(searchParams, numberFields = new Set()) {
|
|
const params = {};
|
|
for (const [key, value] of searchParams.entries()) {
|
|
try {
|
|
// Special handling for empty arrays and objects
|
|
if (value === "[]") {
|
|
params[key] = [];
|
|
continue;
|
|
}
|
|
if (value === "{}") {
|
|
params[key] = {};
|
|
continue;
|
|
}
|
|
// Try to parse as JSON for complex objects/arrays
|
|
// This handles cases like ?obj={"foo":"bar"} or ?arr=[1,2,3]
|
|
if (value.startsWith("{") || value.startsWith("[")) {
|
|
params[key] = JSON.parse(value);
|
|
}
|
|
// Boolean values are stored as strings, convert them
|
|
else if (value === "true" || value === "false") {
|
|
params[key] = value === "true";
|
|
}
|
|
// Only convert to number if it looks numeric AND the schema expects a number
|
|
else if (numberFields.has(key) && value.trim() !== "" && !isNaN(Number(value))) {
|
|
params[key] = Number(value);
|
|
}
|
|
// Handle comma-separated values as arrays (fallback format)
|
|
else if (value.includes(",")) {
|
|
params[key] = value.split(",");
|
|
}
|
|
// Keep everything else as strings
|
|
else {
|
|
params[key] = value;
|
|
}
|
|
}
|
|
catch {
|
|
// If JSON parsing fails, treat as regular string
|
|
// This ensures we don't throw errors during type conversion
|
|
params[key] = value;
|
|
}
|
|
}
|
|
return params;
|
|
}
|
|
/**
|
|
* Extract schema information by validating an empty object.
|
|
* This consolidates multiple schema validation calls into one for efficiency.
|
|
* Works with any StandardSchemaV1-compatible schema (Zod, Valibot, Arktype, etc.)
|
|
*
|
|
* Note: This function expects schemas used with useSearchParams to have defaults for all fields,
|
|
* which is the recommended pattern since URL parameters are inherently optional.
|
|
*
|
|
* @param schema A StandardSchemaV1-compatible schema
|
|
* @returns Object containing schema keys, number fields, and default values
|
|
* @internal
|
|
*/
|
|
function extractSchemaInfo(schema) {
|
|
const validationResult = schema["~standard"].validate({});
|
|
if (!validationResult || !("value" in validationResult)) {
|
|
return { keys: [], numberFields: new Set(), defaultValues: {} };
|
|
}
|
|
const defaultValues = validationResult.value;
|
|
const keys = Object.keys(defaultValues);
|
|
const numberFields = new Set();
|
|
// Determine which fields are number types by checking default value types
|
|
for (const [key, defaultValue] of Object.entries(defaultValues)) {
|
|
if (typeof defaultValue === "number") {
|
|
numberFields.add(key);
|
|
}
|
|
}
|
|
return { keys, numberFields, defaultValues };
|
|
}
|
|
/**
|
|
* Extract and pre-process values from URLSearchParams, but only for keys defined in the schema.
|
|
* This enables SvelteKit's fine-grained reactivity by only accessing specific parameters
|
|
* instead of all parameters via searchParams.entries().
|
|
*
|
|
* @param searchParams The URLSearchParams object to extract values from
|
|
* @param schemaKeys Array of parameter keys that are defined in the schema
|
|
* @param numberFields Set of field names that should be treated as numbers
|
|
* @returns An object with processed parameter values for schema-defined keys only
|
|
* @internal
|
|
*/
|
|
function extractSelectiveParamValues(searchParams, schemaKeys, numberFields = new Set()) {
|
|
const params = {};
|
|
// Only access parameters that are defined in the schema
|
|
// This maintains SvelteKit's fine-grained reactivity
|
|
for (const key of schemaKeys) {
|
|
const value = searchParams.get(key);
|
|
if (value === null)
|
|
continue;
|
|
try {
|
|
if (value === "[]") {
|
|
params[key] = [];
|
|
continue;
|
|
}
|
|
if (value === "{}") {
|
|
params[key] = {};
|
|
continue;
|
|
}
|
|
// Try to parse as JSON for complex objects/arrays
|
|
// This handles cases like ?obj={"foo":"bar"} or ?arr=[1,2,3]
|
|
if (value.startsWith("{") || value.startsWith("[")) {
|
|
params[key] = JSON.parse(value);
|
|
}
|
|
// Boolean values are stored as strings, convert them
|
|
else if (value === "true" || value === "false") {
|
|
params[key] = value === "true";
|
|
}
|
|
// Only convert to number if it looks numeric AND the schema expects a number
|
|
// This handles cases like ?page=2 or ?price=19.99
|
|
else if (numberFields.has(key) && value.trim() !== "" && !isNaN(Number(value))) {
|
|
params[key] = Number(value);
|
|
}
|
|
// Handle comma-separated values as arrays (fallback format)
|
|
else if (value.includes(",")) {
|
|
params[key] = value.split(",");
|
|
}
|
|
// Keep everything else as strings
|
|
else {
|
|
params[key] = value;
|
|
}
|
|
}
|
|
catch {
|
|
// If JSON parsing fails, treat as regular string
|
|
// This ensures we don't throw errors during type conversion
|
|
params[key] = value;
|
|
}
|
|
}
|
|
return params;
|
|
}
|
|
/**
|
|
* Core class that handles URL search parameter operations with schema validation
|
|
*
|
|
* This class provides the foundation for the useSearchParams hook. It:
|
|
* 1. Validates values against a schema
|
|
* 2. Handles type conversion between URL strings and JavaScript types
|
|
* 3. Updates the URL when values change
|
|
* 4. Maintains a cache of valid schema keys for performance
|
|
*/
|
|
class SearchParams {
|
|
/** The schema used for validation and type conversion */
|
|
#schema;
|
|
/**
|
|
* A lookup object containing all valid keys from the schema
|
|
* Used for fast property existence checking without re-validating
|
|
* Format: { propertyName1: true, propertyName2: true, ... }
|
|
*/
|
|
#schemaShape;
|
|
/**
|
|
* Options that configure behavior
|
|
*/
|
|
#options;
|
|
/**
|
|
* Default values from the schema, used for comparison when showDefaults is false
|
|
*/
|
|
#defaultValues;
|
|
/**
|
|
* Set of field names that expect number types based on schema validation
|
|
* Used to intelligently convert URL string values to numbers only when appropriate
|
|
*/
|
|
#numberFields;
|
|
/**
|
|
* Timer ID for debouncing URL updates
|
|
* @private
|
|
*/
|
|
#debounceTimer = null;
|
|
/**
|
|
* Local cache for immediate reads/writes
|
|
* Used regardless of updateURL setting to prevent input lag
|
|
* When updateURL is true, this cache is synced to the URL (with optional debouncing)
|
|
* When updateURL is false, this cache is the sole source of truth
|
|
* @private
|
|
*/
|
|
#localCache = $state(new URLSearchParams());
|
|
/**
|
|
* Flag to track if local cache has been initialized from URL
|
|
* @private
|
|
*/
|
|
#cacheInitialized = false;
|
|
/**
|
|
* Create a new SearchParams instance with the given schema and options
|
|
*
|
|
* @param schema A StandardSchemaV1-compatible schema
|
|
* @param options Configuration options
|
|
*/
|
|
constructor(schema, options = {}) {
|
|
this.#schema = schema;
|
|
this.#options = {
|
|
showDefaults: false,
|
|
debounce: 0,
|
|
pushHistory: true,
|
|
compress: false,
|
|
compressedParamName: "_data",
|
|
updateURL: true,
|
|
noScroll: false,
|
|
...options,
|
|
};
|
|
// Extract schema information (keys, number fields, defaults) in one pass
|
|
const schemaInfo = extractSchemaInfo(schema);
|
|
// Store schema shape for property checking
|
|
this.#schemaShape = schemaInfo.keys.reduce((acc, key) => {
|
|
acc[key] = true;
|
|
return acc;
|
|
}, {});
|
|
// Store default values and number fields
|
|
this.#defaultValues = { ...schemaInfo.defaultValues };
|
|
this.#numberFields = schemaInfo.numberFields;
|
|
}
|
|
/**
|
|
* Initialize the local cache from the URL on first access
|
|
* @private
|
|
*/
|
|
#initializeCacheFromURL() {
|
|
if (this.#cacheInitialized)
|
|
return;
|
|
this.#cacheInitialized = true;
|
|
// only initialize from URL if updateURL is true and we're in browser
|
|
if (!this.#options.updateURL || !BROWSER || building)
|
|
return;
|
|
const urlParams = page.url.searchParams;
|
|
const compressedParamName = this.#options.compressedParamName || "_data";
|
|
// Handle compressed mode
|
|
if (this.#options.compress && urlParams.has(compressedParamName)) {
|
|
try {
|
|
const compressedData = urlParams.get(compressedParamName) || "";
|
|
const decompressed = lzString.decompressFromEncodedURIComponent(compressedData);
|
|
if (decompressed) {
|
|
const decompressedObj = JSON.parse(decompressed);
|
|
const newCache = new URLSearchParams();
|
|
// populate cache with decompressed values
|
|
for (const [key, value] of Object.entries(decompressedObj)) {
|
|
const stringValue = this.#serializeValue(value);
|
|
newCache.set(key, stringValue);
|
|
}
|
|
untrack(() => (this.#localCache = newCache));
|
|
return;
|
|
}
|
|
}
|
|
catch (e) {
|
|
console.error("Error initializing cache from compressed URL", e);
|
|
}
|
|
}
|
|
// Normal mode - copy current URL params to cache
|
|
const newCache = new SvelteURLSearchParams();
|
|
for (const [key, value] of urlParams.entries()) {
|
|
newCache.set(key, value);
|
|
}
|
|
untrack(() => (this.#localCache = newCache));
|
|
}
|
|
/**
|
|
* Get a typed parameter value by key
|
|
* Retrieves the current value from the local cache, runs it through schema validation,
|
|
* and returns the validated, typed result
|
|
*
|
|
* @param key The parameter key to get
|
|
* @returns The typed value after schema validation
|
|
*/
|
|
get(key) {
|
|
this.#initializeCacheFromURL();
|
|
return this.#getTypedValue(key);
|
|
}
|
|
/**
|
|
* Set a parameter value and update the URL
|
|
* Validates the value through the schema before updating the URL
|
|
*
|
|
* @param key The parameter key to set
|
|
* @param value The value to set (will be type-converted and validated)
|
|
*/
|
|
set(key, value) {
|
|
this.#setValue(key, value);
|
|
}
|
|
/**
|
|
* Clean up resources used by this instance
|
|
*
|
|
* IMPORTANT: You only need to call this method when using the debounce option.
|
|
* If you're not using debounce, there's no need to call cleanup.
|
|
*
|
|
* Call this when the component unmounts to prevent memory leaks from debounce timers.
|
|
*
|
|
* @example
|
|
* Example in a Svelte component with Svelte 5 runes:
|
|
* ```svelte
|
|
* <script>
|
|
* import { useSearchParams } from '../../hooks/useSearchParams.svelte';
|
|
*
|
|
* // Using debounce, so we need to handle cleanup
|
|
* const searchParams = useSearchParams(schema, { debounce: 300 });
|
|
*
|
|
* // Register cleanup in a Svelte 5 effect
|
|
* $effect(() => {
|
|
* return () => {
|
|
* // Prevent memory leaks by cleaning up debounce timer
|
|
* searchParams.cleanup();
|
|
* };
|
|
* });
|
|
* </script>
|
|
* ```
|
|
*/
|
|
cleanup() {
|
|
// Clear any debounce timer
|
|
if (this.#debounceTimer) {
|
|
clearTimeout(this.#debounceTimer);
|
|
this.#debounceTimer = null;
|
|
}
|
|
}
|
|
/**
|
|
* Update multiple parameters at once
|
|
*
|
|
* This is more efficient than setting multiple parameters individually
|
|
* because it only triggers one URL update or one in-memory store update.
|
|
*
|
|
* @param values An object containing parameter key-value pairs to update
|
|
*/
|
|
update(values) {
|
|
this.#initializeCacheFromURL();
|
|
if (!values || typeof values !== "object")
|
|
return;
|
|
// Quick optimization: Filter out non-schema keys upfront
|
|
const filteredValues = {};
|
|
let anyValid = false;
|
|
for (const [key, value] of Object.entries(values)) {
|
|
if (this.has(key)) {
|
|
filteredValues[key] = value;
|
|
anyValid = true;
|
|
}
|
|
}
|
|
// no valid keys to update
|
|
if (!anyValid)
|
|
return;
|
|
// Always use local cache for immediate state
|
|
const paramsObject = this.#extractParamValues(this.#localCache);
|
|
// Check if there are any actual changes
|
|
let hasChanges = false;
|
|
for (const [key, value] of Object.entries(filteredValues)) {
|
|
const currentValue = paramsObject[key];
|
|
// Optimization: For primitives, use direct comparison; use dequal only for objects
|
|
const isPrimitive = typeof currentValue !== "object" &&
|
|
typeof value !== "object" &&
|
|
currentValue !== null &&
|
|
value !== null;
|
|
if (isPrimitive ? currentValue !== value : !dequal(currentValue, value)) {
|
|
hasChanges = true;
|
|
break;
|
|
}
|
|
}
|
|
// no changes, skip update
|
|
if (!hasChanges)
|
|
return;
|
|
// Create a new object with the updated values
|
|
const newParamsObject = { ...paramsObject, ...filteredValues };
|
|
// Validate against schema
|
|
const result = this.validate(newParamsObject);
|
|
if (result && "value" in result) {
|
|
const validatedResult = result.value;
|
|
// Always update local cache immediately (for instant reads)
|
|
const updatedCache = this.#updateParamsWithValidatedValues(this.#localCache, filteredValues, validatedResult, true // always treat as in-memory for cache updates
|
|
);
|
|
this.#localCache = updatedCache;
|
|
// If updateURL is true and we're in browser, sync to URL
|
|
if (this.#options.updateURL && BROWSER && !building) {
|
|
// Handle the compression mode if enabled
|
|
if (this.#options.compress) {
|
|
this.#handleCompressedUpdate(validatedResult);
|
|
}
|
|
else {
|
|
// Normal mode - update the URL
|
|
// Start from the current local cache to preserve all params
|
|
const urlParams = this.#updateParamsWithValidatedValues(this.#localCache, filteredValues, validatedResult, false // use URLSearchParams for URL updates
|
|
);
|
|
this.#navigateWithParams(urlParams);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
/**
|
|
* Check if a key exists in the schema
|
|
* This is a critical method used by the Proxy handler to determine
|
|
* which properties should be treated as URL parameters
|
|
*
|
|
* @param key The key to check
|
|
* @returns True if the key is defined in the schema
|
|
*/
|
|
has(key) {
|
|
return key in this.#schemaShape;
|
|
}
|
|
/**
|
|
* Reset all parameters to their default values
|
|
*
|
|
* This method removes all current URL parameters or in-memory parameters
|
|
* and optionally sets parameters with non-default values back to their defaults.
|
|
*
|
|
* @param showDefaults Whether to show default values in the URL or in-memory store after reset.
|
|
* If not provided, uses the instance's showDefaults option.
|
|
*/
|
|
reset(showDefaults) {
|
|
this.#initializeCacheFromURL();
|
|
const useShowDefaults = showDefaults !== undefined ? showDefaults : this.#options.showDefaults;
|
|
if (useShowDefaults) {
|
|
// Reuse the filtered default values for both cache and URL updates
|
|
const validDefaultValues = {};
|
|
// Filter out null/undefined values
|
|
for (const [key, defaultValue] of Object.entries(this.#defaultValues)) {
|
|
if (defaultValue !== null && defaultValue !== undefined) {
|
|
validDefaultValues[key] = defaultValue;
|
|
}
|
|
}
|
|
// Always update local cache immediately
|
|
const newCache = new SvelteURLSearchParams();
|
|
for (const [key, value] of Object.entries(validDefaultValues)) {
|
|
const stringValue = this.#serializeValue(value);
|
|
newCache.set(key, stringValue);
|
|
}
|
|
this.#localCache = newCache;
|
|
// If updateURL is true, sync to URL
|
|
if (this.#options.updateURL && BROWSER && !building) {
|
|
const urlParams = new URLSearchParams();
|
|
for (const [key, value] of Object.entries(validDefaultValues)) {
|
|
const stringValue = this.#serializeValue(value);
|
|
urlParams.set(key, stringValue);
|
|
}
|
|
this.#navigateWithParams(urlParams);
|
|
}
|
|
}
|
|
else {
|
|
// Not showing defaults - just clear everything
|
|
this.#localCache = new SvelteURLSearchParams();
|
|
// If updateURL is true, clear the URL
|
|
if (this.#options.updateURL && BROWSER && !building) {
|
|
goto("?", { replaceState: true, noScroll: this.#options.noScroll });
|
|
}
|
|
}
|
|
}
|
|
/**
|
|
* Validate a value against the schema
|
|
* This is the core method that enforces schema validation
|
|
*
|
|
* @param value The value to validate
|
|
* @returns A StandardSchemaV1.Result containing either the validated value or validation errors
|
|
*/
|
|
validate(value) {
|
|
return this.#schema["~standard"].validate(value);
|
|
}
|
|
/**
|
|
* Helper method to create a new search params object
|
|
* Works with both URLSearchParams and SvelteURLSearchParams
|
|
* @private
|
|
*/
|
|
#createSearchParams(base, isInMemory) {
|
|
if (isInMemory) {
|
|
// For in-memory, always return SvelteURLSearchParams
|
|
return new SvelteURLSearchParams(base.toString());
|
|
}
|
|
else {
|
|
// For URL updates, always return URLSearchParams
|
|
return new URLSearchParams(base.toString());
|
|
}
|
|
}
|
|
/**
|
|
* Helper method to update search params with validated values
|
|
* Works with both URLSearchParams and SvelteURLSearchParams
|
|
* @private
|
|
*/
|
|
#updateParamsWithValidatedValues(searchParams, updates, validatedValues, isInMemory) {
|
|
const newSearchParams = this.#createSearchParams(searchParams, isInMemory);
|
|
for (const key of Object.keys(updates)) {
|
|
// Skip keys not in schema
|
|
if (!this.has(key))
|
|
continue;
|
|
const validatedValue = validatedValues[key];
|
|
// Check if the value should be omitted
|
|
const isDefaultValue = !this.#options.showDefaults && dequal(validatedValue, this.#defaultValues[key]);
|
|
if (validatedValue === undefined || validatedValue === null || isDefaultValue) {
|
|
newSearchParams.delete(key);
|
|
}
|
|
else {
|
|
const stringValue = this.#serializeValue(validatedValue);
|
|
newSearchParams.set(key, stringValue);
|
|
}
|
|
}
|
|
return newSearchParams;
|
|
}
|
|
/**
|
|
* Helper method to handle updates when compression is enabled
|
|
* @private
|
|
*/
|
|
#handleCompressedUpdate(fullParamsObject) {
|
|
try {
|
|
// Convert the entire parameters object to JSON
|
|
const jsonData = JSON.stringify(fullParamsObject);
|
|
// Compress the JSON string
|
|
const compressed = lzString.compressToEncodedURIComponent(jsonData);
|
|
// Create new params with just the compressed data
|
|
const newSearchParams = new URLSearchParams();
|
|
const compressedParamName = this.#options.compressedParamName || "_data";
|
|
newSearchParams.set(compressedParamName, compressed);
|
|
// Update URL
|
|
this.#navigateWithParams(newSearchParams);
|
|
}
|
|
catch (e) {
|
|
console.error("Error compressing data, falling back to normal mode", e);
|
|
// Create new params with all values as fallback
|
|
const newSearchParams = new URLSearchParams();
|
|
for (const [key, value] of Object.entries(fullParamsObject)) {
|
|
if (value === undefined || value === null) {
|
|
continue;
|
|
}
|
|
const stringValue = this.#serializeValue(value);
|
|
newSearchParams.set(key, stringValue);
|
|
}
|
|
this.#navigateWithParams(newSearchParams);
|
|
}
|
|
}
|
|
/**
|
|
* Helper method to navigate with URL parameters
|
|
* Handles debouncing and history state
|
|
* @private
|
|
*/
|
|
#navigateWithParams(params) {
|
|
const navigateToNewUrl = () => {
|
|
if (!BROWSER)
|
|
return;
|
|
// When pushHistory is false, use replaceState to avoid creating a browser history entry
|
|
const gotoOptions = !this.#options.pushHistory
|
|
? { replaceState: true, keepFocus: true }
|
|
: { keepFocus: true };
|
|
goto("?" + params.toString(), { ...gotoOptions, noScroll: this.#options.noScroll });
|
|
};
|
|
// If debounce is set, delay the URL update
|
|
if (this.#options.debounce && this.#options.debounce > 0) {
|
|
// Clear any existing timer
|
|
if (this.#debounceTimer) {
|
|
clearTimeout(this.#debounceTimer);
|
|
}
|
|
// Set a new timer
|
|
this.#debounceTimer = setTimeout(navigateToNewUrl, this.#options.debounce);
|
|
}
|
|
else {
|
|
// No debounce, update immediately
|
|
navigateToNewUrl();
|
|
}
|
|
}
|
|
/**
|
|
* Converts a value to a URL-compatible string representation
|
|
* Handles arrays, objects, and primitive values
|
|
* @private
|
|
*/
|
|
#serializeValue(value) {
|
|
if (Array.isArray(value)) {
|
|
return JSON.stringify(value);
|
|
}
|
|
else if (typeof value === "object" && value !== null) {
|
|
return JSON.stringify(value);
|
|
}
|
|
else {
|
|
return String(value);
|
|
}
|
|
}
|
|
#extractParamValues(searchParams) {
|
|
const compressedParamName = this.#options.compressedParamName || "_data";
|
|
// Check if we're using compression mode and have a compressed parameter
|
|
if (this.#options.compress && searchParams.has(compressedParamName)) {
|
|
try {
|
|
const compressedData = searchParams.get(compressedParamName) || "";
|
|
const decompressed = lzString.decompressFromEncodedURIComponent(compressedData);
|
|
if (decompressed) {
|
|
try {
|
|
// Parse the decompressed JSON
|
|
return JSON.parse(decompressed);
|
|
}
|
|
catch (e) {
|
|
console.error("Failed to parse decompressed data", e);
|
|
return {};
|
|
}
|
|
}
|
|
}
|
|
catch (e) {
|
|
console.error("Error decompressing data", e);
|
|
}
|
|
return {};
|
|
}
|
|
// If not using compression, use the normal extraction with number field detection
|
|
return extractParamValues(searchParams, this.#numberFields);
|
|
}
|
|
/**
|
|
* Get typed values from the local cache using schema validation
|
|
*
|
|
* This method:
|
|
* 1. Gets the current search parameters from the local cache
|
|
* 2. Extracts and processes parameter values
|
|
* 3. Validates them against the schema
|
|
* 4. Returns the typed value for the requested key
|
|
*
|
|
* @param key The parameter key to get
|
|
* @returns The typed value after validation or undefined if invalid
|
|
* @private
|
|
*/
|
|
#getTypedValue(key) {
|
|
// Always read from local cache for immediate state
|
|
const paramsObject = this.#extractParamValues(this.#localCache);
|
|
const result = this.validate(paramsObject);
|
|
if (result instanceof Promise) {
|
|
throw new Error("Async validation is not supported in validateSearchParams");
|
|
}
|
|
if (result && "value" in result) {
|
|
return result.value[key];
|
|
}
|
|
else if (result && "issues" in result) {
|
|
// If validation fails, use defaults
|
|
const emptyResult = this.validate({});
|
|
const defaultValues = emptyResult && "value" in emptyResult ? emptyResult.value : {};
|
|
// find valid params in the paramsObject and use them, do not override all default values
|
|
const validParams = Object.fromEntries(Object.entries(paramsObject).filter(([key]) => !result.issues?.some((issue) => issue.path?.includes(key))));
|
|
return {
|
|
...(typeof defaultValues === "object" && defaultValues !== null ? defaultValues : {}),
|
|
...validParams,
|
|
}[key];
|
|
}
|
|
// If validation failed, return undefined
|
|
return undefined;
|
|
}
|
|
/**
|
|
* Set a parameter value and update the local cache and optionally the URL
|
|
*
|
|
* This method:
|
|
* 1. Gets the current search parameters from the local cache
|
|
* 2. Extracts and processes all current parameters
|
|
* 3. Updates the parameter with the new value
|
|
* 4. Validates the complete parameter object against the schema
|
|
* 5. Updates the local cache immediately (for instant reads)
|
|
* 6. Optionally updates the browser URL if updateURL is true
|
|
*
|
|
* @param key The parameter key to set
|
|
* @param value The value to set
|
|
* @private
|
|
*/
|
|
#setValue(key, value) {
|
|
this.#initializeCacheFromURL();
|
|
// Optimization: Skip if the key is not in schema
|
|
if (!this.has(key))
|
|
return;
|
|
// Always use local cache for immediate state
|
|
const paramsObject = this.#extractParamValues(this.#localCache);
|
|
// Check if the new value is the same as the current value
|
|
// Optimization: For primitives, use direct comparison; use dequal only for objects
|
|
const currentValue = paramsObject[key];
|
|
const isPrimitive = typeof currentValue !== "object" &&
|
|
typeof value !== "object" &&
|
|
currentValue !== null &&
|
|
value !== null;
|
|
if (isPrimitive ? currentValue === value : dequal(currentValue, value)) {
|
|
// Skip the update if values are the same
|
|
return;
|
|
}
|
|
// Create a new object with the updated value
|
|
const newParamsObject = { ...paramsObject, [key]: value };
|
|
// Validate against schema to ensure type correctness
|
|
const result = this.validate(newParamsObject);
|
|
if (result && "value" in result) {
|
|
const validatedResult = result.value;
|
|
const updateObj = { [key]: value };
|
|
// Always update local cache immediately (for instant reads)
|
|
const updatedCache = this.#updateParamsWithValidatedValues(this.#localCache, updateObj, validatedResult, true // always treat as in-memory for cache updates
|
|
);
|
|
this.#localCache = updatedCache;
|
|
// If updateURL is true, sync to URL
|
|
if (this.#options.updateURL && BROWSER && !building) {
|
|
// Handle the compression mode if enabled
|
|
if (this.#options.compress) {
|
|
this.#handleCompressedUpdate(validatedResult);
|
|
}
|
|
else {
|
|
// Normal mode - update the URL
|
|
// Start from the current local cache to preserve all params
|
|
const urlParams = this.#updateParamsWithValidatedValues(this.#localCache, updateObj, validatedResult, false // use URLSearchParams for URL updates
|
|
);
|
|
this.#navigateWithParams(urlParams);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
/**
|
|
* Creates a simple schema compatible with useSearchParams without requiring external validation libraries.
|
|
*
|
|
* This is a lightweight alternative to using full schema validation libraries like Zod, Valibot, or Arktype.
|
|
* Use this when you need basic type conversion and default values without adding dependencies.
|
|
*
|
|
* Limitations:
|
|
* - For 'array' type: supports basic arrays, but doesn't validate array items
|
|
* - For 'object' type: supports generic objects, but doesn't validate nested properties
|
|
* - No custom validation rules or transformations
|
|
* - No granular reactivity: nested property changes require whole-value reassignment
|
|
* (e.g., params.items = [...params.items, newItem] instead of params.items.push(newItem))
|
|
*
|
|
* For complex validation needs (nested validation, refined rules, etc.), use a dedicated
|
|
* validation library instead.
|
|
*
|
|
* Example usage:
|
|
* ```
|
|
* const productSearchSchema = createSearchParamsSchema({
|
|
* // Basic types with defaults
|
|
* page: { type: 'number', default: 1 },
|
|
* filter: { type: 'string', default: '' },
|
|
* sort: { type: 'string', default: 'newest' },
|
|
*
|
|
* // Array type with specific element type
|
|
* tags: {
|
|
* type: 'array',
|
|
* default: ['new'],
|
|
* arrayType: '' // Specify string[] type
|
|
* },
|
|
*
|
|
* // Object type with specific shape
|
|
* config: {
|
|
* type: 'object',
|
|
* default: { theme: 'light' },
|
|
* objectType: { theme: '' } // Specify { theme: string } type
|
|
* }
|
|
* });
|
|
* ```
|
|
*
|
|
* URL storage format:
|
|
* - Arrays are stored as JSON strings: ?tags=["sale","featured"]
|
|
* - Objects are stored as JSON strings: ?config={"theme":"dark","fontSize":14}
|
|
* - Primitive values are stored directly: ?page=2&filter=red
|
|
*/
|
|
export function createSearchParamsSchema(schema) {
|
|
return {
|
|
"~standard": {
|
|
version: 1,
|
|
vendor: "",
|
|
validate: (input) => {
|
|
const output = {};
|
|
const issues = [];
|
|
// Set default values first
|
|
for (const [key, config] of Object.entries(schema)) {
|
|
output[key] =
|
|
config.default !== undefined ? config.default : null;
|
|
}
|
|
if (input && typeof input === "object") {
|
|
for (const [key, config] of Object.entries(schema)) {
|
|
const inputValue = input[key];
|
|
if (inputValue !== undefined) {
|
|
try {
|
|
switch (config.type) {
|
|
case "number": {
|
|
const num = typeof inputValue === "number" ? inputValue : Number(inputValue);
|
|
if (typeof num !== "number" || !Number.isFinite(num)) {
|
|
issues.push({
|
|
message: `Invalid number for "${key}"`,
|
|
path: [key],
|
|
});
|
|
}
|
|
else {
|
|
output[key] = num;
|
|
}
|
|
break;
|
|
}
|
|
case "boolean": {
|
|
if (typeof inputValue === "boolean" ||
|
|
inputValue === "true" ||
|
|
inputValue === "false") {
|
|
output[key] =
|
|
typeof inputValue === "boolean" ? inputValue : inputValue === "true";
|
|
}
|
|
else {
|
|
issues.push({
|
|
message: `Invalid boolean for "${key}"`,
|
|
path: [key],
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
case "array": {
|
|
if (Array.isArray(inputValue)) {
|
|
output[key] = inputValue;
|
|
}
|
|
else {
|
|
issues.push({
|
|
message: `Invalid array for "${key}"`,
|
|
path: [key],
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
case "object": {
|
|
if (typeof inputValue === "object" &&
|
|
inputValue !== null &&
|
|
!Array.isArray(inputValue)) {
|
|
output[key] = inputValue;
|
|
}
|
|
else {
|
|
issues.push({
|
|
message: `Invalid object for "${key}"`,
|
|
path: [key],
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
case "string":
|
|
default: {
|
|
output[key] = String(inputValue);
|
|
}
|
|
}
|
|
}
|
|
catch (e) {
|
|
issues.push({
|
|
message: `Error parsing "${key}": ${e.message}`,
|
|
path: [key],
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (issues.length > 0) {
|
|
return { issues };
|
|
}
|
|
return { value: output };
|
|
},
|
|
types: {
|
|
input: {},
|
|
output: {},
|
|
},
|
|
},
|
|
};
|
|
}
|
|
/**
|
|
* A utility function to extract, validate and convert URL search parameters to [URLSearchParams](https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams)
|
|
*
|
|
* This function makes it easy to use the same schema validation in
|
|
* both client-side components (via useSearchParams) and server-side load functions.
|
|
* Unlike useSearchParams, this function doesn't modify the URL - it only validates
|
|
* parameters and returns them as a new URLSearchParams object.
|
|
*
|
|
* **Important for SvelteKit fine-grained reactivity**: This function only accesses URL parameters
|
|
* that are defined in your schema, ensuring that load functions only re-run when schema-defined
|
|
* parameters change, not when any URL parameter changes.
|
|
*
|
|
* Handles both standard URL parameters and compressed parameters (when compression is enabled).
|
|
*
|
|
* @param url The URL object from SvelteKit load function
|
|
* @param schema A validation schema (createSearchParamsSchema, Zod, Valibot, etc.)
|
|
* @param options Optional configuration (like custom compressedParamName)
|
|
* @returns URLSearchParams object with the validated values
|
|
*
|
|
* Example with SvelteKit page or layout load function:
|
|
* ```ts
|
|
* import { validateSearchParams } from '../../hooks/useSearchParams.svelte';
|
|
* import { productSchema } from './schemas';
|
|
*
|
|
* export const load = ({ url }) => {
|
|
* // Get validated search params as URLSearchParams object
|
|
* // Only accesses 'page', 'filter', 'sort' parameters from the URL
|
|
* // Load function will only re-run when these specific parameters change
|
|
* const searchParams = validateSearchParams(url, productSchema, {
|
|
* compressedParamName: '_compressed'
|
|
* });
|
|
*
|
|
* // Use URLSearchParams directly with fetch
|
|
* const response = await fetch(`/api/products?${searchParams.toString()}`);
|
|
* return {
|
|
* products: await response.json()
|
|
* };
|
|
* };
|
|
* ```
|
|
*/
|
|
export function validateSearchParams(url, schema, options = {}) {
|
|
const compressedParamName = options.compressedParamName || "_data";
|
|
let validatedValue = {};
|
|
// Check if we're dealing with compressed data and handle appropriately
|
|
if (url.searchParams.has(compressedParamName)) {
|
|
try {
|
|
// Get and decompress the data
|
|
const compressedData = url.searchParams.get(compressedParamName) || "";
|
|
const decompressed = lzString.decompressFromEncodedURIComponent(compressedData);
|
|
if (decompressed) {
|
|
try {
|
|
// Parse the decompressed JSON
|
|
const decompressedObj = JSON.parse(decompressed);
|
|
// Validate against schema
|
|
const result = schema["~standard"].validate(decompressedObj);
|
|
if (result && "value" in result) {
|
|
validatedValue = result.value;
|
|
}
|
|
else {
|
|
// Use defaults if validation fails
|
|
const emptyResult = schema["~standard"].validate({});
|
|
validatedValue = (emptyResult && "value" in emptyResult ? emptyResult.value : {});
|
|
}
|
|
}
|
|
catch (e) {
|
|
console.error("Failed to parse decompressed data", e);
|
|
// Use defaults if parsing fails
|
|
const emptyResult = schema["~standard"].validate({});
|
|
validatedValue = (emptyResult && "value" in emptyResult ? emptyResult.value : {});
|
|
}
|
|
}
|
|
else {
|
|
// Use defaults if decompression fails
|
|
const emptyResult = schema["~standard"].validate({});
|
|
validatedValue = (emptyResult && "value" in emptyResult ? emptyResult.value : {});
|
|
}
|
|
}
|
|
catch (e) {
|
|
console.error("Error decompressing data", e);
|
|
// Use defaults if decompression errors
|
|
const emptyResult = schema["~standard"].validate({});
|
|
validatedValue = (emptyResult && "value" in emptyResult ? emptyResult.value : {});
|
|
}
|
|
}
|
|
else {
|
|
// Normal (uncompressed) extraction - use selective extraction for fine-grained reactivity
|
|
const schemaInfo = extractSchemaInfo(schema);
|
|
const paramsObject = extractSelectiveParamValues(url.searchParams, schemaInfo.keys, schemaInfo.numberFields);
|
|
// Validate the parameters against the schema
|
|
let result = schema["~standard"].validate(paramsObject);
|
|
if (result instanceof Promise) {
|
|
throw new Error("Async validation is not supported in validateSearchParams");
|
|
}
|
|
if (result && "value" in result) {
|
|
validatedValue = result.value;
|
|
}
|
|
else if (result && "issues" in result) {
|
|
// If validation fails, use defaults
|
|
const emptyResult = schema["~standard"].validate({});
|
|
validatedValue = (emptyResult && "value" in emptyResult ? emptyResult.value : {});
|
|
// find valid params in the paramsObject and use them, do not override all default values
|
|
const validParams = Object.fromEntries(Object.entries(paramsObject).filter(([key]) => !result.issues?.some((issue) => issue.path?.includes(key))));
|
|
validatedValue = { ...validatedValue, ...validParams };
|
|
}
|
|
}
|
|
// Create a new URLSearchParams object with the validated values
|
|
const newSearchParams = new URLSearchParams();
|
|
// Helper function to serialize values
|
|
const serializeValue = (value) => {
|
|
if (Array.isArray(value)) {
|
|
return JSON.stringify(value);
|
|
}
|
|
else if (typeof value === "object" && value !== null) {
|
|
return JSON.stringify(value);
|
|
}
|
|
else {
|
|
return String(value);
|
|
}
|
|
};
|
|
// Add each validated parameter to the URLSearchParams
|
|
for (const [key, value] of Object.entries(validatedValue)) {
|
|
if (value === undefined || value === null)
|
|
continue;
|
|
const stringValue = serializeValue(value);
|
|
newSearchParams.set(key, stringValue);
|
|
}
|
|
return {
|
|
searchParams: newSearchParams,
|
|
data: validatedValue,
|
|
};
|
|
}
|
|
/**
|
|
* Hook to create a reactive search params object with property access
|
|
*
|
|
* This client-side hook automatically updates the URL when parameters change.
|
|
* It provides type-safe access to URL search parameters through direct property access.
|
|
*
|
|
* @param schema A validation schema compatible with StandardSchemaV1
|
|
* @param options Configuration options that affect URL behavior
|
|
* @returns A reactive object for working with typed search parameters
|
|
*
|
|
* Available options:
|
|
* - `showDefaults` (boolean): When true, parameters with default values will be shown in the URL.
|
|
* When false (default), parameters with default values will be omitted from the URL.
|
|
* - `debounce` (number): Milliseconds to delay URL updates when parameters change.
|
|
* Useful to avoid cluttering browser history when values change rapidly (default: 0, no debounce).
|
|
* - `pushHistory` (boolean): Controls whether URL updates create new browser history entries.
|
|
* If true (default), each update adds a new entry to the browser history.
|
|
* If false, updates replace the current URL without creating new history entries.
|
|
* - `compress` (boolean): When true, all parameters are compressed into a single parameter
|
|
* using lz-string compression. This helps reduce URL length and provides basic obfuscation (default: false).
|
|
* Use validateSearchParams with the same compressedParamName option when handling compressed URLs server-side.
|
|
* - `compressedParamName` (string): The name of the parameter used to store compressed data
|
|
* when compression is enabled. Customize this to avoid conflicts with parameters in your schema.
|
|
* Default is '_data'.
|
|
* - `updateURL` (boolean): When true (default), the URL is updated when parameters change.
|
|
* When false, only in-memory parameters are updated.
|
|
*
|
|
* Example with Zod:
|
|
* ```
|
|
* import { z } from 'zod';
|
|
*
|
|
* const productSearchSchema = z.object({
|
|
* page: z.number().catch(1),
|
|
* filter: z.string().catch(''),
|
|
* sort: z.enum(['newest', 'oldest', 'price']).catch('newest'),
|
|
* });
|
|
*
|
|
* const params = useSearchParams(productSearchSchema);
|
|
*
|
|
* // Access parameters directly
|
|
* const page = $derived(params.page); // number (defaults to 1)
|
|
* const sort = $derived(params.sort); // 'newest' | 'oldest' | 'price'
|
|
*
|
|
* // Update parameters directly
|
|
* params.page = 2; // Updates URL to include ?page=2
|
|
* params.sort = 'price'; // Updates URL to include &sort=price
|
|
* ```
|
|
*
|
|
* Example with options:
|
|
* ```typescript
|
|
* // Show default values in URL, debounce updates by 300ms,
|
|
* // don't create new history entries, and compress params
|
|
* const params = useSearchParams(schema, {
|
|
* showDefaults: true,
|
|
* debounce: 300,
|
|
* pushHistory: false,
|
|
* compress: true,
|
|
* compressedParamName: '_compressed' // Custom name to avoid conflicts
|
|
* });
|
|
*
|
|
* // Great for binding to input fields (updates URL without cluttering history)
|
|
* <input type="text" bind:value={params.search} />
|
|
* // Resulting URL will be something like: /?_compressed=N4IgDgTg9g...
|
|
* ```
|
|
* Example with Valibot:
|
|
* ```
|
|
* import * as v from 'valibot';
|
|
*
|
|
* const productSearchSchema = v.object({
|
|
* page: v.optional(v.fallback(v.number(), 1), 1),
|
|
* filter: v.optional(v.fallback(v.string(), ''), ''),
|
|
* sort: v.optional(v.fallback(v.picklist(['newest', 'oldest', 'price']), 'newest'), 'newest'),
|
|
* });
|
|
*
|
|
* const params = useSearchParams(productSearchSchema);
|
|
* ``` * Example with Arktype:
|
|
* ```
|
|
* import { type } from 'arktype';
|
|
*
|
|
* const productSearchSchema = type({
|
|
* page: 'number = 1',
|
|
* filter: 'string = ""',
|
|
* sort: '"newest" | "oldest" | "price" = "newest"',
|
|
* });
|
|
*
|
|
* const params = useSearchParams(productSearchSchema);
|
|
* ```
|
|
* Or with our built-in schema creator (no additional dependencies):
|
|
*
|
|
* ```
|
|
* const productSearchSchema = createSearchParamsSchema({
|
|
* page: { type: 'number', default: 1 },
|
|
* filter: { type: 'string', default: '' },
|
|
* sort: { type: 'string', default: 'newest' }
|
|
* });
|
|
*
|
|
* const params = useSearchParams(productSearchSchema);
|
|
* ```
|
|
*/
|
|
export function useSearchParams(schema, options = {}) {
|
|
// Create the SearchParams instance to handle validation and URL updates
|
|
// This is the core class that implements the actual functionality
|
|
const searchParams = new SearchParams(schema, options);
|
|
// Wait for hydration to complete before executing browser-specific initialization
|
|
const isMounted = new IsMounted();
|
|
// Only run initialization logic after hydration is complete
|
|
$effect(() => {
|
|
if (!isMounted.current || !browser || building)
|
|
return;
|
|
// Remove incorrect params on initialization (only after hydration)
|
|
if (options.updateURL !== false) {
|
|
const schemaInfo = extractSchemaInfo(schema);
|
|
const currentParams = extractParamValues(page.url.searchParams, schemaInfo.numberFields);
|
|
const validationResult = schema["~standard"].validate(currentParams);
|
|
if (validationResult &&
|
|
"issues" in validationResult &&
|
|
Array.isArray(validationResult.issues) &&
|
|
validationResult.issues.length > 0) {
|
|
// Find all incorrect param keys
|
|
const invalidKeys = validationResult.issues
|
|
.map((issue) => Array.isArray(issue.path) && issue.path.length > 0 ? issue.path[0] : null)
|
|
.filter(Boolean);
|
|
if (invalidKeys.length > 0) {
|
|
const newSearchParams = new URLSearchParams(page.url.searchParams.toString());
|
|
for (const key of invalidKeys) {
|
|
newSearchParams.delete(String(key));
|
|
}
|
|
goto("?" + newSearchParams.toString(), { replaceState: true });
|
|
}
|
|
}
|
|
}
|
|
// If showDefaults is true, we need to initialize the URL with all default values (only after hydration)
|
|
if (options.showDefaults) {
|
|
// Get all the schema information in one pass
|
|
const schemaInfo = extractSchemaInfo(schema);
|
|
if (schemaInfo.keys.length > 0) {
|
|
// If compression is enabled, use SearchParams.update() method which handles compression
|
|
if (options.compress) {
|
|
// Call the update method with the default values to properly handle compression
|
|
searchParams.update(schemaInfo.defaultValues);
|
|
}
|
|
else {
|
|
// For non-compressed mode, use the original approach
|
|
const currentParams = extractParamValues(page.url.searchParams, schemaInfo.numberFields);
|
|
const newSearchParams = new URLSearchParams(page.url.searchParams.toString());
|
|
let needsUpdate = false;
|
|
// For each default value, add it to the URL if not already present
|
|
for (const [key, defaultValue] of Object.entries(schemaInfo.defaultValues)) {
|
|
// Skip if the parameter is already in the URL (don't override user values)
|
|
if (key in currentParams)
|
|
continue;
|
|
needsUpdate = true;
|
|
if (defaultValue === null || defaultValue === undefined) {
|
|
continue;
|
|
}
|
|
let stringValue;
|
|
if (Array.isArray(defaultValue)) {
|
|
stringValue = JSON.stringify(defaultValue);
|
|
}
|
|
else if (typeof defaultValue === "object" && defaultValue !== null) {
|
|
stringValue = JSON.stringify(defaultValue);
|
|
}
|
|
else {
|
|
stringValue = String(defaultValue);
|
|
}
|
|
newSearchParams.set(key, stringValue);
|
|
}
|
|
// Only update the URL if we added parameters
|
|
if (needsUpdate) {
|
|
// Always use replaceState: true for initialization to avoid creating a new history entry
|
|
// Don't use debouncing for initialization as this is a one-time operation
|
|
goto("?" + newSearchParams.toString(), { replaceState: true });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
// Only run this logic in the browser and if debounce is enabled
|
|
if (browser && !building && options.debounce && options.debounce > 0) {
|
|
$effect(() => {
|
|
// This effect runs once when the hook is initialized within a component.
|
|
// It has no dependencies, so it doesn't re-run.
|
|
return () => {
|
|
searchParams.cleanup();
|
|
};
|
|
});
|
|
}
|
|
// Create a proxy to intercept property access/assignment
|
|
// This enables the direct property syntax: params.page instead of params.get('page')
|
|
// The proxy pattern is what makes the API feel natural and type-safe
|
|
//
|
|
// IMPORTANT LIMITATION: This proxy only provides top-level reactivity
|
|
// - Direct property access works: params.page, params.filter
|
|
// - Nested property changes require whole-object updates: params.fields = {...fields, newProp: value}
|
|
// - Arrays/objects are not granularly reactive: params.items[0].name = 'new' won't trigger URL updates
|
|
//
|
|
// For granular nested reactivity, you would need:
|
|
// 1. Recursive proxy creation for nested objects/arrays
|
|
// 2. Path tracking system (e.g., "fields.0.name")
|
|
// 3. Granular URL serialization instead of JSON
|
|
// 4. Complete rewrite of validation and type systems
|
|
// This would be a breaking change requiring a new major version
|
|
const handler = {
|
|
get: (target, prop) => {
|
|
// Special methods we want to expose directly with proper binding
|
|
if (prop === "reset") {
|
|
// We need to handle each method individually to satisfy TypeScript
|
|
return function (showDefaults) {
|
|
return target.reset(showDefaults);
|
|
};
|
|
}
|
|
else if (prop === "update") {
|
|
return function (values) {
|
|
return target.update(values);
|
|
};
|
|
}
|
|
else if (prop === "cleanup") {
|
|
return function () {
|
|
return target.cleanup();
|
|
};
|
|
}
|
|
else if (prop === "toURLSearchParams") {
|
|
// Implementation of toURLSearchParams
|
|
return function () {
|
|
const newSearchParams = new URLSearchParams();
|
|
// Get the schema's default values + current values
|
|
const validationResult = schema["~standard"].validate({});
|
|
if (validationResult && "value" in validationResult) {
|
|
const schemaValues = validationResult.value;
|
|
// Create an object with all the values from the schema
|
|
// First set the default values
|
|
const allValues = { ...schemaValues };
|
|
// Then override with the current values from the proxy
|
|
// This ensures we get the most up-to-date values that might not yet be in the URL or in-memory store
|
|
for (const key of Object.keys(schemaValues)) {
|
|
// Get the current value using the proxy's access
|
|
if (typeof key === "string" && target.has(key)) {
|
|
const currentValue = target.get(key);
|
|
if (currentValue !== undefined) {
|
|
allValues[key] = currentValue;
|
|
}
|
|
}
|
|
}
|
|
// Add each parameter to the URLSearchParams
|
|
for (const [key, value] of Object.entries(allValues)) {
|
|
// Skip undefined/null values
|
|
if (value === undefined || value === null) {
|
|
continue;
|
|
}
|
|
// Serialize the value
|
|
let stringValue;
|
|
if (Array.isArray(value)) {
|
|
stringValue = JSON.stringify(value);
|
|
}
|
|
else if (typeof value === "object" && value !== null) {
|
|
stringValue = JSON.stringify(value);
|
|
}
|
|
else {
|
|
stringValue = String(value);
|
|
}
|
|
// Set the parameter
|
|
newSearchParams.set(key, stringValue);
|
|
}
|
|
}
|
|
return newSearchParams;
|
|
};
|
|
}
|
|
// IMPORTANT: We use the has() method to determine if this is a schema parameter
|
|
// This is why the has() method must be maintained
|
|
if (typeof prop === "string" && target.has(prop)) {
|
|
// Type assertion needed here because TypeScript can't infer that our runtime check
|
|
// with target.has() guarantees that prop is a valid key of our schema output type
|
|
// NOTE: This returns the raw value (object/array/primitive) without nested proxification
|
|
// If the value is an object or array, changes to its nested properties won't trigger
|
|
// URL updates automatically. Users must reassign the entire object/array to trigger updates:
|
|
// ❌ Won't work: params.config.theme = 'dark'
|
|
// ✅ Works: params.config = {...params.config, theme: 'dark'}
|
|
// ❌ Won't work: params.items.push(newItem)
|
|
// ✅ Works: params.items = [...params.items, newItem]
|
|
return target.get(prop);
|
|
}
|
|
return Reflect.get(target, prop);
|
|
},
|
|
set: (target, prop, value) => {
|
|
// Similar to get(), we use has() to determine if this is a schema parameter
|
|
if (typeof prop === "string" && target.has(prop)) {
|
|
// Same type assertion needed here to tell TypeScript that we've verified
|
|
// this string is a valid key in our schema through the target.has() check
|
|
// NOTE: This triggers a complete re-serialization of the value to the URL
|
|
// For objects/arrays, the entire structure is JSON-stringified and stored
|
|
// This is why nested property mutations don't work - they don't trigger this setter
|
|
// Only direct assignment to the top-level property triggers URL updates
|
|
target.set(prop, value);
|
|
return true;
|
|
}
|
|
return Reflect.set(target, prop, value);
|
|
},
|
|
};
|
|
return new Proxy(searchParams, handler);
|
|
}
|