@pyreon/url-state 0.11.2 → 0.11.4

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.
@@ -5386,7 +5386,7 @@ var drawChart = (function (exports) {
5386
5386
  </script>
5387
5387
  <script>
5388
5388
  /*<!--*/
5389
- const data = {"version":2,"tree":{"name":"root","children":[{"name":"index.js","children":[{"name":"src","children":[{"uid":"cb475d5c-1","name":"url.ts"},{"uid":"cb475d5c-3","name":"serializers.ts"},{"uid":"cb475d5c-5","name":"use-url-state.ts"},{"uid":"cb475d5c-7","name":"index.ts"}]}]}],"isRoot":true},"nodeParts":{"cb475d5c-1":{"renderedLength":1966,"gzipLength":699,"brotliLength":0,"metaUid":"cb475d5c-0"},"cb475d5c-3":{"renderedLength":999,"gzipLength":366,"brotliLength":0,"metaUid":"cb475d5c-2"},"cb475d5c-5":{"renderedLength":2955,"gzipLength":972,"brotliLength":0,"metaUid":"cb475d5c-4"},"cb475d5c-7":{"renderedLength":0,"gzipLength":0,"brotliLength":0,"metaUid":"cb475d5c-6"}},"nodeMetas":{"cb475d5c-0":{"id":"/src/url.ts","moduleParts":{"index.js":"cb475d5c-1"},"imported":[],"importedBy":[{"uid":"cb475d5c-6"},{"uid":"cb475d5c-4"}]},"cb475d5c-2":{"id":"/src/serializers.ts","moduleParts":{"index.js":"cb475d5c-3"},"imported":[],"importedBy":[{"uid":"cb475d5c-4"}]},"cb475d5c-4":{"id":"/src/use-url-state.ts","moduleParts":{"index.js":"cb475d5c-5"},"imported":[{"uid":"cb475d5c-8"},{"uid":"cb475d5c-2"},{"uid":"cb475d5c-0"}],"importedBy":[{"uid":"cb475d5c-6"}]},"cb475d5c-6":{"id":"/src/index.ts","moduleParts":{"index.js":"cb475d5c-7"},"imported":[{"uid":"cb475d5c-0"},{"uid":"cb475d5c-4"}],"importedBy":[],"isEntry":true},"cb475d5c-8":{"id":"@pyreon/reactivity","moduleParts":{},"imported":[],"importedBy":[{"uid":"cb475d5c-4"}]}},"env":{"rollup":"4.23.0"},"options":{"gzip":true,"brotli":false,"sourcemap":false}};
5389
+ const data = {"version":2,"tree":{"name":"root","children":[{"name":"index.js","children":[{"name":"src","children":[{"uid":"e948e7cf-1","name":"url.ts"},{"uid":"e948e7cf-3","name":"serializers.ts"},{"uid":"e948e7cf-5","name":"use-url-state.ts"},{"uid":"e948e7cf-7","name":"index.ts"}]}]}],"isRoot":true},"nodeParts":{"e948e7cf-1":{"renderedLength":1966,"gzipLength":699,"brotliLength":0,"metaUid":"e948e7cf-0"},"e948e7cf-3":{"renderedLength":1068,"gzipLength":397,"brotliLength":0,"metaUid":"e948e7cf-2"},"e948e7cf-5":{"renderedLength":2955,"gzipLength":972,"brotliLength":0,"metaUid":"e948e7cf-4"},"e948e7cf-7":{"renderedLength":0,"gzipLength":0,"brotliLength":0,"metaUid":"e948e7cf-6"}},"nodeMetas":{"e948e7cf-0":{"id":"/src/url.ts","moduleParts":{"index.js":"e948e7cf-1"},"imported":[],"importedBy":[{"uid":"e948e7cf-6"},{"uid":"e948e7cf-4"}]},"e948e7cf-2":{"id":"/src/serializers.ts","moduleParts":{"index.js":"e948e7cf-3"},"imported":[],"importedBy":[{"uid":"e948e7cf-4"}]},"e948e7cf-4":{"id":"/src/use-url-state.ts","moduleParts":{"index.js":"e948e7cf-5"},"imported":[{"uid":"e948e7cf-8"},{"uid":"e948e7cf-2"},{"uid":"e948e7cf-0"}],"importedBy":[{"uid":"e948e7cf-6"}]},"e948e7cf-6":{"id":"/src/index.ts","moduleParts":{"index.js":"e948e7cf-7"},"imported":[{"uid":"e948e7cf-0"},{"uid":"e948e7cf-4"}],"importedBy":[],"isEntry":true},"e948e7cf-8":{"id":"@pyreon/reactivity","moduleParts":{},"imported":[],"importedBy":[{"uid":"e948e7cf-4"}]}},"env":{"rollup":"4.23.0"},"options":{"gzip":true,"brotli":false,"sourcemap":false}};
5390
5390
 
5391
5391
  const run = () => {
5392
5392
  const width = window.innerWidth;
package/lib/index.js CHANGED
@@ -72,7 +72,10 @@ function inferSerializer(defaultValue, arrayFormat = "comma") {
72
72
  switch (typeof defaultValue) {
73
73
  case "number": return {
74
74
  serialize: (v) => String(v),
75
- deserialize: (raw) => Number(raw)
75
+ deserialize: (raw) => {
76
+ const n = Number(raw);
77
+ return Number.isNaN(n) ? defaultValue : n;
78
+ }
76
79
  };
77
80
  case "boolean": return {
78
81
  serialize: (v) => String(v),
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) => Number(raw) as T,\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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyreon/url-state",
3
- "version": "0.11.2",
3
+ "version": "0.11.4",
4
4
  "description": "Reactive URL search-param state for Pyreon — signal-backed, type-coerced, SSR-safe",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -41,11 +41,11 @@
41
41
  "lint": "biome check ."
42
42
  },
43
43
  "peerDependencies": {
44
- "@pyreon/reactivity": "^0.11.2"
44
+ "@pyreon/reactivity": "^0.11.4"
45
45
  },
46
46
  "devDependencies": {
47
47
  "@happy-dom/global-registrator": "^20.8.3",
48
- "@pyreon/reactivity": "^0.11.2",
48
+ "@pyreon/reactivity": "^0.11.4",
49
49
  "@vitus-labs/tools-lint": "^1.11.0"
50
50
  }
51
51
  }
@@ -23,7 +23,10 @@ export function inferSerializer<T>(
23
23
  case "number":
24
24
  return {
25
25
  serialize: (v: T) => String(v),
26
- deserialize: (raw: string) => Number(raw) as T,
26
+ deserialize: (raw: string) => {
27
+ const n = Number(raw)
28
+ return (Number.isNaN(n) ? defaultValue : n) as T
29
+ },
27
30
  }
28
31
  case "boolean":
29
32
  return {
@@ -563,4 +563,25 @@ describe("useUrlState", () => {
563
563
  expect(replaceCalls[0]).not.toContain("page=")
564
564
  })
565
565
  })
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)
571
+ expect(count()).toBe(0) // not NaN
572
+ })
573
+
574
+ it("empty string coerces to 0 for number param", () => {
575
+ setSearch("?count=")
576
+ const count = useUrlState("count", 42)
577
+ // Number("") is 0, not NaN — this is valid coercion
578
+ expect(count()).toBe(0)
579
+ })
580
+
581
+ it("parses valid numbers normally", () => {
582
+ setSearch("?count=7")
583
+ const count = useUrlState("count", 0)
584
+ expect(count()).toBe(7)
585
+ })
586
+ })
566
587
  })