@pyreon/url-state 0.11.5 → 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 +8 -8
- package/lib/index.js.map +1 -1
- package/lib/types/index.d.ts +1 -1
- package/package.json +14 -14
- package/src/index.ts +4 -4
- package/src/serializers.ts +12 -12
- package/src/tests/url-state.test.ts +225 -225
- package/src/types.ts +2 -2
- package/src/url.ts +5 -5
- package/src/use-url-state.ts +9 -9
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
|
|
14
|
+
import { useUrlState } from '@pyreon/url-state'
|
|
15
15
|
|
|
16
16
|
// Single param — auto type coercion from default value
|
|
17
|
-
const page = useUrlState(
|
|
18
|
-
page()
|
|
19
|
-
page.set(2)
|
|
20
|
-
page.reset()
|
|
21
|
-
page.remove()
|
|
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:
|
|
24
|
+
const { page, sort, q } = useUrlState({ page: 1, sort: 'name', q: '' })
|
|
25
25
|
|
|
26
26
|
// Debounced (for search inputs)
|
|
27
|
-
const q = useUrlState(
|
|
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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"}
|
package/lib/types/index.d.ts
CHANGED
|
@@ -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` */
|
|
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.
|
|
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
|
-
"
|
|
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": "
|
|
42
|
-
},
|
|
43
|
-
"peerDependencies": {
|
|
44
|
-
"@pyreon/reactivity": "^0.11.5"
|
|
41
|
+
"lint": "oxlint ."
|
|
45
42
|
},
|
|
46
43
|
"devDependencies": {
|
|
47
44
|
"@happy-dom/global-registrator": "^20.8.3",
|
|
48
|
-
"@pyreon/reactivity": "^0.11.
|
|
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
|
|
33
|
-
export { useUrlState } from
|
|
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
|
|
38
|
-
export type { UrlRouter } from
|
|
37
|
+
export type { ArrayFormat, Serializer, UrlStateOptions, UrlStateSignal } from './types'
|
|
38
|
+
export type { UrlRouter } from './url'
|
package/src/serializers.ts
CHANGED
|
@@ -1,26 +1,26 @@
|
|
|
1
|
-
import type { ArrayFormat, Serializer } from
|
|
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 =
|
|
6
|
+
arrayFormat: ArrayFormat = 'comma',
|
|
7
7
|
): Serializer<T> {
|
|
8
8
|
if (Array.isArray(defaultValue)) {
|
|
9
|
-
if (arrayFormat ===
|
|
9
|
+
if (arrayFormat === 'repeat') {
|
|
10
10
|
return {
|
|
11
|
-
serialize: (v: T) => (v as string[]).join(
|
|
12
|
-
deserialize: (raw: string) => (raw ===
|
|
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 ===
|
|
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
|
|
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
|
|
31
|
+
case 'boolean':
|
|
32
32
|
return {
|
|
33
33
|
serialize: (v: T) => String(v),
|
|
34
|
-
deserialize: (raw: string) => (raw ===
|
|
34
|
+
deserialize: (raw: string) => (raw === 'true') as T,
|
|
35
35
|
}
|
|
36
|
-
case
|
|
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
|
|
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
|
|
2
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from
|
|
3
|
-
import { setUrlRouter, useUrlState } from
|
|
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,
|
|
12
|
+
history.replaceState(null, '', url.toString())
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
describe(
|
|
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(
|
|
29
|
-
it(
|
|
30
|
-
const page = useUrlState(
|
|
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(
|
|
35
|
-
setSearch(
|
|
36
|
-
const page = useUrlState(
|
|
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(
|
|
41
|
-
const page = useUrlState(
|
|
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(
|
|
44
|
+
expect(new URLSearchParams(window.location.search).get('page')).toBe('3')
|
|
45
45
|
})
|
|
46
46
|
|
|
47
|
-
it(
|
|
48
|
-
setSearch(
|
|
49
|
-
const page = useUrlState(
|
|
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(
|
|
55
|
+
expect(new URLSearchParams(window.location.search).has('page')).toBe(false)
|
|
56
56
|
})
|
|
57
57
|
|
|
58
|
-
it(
|
|
59
|
-
const page = useUrlState(
|
|
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(
|
|
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(
|
|
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(
|
|
70
|
-
it(
|
|
71
|
-
setSearch(
|
|
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(
|
|
75
|
+
expect(state.q()).toBe('hello')
|
|
76
76
|
})
|
|
77
77
|
|
|
78
|
-
it(
|
|
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(
|
|
85
|
-
const state = useUrlState({ page: 1, q:
|
|
86
|
-
state.q.set(
|
|
87
|
-
expect(state.q()).toBe(
|
|
88
|
-
expect(new URLSearchParams(window.location.search).get(
|
|
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(
|
|
92
|
-
setSearch(
|
|
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(
|
|
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(
|
|
104
|
-
it(
|
|
105
|
-
setSearch(
|
|
106
|
-
const count = useUrlState(
|
|
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(
|
|
108
|
+
expect(typeof count()).toBe('number')
|
|
109
109
|
})
|
|
110
110
|
|
|
111
|
-
it(
|
|
112
|
-
setSearch(
|
|
113
|
-
const active = useUrlState(
|
|
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(
|
|
115
|
+
expect(typeof active()).toBe('boolean')
|
|
116
116
|
})
|
|
117
117
|
|
|
118
|
-
it(
|
|
119
|
-
setSearch(
|
|
120
|
-
const active = useUrlState(
|
|
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(
|
|
125
|
-
setSearch(
|
|
126
|
-
const name = useUrlState(
|
|
127
|
-
expect(name()).toBe(
|
|
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(
|
|
131
|
-
setSearch(
|
|
132
|
-
const tags = useUrlState(
|
|
133
|
-
expect(tags()).toEqual([
|
|
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(
|
|
137
|
-
setSearch(
|
|
138
|
-
const tags = useUrlState(
|
|
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(
|
|
143
|
-
const tags = useUrlState(
|
|
144
|
-
tags.set([
|
|
145
|
-
expect(new URLSearchParams(window.location.search).get(
|
|
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(
|
|
148
|
+
it('handles object via JSON', () => {
|
|
149
149
|
setSearch(`?filter=${encodeURIComponent(JSON.stringify({ min: 1, max: 10 }))}`)
|
|
150
|
-
const filter = useUrlState(
|
|
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(
|
|
158
|
-
it(
|
|
159
|
-
setSearch(
|
|
160
|
-
const date = useUrlState(
|
|
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(
|
|
167
|
-
expect(new URLSearchParams(window.location.search).get(
|
|
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(
|
|
174
|
-
it(
|
|
175
|
-
const spy = vi.spyOn(history,
|
|
176
|
-
const page = useUrlState(
|
|
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(
|
|
183
|
-
const spy = vi.spyOn(history,
|
|
184
|
-
const page = useUrlState(
|
|
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(
|
|
194
|
-
it(
|
|
195
|
-
const page = useUrlState(
|
|
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(
|
|
201
|
-
window.dispatchEvent(new Event(
|
|
200
|
+
setSearch('?page=3')
|
|
201
|
+
window.dispatchEvent(new Event('popstate'))
|
|
202
202
|
expect(page()).toBe(3)
|
|
203
203
|
})
|
|
204
204
|
|
|
205
|
-
it(
|
|
206
|
-
setSearch(
|
|
207
|
-
const page = useUrlState(
|
|
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(
|
|
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(
|
|
219
|
-
it(
|
|
218
|
+
describe('debounce', () => {
|
|
219
|
+
it('batches rapid writes', async () => {
|
|
220
220
|
vi.useFakeTimers()
|
|
221
|
-
const page = useUrlState(
|
|
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(
|
|
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(
|
|
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(
|
|
244
|
-
it(
|
|
245
|
-
const page = useUrlState(
|
|
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(
|
|
263
|
-
it(
|
|
264
|
-
const page = useUrlState(
|
|
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(
|
|
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(
|
|
271
|
+
expect(new URLSearchParams(window.location.search).has('page')).toBe(false)
|
|
272
272
|
})
|
|
273
273
|
|
|
274
|
-
it(
|
|
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(
|
|
277
|
-
const page = useUrlState(
|
|
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(
|
|
282
|
+
expect(new URLSearchParams(window.location.search).has('page')).toBe(false)
|
|
283
283
|
})
|
|
284
284
|
|
|
285
|
-
it(
|
|
285
|
+
it('cancels pending debounced write', () => {
|
|
286
286
|
vi.useFakeTimers()
|
|
287
|
-
const page = useUrlState(
|
|
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(
|
|
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(
|
|
299
|
+
expect(new URLSearchParams(window.location.search).has('page')).toBe(false)
|
|
300
300
|
|
|
301
301
|
vi.useRealTimers()
|
|
302
302
|
})
|
|
303
303
|
|
|
304
|
-
it(
|
|
305
|
-
const tags = useUrlState(
|
|
306
|
-
tags.set([
|
|
307
|
-
expect(new URLSearchParams(window.location.search).get(
|
|
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(
|
|
311
|
+
expect(new URLSearchParams(window.location.search).has('tags')).toBe(false)
|
|
312
312
|
})
|
|
313
313
|
|
|
314
|
-
it(
|
|
315
|
-
const tags = useUrlState(
|
|
316
|
-
tags.set([
|
|
317
|
-
expect(new URLSearchParams(window.location.search).getAll(
|
|
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(
|
|
321
|
+
expect(new URLSearchParams(window.location.search).has('tags')).toBe(false)
|
|
322
322
|
})
|
|
323
323
|
|
|
324
|
-
it(
|
|
325
|
-
setSearch(
|
|
326
|
-
const page = useUrlState(
|
|
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(
|
|
329
|
-
expect(new URLSearchParams(window.location.search).get(
|
|
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(
|
|
336
|
-
describe(
|
|
337
|
-
it(
|
|
338
|
-
setSearch(
|
|
339
|
-
const tags = useUrlState(
|
|
340
|
-
expect(tags()).toEqual([
|
|
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(
|
|
344
|
-
const tags = useUrlState(
|
|
345
|
-
tags.set([
|
|
346
|
-
expect(new URLSearchParams(window.location.search).get(
|
|
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(
|
|
350
|
-
setSearch(
|
|
351
|
-
const tags = useUrlState(
|
|
352
|
-
expect(tags()).toEqual([
|
|
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(
|
|
357
|
-
it(
|
|
358
|
-
setSearch(
|
|
359
|
-
const tags = useUrlState(
|
|
360
|
-
expect(tags()).toEqual([
|
|
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(
|
|
364
|
-
const tags = useUrlState(
|
|
365
|
-
tags.set([
|
|
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(
|
|
367
|
+
expect(params.getAll('tags')).toEqual(['x', 'y'])
|
|
368
368
|
})
|
|
369
369
|
|
|
370
|
-
it(
|
|
371
|
-
const tags = useUrlState(
|
|
372
|
-
expect(tags()).toEqual([
|
|
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(
|
|
376
|
-
const tags = useUrlState(
|
|
377
|
-
tags.set([
|
|
378
|
-
expect(new URLSearchParams(window.location.search).getAll(
|
|
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(
|
|
381
|
+
expect(new URLSearchParams(window.location.search).has('tags')).toBe(false)
|
|
382
382
|
})
|
|
383
383
|
|
|
384
|
-
it(
|
|
385
|
-
const tags = useUrlState(
|
|
386
|
-
tags.set([
|
|
387
|
-
expect(tags()).toEqual([
|
|
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(
|
|
390
|
-
window.dispatchEvent(new Event(
|
|
391
|
-
expect(tags()).toEqual([
|
|
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(
|
|
395
|
-
setSearch(
|
|
396
|
-
const tags = useUrlState(
|
|
397
|
-
expect(tags()).toEqual([
|
|
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(
|
|
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(
|
|
409
|
-
it(
|
|
408
|
+
describe('onChange', () => {
|
|
409
|
+
it('does not fire on .set() (only external changes)', () => {
|
|
410
410
|
const changes: number[] = []
|
|
411
|
-
const page = useUrlState(
|
|
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(
|
|
421
|
+
it('fires on popstate (external change)', () => {
|
|
422
422
|
const changes: number[] = []
|
|
423
|
-
useUrlState(
|
|
423
|
+
useUrlState('page', 1, {
|
|
424
424
|
onChange: (v) => changes.push(v),
|
|
425
425
|
})
|
|
426
426
|
|
|
427
|
-
setSearch(
|
|
428
|
-
window.dispatchEvent(new Event(
|
|
427
|
+
setSearch('?page=7')
|
|
428
|
+
window.dispatchEvent(new Event('popstate'))
|
|
429
429
|
expect(changes).toEqual([7])
|
|
430
430
|
})
|
|
431
431
|
|
|
432
|
-
it(
|
|
433
|
-
setSearch(
|
|
432
|
+
it('fires with default value on popstate when param removed', () => {
|
|
433
|
+
setSearch('?page=5')
|
|
434
434
|
const changes: number[] = []
|
|
435
|
-
useUrlState(
|
|
435
|
+
useUrlState('page', 1, {
|
|
436
436
|
onChange: (v) => changes.push(v),
|
|
437
437
|
})
|
|
438
438
|
|
|
439
|
-
setSearch(
|
|
440
|
-
window.dispatchEvent(new Event(
|
|
439
|
+
setSearch('')
|
|
440
|
+
window.dispatchEvent(new Event('popstate'))
|
|
441
441
|
expect(changes).toEqual([1])
|
|
442
442
|
})
|
|
443
443
|
|
|
444
|
-
it(
|
|
444
|
+
it('does not fire on .reset()', () => {
|
|
445
445
|
const changes: number[] = []
|
|
446
|
-
const page = useUrlState(
|
|
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(
|
|
455
|
+
it('does not fire on .remove()', () => {
|
|
456
456
|
const changes: number[] = []
|
|
457
|
-
const page = useUrlState(
|
|
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(
|
|
466
|
+
it('works with array values and popstate', () => {
|
|
467
467
|
const changes: string[][] = []
|
|
468
|
-
useUrlState(
|
|
469
|
-
arrayFormat:
|
|
468
|
+
useUrlState('tags', [] as string[], {
|
|
469
|
+
arrayFormat: 'repeat',
|
|
470
470
|
onChange: (v) => changes.push(v as string[]),
|
|
471
471
|
})
|
|
472
472
|
|
|
473
|
-
setSearch(
|
|
474
|
-
window.dispatchEvent(new Event(
|
|
475
|
-
expect(changes).toEqual([[
|
|
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(
|
|
482
|
-
it(
|
|
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,
|
|
488
|
+
history.replaceState(null, '', path)
|
|
489
489
|
},
|
|
490
490
|
})
|
|
491
491
|
|
|
492
|
-
const page = useUrlState(
|
|
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(
|
|
496
|
+
expect(replaceCalls[0]).toContain('page=3')
|
|
497
497
|
})
|
|
498
498
|
|
|
499
|
-
it(
|
|
500
|
-
const spy = vi.spyOn(history,
|
|
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(
|
|
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(
|
|
521
|
-
const spy = vi.spyOn(history,
|
|
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(
|
|
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(
|
|
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,
|
|
536
|
+
history.replaceState(null, '', path)
|
|
537
537
|
},
|
|
538
538
|
})
|
|
539
539
|
|
|
540
|
-
const tags = useUrlState(
|
|
541
|
-
tags.set([
|
|
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(
|
|
544
|
+
expect(replaceCalls[0]).toContain('tags=a&tags=b')
|
|
545
545
|
})
|
|
546
546
|
|
|
547
|
-
it(
|
|
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,
|
|
552
|
+
history.replaceState(null, '', path)
|
|
553
553
|
},
|
|
554
554
|
})
|
|
555
555
|
|
|
556
|
-
const page = useUrlState(
|
|
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(
|
|
563
|
+
expect(replaceCalls[0]).not.toContain('page=')
|
|
564
564
|
})
|
|
565
565
|
})
|
|
566
566
|
|
|
567
|
-
describe(
|
|
568
|
-
it(
|
|
569
|
-
setSearch(
|
|
570
|
-
const count = useUrlState(
|
|
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(
|
|
575
|
-
setSearch(
|
|
576
|
-
const count = useUrlState(
|
|
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(
|
|
582
|
-
setSearch(
|
|
583
|
-
const count = useUrlState(
|
|
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
|
-
|
|
|
16
|
+
| 'comma'
|
|
17
17
|
/** Repeated keys: `?tags=a&tags=b` */
|
|
18
|
-
|
|
|
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 !==
|
|
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,
|
|
62
|
+
history.replaceState(null, '', url)
|
|
63
63
|
} else {
|
|
64
|
-
history.pushState(null,
|
|
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,
|
|
93
|
+
history.replaceState(null, '', url)
|
|
94
94
|
} else {
|
|
95
|
-
history.pushState(null,
|
|
95
|
+
history.pushState(null, '', url)
|
|
96
96
|
}
|
|
97
97
|
}
|
|
98
98
|
|
package/src/use-url-state.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { effect, onCleanup, signal } from
|
|
2
|
-
import { inferSerializer } from
|
|
3
|
-
import type { Serializer, UrlStateOptions, UrlStateSignal } from
|
|
4
|
-
import { _isBrowser, getParam, getParamAll, setParamRepeated, setParams } from
|
|
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 ===
|
|
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 ??
|
|
79
|
+
const arrayFormat = options?.arrayFormat ?? 'comma'
|
|
80
80
|
const isArray = Array.isArray(defaultValue)
|
|
81
|
-
const isRepeat = isArray && arrayFormat ===
|
|
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(
|
|
165
|
+
window.addEventListener('popstate', onPopState)
|
|
166
166
|
onCleanup(() => {
|
|
167
|
-
window.removeEventListener(
|
|
167
|
+
window.removeEventListener('popstate', onPopState)
|
|
168
168
|
if (timer !== undefined) clearTimeout(timer)
|
|
169
169
|
})
|
|
170
170
|
})
|