Files
Stackq/node_modules/runed/dist/utilities/use-search-params/use-search-params.svelte.js
T
eewing cf73cd3b4c INIT
2026-02-17 14:10:16 -06:00

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);
}