@pyreon/url-state 0.11.0

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.
@@ -0,0 +1,566 @@
1
+ import { effect } from "@pyreon/reactivity"
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
3
+ import { setUrlRouter, useUrlState } from "../index"
4
+
5
+ /**
6
+ * Helper: set window.location.search to a given query string.
7
+ * happy-dom allows direct assignment to window.location properties.
8
+ */
9
+ function setSearch(search: string) {
10
+ const url = new URL(window.location.href)
11
+ url.search = search
12
+ history.replaceState(null, "", url.toString())
13
+ }
14
+
15
+ describe("useUrlState", () => {
16
+ beforeEach(() => {
17
+ setSearch("")
18
+ setUrlRouter(null)
19
+ })
20
+
21
+ afterEach(() => {
22
+ setSearch("")
23
+ setUrlRouter(null)
24
+ })
25
+
26
+ // ── Single param ────────────────────────────────────────────────────────
27
+
28
+ describe("single param mode", () => {
29
+ it("returns default value when param is not in URL", () => {
30
+ const page = useUrlState("page", 1)
31
+ expect(page()).toBe(1)
32
+ })
33
+
34
+ it("reads initial value from URL", () => {
35
+ setSearch("?page=5")
36
+ const page = useUrlState("page", 1)
37
+ expect(page()).toBe(5)
38
+ })
39
+
40
+ it(".set() updates signal and URL", () => {
41
+ const page = useUrlState("page", 1)
42
+ page.set(3)
43
+ expect(page()).toBe(3)
44
+ expect(new URLSearchParams(window.location.search).get("page")).toBe("3")
45
+ })
46
+
47
+ it(".reset() returns to default and cleans URL", () => {
48
+ setSearch("?page=5")
49
+ const page = useUrlState("page", 1)
50
+ expect(page()).toBe(5)
51
+
52
+ page.reset()
53
+ expect(page()).toBe(1)
54
+ // Default value removes the param from URL
55
+ expect(new URLSearchParams(window.location.search).has("page")).toBe(false)
56
+ })
57
+
58
+ it("removes param from URL when value equals default", () => {
59
+ const page = useUrlState("page", 1)
60
+ page.set(5)
61
+ expect(new URLSearchParams(window.location.search).get("page")).toBe("5")
62
+ page.set(1) // back to default
63
+ expect(new URLSearchParams(window.location.search).has("page")).toBe(false)
64
+ })
65
+ })
66
+
67
+ // ── Schema mode ─────────────────────────────────────────────────────────
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: "" })
73
+
74
+ expect(state.page()).toBe(3)
75
+ expect(state.q()).toBe("hello")
76
+ })
77
+
78
+ it("defaults when params are missing", () => {
79
+ const state = useUrlState({ page: 1, q: "" })
80
+ expect(state.page()).toBe(1)
81
+ expect(state.q()).toBe("")
82
+ })
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")
89
+ })
90
+
91
+ it("reset individual param", () => {
92
+ setSearch("?page=5&q=hello")
93
+ const state = useUrlState({ page: 1, q: "" })
94
+ state.page.reset()
95
+ expect(state.page()).toBe(1)
96
+ // q should remain
97
+ expect(new URLSearchParams(window.location.search).get("q")).toBe("hello")
98
+ })
99
+ })
100
+
101
+ // ── Type coercion ─────────────────────────────────────────────────────
102
+
103
+ describe("type coercion", () => {
104
+ it("coerces number from URL string", () => {
105
+ setSearch("?count=42")
106
+ const count = useUrlState("count", 0)
107
+ expect(count()).toBe(42)
108
+ expect(typeof count()).toBe("number")
109
+ })
110
+
111
+ it("coerces boolean from URL string", () => {
112
+ setSearch("?active=true")
113
+ const active = useUrlState("active", false)
114
+ expect(active()).toBe(true)
115
+ expect(typeof active()).toBe("boolean")
116
+ })
117
+
118
+ it("coerces boolean false from URL string", () => {
119
+ setSearch("?active=false")
120
+ const active = useUrlState("active", true)
121
+ expect(active()).toBe(false)
122
+ })
123
+
124
+ it("handles string identity", () => {
125
+ setSearch("?name=alice")
126
+ const name = useUrlState("name", "")
127
+ expect(name()).toBe("alice")
128
+ })
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"])
134
+ })
135
+
136
+ it("handles empty string[] from URL", () => {
137
+ setSearch("?tags=")
138
+ const tags = useUrlState("tags", [] as string[])
139
+ expect(tags()).toEqual([])
140
+ })
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")
146
+ })
147
+
148
+ it("handles object via JSON", () => {
149
+ setSearch(`?filter=${encodeURIComponent(JSON.stringify({ min: 1, max: 10 }))}`)
150
+ const filter = useUrlState("filter", { min: 0, max: 100 })
151
+ expect(filter()).toEqual({ min: 1, max: 10 })
152
+ })
153
+ })
154
+
155
+ // ── Custom serializer ──────────────────────────────────────────────────
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), {
161
+ serialize: (d) => d.toISOString().slice(0, 10),
162
+ deserialize: (s) => new Date(s),
163
+ })
164
+ expect(date().getFullYear()).toBe(2024)
165
+
166
+ date.set(new Date("2025-06-01"))
167
+ expect(new URLSearchParams(window.location.search).get("date")).toBe("2025-06-01")
168
+ })
169
+ })
170
+
171
+ // ── replace vs push ────────────────────────────────────────────────────
172
+
173
+ describe("history mode", () => {
174
+ it("uses replaceState by default", () => {
175
+ const spy = vi.spyOn(history, "replaceState")
176
+ const page = useUrlState("page", 1)
177
+ page.set(2)
178
+ expect(spy).toHaveBeenCalled()
179
+ spy.mockRestore()
180
+ })
181
+
182
+ it("uses pushState when replace: false", () => {
183
+ const spy = vi.spyOn(history, "pushState")
184
+ const page = useUrlState("page", 1, { replace: false })
185
+ page.set(2)
186
+ expect(spy).toHaveBeenCalled()
187
+ spy.mockRestore()
188
+ })
189
+ })
190
+
191
+ // ── Popstate sync ─────────────────────────────────────────────────────
192
+
193
+ describe("popstate sync", () => {
194
+ it("updates signal on popstate event", () => {
195
+ const page = useUrlState("page", 1)
196
+ page.set(5)
197
+ expect(page()).toBe(5)
198
+
199
+ // Simulate browser back: change URL then fire popstate
200
+ setSearch("?page=3")
201
+ window.dispatchEvent(new Event("popstate"))
202
+ expect(page()).toBe(3)
203
+ })
204
+
205
+ it("resets to default on popstate when param removed", () => {
206
+ setSearch("?page=5")
207
+ const page = useUrlState("page", 1)
208
+ expect(page()).toBe(5)
209
+
210
+ setSearch("")
211
+ window.dispatchEvent(new Event("popstate"))
212
+ expect(page()).toBe(1)
213
+ })
214
+ })
215
+
216
+ // ── Debounce ──────────────────────────────────────────────────────────
217
+
218
+ describe("debounce", () => {
219
+ it("batches rapid writes", async () => {
220
+ vi.useFakeTimers()
221
+ const page = useUrlState("page", 1, { debounce: 50 })
222
+
223
+ page.set(2)
224
+ page.set(3)
225
+ page.set(4)
226
+
227
+ // Signal updates immediately
228
+ expect(page()).toBe(4)
229
+ // URL not yet updated (debounced)
230
+ expect(new URLSearchParams(window.location.search).has("page")).toBe(false)
231
+
232
+ vi.advanceTimersByTime(50)
233
+
234
+ // Now URL is updated with final value
235
+ expect(new URLSearchParams(window.location.search).get("page")).toBe("4")
236
+
237
+ vi.useRealTimers()
238
+ })
239
+ })
240
+
241
+ // ── Reactivity ────────────────────────────────────────────────────────
242
+
243
+ describe("reactivity", () => {
244
+ it("signal is reactive in effects", () => {
245
+ const page = useUrlState("page", 1)
246
+ const values: number[] = []
247
+
248
+ const fx = effect(() => {
249
+ values.push(page())
250
+ })
251
+
252
+ page.set(2)
253
+ page.set(3)
254
+
255
+ expect(values).toEqual([1, 2, 3])
256
+ fx.dispose()
257
+ })
258
+ })
259
+
260
+ // ── remove() ──────────────────────────────────────────────────────────
261
+
262
+ describe("remove()", () => {
263
+ it("removes param from URL and resets signal to default", () => {
264
+ const page = useUrlState("page", 1)
265
+ page.set(5)
266
+ expect(page()).toBe(5)
267
+ expect(new URLSearchParams(window.location.search).get("page")).toBe("5")
268
+
269
+ page.remove()
270
+ expect(page()).toBe(1)
271
+ expect(new URLSearchParams(window.location.search).has("page")).toBe(false)
272
+ })
273
+
274
+ it("removes param even when value equals default", () => {
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)
278
+ // Value is 1 (default), but param is in URL
279
+ // remove() should guarantee it's gone
280
+ page.remove()
281
+ expect(page()).toBe(1)
282
+ expect(new URLSearchParams(window.location.search).has("page")).toBe(false)
283
+ })
284
+
285
+ it("cancels pending debounced write", () => {
286
+ vi.useFakeTimers()
287
+ const page = useUrlState("page", 1, { debounce: 100 })
288
+
289
+ page.set(5) // starts debounce timer
290
+ page.remove() // should cancel the debounce and remove immediately
291
+
292
+ // Signal is default
293
+ expect(page()).toBe(1)
294
+ // URL should not have the param (remove is immediate, not debounced)
295
+ expect(new URLSearchParams(window.location.search).has("page")).toBe(false)
296
+
297
+ vi.advanceTimersByTime(100)
298
+ // Still removed — the debounced write should not have fired
299
+ expect(new URLSearchParams(window.location.search).has("page")).toBe(false)
300
+
301
+ vi.useRealTimers()
302
+ })
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")
308
+
309
+ tags.remove()
310
+ expect(tags()).toEqual([])
311
+ expect(new URLSearchParams(window.location.search).has("tags")).toBe(false)
312
+ })
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"])
318
+
319
+ tags.remove()
320
+ expect(tags()).toEqual([])
321
+ expect(new URLSearchParams(window.location.search).has("tags")).toBe(false)
322
+ })
323
+
324
+ it("preserves other params when removing", () => {
325
+ setSearch("?page=3&q=hello")
326
+ const page = useUrlState("page", 1)
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")
330
+ })
331
+ })
332
+
333
+ // ── Array format ──────────────────────────────────────────────────────
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"])
341
+ })
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")
347
+ })
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"])
353
+ })
354
+ })
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"])
361
+ })
362
+
363
+ it("writes repeated keys to URL", () => {
364
+ const tags = useUrlState("tags", [] as string[], { arrayFormat: "repeat" })
365
+ tags.set(["x", "y"])
366
+ const params = new URLSearchParams(window.location.search)
367
+ expect(params.getAll("tags")).toEqual(["x", "y"])
368
+ })
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"])
373
+ })
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"])
379
+
380
+ tags.set([]) // back to default
381
+ expect(new URLSearchParams(window.location.search).has("tags")).toBe(false)
382
+ })
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"])
388
+
389
+ setSearch("?tags=x&tags=y&tags=z")
390
+ window.dispatchEvent(new Event("popstate"))
391
+ expect(tags()).toEqual(["x", "y", "z"])
392
+ })
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"])
398
+
399
+ setSearch("")
400
+ window.dispatchEvent(new Event("popstate"))
401
+ expect(tags()).toEqual([])
402
+ })
403
+ })
404
+ })
405
+
406
+ // ── onChange callback ────────────────────────────────────────────────
407
+
408
+ describe("onChange", () => {
409
+ it("does not fire on .set() (only external changes)", () => {
410
+ const changes: number[] = []
411
+ const page = useUrlState("page", 1, {
412
+ onChange: (v) => changes.push(v),
413
+ })
414
+
415
+ page.set(2)
416
+ page.set(3)
417
+ // .set() is an explicit call — onChange only fires on external changes
418
+ expect(changes).toEqual([])
419
+ })
420
+
421
+ it("fires on popstate (external change)", () => {
422
+ const changes: number[] = []
423
+ useUrlState("page", 1, {
424
+ onChange: (v) => changes.push(v),
425
+ })
426
+
427
+ setSearch("?page=7")
428
+ window.dispatchEvent(new Event("popstate"))
429
+ expect(changes).toEqual([7])
430
+ })
431
+
432
+ it("fires with default value on popstate when param removed", () => {
433
+ setSearch("?page=5")
434
+ const changes: number[] = []
435
+ useUrlState("page", 1, {
436
+ onChange: (v) => changes.push(v),
437
+ })
438
+
439
+ setSearch("")
440
+ window.dispatchEvent(new Event("popstate"))
441
+ expect(changes).toEqual([1])
442
+ })
443
+
444
+ it("does not fire on .reset()", () => {
445
+ const changes: number[] = []
446
+ const page = useUrlState("page", 1, {
447
+ onChange: (v) => changes.push(v),
448
+ })
449
+ page.set(5)
450
+
451
+ page.reset()
452
+ expect(changes).toEqual([])
453
+ })
454
+
455
+ it("does not fire on .remove()", () => {
456
+ const changes: number[] = []
457
+ const page = useUrlState("page", 1, {
458
+ onChange: (v) => changes.push(v),
459
+ })
460
+ page.set(5)
461
+
462
+ page.remove()
463
+ expect(changes).toEqual([])
464
+ })
465
+
466
+ it("works with array values and popstate", () => {
467
+ const changes: string[][] = []
468
+ useUrlState("tags", [] as string[], {
469
+ arrayFormat: "repeat",
470
+ onChange: (v) => changes.push(v as string[]),
471
+ })
472
+
473
+ setSearch("?tags=a&tags=b")
474
+ window.dispatchEvent(new Event("popstate"))
475
+ expect(changes).toEqual([["a", "b"]])
476
+ })
477
+ })
478
+
479
+ // ── Router integration ────────────────────────────────────────────────
480
+
481
+ describe("router integration", () => {
482
+ it("uses router.replace() when router is set", () => {
483
+ const replaceCalls: string[] = []
484
+ setUrlRouter({
485
+ replace: (path: string) => {
486
+ replaceCalls.push(path)
487
+ // Simulate what a real router does — update the URL
488
+ history.replaceState(null, "", path)
489
+ },
490
+ })
491
+
492
+ const page = useUrlState("page", 1)
493
+ page.set(3)
494
+
495
+ expect(replaceCalls.length).toBe(1)
496
+ expect(replaceCalls[0]).toContain("page=3")
497
+ })
498
+
499
+ it("does not call history.replaceState directly when router is set", () => {
500
+ const spy = vi.spyOn(history, "replaceState")
501
+
502
+ setUrlRouter({
503
+ replace: (_path: string) => {
504
+ // No-op — intentionally doesn't call history API
505
+ },
506
+ })
507
+
508
+ const page = useUrlState("page", 1)
509
+ page.set(3)
510
+
511
+ // replaceState should NOT have been called by setParams (only by setSearch in beforeEach)
512
+ // We need to account for the beforeEach call
513
+ const callCountBefore = spy.mock.calls.length
514
+ page.set(4)
515
+ expect(spy.mock.calls.length).toBe(callCountBefore) // no new calls
516
+
517
+ spy.mockRestore()
518
+ })
519
+
520
+ it("falls back to history API when no router is set", () => {
521
+ const spy = vi.spyOn(history, "replaceState")
522
+ setUrlRouter(null)
523
+
524
+ const page = useUrlState("page", 1)
525
+ page.set(2)
526
+
527
+ expect(spy).toHaveBeenCalled()
528
+ spy.mockRestore()
529
+ })
530
+
531
+ it("router.replace() receives correct URL for repeat arrays", () => {
532
+ const replaceCalls: string[] = []
533
+ setUrlRouter({
534
+ replace: (path: string) => {
535
+ replaceCalls.push(path)
536
+ history.replaceState(null, "", path)
537
+ },
538
+ })
539
+
540
+ const tags = useUrlState("tags", [] as string[], { arrayFormat: "repeat" })
541
+ tags.set(["a", "b"])
542
+
543
+ expect(replaceCalls.length).toBe(1)
544
+ expect(replaceCalls[0]).toContain("tags=a&tags=b")
545
+ })
546
+
547
+ it("router.replace() used for remove()", () => {
548
+ const replaceCalls: string[] = []
549
+ setUrlRouter({
550
+ replace: (path: string) => {
551
+ replaceCalls.push(path)
552
+ history.replaceState(null, "", path)
553
+ },
554
+ })
555
+
556
+ const page = useUrlState("page", 1)
557
+ page.set(5)
558
+ replaceCalls.length = 0
559
+
560
+ page.remove()
561
+ expect(replaceCalls.length).toBe(1)
562
+ // The param should not be in the URL
563
+ expect(replaceCalls[0]).not.toContain("page=")
564
+ })
565
+ })
566
+ })
package/src/types.ts ADDED
@@ -0,0 +1,54 @@
1
+ /** A signal-like accessor for a single URL search parameter. */
2
+ export interface UrlStateSignal<T> {
3
+ /** Read the current value reactively. */
4
+ (): T
5
+ /** Write a new value and update the URL. */
6
+ set(value: T): void
7
+ /** Reset to the default value and update the URL. */
8
+ reset(): void
9
+ /** Remove the parameter from the URL entirely and reset signal to default. */
10
+ remove(): void
11
+ }
12
+
13
+ /** Encoding strategy for array values in the URL. */
14
+ export type ArrayFormat =
15
+ /** Comma-separated: `?tags=a,b` */
16
+ | "comma"
17
+ /** Repeated keys: `?tags=a&tags=b` */
18
+ | "repeat"
19
+
20
+ /** Options for `useUrlState`. */
21
+ export interface UrlStateOptions<T = unknown> {
22
+ /** Custom serializer — converts value to a URL-safe string. */
23
+ serialize?: (value: T) => string
24
+ /** Custom deserializer — converts URL string back to a value. */
25
+ deserialize?: (raw: string) => T
26
+ /**
27
+ * Use `history.replaceState` (true) or `history.pushState` (false).
28
+ * @default true
29
+ */
30
+ replace?: boolean
31
+ /**
32
+ * Debounce URL writes by this many milliseconds.
33
+ * @default 0
34
+ */
35
+ debounce?: number
36
+ /**
37
+ * Encoding strategy for array values.
38
+ * - `"comma"` — comma-separated: `?tags=a,b` (default)
39
+ * - `"repeat"` — repeated keys: `?tags=a&tags=b`
40
+ * @default "comma"
41
+ */
42
+ arrayFormat?: ArrayFormat
43
+ /**
44
+ * Called when the URL param changes externally (popstate or another
45
+ * `useUrlState` call updating the same param).
46
+ */
47
+ onChange?: (value: T) => void
48
+ }
49
+
50
+ /** Serializer pair for a given type. */
51
+ export interface Serializer<T> {
52
+ serialize: (value: T) => string
53
+ deserialize: (raw: string) => T
54
+ }