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 * * ``` */ 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) * * // 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); }