@pyreon/url-state 0.11.4 → 0.11.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -11,20 +11,20 @@ bun add @pyreon/url-state
11
11
  ## Usage
12
12
 
13
13
  ```ts
14
- import { useUrlState } from "@pyreon/url-state"
14
+ import { useUrlState } from '@pyreon/url-state'
15
15
 
16
16
  // Single param — auto type coercion from default value
17
- const page = useUrlState("page", 1) // reads ?page=X, defaults to 1
18
- page() // 1
19
- page.set(2) // URL becomes ?page=2
20
- page.reset() // back to default
21
- page.remove() // removes ?page entirely
17
+ const page = useUrlState('page', 1) // reads ?page=X, defaults to 1
18
+ page() // 1
19
+ page.set(2) // URL becomes ?page=2
20
+ page.reset() // back to default
21
+ page.remove() // removes ?page entirely
22
22
 
23
23
  // Schema mode — multiple params
24
- const { page, sort, q } = useUrlState({ page: 1, sort: "name", q: "" })
24
+ const { page, sort, q } = useUrlState({ page: 1, sort: 'name', q: '' })
25
25
 
26
26
  // Debounced (for search inputs)
27
- const q = useUrlState("q", "", { debounce: 300 })
27
+ const q = useUrlState('q', '', { debounce: 300 })
28
28
  ```
29
29
 
30
30
  ## Features
package/lib/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":[],"sources":["../src/url.ts","../src/serializers.ts","../src/use-url-state.ts"],"sourcesContent":["const _isBrowser = typeof window !== \"undefined\"\n\n/** Read a search param from the current URL. Returns `null` if not present. */\nexport function getParam(key: string): string | null {\n if (!_isBrowser) return null\n return new URLSearchParams(window.location.search).get(key)\n}\n\n/**\n * Read all values for a repeated param (e.g. `?tags=a&tags=b`).\n * Returns an empty array if the param is not present.\n */\nexport function getParamAll(key: string): string[] {\n if (!_isBrowser) return []\n return new URLSearchParams(window.location.search).getAll(key)\n}\n\n/**\n * Minimal router-like interface — only the `replace` method is needed.\n * This avoids a hard dependency on `@pyreon/router`.\n */\nexport interface UrlRouter {\n replace(path: string): void | Promise<void>\n}\n\n/** Module-level router reference. Set via `setUrlRouter()`. */\nlet _router: UrlRouter | null = null\n\n/** Register a router to use for URL updates instead of the raw history API. */\nexport function setUrlRouter(router: UrlRouter | null): void {\n _router = router\n}\n\n/** @internal */\nexport function getUrlRouter(): UrlRouter | null {\n return _router\n}\n\n/** Write one or more search params to the URL without a full navigation. */\nexport function setParams(entries: Record<string, string | null>, replace: boolean): void {\n if (!_isBrowser) return\n\n const params = new URLSearchParams(window.location.search)\n\n for (const [key, value] of Object.entries(entries)) {\n if (value === null) {\n params.delete(key)\n } else {\n params.set(key, value)\n }\n }\n\n const search = params.toString()\n const url = search ? `${window.location.pathname}?${search}` : window.location.pathname\n\n if (_router) {\n _router.replace(url)\n return\n }\n\n if (replace) {\n history.replaceState(null, \"\", url)\n } else {\n history.pushState(null, \"\", url)\n }\n}\n\n/**\n * Write an array param using repeated keys (e.g. `?tags=a&tags=b`).\n * When `values` is null the param is deleted.\n */\nexport function setParamRepeated(key: string, values: string[] | null, replace: boolean): void {\n if (!_isBrowser) return\n\n const params = new URLSearchParams(window.location.search)\n params.delete(key)\n\n if (values !== null) {\n for (const v of values) {\n params.append(key, v)\n }\n }\n\n const search = params.toString()\n const url = search ? `${window.location.pathname}?${search}` : window.location.pathname\n\n if (_router) {\n _router.replace(url)\n return\n }\n\n if (replace) {\n history.replaceState(null, \"\", url)\n } else {\n history.pushState(null, \"\", url)\n }\n}\n\nexport { _isBrowser }\n","import type { ArrayFormat, Serializer } from \"./types\"\n\n/** Infer a serializer pair from the type of the default value. */\nexport function inferSerializer<T>(\n defaultValue: T,\n arrayFormat: ArrayFormat = \"comma\",\n): Serializer<T> {\n if (Array.isArray(defaultValue)) {\n if (arrayFormat === \"repeat\") {\n return {\n serialize: (v: T) => (v as string[]).join(\"\\0REPEAT\\0\"),\n deserialize: (raw: string) => (raw === \"\" ? [] : raw.split(\"\\0REPEAT\\0\")) as T,\n }\n }\n // comma (default)\n return {\n serialize: (v: T) => (v as string[]).join(\",\"),\n deserialize: (raw: string) => (raw === \"\" ? [] : raw.split(\",\")) as T,\n }\n }\n\n switch (typeof defaultValue) {\n case \"number\":\n return {\n serialize: (v: T) => String(v),\n deserialize: (raw: string) => {\n const n = Number(raw)\n return (Number.isNaN(n) ? defaultValue : n) as T\n },\n }\n case \"boolean\":\n return {\n serialize: (v: T) => String(v),\n deserialize: (raw: string) => (raw === \"true\") as T,\n }\n case \"string\":\n return {\n serialize: (v: T) => v as string,\n deserialize: (raw: string) => raw as T,\n }\n case \"object\":\n return {\n serialize: (v: T) => JSON.stringify(v),\n deserialize: (raw: string) => JSON.parse(raw) as T,\n }\n default:\n return {\n serialize: (v: T) => String(v),\n deserialize: (raw: string) => raw as T,\n }\n }\n}\n","import { effect, onCleanup, signal } from \"@pyreon/reactivity\"\nimport { inferSerializer } from \"./serializers\"\nimport type { Serializer, UrlStateOptions, UrlStateSignal } from \"./types\"\nimport { _isBrowser, getParam, getParamAll, setParamRepeated, setParams } from \"./url\"\n\n// ─── Single-param overload ──────────────────────────────────────────────────\n\n/**\n * Bind a single URL search parameter to a reactive signal.\n *\n * @example\n * ```ts\n * const page = useUrlState(\"page\", 1)\n * page() // read reactively (number)\n * page.set(2) // updates signal + URL\n * page.reset() // back to 1\n * page.remove() // removes ?page entirely\n * ```\n */\nexport function useUrlState<T>(\n key: string,\n defaultValue: T,\n options?: UrlStateOptions<T>,\n): UrlStateSignal<T>\n\n// ─── Schema overload ────────────────────────────────────────────────────────\n\n/**\n * Bind multiple URL search parameters at once via a schema object.\n *\n * @example\n * ```ts\n * const { page, q } = useUrlState({ page: 1, q: \"\" })\n * page() // number\n * q.set(\"hi\") // updates ?q=hi\n * ```\n */\nexport function useUrlState<T extends Record<string, unknown>>(\n schema: T,\n options?: UrlStateOptions,\n): { [K in keyof T]: UrlStateSignal<T[K]> }\n\n// ─── Implementation ─────────────────────────────────────────────────────────\n\nexport function useUrlState<T>(\n keyOrSchema: string | Record<string, unknown>,\n defaultOrOptions?: T | UrlStateOptions,\n maybeOptions?: UrlStateOptions<T>,\n): UrlStateSignal<T> | Record<string, UrlStateSignal<unknown>> {\n // Schema mode\n if (typeof keyOrSchema === \"object\") {\n const schema = keyOrSchema as Record<string, unknown>\n const opts = defaultOrOptions as UrlStateOptions | undefined\n const result: Record<string, UrlStateSignal<unknown>> = {}\n\n for (const key of Object.keys(schema)) {\n result[key] = createUrlSignal(key, schema[key], opts)\n }\n\n return result\n }\n\n // Single-param mode\n const key = keyOrSchema\n const defaultValue = defaultOrOptions as T\n const options = maybeOptions\n return createUrlSignal(key, defaultValue, options)\n}\n\n// ─── Core factory ───────────────────────────────────────────────────────────\n\nfunction createUrlSignal<T>(\n key: string,\n defaultValue: T,\n options?: UrlStateOptions<T>,\n): UrlStateSignal<T> {\n const replace = options?.replace !== false\n const debounceMs = options?.debounce ?? 0\n const arrayFormat = options?.arrayFormat ?? \"comma\"\n const isArray = Array.isArray(defaultValue)\n const isRepeat = isArray && arrayFormat === \"repeat\"\n\n const { serialize, deserialize }: Serializer<T> =\n options?.serialize && options?.deserialize\n ? { serialize: options.serialize, deserialize: options.deserialize }\n : inferSerializer(defaultValue, arrayFormat)\n\n // Read initial value from URL (falls back to default when missing or in SSR)\n let initial: T\n if (isRepeat) {\n const values = getParamAll(key)\n initial = values.length > 0 ? (values as T) : defaultValue\n } else {\n const raw = getParam(key)\n initial = raw !== null ? deserialize(raw) : defaultValue\n }\n\n const state = signal<T>(initial)\n\n // Pending debounce timer\n let timer: ReturnType<typeof setTimeout> | undefined\n\n // Write URL when signal changes\n const writeUrl = (value: T) => {\n if (isRepeat) {\n const arr = value as string[]\n const defaultArr = defaultValue as string[]\n // Remove param when value equals default to keep URLs clean\n if (arr.length === defaultArr.length && arr.every((v, i) => v === defaultArr[i])) {\n setParamRepeated(key, null, replace)\n } else {\n setParamRepeated(key, arr, replace)\n }\n return\n }\n\n const serialized = serialize(value)\n const defaultSerialized = serialize(defaultValue)\n\n // Remove param when value equals default to keep URLs clean\n if (serialized === defaultSerialized) {\n setParams({ [key]: null }, replace)\n } else {\n setParams({ [key]: serialized }, replace)\n }\n }\n\n /** Force-remove the param from URL regardless of value. */\n const removeFromUrl = () => {\n if (isRepeat) {\n setParamRepeated(key, null, replace)\n } else {\n setParams({ [key]: null }, replace)\n }\n }\n\n const scheduleWrite = (value: T) => {\n if (debounceMs <= 0) {\n writeUrl(value)\n return\n }\n if (timer !== undefined) clearTimeout(timer)\n timer = setTimeout(() => {\n timer = undefined\n writeUrl(value)\n }, debounceMs)\n }\n\n // Listen for popstate (back/forward navigation)\n if (_isBrowser) {\n const onPopState = () => {\n let value: T\n if (isRepeat) {\n const values = getParamAll(key)\n value = values.length > 0 ? (values as T) : defaultValue\n } else {\n const current = getParam(key)\n value = current !== null ? deserialize(current) : defaultValue\n }\n state.set(value)\n options?.onChange?.(value)\n }\n\n effect(() => {\n window.addEventListener(\"popstate\", onPopState)\n onCleanup(() => {\n window.removeEventListener(\"popstate\", onPopState)\n if (timer !== undefined) clearTimeout(timer)\n })\n })\n }\n\n // Build the signal-like accessor\n const accessor = (() => state()) as UrlStateSignal<T>\n\n accessor.set = (value: T) => {\n state.set(value)\n scheduleWrite(value)\n }\n\n accessor.reset = () => {\n state.set(defaultValue)\n scheduleWrite(defaultValue)\n }\n\n accessor.remove = () => {\n state.set(defaultValue)\n if (timer !== undefined) clearTimeout(timer)\n removeFromUrl()\n }\n\n return accessor\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.js","names":[],"sources":["../src/url.ts","../src/serializers.ts","../src/use-url-state.ts"],"sourcesContent":["const _isBrowser = typeof window !== 'undefined'\n\n/** Read a search param from the current URL. Returns `null` if not present. */\nexport function getParam(key: string): string | null {\n if (!_isBrowser) return null\n return new URLSearchParams(window.location.search).get(key)\n}\n\n/**\n * Read all values for a repeated param (e.g. `?tags=a&tags=b`).\n * Returns an empty array if the param is not present.\n */\nexport function getParamAll(key: string): string[] {\n if (!_isBrowser) return []\n return new URLSearchParams(window.location.search).getAll(key)\n}\n\n/**\n * Minimal router-like interface — only the `replace` method is needed.\n * This avoids a hard dependency on `@pyreon/router`.\n */\nexport interface UrlRouter {\n replace(path: string): void | Promise<void>\n}\n\n/** Module-level router reference. Set via `setUrlRouter()`. */\nlet _router: UrlRouter | null = null\n\n/** Register a router to use for URL updates instead of the raw history API. */\nexport function setUrlRouter(router: UrlRouter | null): void {\n _router = router\n}\n\n/** @internal */\nexport function getUrlRouter(): UrlRouter | null {\n return _router\n}\n\n/** Write one or more search params to the URL without a full navigation. */\nexport function setParams(entries: Record<string, string | null>, replace: boolean): void {\n if (!_isBrowser) return\n\n const params = new URLSearchParams(window.location.search)\n\n for (const [key, value] of Object.entries(entries)) {\n if (value === null) {\n params.delete(key)\n } else {\n params.set(key, value)\n }\n }\n\n const search = params.toString()\n const url = search ? `${window.location.pathname}?${search}` : window.location.pathname\n\n if (_router) {\n _router.replace(url)\n return\n }\n\n if (replace) {\n history.replaceState(null, '', url)\n } else {\n history.pushState(null, '', url)\n }\n}\n\n/**\n * Write an array param using repeated keys (e.g. `?tags=a&tags=b`).\n * When `values` is null the param is deleted.\n */\nexport function setParamRepeated(key: string, values: string[] | null, replace: boolean): void {\n if (!_isBrowser) return\n\n const params = new URLSearchParams(window.location.search)\n params.delete(key)\n\n if (values !== null) {\n for (const v of values) {\n params.append(key, v)\n }\n }\n\n const search = params.toString()\n const url = search ? `${window.location.pathname}?${search}` : window.location.pathname\n\n if (_router) {\n _router.replace(url)\n return\n }\n\n if (replace) {\n history.replaceState(null, '', url)\n } else {\n history.pushState(null, '', url)\n }\n}\n\nexport { _isBrowser }\n","import type { ArrayFormat, Serializer } from './types'\n\n/** Infer a serializer pair from the type of the default value. */\nexport function inferSerializer<T>(\n defaultValue: T,\n arrayFormat: ArrayFormat = 'comma',\n): Serializer<T> {\n if (Array.isArray(defaultValue)) {\n if (arrayFormat === 'repeat') {\n return {\n serialize: (v: T) => (v as string[]).join('\\0REPEAT\\0'),\n deserialize: (raw: string) => (raw === '' ? [] : raw.split('\\0REPEAT\\0')) as T,\n }\n }\n // comma (default)\n return {\n serialize: (v: T) => (v as string[]).join(','),\n deserialize: (raw: string) => (raw === '' ? [] : raw.split(',')) as T,\n }\n }\n\n switch (typeof defaultValue) {\n case 'number':\n return {\n serialize: (v: T) => String(v),\n deserialize: (raw: string) => {\n const n = Number(raw)\n return (Number.isNaN(n) ? defaultValue : n) as T\n },\n }\n case 'boolean':\n return {\n serialize: (v: T) => String(v),\n deserialize: (raw: string) => (raw === 'true') as T,\n }\n case 'string':\n return {\n serialize: (v: T) => v as string,\n deserialize: (raw: string) => raw as T,\n }\n case 'object':\n return {\n serialize: (v: T) => JSON.stringify(v),\n deserialize: (raw: string) => JSON.parse(raw) as T,\n }\n default:\n return {\n serialize: (v: T) => String(v),\n deserialize: (raw: string) => raw as T,\n }\n }\n}\n","import { effect, onCleanup, signal } from '@pyreon/reactivity'\nimport { inferSerializer } from './serializers'\nimport type { Serializer, UrlStateOptions, UrlStateSignal } from './types'\nimport { _isBrowser, getParam, getParamAll, setParamRepeated, setParams } from './url'\n\n// ─── Single-param overload ──────────────────────────────────────────────────\n\n/**\n * Bind a single URL search parameter to a reactive signal.\n *\n * @example\n * ```ts\n * const page = useUrlState(\"page\", 1)\n * page() // read reactively (number)\n * page.set(2) // updates signal + URL\n * page.reset() // back to 1\n * page.remove() // removes ?page entirely\n * ```\n */\nexport function useUrlState<T>(\n key: string,\n defaultValue: T,\n options?: UrlStateOptions<T>,\n): UrlStateSignal<T>\n\n// ─── Schema overload ────────────────────────────────────────────────────────\n\n/**\n * Bind multiple URL search parameters at once via a schema object.\n *\n * @example\n * ```ts\n * const { page, q } = useUrlState({ page: 1, q: \"\" })\n * page() // number\n * q.set(\"hi\") // updates ?q=hi\n * ```\n */\nexport function useUrlState<T extends Record<string, unknown>>(\n schema: T,\n options?: UrlStateOptions,\n): { [K in keyof T]: UrlStateSignal<T[K]> }\n\n// ─── Implementation ─────────────────────────────────────────────────────────\n\nexport function useUrlState<T>(\n keyOrSchema: string | Record<string, unknown>,\n defaultOrOptions?: T | UrlStateOptions,\n maybeOptions?: UrlStateOptions<T>,\n): UrlStateSignal<T> | Record<string, UrlStateSignal<unknown>> {\n // Schema mode\n if (typeof keyOrSchema === 'object') {\n const schema = keyOrSchema as Record<string, unknown>\n const opts = defaultOrOptions as UrlStateOptions | undefined\n const result: Record<string, UrlStateSignal<unknown>> = {}\n\n for (const key of Object.keys(schema)) {\n result[key] = createUrlSignal(key, schema[key], opts)\n }\n\n return result\n }\n\n // Single-param mode\n const key = keyOrSchema\n const defaultValue = defaultOrOptions as T\n const options = maybeOptions\n return createUrlSignal(key, defaultValue, options)\n}\n\n// ─── Core factory ───────────────────────────────────────────────────────────\n\nfunction createUrlSignal<T>(\n key: string,\n defaultValue: T,\n options?: UrlStateOptions<T>,\n): UrlStateSignal<T> {\n const replace = options?.replace !== false\n const debounceMs = options?.debounce ?? 0\n const arrayFormat = options?.arrayFormat ?? 'comma'\n const isArray = Array.isArray(defaultValue)\n const isRepeat = isArray && arrayFormat === 'repeat'\n\n const { serialize, deserialize }: Serializer<T> =\n options?.serialize && options?.deserialize\n ? { serialize: options.serialize, deserialize: options.deserialize }\n : inferSerializer(defaultValue, arrayFormat)\n\n // Read initial value from URL (falls back to default when missing or in SSR)\n let initial: T\n if (isRepeat) {\n const values = getParamAll(key)\n initial = values.length > 0 ? (values as T) : defaultValue\n } else {\n const raw = getParam(key)\n initial = raw !== null ? deserialize(raw) : defaultValue\n }\n\n const state = signal<T>(initial)\n\n // Pending debounce timer\n let timer: ReturnType<typeof setTimeout> | undefined\n\n // Write URL when signal changes\n const writeUrl = (value: T) => {\n if (isRepeat) {\n const arr = value as string[]\n const defaultArr = defaultValue as string[]\n // Remove param when value equals default to keep URLs clean\n if (arr.length === defaultArr.length && arr.every((v, i) => v === defaultArr[i])) {\n setParamRepeated(key, null, replace)\n } else {\n setParamRepeated(key, arr, replace)\n }\n return\n }\n\n const serialized = serialize(value)\n const defaultSerialized = serialize(defaultValue)\n\n // Remove param when value equals default to keep URLs clean\n if (serialized === defaultSerialized) {\n setParams({ [key]: null }, replace)\n } else {\n setParams({ [key]: serialized }, replace)\n }\n }\n\n /** Force-remove the param from URL regardless of value. */\n const removeFromUrl = () => {\n if (isRepeat) {\n setParamRepeated(key, null, replace)\n } else {\n setParams({ [key]: null }, replace)\n }\n }\n\n const scheduleWrite = (value: T) => {\n if (debounceMs <= 0) {\n writeUrl(value)\n return\n }\n if (timer !== undefined) clearTimeout(timer)\n timer = setTimeout(() => {\n timer = undefined\n writeUrl(value)\n }, debounceMs)\n }\n\n // Listen for popstate (back/forward navigation)\n if (_isBrowser) {\n const onPopState = () => {\n let value: T\n if (isRepeat) {\n const values = getParamAll(key)\n value = values.length > 0 ? (values as T) : defaultValue\n } else {\n const current = getParam(key)\n value = current !== null ? deserialize(current) : defaultValue\n }\n state.set(value)\n options?.onChange?.(value)\n }\n\n effect(() => {\n window.addEventListener('popstate', onPopState)\n onCleanup(() => {\n window.removeEventListener('popstate', onPopState)\n if (timer !== undefined) clearTimeout(timer)\n })\n })\n }\n\n // Build the signal-like accessor\n const accessor = (() => state()) as UrlStateSignal<T>\n\n accessor.set = (value: T) => {\n state.set(value)\n scheduleWrite(value)\n }\n\n accessor.reset = () => {\n state.set(defaultValue)\n scheduleWrite(defaultValue)\n }\n\n accessor.remove = () => {\n state.set(defaultValue)\n if (timer !== undefined) clearTimeout(timer)\n removeFromUrl()\n }\n\n return accessor\n}\n"],"mappings":"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"}
@@ -22,7 +22,7 @@ interface UrlStateSignal<T> {
22
22
  remove(): void;
23
23
  }
24
24
  /** Encoding strategy for array values in the URL. */
25
- type ArrayFormat = /** Comma-separated: `?tags=a,b` */"comma" /** Repeated keys: `?tags=a&tags=b` */ | "repeat";
25
+ type ArrayFormat = /** Comma-separated: `?tags=a,b` */'comma' /** Repeated keys: `?tags=a&tags=b` */ | 'repeat';
26
26
  /** Options for `useUrlState`. */
27
27
  interface UrlStateOptions<T = unknown> {
28
28
  /** Custom serializer — converts value to a URL-safe string. */
package/package.json CHANGED
@@ -1,20 +1,17 @@
1
1
  {
2
2
  "name": "@pyreon/url-state",
3
- "version": "0.11.4",
3
+ "version": "0.11.6",
4
4
  "description": "Reactive URL search-param state for Pyreon — signal-backed, type-coerced, SSR-safe",
5
+ "homepage": "https://github.com/pyreon/pyreon/tree/main/packages/url-state#readme",
6
+ "bugs": {
7
+ "url": "https://github.com/pyreon/pyreon/issues"
8
+ },
5
9
  "license": "MIT",
6
10
  "repository": {
7
11
  "type": "git",
8
12
  "url": "https://github.com/pyreon/pyreon.git",
9
13
  "directory": "packages/fundamentals/url-state"
10
14
  },
11
- "homepage": "https://github.com/pyreon/pyreon/tree/main/packages/url-state#readme",
12
- "bugs": {
13
- "url": "https://github.com/pyreon/pyreon/issues"
14
- },
15
- "publishConfig": {
16
- "access": "public"
17
- },
18
15
  "files": [
19
16
  "lib",
20
17
  "src",
@@ -22,6 +19,7 @@
22
19
  "LICENSE"
23
20
  ],
24
21
  "type": "module",
22
+ "sideEffects": false,
25
23
  "main": "./lib/index.js",
26
24
  "module": "./lib/index.js",
27
25
  "types": "./lib/types/index.d.ts",
@@ -32,20 +30,22 @@
32
30
  "types": "./lib/types/index.d.ts"
33
31
  }
34
32
  },
35
- "sideEffects": false,
33
+ "publishConfig": {
34
+ "access": "public"
35
+ },
36
36
  "scripts": {
37
37
  "build": "vl_rolldown_build",
38
38
  "dev": "vl_rolldown_build-watch",
39
39
  "test": "vitest run",
40
40
  "typecheck": "tsc --noEmit",
41
- "lint": "biome check ."
42
- },
43
- "peerDependencies": {
44
- "@pyreon/reactivity": "^0.11.4"
41
+ "lint": "oxlint ."
45
42
  },
46
43
  "devDependencies": {
47
44
  "@happy-dom/global-registrator": "^20.8.3",
48
- "@pyreon/reactivity": "^0.11.4",
45
+ "@pyreon/reactivity": "^0.11.6",
49
46
  "@vitus-labs/tools-lint": "^1.11.0"
47
+ },
48
+ "peerDependencies": {
49
+ "@pyreon/reactivity": "^0.11.6"
50
50
  }
51
51
  }
package/src/index.ts CHANGED
@@ -29,10 +29,10 @@
29
29
  * ```
30
30
  */
31
31
 
32
- export { setUrlRouter } from "./url"
33
- export { useUrlState } from "./use-url-state"
32
+ export { setUrlRouter } from './url'
33
+ export { useUrlState } from './use-url-state'
34
34
 
35
35
  // ─── Types ───────────────────────────────────────────────────────────────────
36
36
 
37
- export type { ArrayFormat, Serializer, UrlStateOptions, UrlStateSignal } from "./types"
38
- export type { UrlRouter } from "./url"
37
+ export type { ArrayFormat, Serializer, UrlStateOptions, UrlStateSignal } from './types'
38
+ export type { UrlRouter } from './url'
@@ -1,26 +1,26 @@
1
- import type { ArrayFormat, Serializer } from "./types"
1
+ import type { ArrayFormat, Serializer } from './types'
2
2
 
3
3
  /** Infer a serializer pair from the type of the default value. */
4
4
  export function inferSerializer<T>(
5
5
  defaultValue: T,
6
- arrayFormat: ArrayFormat = "comma",
6
+ arrayFormat: ArrayFormat = 'comma',
7
7
  ): Serializer<T> {
8
8
  if (Array.isArray(defaultValue)) {
9
- if (arrayFormat === "repeat") {
9
+ if (arrayFormat === 'repeat') {
10
10
  return {
11
- serialize: (v: T) => (v as string[]).join("\0REPEAT\0"),
12
- deserialize: (raw: string) => (raw === "" ? [] : raw.split("\0REPEAT\0")) as T,
11
+ serialize: (v: T) => (v as string[]).join('\0REPEAT\0'),
12
+ deserialize: (raw: string) => (raw === '' ? [] : raw.split('\0REPEAT\0')) as T,
13
13
  }
14
14
  }
15
15
  // comma (default)
16
16
  return {
17
- serialize: (v: T) => (v as string[]).join(","),
18
- deserialize: (raw: string) => (raw === "" ? [] : raw.split(",")) as T,
17
+ serialize: (v: T) => (v as string[]).join(','),
18
+ deserialize: (raw: string) => (raw === '' ? [] : raw.split(',')) as T,
19
19
  }
20
20
  }
21
21
 
22
22
  switch (typeof defaultValue) {
23
- case "number":
23
+ case 'number':
24
24
  return {
25
25
  serialize: (v: T) => String(v),
26
26
  deserialize: (raw: string) => {
@@ -28,17 +28,17 @@ export function inferSerializer<T>(
28
28
  return (Number.isNaN(n) ? defaultValue : n) as T
29
29
  },
30
30
  }
31
- case "boolean":
31
+ case 'boolean':
32
32
  return {
33
33
  serialize: (v: T) => String(v),
34
- deserialize: (raw: string) => (raw === "true") as T,
34
+ deserialize: (raw: string) => (raw === 'true') as T,
35
35
  }
36
- case "string":
36
+ case 'string':
37
37
  return {
38
38
  serialize: (v: T) => v as string,
39
39
  deserialize: (raw: string) => raw as T,
40
40
  }
41
- case "object":
41
+ case 'object':
42
42
  return {
43
43
  serialize: (v: T) => JSON.stringify(v),
44
44
  deserialize: (raw: string) => JSON.parse(raw) as T,
@@ -1,6 +1,6 @@
1
- import { effect } from "@pyreon/reactivity"
2
- import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
3
- import { setUrlRouter, useUrlState } from "../index"
1
+ import { effect } from '@pyreon/reactivity'
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
3
+ import { setUrlRouter, useUrlState } from '../index'
4
4
 
5
5
  /**
6
6
  * Helper: set window.location.search to a given query string.
@@ -9,179 +9,179 @@ import { setUrlRouter, useUrlState } from "../index"
9
9
  function setSearch(search: string) {
10
10
  const url = new URL(window.location.href)
11
11
  url.search = search
12
- history.replaceState(null, "", url.toString())
12
+ history.replaceState(null, '', url.toString())
13
13
  }
14
14
 
15
- describe("useUrlState", () => {
15
+ describe('useUrlState', () => {
16
16
  beforeEach(() => {
17
- setSearch("")
17
+ setSearch('')
18
18
  setUrlRouter(null)
19
19
  })
20
20
 
21
21
  afterEach(() => {
22
- setSearch("")
22
+ setSearch('')
23
23
  setUrlRouter(null)
24
24
  })
25
25
 
26
26
  // ── Single param ────────────────────────────────────────────────────────
27
27
 
28
- describe("single param mode", () => {
29
- it("returns default value when param is not in URL", () => {
30
- const page = useUrlState("page", 1)
28
+ describe('single param mode', () => {
29
+ it('returns default value when param is not in URL', () => {
30
+ const page = useUrlState('page', 1)
31
31
  expect(page()).toBe(1)
32
32
  })
33
33
 
34
- it("reads initial value from URL", () => {
35
- setSearch("?page=5")
36
- const page = useUrlState("page", 1)
34
+ it('reads initial value from URL', () => {
35
+ setSearch('?page=5')
36
+ const page = useUrlState('page', 1)
37
37
  expect(page()).toBe(5)
38
38
  })
39
39
 
40
- it(".set() updates signal and URL", () => {
41
- const page = useUrlState("page", 1)
40
+ it('.set() updates signal and URL', () => {
41
+ const page = useUrlState('page', 1)
42
42
  page.set(3)
43
43
  expect(page()).toBe(3)
44
- expect(new URLSearchParams(window.location.search).get("page")).toBe("3")
44
+ expect(new URLSearchParams(window.location.search).get('page')).toBe('3')
45
45
  })
46
46
 
47
- it(".reset() returns to default and cleans URL", () => {
48
- setSearch("?page=5")
49
- const page = useUrlState("page", 1)
47
+ it('.reset() returns to default and cleans URL', () => {
48
+ setSearch('?page=5')
49
+ const page = useUrlState('page', 1)
50
50
  expect(page()).toBe(5)
51
51
 
52
52
  page.reset()
53
53
  expect(page()).toBe(1)
54
54
  // Default value removes the param from URL
55
- expect(new URLSearchParams(window.location.search).has("page")).toBe(false)
55
+ expect(new URLSearchParams(window.location.search).has('page')).toBe(false)
56
56
  })
57
57
 
58
- it("removes param from URL when value equals default", () => {
59
- const page = useUrlState("page", 1)
58
+ it('removes param from URL when value equals default', () => {
59
+ const page = useUrlState('page', 1)
60
60
  page.set(5)
61
- expect(new URLSearchParams(window.location.search).get("page")).toBe("5")
61
+ expect(new URLSearchParams(window.location.search).get('page')).toBe('5')
62
62
  page.set(1) // back to default
63
- expect(new URLSearchParams(window.location.search).has("page")).toBe(false)
63
+ expect(new URLSearchParams(window.location.search).has('page')).toBe(false)
64
64
  })
65
65
  })
66
66
 
67
67
  // ── Schema mode ─────────────────────────────────────────────────────────
68
68
 
69
- describe("schema mode", () => {
70
- it("returns object of signals matching schema keys", () => {
71
- setSearch("?page=3&q=hello")
72
- const state = useUrlState({ page: 1, q: "" })
69
+ describe('schema mode', () => {
70
+ it('returns object of signals matching schema keys', () => {
71
+ setSearch('?page=3&q=hello')
72
+ const state = useUrlState({ page: 1, q: '' })
73
73
 
74
74
  expect(state.page()).toBe(3)
75
- expect(state.q()).toBe("hello")
75
+ expect(state.q()).toBe('hello')
76
76
  })
77
77
 
78
- it("defaults when params are missing", () => {
79
- const state = useUrlState({ page: 1, q: "" })
78
+ it('defaults when params are missing', () => {
79
+ const state = useUrlState({ page: 1, q: '' })
80
80
  expect(state.page()).toBe(1)
81
- expect(state.q()).toBe("")
81
+ expect(state.q()).toBe('')
82
82
  })
83
83
 
84
- it("set updates individual params", () => {
85
- const state = useUrlState({ page: 1, q: "" })
86
- state.q.set("search term")
87
- expect(state.q()).toBe("search term")
88
- expect(new URLSearchParams(window.location.search).get("q")).toBe("search term")
84
+ it('set updates individual params', () => {
85
+ const state = useUrlState({ page: 1, q: '' })
86
+ state.q.set('search term')
87
+ expect(state.q()).toBe('search term')
88
+ expect(new URLSearchParams(window.location.search).get('q')).toBe('search term')
89
89
  })
90
90
 
91
- it("reset individual param", () => {
92
- setSearch("?page=5&q=hello")
93
- const state = useUrlState({ page: 1, q: "" })
91
+ it('reset individual param', () => {
92
+ setSearch('?page=5&q=hello')
93
+ const state = useUrlState({ page: 1, q: '' })
94
94
  state.page.reset()
95
95
  expect(state.page()).toBe(1)
96
96
  // q should remain
97
- expect(new URLSearchParams(window.location.search).get("q")).toBe("hello")
97
+ expect(new URLSearchParams(window.location.search).get('q')).toBe('hello')
98
98
  })
99
99
  })
100
100
 
101
101
  // ── Type coercion ─────────────────────────────────────────────────────
102
102
 
103
- describe("type coercion", () => {
104
- it("coerces number from URL string", () => {
105
- setSearch("?count=42")
106
- const count = useUrlState("count", 0)
103
+ describe('type coercion', () => {
104
+ it('coerces number from URL string', () => {
105
+ setSearch('?count=42')
106
+ const count = useUrlState('count', 0)
107
107
  expect(count()).toBe(42)
108
- expect(typeof count()).toBe("number")
108
+ expect(typeof count()).toBe('number')
109
109
  })
110
110
 
111
- it("coerces boolean from URL string", () => {
112
- setSearch("?active=true")
113
- const active = useUrlState("active", false)
111
+ it('coerces boolean from URL string', () => {
112
+ setSearch('?active=true')
113
+ const active = useUrlState('active', false)
114
114
  expect(active()).toBe(true)
115
- expect(typeof active()).toBe("boolean")
115
+ expect(typeof active()).toBe('boolean')
116
116
  })
117
117
 
118
- it("coerces boolean false from URL string", () => {
119
- setSearch("?active=false")
120
- const active = useUrlState("active", true)
118
+ it('coerces boolean false from URL string', () => {
119
+ setSearch('?active=false')
120
+ const active = useUrlState('active', true)
121
121
  expect(active()).toBe(false)
122
122
  })
123
123
 
124
- it("handles string identity", () => {
125
- setSearch("?name=alice")
126
- const name = useUrlState("name", "")
127
- expect(name()).toBe("alice")
124
+ it('handles string identity', () => {
125
+ setSearch('?name=alice')
126
+ const name = useUrlState('name', '')
127
+ expect(name()).toBe('alice')
128
128
  })
129
129
 
130
- it("handles string[] via comma-separated", () => {
131
- setSearch("?tags=a,b,c")
132
- const tags = useUrlState("tags", [] as string[])
133
- expect(tags()).toEqual(["a", "b", "c"])
130
+ it('handles string[] via comma-separated', () => {
131
+ setSearch('?tags=a,b,c')
132
+ const tags = useUrlState('tags', [] as string[])
133
+ expect(tags()).toEqual(['a', 'b', 'c'])
134
134
  })
135
135
 
136
- it("handles empty string[] from URL", () => {
137
- setSearch("?tags=")
138
- const tags = useUrlState("tags", [] as string[])
136
+ it('handles empty string[] from URL', () => {
137
+ setSearch('?tags=')
138
+ const tags = useUrlState('tags', [] as string[])
139
139
  expect(tags()).toEqual([])
140
140
  })
141
141
 
142
- it("serializes string[] with commas", () => {
143
- const tags = useUrlState("tags", [] as string[])
144
- tags.set(["x", "y"])
145
- expect(new URLSearchParams(window.location.search).get("tags")).toBe("x,y")
142
+ it('serializes string[] with commas', () => {
143
+ const tags = useUrlState('tags', [] as string[])
144
+ tags.set(['x', 'y'])
145
+ expect(new URLSearchParams(window.location.search).get('tags')).toBe('x,y')
146
146
  })
147
147
 
148
- it("handles object via JSON", () => {
148
+ it('handles object via JSON', () => {
149
149
  setSearch(`?filter=${encodeURIComponent(JSON.stringify({ min: 1, max: 10 }))}`)
150
- const filter = useUrlState("filter", { min: 0, max: 100 })
150
+ const filter = useUrlState('filter', { min: 0, max: 100 })
151
151
  expect(filter()).toEqual({ min: 1, max: 10 })
152
152
  })
153
153
  })
154
154
 
155
155
  // ── Custom serializer ──────────────────────────────────────────────────
156
156
 
157
- describe("custom serializer", () => {
158
- it("uses custom serialize/deserialize", () => {
159
- setSearch("?date=2024-01-15")
160
- const date = useUrlState("date", new Date(0), {
157
+ describe('custom serializer', () => {
158
+ it('uses custom serialize/deserialize', () => {
159
+ setSearch('?date=2024-01-15')
160
+ const date = useUrlState('date', new Date(0), {
161
161
  serialize: (d) => d.toISOString().slice(0, 10),
162
162
  deserialize: (s) => new Date(s),
163
163
  })
164
164
  expect(date().getFullYear()).toBe(2024)
165
165
 
166
- date.set(new Date("2025-06-01"))
167
- expect(new URLSearchParams(window.location.search).get("date")).toBe("2025-06-01")
166
+ date.set(new Date('2025-06-01'))
167
+ expect(new URLSearchParams(window.location.search).get('date')).toBe('2025-06-01')
168
168
  })
169
169
  })
170
170
 
171
171
  // ── replace vs push ────────────────────────────────────────────────────
172
172
 
173
- describe("history mode", () => {
174
- it("uses replaceState by default", () => {
175
- const spy = vi.spyOn(history, "replaceState")
176
- const page = useUrlState("page", 1)
173
+ describe('history mode', () => {
174
+ it('uses replaceState by default', () => {
175
+ const spy = vi.spyOn(history, 'replaceState')
176
+ const page = useUrlState('page', 1)
177
177
  page.set(2)
178
178
  expect(spy).toHaveBeenCalled()
179
179
  spy.mockRestore()
180
180
  })
181
181
 
182
- it("uses pushState when replace: false", () => {
183
- const spy = vi.spyOn(history, "pushState")
184
- const page = useUrlState("page", 1, { replace: false })
182
+ it('uses pushState when replace: false', () => {
183
+ const spy = vi.spyOn(history, 'pushState')
184
+ const page = useUrlState('page', 1, { replace: false })
185
185
  page.set(2)
186
186
  expect(spy).toHaveBeenCalled()
187
187
  spy.mockRestore()
@@ -190,35 +190,35 @@ describe("useUrlState", () => {
190
190
 
191
191
  // ── Popstate sync ─────────────────────────────────────────────────────
192
192
 
193
- describe("popstate sync", () => {
194
- it("updates signal on popstate event", () => {
195
- const page = useUrlState("page", 1)
193
+ describe('popstate sync', () => {
194
+ it('updates signal on popstate event', () => {
195
+ const page = useUrlState('page', 1)
196
196
  page.set(5)
197
197
  expect(page()).toBe(5)
198
198
 
199
199
  // Simulate browser back: change URL then fire popstate
200
- setSearch("?page=3")
201
- window.dispatchEvent(new Event("popstate"))
200
+ setSearch('?page=3')
201
+ window.dispatchEvent(new Event('popstate'))
202
202
  expect(page()).toBe(3)
203
203
  })
204
204
 
205
- it("resets to default on popstate when param removed", () => {
206
- setSearch("?page=5")
207
- const page = useUrlState("page", 1)
205
+ it('resets to default on popstate when param removed', () => {
206
+ setSearch('?page=5')
207
+ const page = useUrlState('page', 1)
208
208
  expect(page()).toBe(5)
209
209
 
210
- setSearch("")
211
- window.dispatchEvent(new Event("popstate"))
210
+ setSearch('')
211
+ window.dispatchEvent(new Event('popstate'))
212
212
  expect(page()).toBe(1)
213
213
  })
214
214
  })
215
215
 
216
216
  // ── Debounce ──────────────────────────────────────────────────────────
217
217
 
218
- describe("debounce", () => {
219
- it("batches rapid writes", async () => {
218
+ describe('debounce', () => {
219
+ it('batches rapid writes', async () => {
220
220
  vi.useFakeTimers()
221
- const page = useUrlState("page", 1, { debounce: 50 })
221
+ const page = useUrlState('page', 1, { debounce: 50 })
222
222
 
223
223
  page.set(2)
224
224
  page.set(3)
@@ -227,12 +227,12 @@ describe("useUrlState", () => {
227
227
  // Signal updates immediately
228
228
  expect(page()).toBe(4)
229
229
  // URL not yet updated (debounced)
230
- expect(new URLSearchParams(window.location.search).has("page")).toBe(false)
230
+ expect(new URLSearchParams(window.location.search).has('page')).toBe(false)
231
231
 
232
232
  vi.advanceTimersByTime(50)
233
233
 
234
234
  // Now URL is updated with final value
235
- expect(new URLSearchParams(window.location.search).get("page")).toBe("4")
235
+ expect(new URLSearchParams(window.location.search).get('page')).toBe('4')
236
236
 
237
237
  vi.useRealTimers()
238
238
  })
@@ -240,9 +240,9 @@ describe("useUrlState", () => {
240
240
 
241
241
  // ── Reactivity ────────────────────────────────────────────────────────
242
242
 
243
- describe("reactivity", () => {
244
- it("signal is reactive in effects", () => {
245
- const page = useUrlState("page", 1)
243
+ describe('reactivity', () => {
244
+ it('signal is reactive in effects', () => {
245
+ const page = useUrlState('page', 1)
246
246
  const values: number[] = []
247
247
 
248
248
  const fx = effect(() => {
@@ -259,32 +259,32 @@ describe("useUrlState", () => {
259
259
 
260
260
  // ── remove() ──────────────────────────────────────────────────────────
261
261
 
262
- describe("remove()", () => {
263
- it("removes param from URL and resets signal to default", () => {
264
- const page = useUrlState("page", 1)
262
+ describe('remove()', () => {
263
+ it('removes param from URL and resets signal to default', () => {
264
+ const page = useUrlState('page', 1)
265
265
  page.set(5)
266
266
  expect(page()).toBe(5)
267
- expect(new URLSearchParams(window.location.search).get("page")).toBe("5")
267
+ expect(new URLSearchParams(window.location.search).get('page')).toBe('5')
268
268
 
269
269
  page.remove()
270
270
  expect(page()).toBe(1)
271
- expect(new URLSearchParams(window.location.search).has("page")).toBe(false)
271
+ expect(new URLSearchParams(window.location.search).has('page')).toBe(false)
272
272
  })
273
273
 
274
- it("removes param even when value equals default", () => {
274
+ it('removes param even when value equals default', () => {
275
275
  // Set URL with a non-default value, then reset, then set again to default
276
- setSearch("?page=1")
277
- const page = useUrlState("page", 1)
276
+ setSearch('?page=1')
277
+ const page = useUrlState('page', 1)
278
278
  // Value is 1 (default), but param is in URL
279
279
  // remove() should guarantee it's gone
280
280
  page.remove()
281
281
  expect(page()).toBe(1)
282
- expect(new URLSearchParams(window.location.search).has("page")).toBe(false)
282
+ expect(new URLSearchParams(window.location.search).has('page')).toBe(false)
283
283
  })
284
284
 
285
- it("cancels pending debounced write", () => {
285
+ it('cancels pending debounced write', () => {
286
286
  vi.useFakeTimers()
287
- const page = useUrlState("page", 1, { debounce: 100 })
287
+ const page = useUrlState('page', 1, { debounce: 100 })
288
288
 
289
289
  page.set(5) // starts debounce timer
290
290
  page.remove() // should cancel the debounce and remove immediately
@@ -292,112 +292,112 @@ describe("useUrlState", () => {
292
292
  // Signal is default
293
293
  expect(page()).toBe(1)
294
294
  // URL should not have the param (remove is immediate, not debounced)
295
- expect(new URLSearchParams(window.location.search).has("page")).toBe(false)
295
+ expect(new URLSearchParams(window.location.search).has('page')).toBe(false)
296
296
 
297
297
  vi.advanceTimersByTime(100)
298
298
  // Still removed — the debounced write should not have fired
299
- expect(new URLSearchParams(window.location.search).has("page")).toBe(false)
299
+ expect(new URLSearchParams(window.location.search).has('page')).toBe(false)
300
300
 
301
301
  vi.useRealTimers()
302
302
  })
303
303
 
304
- it("works with array values (comma format)", () => {
305
- const tags = useUrlState("tags", [] as string[])
306
- tags.set(["a", "b"])
307
- expect(new URLSearchParams(window.location.search).get("tags")).toBe("a,b")
304
+ it('works with array values (comma format)', () => {
305
+ const tags = useUrlState('tags', [] as string[])
306
+ tags.set(['a', 'b'])
307
+ expect(new URLSearchParams(window.location.search).get('tags')).toBe('a,b')
308
308
 
309
309
  tags.remove()
310
310
  expect(tags()).toEqual([])
311
- expect(new URLSearchParams(window.location.search).has("tags")).toBe(false)
311
+ expect(new URLSearchParams(window.location.search).has('tags')).toBe(false)
312
312
  })
313
313
 
314
- it("works with array values (repeat format)", () => {
315
- const tags = useUrlState("tags", [] as string[], { arrayFormat: "repeat" })
316
- tags.set(["x", "y"])
317
- expect(new URLSearchParams(window.location.search).getAll("tags")).toEqual(["x", "y"])
314
+ it('works with array values (repeat format)', () => {
315
+ const tags = useUrlState('tags', [] as string[], { arrayFormat: 'repeat' })
316
+ tags.set(['x', 'y'])
317
+ expect(new URLSearchParams(window.location.search).getAll('tags')).toEqual(['x', 'y'])
318
318
 
319
319
  tags.remove()
320
320
  expect(tags()).toEqual([])
321
- expect(new URLSearchParams(window.location.search).has("tags")).toBe(false)
321
+ expect(new URLSearchParams(window.location.search).has('tags')).toBe(false)
322
322
  })
323
323
 
324
- it("preserves other params when removing", () => {
325
- setSearch("?page=3&q=hello")
326
- const page = useUrlState("page", 1)
324
+ it('preserves other params when removing', () => {
325
+ setSearch('?page=3&q=hello')
326
+ const page = useUrlState('page', 1)
327
327
  page.remove()
328
- expect(new URLSearchParams(window.location.search).has("page")).toBe(false)
329
- expect(new URLSearchParams(window.location.search).get("q")).toBe("hello")
328
+ expect(new URLSearchParams(window.location.search).has('page')).toBe(false)
329
+ expect(new URLSearchParams(window.location.search).get('q')).toBe('hello')
330
330
  })
331
331
  })
332
332
 
333
333
  // ── Array format ──────────────────────────────────────────────────────
334
334
 
335
- describe("arrayFormat", () => {
336
- describe("comma (default)", () => {
337
- it("reads comma-separated values from URL", () => {
338
- setSearch("?tags=a,b,c")
339
- const tags = useUrlState("tags", [] as string[])
340
- expect(tags()).toEqual(["a", "b", "c"])
335
+ describe('arrayFormat', () => {
336
+ describe('comma (default)', () => {
337
+ it('reads comma-separated values from URL', () => {
338
+ setSearch('?tags=a,b,c')
339
+ const tags = useUrlState('tags', [] as string[])
340
+ expect(tags()).toEqual(['a', 'b', 'c'])
341
341
  })
342
342
 
343
- it("writes comma-separated values to URL", () => {
344
- const tags = useUrlState("tags", [] as string[])
345
- tags.set(["x", "y", "z"])
346
- expect(new URLSearchParams(window.location.search).get("tags")).toBe("x,y,z")
343
+ it('writes comma-separated values to URL', () => {
344
+ const tags = useUrlState('tags', [] as string[])
345
+ tags.set(['x', 'y', 'z'])
346
+ expect(new URLSearchParams(window.location.search).get('tags')).toBe('x,y,z')
347
347
  })
348
348
 
349
- it("explicit comma format matches default behavior", () => {
350
- setSearch("?tags=a,b")
351
- const tags = useUrlState("tags", [] as string[], { arrayFormat: "comma" })
352
- expect(tags()).toEqual(["a", "b"])
349
+ it('explicit comma format matches default behavior', () => {
350
+ setSearch('?tags=a,b')
351
+ const tags = useUrlState('tags', [] as string[], { arrayFormat: 'comma' })
352
+ expect(tags()).toEqual(['a', 'b'])
353
353
  })
354
354
  })
355
355
 
356
- describe("repeat", () => {
357
- it("reads repeated keys from URL", () => {
358
- setSearch("?tags=a&tags=b&tags=c")
359
- const tags = useUrlState("tags", [] as string[], { arrayFormat: "repeat" })
360
- expect(tags()).toEqual(["a", "b", "c"])
356
+ describe('repeat', () => {
357
+ it('reads repeated keys from URL', () => {
358
+ setSearch('?tags=a&tags=b&tags=c')
359
+ const tags = useUrlState('tags', [] as string[], { arrayFormat: 'repeat' })
360
+ expect(tags()).toEqual(['a', 'b', 'c'])
361
361
  })
362
362
 
363
- it("writes repeated keys to URL", () => {
364
- const tags = useUrlState("tags", [] as string[], { arrayFormat: "repeat" })
365
- tags.set(["x", "y"])
363
+ it('writes repeated keys to URL', () => {
364
+ const tags = useUrlState('tags', [] as string[], { arrayFormat: 'repeat' })
365
+ tags.set(['x', 'y'])
366
366
  const params = new URLSearchParams(window.location.search)
367
- expect(params.getAll("tags")).toEqual(["x", "y"])
367
+ expect(params.getAll('tags')).toEqual(['x', 'y'])
368
368
  })
369
369
 
370
- it("falls back to default when no repeated keys in URL", () => {
371
- const tags = useUrlState("tags", ["default"] as string[], { arrayFormat: "repeat" })
372
- expect(tags()).toEqual(["default"])
370
+ it('falls back to default when no repeated keys in URL', () => {
371
+ const tags = useUrlState('tags', ['default'] as string[], { arrayFormat: 'repeat' })
372
+ expect(tags()).toEqual(['default'])
373
373
  })
374
374
 
375
- it("removes repeated keys when value equals default (empty array)", () => {
376
- const tags = useUrlState("tags", [] as string[], { arrayFormat: "repeat" })
377
- tags.set(["a", "b"])
378
- expect(new URLSearchParams(window.location.search).getAll("tags")).toEqual(["a", "b"])
375
+ it('removes repeated keys when value equals default (empty array)', () => {
376
+ const tags = useUrlState('tags', [] as string[], { arrayFormat: 'repeat' })
377
+ tags.set(['a', 'b'])
378
+ expect(new URLSearchParams(window.location.search).getAll('tags')).toEqual(['a', 'b'])
379
379
 
380
380
  tags.set([]) // back to default
381
- expect(new URLSearchParams(window.location.search).has("tags")).toBe(false)
381
+ expect(new URLSearchParams(window.location.search).has('tags')).toBe(false)
382
382
  })
383
383
 
384
- it("popstate syncs repeated keys", () => {
385
- const tags = useUrlState("tags", [] as string[], { arrayFormat: "repeat" })
386
- tags.set(["a", "b"])
387
- expect(tags()).toEqual(["a", "b"])
384
+ it('popstate syncs repeated keys', () => {
385
+ const tags = useUrlState('tags', [] as string[], { arrayFormat: 'repeat' })
386
+ tags.set(['a', 'b'])
387
+ expect(tags()).toEqual(['a', 'b'])
388
388
 
389
- setSearch("?tags=x&tags=y&tags=z")
390
- window.dispatchEvent(new Event("popstate"))
391
- expect(tags()).toEqual(["x", "y", "z"])
389
+ setSearch('?tags=x&tags=y&tags=z')
390
+ window.dispatchEvent(new Event('popstate'))
391
+ expect(tags()).toEqual(['x', 'y', 'z'])
392
392
  })
393
393
 
394
- it("popstate resets to default when repeated keys removed", () => {
395
- setSearch("?tags=a&tags=b")
396
- const tags = useUrlState("tags", [] as string[], { arrayFormat: "repeat" })
397
- expect(tags()).toEqual(["a", "b"])
394
+ it('popstate resets to default when repeated keys removed', () => {
395
+ setSearch('?tags=a&tags=b')
396
+ const tags = useUrlState('tags', [] as string[], { arrayFormat: 'repeat' })
397
+ expect(tags()).toEqual(['a', 'b'])
398
398
 
399
- setSearch("")
400
- window.dispatchEvent(new Event("popstate"))
399
+ setSearch('')
400
+ window.dispatchEvent(new Event('popstate'))
401
401
  expect(tags()).toEqual([])
402
402
  })
403
403
  })
@@ -405,10 +405,10 @@ describe("useUrlState", () => {
405
405
 
406
406
  // ── onChange callback ────────────────────────────────────────────────
407
407
 
408
- describe("onChange", () => {
409
- it("does not fire on .set() (only external changes)", () => {
408
+ describe('onChange', () => {
409
+ it('does not fire on .set() (only external changes)', () => {
410
410
  const changes: number[] = []
411
- const page = useUrlState("page", 1, {
411
+ const page = useUrlState('page', 1, {
412
412
  onChange: (v) => changes.push(v),
413
413
  })
414
414
 
@@ -418,32 +418,32 @@ describe("useUrlState", () => {
418
418
  expect(changes).toEqual([])
419
419
  })
420
420
 
421
- it("fires on popstate (external change)", () => {
421
+ it('fires on popstate (external change)', () => {
422
422
  const changes: number[] = []
423
- useUrlState("page", 1, {
423
+ useUrlState('page', 1, {
424
424
  onChange: (v) => changes.push(v),
425
425
  })
426
426
 
427
- setSearch("?page=7")
428
- window.dispatchEvent(new Event("popstate"))
427
+ setSearch('?page=7')
428
+ window.dispatchEvent(new Event('popstate'))
429
429
  expect(changes).toEqual([7])
430
430
  })
431
431
 
432
- it("fires with default value on popstate when param removed", () => {
433
- setSearch("?page=5")
432
+ it('fires with default value on popstate when param removed', () => {
433
+ setSearch('?page=5')
434
434
  const changes: number[] = []
435
- useUrlState("page", 1, {
435
+ useUrlState('page', 1, {
436
436
  onChange: (v) => changes.push(v),
437
437
  })
438
438
 
439
- setSearch("")
440
- window.dispatchEvent(new Event("popstate"))
439
+ setSearch('')
440
+ window.dispatchEvent(new Event('popstate'))
441
441
  expect(changes).toEqual([1])
442
442
  })
443
443
 
444
- it("does not fire on .reset()", () => {
444
+ it('does not fire on .reset()', () => {
445
445
  const changes: number[] = []
446
- const page = useUrlState("page", 1, {
446
+ const page = useUrlState('page', 1, {
447
447
  onChange: (v) => changes.push(v),
448
448
  })
449
449
  page.set(5)
@@ -452,9 +452,9 @@ describe("useUrlState", () => {
452
452
  expect(changes).toEqual([])
453
453
  })
454
454
 
455
- it("does not fire on .remove()", () => {
455
+ it('does not fire on .remove()', () => {
456
456
  const changes: number[] = []
457
- const page = useUrlState("page", 1, {
457
+ const page = useUrlState('page', 1, {
458
458
  onChange: (v) => changes.push(v),
459
459
  })
460
460
  page.set(5)
@@ -463,41 +463,41 @@ describe("useUrlState", () => {
463
463
  expect(changes).toEqual([])
464
464
  })
465
465
 
466
- it("works with array values and popstate", () => {
466
+ it('works with array values and popstate', () => {
467
467
  const changes: string[][] = []
468
- useUrlState("tags", [] as string[], {
469
- arrayFormat: "repeat",
468
+ useUrlState('tags', [] as string[], {
469
+ arrayFormat: 'repeat',
470
470
  onChange: (v) => changes.push(v as string[]),
471
471
  })
472
472
 
473
- setSearch("?tags=a&tags=b")
474
- window.dispatchEvent(new Event("popstate"))
475
- expect(changes).toEqual([["a", "b"]])
473
+ setSearch('?tags=a&tags=b')
474
+ window.dispatchEvent(new Event('popstate'))
475
+ expect(changes).toEqual([['a', 'b']])
476
476
  })
477
477
  })
478
478
 
479
479
  // ── Router integration ────────────────────────────────────────────────
480
480
 
481
- describe("router integration", () => {
482
- it("uses router.replace() when router is set", () => {
481
+ describe('router integration', () => {
482
+ it('uses router.replace() when router is set', () => {
483
483
  const replaceCalls: string[] = []
484
484
  setUrlRouter({
485
485
  replace: (path: string) => {
486
486
  replaceCalls.push(path)
487
487
  // Simulate what a real router does — update the URL
488
- history.replaceState(null, "", path)
488
+ history.replaceState(null, '', path)
489
489
  },
490
490
  })
491
491
 
492
- const page = useUrlState("page", 1)
492
+ const page = useUrlState('page', 1)
493
493
  page.set(3)
494
494
 
495
495
  expect(replaceCalls.length).toBe(1)
496
- expect(replaceCalls[0]).toContain("page=3")
496
+ expect(replaceCalls[0]).toContain('page=3')
497
497
  })
498
498
 
499
- it("does not call history.replaceState directly when router is set", () => {
500
- const spy = vi.spyOn(history, "replaceState")
499
+ it('does not call history.replaceState directly when router is set', () => {
500
+ const spy = vi.spyOn(history, 'replaceState')
501
501
 
502
502
  setUrlRouter({
503
503
  replace: (_path: string) => {
@@ -505,7 +505,7 @@ describe("useUrlState", () => {
505
505
  },
506
506
  })
507
507
 
508
- const page = useUrlState("page", 1)
508
+ const page = useUrlState('page', 1)
509
509
  page.set(3)
510
510
 
511
511
  // replaceState should NOT have been called by setParams (only by setSearch in beforeEach)
@@ -517,70 +517,70 @@ describe("useUrlState", () => {
517
517
  spy.mockRestore()
518
518
  })
519
519
 
520
- it("falls back to history API when no router is set", () => {
521
- const spy = vi.spyOn(history, "replaceState")
520
+ it('falls back to history API when no router is set', () => {
521
+ const spy = vi.spyOn(history, 'replaceState')
522
522
  setUrlRouter(null)
523
523
 
524
- const page = useUrlState("page", 1)
524
+ const page = useUrlState('page', 1)
525
525
  page.set(2)
526
526
 
527
527
  expect(spy).toHaveBeenCalled()
528
528
  spy.mockRestore()
529
529
  })
530
530
 
531
- it("router.replace() receives correct URL for repeat arrays", () => {
531
+ it('router.replace() receives correct URL for repeat arrays', () => {
532
532
  const replaceCalls: string[] = []
533
533
  setUrlRouter({
534
534
  replace: (path: string) => {
535
535
  replaceCalls.push(path)
536
- history.replaceState(null, "", path)
536
+ history.replaceState(null, '', path)
537
537
  },
538
538
  })
539
539
 
540
- const tags = useUrlState("tags", [] as string[], { arrayFormat: "repeat" })
541
- tags.set(["a", "b"])
540
+ const tags = useUrlState('tags', [] as string[], { arrayFormat: 'repeat' })
541
+ tags.set(['a', 'b'])
542
542
 
543
543
  expect(replaceCalls.length).toBe(1)
544
- expect(replaceCalls[0]).toContain("tags=a&tags=b")
544
+ expect(replaceCalls[0]).toContain('tags=a&tags=b')
545
545
  })
546
546
 
547
- it("router.replace() used for remove()", () => {
547
+ it('router.replace() used for remove()', () => {
548
548
  const replaceCalls: string[] = []
549
549
  setUrlRouter({
550
550
  replace: (path: string) => {
551
551
  replaceCalls.push(path)
552
- history.replaceState(null, "", path)
552
+ history.replaceState(null, '', path)
553
553
  },
554
554
  })
555
555
 
556
- const page = useUrlState("page", 1)
556
+ const page = useUrlState('page', 1)
557
557
  page.set(5)
558
558
  replaceCalls.length = 0
559
559
 
560
560
  page.remove()
561
561
  expect(replaceCalls.length).toBe(1)
562
562
  // The param should not be in the URL
563
- expect(replaceCalls[0]).not.toContain("page=")
563
+ expect(replaceCalls[0]).not.toContain('page=')
564
564
  })
565
565
  })
566
566
 
567
- describe("NaN safety (regression)", () => {
568
- it("falls back to default when URL contains non-numeric value for number param", () => {
569
- setSearch("?count=abc")
570
- const count = useUrlState("count", 0)
567
+ describe('NaN safety (regression)', () => {
568
+ it('falls back to default when URL contains non-numeric value for number param', () => {
569
+ setSearch('?count=abc')
570
+ const count = useUrlState('count', 0)
571
571
  expect(count()).toBe(0) // not NaN
572
572
  })
573
573
 
574
- it("empty string coerces to 0 for number param", () => {
575
- setSearch("?count=")
576
- const count = useUrlState("count", 42)
574
+ it('empty string coerces to 0 for number param', () => {
575
+ setSearch('?count=')
576
+ const count = useUrlState('count', 42)
577
577
  // Number("") is 0, not NaN — this is valid coercion
578
578
  expect(count()).toBe(0)
579
579
  })
580
580
 
581
- it("parses valid numbers normally", () => {
582
- setSearch("?count=7")
583
- const count = useUrlState("count", 0)
581
+ it('parses valid numbers normally', () => {
582
+ setSearch('?count=7')
583
+ const count = useUrlState('count', 0)
584
584
  expect(count()).toBe(7)
585
585
  })
586
586
  })
package/src/types.ts CHANGED
@@ -13,9 +13,9 @@ export interface UrlStateSignal<T> {
13
13
  /** Encoding strategy for array values in the URL. */
14
14
  export type ArrayFormat =
15
15
  /** Comma-separated: `?tags=a,b` */
16
- | "comma"
16
+ | 'comma'
17
17
  /** Repeated keys: `?tags=a&tags=b` */
18
- | "repeat"
18
+ | 'repeat'
19
19
 
20
20
  /** Options for `useUrlState`. */
21
21
  export interface UrlStateOptions<T = unknown> {
package/src/url.ts CHANGED
@@ -1,4 +1,4 @@
1
- const _isBrowser = typeof window !== "undefined"
1
+ const _isBrowser = typeof window !== 'undefined'
2
2
 
3
3
  /** Read a search param from the current URL. Returns `null` if not present. */
4
4
  export function getParam(key: string): string | null {
@@ -59,9 +59,9 @@ export function setParams(entries: Record<string, string | null>, replace: boole
59
59
  }
60
60
 
61
61
  if (replace) {
62
- history.replaceState(null, "", url)
62
+ history.replaceState(null, '', url)
63
63
  } else {
64
- history.pushState(null, "", url)
64
+ history.pushState(null, '', url)
65
65
  }
66
66
  }
67
67
 
@@ -90,9 +90,9 @@ export function setParamRepeated(key: string, values: string[] | null, replace:
90
90
  }
91
91
 
92
92
  if (replace) {
93
- history.replaceState(null, "", url)
93
+ history.replaceState(null, '', url)
94
94
  } else {
95
- history.pushState(null, "", url)
95
+ history.pushState(null, '', url)
96
96
  }
97
97
  }
98
98
 
@@ -1,7 +1,7 @@
1
- import { effect, onCleanup, signal } from "@pyreon/reactivity"
2
- import { inferSerializer } from "./serializers"
3
- import type { Serializer, UrlStateOptions, UrlStateSignal } from "./types"
4
- import { _isBrowser, getParam, getParamAll, setParamRepeated, setParams } from "./url"
1
+ import { effect, onCleanup, signal } from '@pyreon/reactivity'
2
+ import { inferSerializer } from './serializers'
3
+ import type { Serializer, UrlStateOptions, UrlStateSignal } from './types'
4
+ import { _isBrowser, getParam, getParamAll, setParamRepeated, setParams } from './url'
5
5
 
6
6
  // ─── Single-param overload ──────────────────────────────────────────────────
7
7
 
@@ -48,7 +48,7 @@ export function useUrlState<T>(
48
48
  maybeOptions?: UrlStateOptions<T>,
49
49
  ): UrlStateSignal<T> | Record<string, UrlStateSignal<unknown>> {
50
50
  // Schema mode
51
- if (typeof keyOrSchema === "object") {
51
+ if (typeof keyOrSchema === 'object') {
52
52
  const schema = keyOrSchema as Record<string, unknown>
53
53
  const opts = defaultOrOptions as UrlStateOptions | undefined
54
54
  const result: Record<string, UrlStateSignal<unknown>> = {}
@@ -76,9 +76,9 @@ function createUrlSignal<T>(
76
76
  ): UrlStateSignal<T> {
77
77
  const replace = options?.replace !== false
78
78
  const debounceMs = options?.debounce ?? 0
79
- const arrayFormat = options?.arrayFormat ?? "comma"
79
+ const arrayFormat = options?.arrayFormat ?? 'comma'
80
80
  const isArray = Array.isArray(defaultValue)
81
- const isRepeat = isArray && arrayFormat === "repeat"
81
+ const isRepeat = isArray && arrayFormat === 'repeat'
82
82
 
83
83
  const { serialize, deserialize }: Serializer<T> =
84
84
  options?.serialize && options?.deserialize
@@ -162,9 +162,9 @@ function createUrlSignal<T>(
162
162
  }
163
163
 
164
164
  effect(() => {
165
- window.addEventListener("popstate", onPopState)
165
+ window.addEventListener('popstate', onPopState)
166
166
  onCleanup(() => {
167
- window.removeEventListener("popstate", onPopState)
167
+ window.removeEventListener('popstate', onPopState)
168
168
  if (timer !== undefined) clearTimeout(timer)
169
169
  })
170
170
  })