@cfasim-ui/shared 0.4.11 → 0.4.13

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cfasim-ui/shared",
3
- "version": "0.4.11",
3
+ "version": "0.4.13",
4
4
  "type": "module",
5
5
  "description": "Shared utilities for cfasim-ui",
6
6
  "license": "Apache-2.0",
package/src/index.ts CHANGED
@@ -22,5 +22,10 @@ export {
22
22
  deserialize,
23
23
  paramsToQuery,
24
24
  queryToParams,
25
+ jsonCodec,
26
+ } from "./useUrlParams.js";
27
+ export type {
28
+ UrlParamsOptions,
29
+ ParamCodec,
30
+ ParamCodecs,
25
31
  } from "./useUrlParams.js";
26
- export type { UrlParamsOptions } from "./useUrlParams.js";
@@ -7,6 +7,7 @@ import {
7
7
  paramsToQuery,
8
8
  queryToParams,
9
9
  useUrlParams,
10
+ jsonCodec,
10
11
  type UrlParamsRouter,
11
12
  type UrlParamsRoute,
12
13
  } from "./useUrlParams.js";
@@ -92,6 +93,105 @@ describe("queryToParams", () => {
92
93
  });
93
94
  });
94
95
 
96
+ type InfectionRate =
97
+ | { type: "constant"; value: number; duration: number }
98
+ | { type: "empirical"; points: [number, number][] };
99
+
100
+ describe("paramsToQuery (with codecs)", () => {
101
+ it("treats a codec path as a single atomic URL key", () => {
102
+ const defaults: { rate: InfectionRate } = {
103
+ rate: { type: "constant", value: 0.5, duration: 3 },
104
+ };
105
+ const params: { rate: InfectionRate } = {
106
+ rate: {
107
+ type: "empirical",
108
+ points: [
109
+ [0, 0],
110
+ [2, 1],
111
+ ],
112
+ },
113
+ };
114
+ expect(paramsToQuery(params, defaults, { rate: jsonCodec })).toEqual({
115
+ rate: JSON.stringify(params.rate),
116
+ });
117
+ });
118
+
119
+ it("omits a codec value that stringifies equal to the default", () => {
120
+ const defaults = { rate: { type: "constant", value: 0.5 } };
121
+ const params = { rate: { type: "constant", value: 0.5 } };
122
+ expect(paramsToQuery(params, defaults, { rate: jsonCodec })).toEqual({});
123
+ });
124
+
125
+ it("emits codec keys alongside ordinary leaves", () => {
126
+ const defaults = {
127
+ population: 10_000,
128
+ rate: { type: "constant", value: 0.5 } as { type: string; value: number },
129
+ };
130
+ const params = {
131
+ population: 20_000,
132
+ rate: { type: "constant", value: 0.5 } as { type: string; value: number },
133
+ };
134
+ expect(paramsToQuery(params, defaults, { rate: jsonCodec })).toEqual({
135
+ population: "20000",
136
+ // rate matches → omitted
137
+ });
138
+ });
139
+ });
140
+
141
+ describe("queryToParams (with codecs)", () => {
142
+ it("decodes a codec value through its custom deserializer", () => {
143
+ const defaults: { rate: InfectionRate } = {
144
+ rate: { type: "constant", value: 0.5, duration: 3 },
145
+ };
146
+ const variant: InfectionRate = {
147
+ type: "empirical",
148
+ points: [
149
+ [0, 0],
150
+ [2, 1],
151
+ ],
152
+ };
153
+ expect(
154
+ queryToParams({ rate: JSON.stringify(variant) }, defaults, {
155
+ rate: jsonCodec,
156
+ }),
157
+ ).toEqual({ rate: variant });
158
+ });
159
+
160
+ it("drops URL keys nested inside a codec boundary", () => {
161
+ const defaults: { rate: InfectionRate } = {
162
+ rate: { type: "constant", value: 0.5, duration: 3 },
163
+ };
164
+ // Stale leaf keys from the constant variant should not punch through
165
+ // the codec value.
166
+ expect(
167
+ queryToParams({ "rate.value": "0.9", "rate.duration": "7" }, defaults, {
168
+ rate: jsonCodec,
169
+ }),
170
+ ).toEqual({});
171
+ });
172
+
173
+ it("swallows malformed codec payloads instead of throwing", () => {
174
+ const defaults = { rate: { type: "constant", value: 0.5 } };
175
+ expect(
176
+ queryToParams({ rate: "{not-valid-json" }, defaults, { rate: jsonCodec }),
177
+ ).toEqual({});
178
+ });
179
+
180
+ it("supports a custom codec (base-N encoded number)", () => {
181
+ const hexCodec = {
182
+ serialize: (v: unknown) => Number(v).toString(16),
183
+ deserialize: (raw: string) => Number.parseInt(raw, 16),
184
+ };
185
+ const defaults = { n: 0 };
186
+ expect(queryToParams({ n: "ff" }, defaults, { n: hexCodec })).toEqual({
187
+ n: 255,
188
+ });
189
+ expect(paramsToQuery({ n: 255 }, defaults, { n: hexCodec })).toEqual({
190
+ n: "ff",
191
+ });
192
+ });
193
+ });
194
+
95
195
  function makeRouterStub(initialQuery: Record<string, unknown> = {}) {
96
196
  const route = reactive<UrlParamsRoute>({ query: { ...initialQuery } });
97
197
  const replace = vi.fn(({ query }: { query: Record<string, string> }) => {
@@ -312,6 +412,247 @@ describe("useUrlParams (composable)", () => {
312
412
  });
313
413
  });
314
414
 
415
+ describe("nested params", () => {
416
+ it("hydrates a nested leaf from a dotted query key", async () => {
417
+ const { router, route } = makeRouterStub({ "nested.foo": "9" });
418
+ const params = reactive({ a: 1, nested: { foo: 5, bar: 10 } });
419
+ mountWith(() =>
420
+ useUrlParams(
421
+ params,
422
+ { a: 1, nested: { foo: 5, bar: 10 } },
423
+ { router, route, debounceMs: 0 },
424
+ ),
425
+ );
426
+ await nextTick();
427
+ expect(params.a).toBe(1);
428
+ expect(params.nested).toEqual({ foo: 9, bar: 10 });
429
+ });
430
+
431
+ it("writes only changed nested leaves with dotted keys", async () => {
432
+ const { router, route } = makeRouterStub();
433
+ const params = reactive({ a: 1, nested: { foo: 5, bar: 10 } });
434
+ mountWith(() =>
435
+ useUrlParams(
436
+ params,
437
+ { a: 1, nested: { foo: 5, bar: 10 } },
438
+ { router, route, debounceMs: 0 },
439
+ ),
440
+ );
441
+ await nextTick();
442
+ params.nested.foo = 7;
443
+ await nextTick();
444
+ await new Promise((r) => setTimeout(r, 5));
445
+ expect(route.query).toEqual({ "nested.foo": "7" });
446
+ });
447
+
448
+ it("round-trips a deeply nested structure", async () => {
449
+ const { router, route } = makeRouterStub({
450
+ "deep.a.b.c": "42",
451
+ "deep.flag": "true",
452
+ });
453
+ const defaults = { deep: { a: { b: { c: 0 } }, flag: false } };
454
+ const params = reactive(structuredClone(defaults));
455
+ mountWith(() =>
456
+ useUrlParams(params, defaults, { router, route, debounceMs: 0 }),
457
+ );
458
+ await nextTick();
459
+ expect(params.deep.a.b.c).toBe(42);
460
+ expect(params.deep.flag).toBe(true);
461
+ });
462
+
463
+ it("preserves sibling leaves when an override updates one branch", async () => {
464
+ const { router, route } = makeRouterStub({ "nested.foo": "99" });
465
+ const params = reactive({ nested: { foo: 1, bar: 2, baz: 3 } });
466
+ mountWith(() =>
467
+ useUrlParams(
468
+ params,
469
+ { nested: { foo: 1, bar: 2, baz: 3 } },
470
+ { router, route, debounceMs: 0 },
471
+ ),
472
+ );
473
+ await nextTick();
474
+ expect(params.nested).toEqual({ foo: 99, bar: 2, baz: 3 });
475
+ });
476
+
477
+ it("drops dotted keys that have no matching default leaf", async () => {
478
+ const defaults = { nested: { foo: 1 } };
479
+ expect(
480
+ queryToParams({ "nested.unknown": "5", "nested.foo": "9" }, defaults),
481
+ ).toEqual({ nested: { foo: 9 } });
482
+ });
483
+
484
+ it("paramsToQuery emits only nested leaves that differ", () => {
485
+ const defaults = { a: 1, nested: { foo: 5, bar: 10 } };
486
+ expect(
487
+ paramsToQuery({ a: 1, nested: { foo: 7, bar: 10 } }, defaults),
488
+ ).toEqual({ "nested.foo": "7" });
489
+ });
490
+
491
+ it("include with a parent path covers all descendants", async () => {
492
+ const { router, route } = makeRouterStub();
493
+ const params = reactive({ a: 1, nested: { foo: 5, bar: 10 } });
494
+ mountWith(() =>
495
+ useUrlParams(
496
+ params,
497
+ { a: 1, nested: { foo: 5, bar: 10 } },
498
+ { router, route, debounceMs: 0, include: ["nested"] },
499
+ ),
500
+ );
501
+ await nextTick();
502
+ params.a = 99;
503
+ params.nested.foo = 7;
504
+ params.nested.bar = 11;
505
+ await nextTick();
506
+ await new Promise((r) => setTimeout(r, 5));
507
+ expect(route.query).toEqual({ "nested.foo": "7", "nested.bar": "11" });
508
+ });
509
+
510
+ it("ignore with a dotted path skips a single leaf", async () => {
511
+ const { router, route } = makeRouterStub();
512
+ const params = reactive({ nested: { foo: 5, bar: 10 } });
513
+ mountWith(() =>
514
+ useUrlParams(
515
+ params,
516
+ { nested: { foo: 5, bar: 10 } },
517
+ { router, route, debounceMs: 0, ignore: ["nested.bar"] },
518
+ ),
519
+ );
520
+ await nextTick();
521
+ params.nested.foo = 7;
522
+ params.nested.bar = 11;
523
+ await nextTick();
524
+ await new Promise((r) => setTimeout(r, 5));
525
+ expect(route.query).toEqual({ "nested.foo": "7" });
526
+ });
527
+
528
+ it("URI-encodes segments so keys containing dots round-trip", () => {
529
+ const defaults = { "weird.key": 1, nested: { "a.b": 2 } };
530
+ const params = { "weird.key": 5, nested: { "a.b": 7 } };
531
+ const query = paramsToQuery(params, defaults);
532
+ expect(query).toEqual({
533
+ "weird%2Ekey": "5",
534
+ "nested.a%2Eb": "7",
535
+ });
536
+ expect(queryToParams(query, defaults)).toEqual({
537
+ "weird.key": 5,
538
+ nested: { "a.b": 7 },
539
+ });
540
+ });
541
+
542
+ it("flattens an array of objects into indexed dotted paths", () => {
543
+ const defaults = {
544
+ items: [
545
+ { name: "a", value: 1 },
546
+ { name: "b", value: 2 },
547
+ ],
548
+ };
549
+ const params = {
550
+ items: [
551
+ { name: "a", value: 1 },
552
+ { name: "b", value: 99 },
553
+ ],
554
+ };
555
+ expect(paramsToQuery(params, defaults)).toEqual({
556
+ "items.1.value": "99",
557
+ });
558
+ });
559
+
560
+ it("rebuilds array-shaped defaults from indexed dotted keys", () => {
561
+ const defaults = {
562
+ items: [
563
+ { name: "a", value: 1 },
564
+ { name: "b", value: 2 },
565
+ ],
566
+ };
567
+ expect(queryToParams({ "items.0.value": "5" }, defaults)).toEqual({
568
+ items: [{ value: 5 }],
569
+ });
570
+ });
571
+
572
+ it("array of arrays recurses with numeric indices, inner arrays stay leaves", () => {
573
+ const defaults = {
574
+ matrix: [
575
+ [1, 2],
576
+ [3, 4],
577
+ ],
578
+ };
579
+ const params = {
580
+ matrix: [
581
+ [1, 2],
582
+ [9, 9],
583
+ ],
584
+ };
585
+ expect(paramsToQuery(params, defaults)).toEqual({
586
+ "matrix.1": "9,9",
587
+ });
588
+ expect(queryToParams({ "matrix.0": "7,8" }, defaults)).toEqual({
589
+ matrix: [[7, 8]],
590
+ });
591
+ });
592
+
593
+ it("URL override on a leaf array replaces it wholesale (does not merge element-wise)", async () => {
594
+ const { router, route } = makeRouterStub({ "matrix.0": "7" });
595
+ const defaults = {
596
+ matrix: [
597
+ [1, 2],
598
+ [3, 4],
599
+ ],
600
+ };
601
+ const params = reactive(structuredClone(defaults));
602
+ mountWith(() =>
603
+ useUrlParams(params, defaults, { router, route, debounceMs: 0 }),
604
+ );
605
+ await nextTick();
606
+ expect(params.matrix).toEqual([[7], [3, 4]]);
607
+ });
608
+
609
+ it("array-of-primitives still round-trips as a comma-joined leaf", () => {
610
+ const defaults = { tags: [1, 2, 3] };
611
+ expect(paramsToQuery({ tags: [4, 5, 6] }, defaults)).toEqual({
612
+ tags: "4,5,6",
613
+ });
614
+ expect(queryToParams({ tags: "4,5,6" }, defaults)).toEqual({
615
+ tags: [4, 5, 6],
616
+ });
617
+ });
618
+
619
+ it("updates one element of an array-of-objects via the URL while preserving siblings", async () => {
620
+ const { router, route } = makeRouterStub({ "items.1.value": "99" });
621
+ const defaults = {
622
+ items: [
623
+ { name: "a", value: 1 },
624
+ { name: "b", value: 2 },
625
+ ],
626
+ };
627
+ const params = reactive(structuredClone(defaults));
628
+ mountWith(() =>
629
+ useUrlParams(params, defaults, { router, route, debounceMs: 0 }),
630
+ );
631
+ await nextTick();
632
+ expect(params.items).toEqual([
633
+ { name: "a", value: 1 },
634
+ { name: "b", value: 99 },
635
+ ]);
636
+ });
637
+
638
+ it("reset restores nested defaults", async () => {
639
+ const { router, route } = makeRouterStub({ "nested.foo": "9" });
640
+ const params = reactive({ nested: { foo: 1, bar: 2 } });
641
+ const { api } = mountWith(() =>
642
+ useUrlParams(
643
+ params,
644
+ { nested: { foo: 1, bar: 2 } },
645
+ { router, route, debounceMs: 0 },
646
+ ),
647
+ );
648
+ await nextTick();
649
+ expect(params.nested.foo).toBe(9);
650
+ api.reset();
651
+ await nextTick();
652
+ expect(params.nested).toEqual({ foo: 1, bar: 2 });
653
+ });
654
+ });
655
+
315
656
  describe("defaults variants", () => {
316
657
  it("accepts a Ref as defaults", async () => {
317
658
  const { router, route } = makeRouterStub({ a: "7" });
@@ -358,4 +699,118 @@ describe("useUrlParams (composable)", () => {
358
699
  expect(params.a).toBe(0);
359
700
  });
360
701
  });
702
+
703
+ describe("codecs", () => {
704
+ it("round-trips a tagged-union field through a single URL key", async () => {
705
+ const { router, route } = makeRouterStub();
706
+ const defaults: { rate: InfectionRate; population: number } = {
707
+ rate: { type: "constant", value: 0.5, duration: 3 },
708
+ population: 10_000,
709
+ };
710
+ const params = reactive(structuredClone(defaults));
711
+ mountWith(() =>
712
+ useUrlParams(params, defaults, {
713
+ router,
714
+ route,
715
+ debounceMs: 0,
716
+ codecs: { rate: jsonCodec },
717
+ }),
718
+ );
719
+ await nextTick();
720
+ params.rate = {
721
+ type: "empirical",
722
+ points: [
723
+ [0, 0],
724
+ [2, 1],
725
+ [4, 1.2],
726
+ ],
727
+ };
728
+ await nextTick();
729
+ await new Promise((r) => setTimeout(r, 5));
730
+ expect(route.query).toEqual({ rate: JSON.stringify(params.rate) });
731
+ });
732
+
733
+ it("hydrates a codec-encoded variant from the URL", async () => {
734
+ const variant: InfectionRate = {
735
+ type: "empirical",
736
+ points: [
737
+ [0, 0],
738
+ [2, 1],
739
+ ],
740
+ };
741
+ const { router, route } = makeRouterStub({
742
+ rate: JSON.stringify(variant),
743
+ });
744
+ const defaults: { rate: InfectionRate } = {
745
+ rate: { type: "constant", value: 0.5, duration: 3 },
746
+ };
747
+ const params = reactive(structuredClone(defaults));
748
+ mountWith(() =>
749
+ useUrlParams(params, defaults, {
750
+ router,
751
+ route,
752
+ debounceMs: 0,
753
+ codecs: { rate: jsonCodec },
754
+ }),
755
+ );
756
+ await nextTick();
757
+ expect(params.rate).toEqual(variant);
758
+ });
759
+
760
+ it("ignores stale descendant URL keys at a codec path", async () => {
761
+ // Pretend an older version stored `rate.value=0.9`; with a codec at
762
+ // `rate` we must not punch that through into the variant.
763
+ const { router, route } = makeRouterStub({ "rate.value": "0.9" });
764
+ const defaults: { rate: InfectionRate } = {
765
+ rate: { type: "constant", value: 0.5, duration: 3 },
766
+ };
767
+ const params = reactive(structuredClone(defaults));
768
+ mountWith(() =>
769
+ useUrlParams(params, defaults, {
770
+ router,
771
+ route,
772
+ debounceMs: 0,
773
+ codecs: { rate: jsonCodec },
774
+ }),
775
+ );
776
+ await nextTick();
777
+ expect(params.rate).toEqual({
778
+ type: "constant",
779
+ value: 0.5,
780
+ duration: 3,
781
+ });
782
+ });
783
+
784
+ it("reset clears a codec value from the URL", async () => {
785
+ const variant: InfectionRate = {
786
+ type: "empirical",
787
+ points: [[0, 1]],
788
+ };
789
+ const { router, route } = makeRouterStub({
790
+ rate: JSON.stringify(variant),
791
+ });
792
+ const defaults: { rate: InfectionRate } = {
793
+ rate: { type: "constant", value: 0.5, duration: 3 },
794
+ };
795
+ const params = reactive(structuredClone(defaults));
796
+ const { api } = mountWith(() =>
797
+ useUrlParams(params, defaults, {
798
+ router,
799
+ route,
800
+ debounceMs: 0,
801
+ codecs: { rate: jsonCodec },
802
+ }),
803
+ );
804
+ await nextTick();
805
+ expect(params.rate).toEqual(variant);
806
+ api.reset();
807
+ await nextTick();
808
+ expect(params.rate).toEqual({
809
+ type: "constant",
810
+ value: 0.5,
811
+ duration: 3,
812
+ });
813
+ expect(route.query).toEqual({});
814
+ });
815
+ });
361
816
  });
@@ -28,6 +28,50 @@ export interface UrlParamsRoute {
28
28
  */
29
29
  export type DefaultsInput<T> = T | Ref<T> | (() => T | undefined);
30
30
 
31
+ /**
32
+ * A path into the params object. Either a top-level key (autocompleted from
33
+ * `T`) or a dotted path to a nested leaf (`"nested.foo"`). Matches the leaf
34
+ * exactly or any descendant when used as `include`/`ignore`.
35
+ */
36
+ export type ParamPath<T> = keyof T | (string & {});
37
+
38
+ /**
39
+ * Custom serializer/deserializer pair for a single path. Lets the consumer
40
+ * round-trip a value through one URL key as an opaque string instead of
41
+ * letting `useUrlParams` flatten through it. Use for tagged unions,
42
+ * polymorphic objects, or anything else whose shape can change at runtime —
43
+ * the default leaf-flattening can only enumerate fields present in
44
+ * `defaults`, so variants that aren't the default variant lose their
45
+ * fields.
46
+ */
47
+ export type ParamCodec = {
48
+ serialize: (value: unknown) => string;
49
+ deserialize: (raw: string) => unknown;
50
+ };
51
+
52
+ /**
53
+ * Map of dotted path → codec. A path with a codec is treated as a single
54
+ * atomic leaf: its whole subtree round-trips through one URL key, and any
55
+ * descendant URL keys are ignored. Top-level keys (`"infectionRate"`) and
56
+ * nested paths (`"model.infectionRate"`) both work; segments in the path
57
+ * use `.` as the separator, matching the same convention as
58
+ * `include`/`ignore`.
59
+ */
60
+ export type ParamCodecs = Record<string, ParamCodec>;
61
+
62
+ /**
63
+ * Convenience codec that uses `JSON.stringify` / `JSON.parse`. Suitable
64
+ * for tagged unions and small object/array values. Note that comparison
65
+ * during URL writes is string-equality on the stringified form, so two
66
+ * objects with the same fields in different key orders will look distinct;
67
+ * provide a custom codec with a normalized stringifier if you need
68
+ * order-insensitive comparison.
69
+ */
70
+ export const jsonCodec: ParamCodec = {
71
+ serialize: (value) => JSON.stringify(value),
72
+ deserialize: (raw) => JSON.parse(raw),
73
+ };
74
+
31
75
  export type UrlParamsOptions<T> = {
32
76
  debounceMs?: number;
33
77
  /**
@@ -39,14 +83,23 @@ export type UrlParamsOptions<T> = {
39
83
  router?: UrlParamsRouter;
40
84
  route?: UrlParamsRoute;
41
85
  /**
42
- * Only sync these keys. Useful when `defaults` contains labels, flags, or
43
- * other fields the user never edits.
86
+ * Only sync these paths. Accepts top-level keys or dotted paths to nested
87
+ * leaves; a parent path implicitly covers all of its descendants.
44
88
  */
45
- include?: (keyof T)[];
89
+ include?: ParamPath<T>[];
46
90
  /**
47
- * Sync all keys except these. Ignored if `include` is provided.
91
+ * Sync all paths except these. Same matching rules as `include`. Ignored
92
+ * if `include` is provided.
48
93
  */
49
- ignore?: (keyof T)[];
94
+ ignore?: ParamPath<T>[];
95
+ /**
96
+ * Per-path codecs. A path listed here is round-tripped as one opaque
97
+ * string under that key (using the codec's `serialize`/`deserialize`)
98
+ * instead of being flattened. URL keys nested inside a codec path are
99
+ * ignored, so a leftover `foo.bar=1` from an earlier shape doesn't
100
+ * punch through the atomic value.
101
+ */
102
+ codecs?: ParamCodecs;
50
103
  };
51
104
 
52
105
  export type ResetOptions = {
@@ -74,16 +127,169 @@ export function deserialize(raw: string, defaultValue: unknown): unknown {
74
127
  return raw;
75
128
  }
76
129
 
130
+ // A "container" is any non-null object — plain object or array. A "branch"
131
+ // is a container we recurse into when flattening defaults: plain objects
132
+ // always recurse; arrays only recurse when at least one element is itself a
133
+ // plain object (so `[1, 2, 3]` stays a comma-joined leaf, while
134
+ // `[{foo: 1}, {foo: 2}]` becomes `items.0.foo=…&items.1.foo=…`).
135
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
136
+ return value !== null && typeof value === "object" && !Array.isArray(value);
137
+ }
138
+
139
+ function isContainer(
140
+ value: unknown,
141
+ ): value is Record<string, unknown> | unknown[] {
142
+ return value !== null && typeof value === "object";
143
+ }
144
+
145
+ function isBranch(value: unknown): boolean {
146
+ if (isPlainObject(value)) return true;
147
+ // Recurse into arrays whose elements are themselves containers (plain
148
+ // objects or arrays), so array-of-arrays and array-of-objects map onto
149
+ // indexed dotted paths. Arrays of primitives stay as comma-joined leaves.
150
+ if (Array.isArray(value)) return value.some(isContainer);
151
+ return false;
152
+ }
153
+
154
+ function* flattenLeaves(
155
+ obj: Record<string, unknown> | unknown[],
156
+ prefix: string[] = [],
157
+ stopPaths?: Set<string>,
158
+ ): Generator<[string[], unknown]> {
159
+ const entries: Array<[string, unknown]> = Array.isArray(obj)
160
+ ? obj.map((v, i) => [String(i), v])
161
+ : Object.entries(obj);
162
+ for (const [k, v] of entries) {
163
+ const path = [...prefix, k];
164
+ // `stopPaths` (codec paths) halt recursion: the whole subtree at that
165
+ // path is yielded as a single leaf value so a codec can serialize it
166
+ // atomically.
167
+ if (isBranch(v) && !stopPaths?.has(path.join("."))) {
168
+ yield* flattenLeaves(
169
+ v as Record<string, unknown> | unknown[],
170
+ path,
171
+ stopPaths,
172
+ );
173
+ } else {
174
+ yield [path, v];
175
+ }
176
+ }
177
+ }
178
+
179
+ // True iff some proper-prefix ancestor of `segments` is a codec path. Used
180
+ // to drop URL keys that would punch through a codec boundary (e.g. a stale
181
+ // `infectionRate.value=0.5` left over from before infectionRate gained a
182
+ // codec).
183
+ function pathIsInsideAnyCodec(
184
+ segments: string[],
185
+ codecPaths: Set<string>,
186
+ ): boolean {
187
+ for (let i = 1; i < segments.length; i++) {
188
+ if (codecPaths.has(segments.slice(0, i).join("."))) return true;
189
+ }
190
+ return false;
191
+ }
192
+
193
+ function getAtSegments(obj: unknown, segments: string[]): unknown {
194
+ let cur: unknown = obj;
195
+ for (const p of segments) {
196
+ if (!isContainer(cur)) return undefined;
197
+ cur = (cur as Record<string, unknown>)[p];
198
+ }
199
+ return cur;
200
+ }
201
+
202
+ // Sets `value` at `segments` inside `obj`. When `defaults` is supplied,
203
+ // missing intermediate slots match the defaults' shape (array vs object) at
204
+ // the same path — so a URL like `items.0.foo=5` rebuilds an array, not an
205
+ // object with numeric keys.
206
+ function setAtSegments(
207
+ obj: Record<string, unknown> | unknown[],
208
+ segments: string[],
209
+ value: unknown,
210
+ defaults?: unknown,
211
+ ): void {
212
+ let cur: Record<string, unknown> | unknown[] = obj;
213
+ let defCur: unknown = defaults;
214
+ for (let i = 0; i < segments.length - 1; i++) {
215
+ const p = segments[i];
216
+ const defNext = isContainer(defCur)
217
+ ? (defCur as Record<string, unknown>)[p]
218
+ : undefined;
219
+ const slot = (cur as Record<string, unknown>)[p];
220
+ if (!isContainer(slot)) {
221
+ (cur as Record<string, unknown>)[p] = Array.isArray(defNext) ? [] : {};
222
+ }
223
+ cur = (cur as Record<string, unknown>)[p] as
224
+ | Record<string, unknown>
225
+ | unknown[];
226
+ defCur = defNext;
227
+ }
228
+ (cur as Record<string, unknown>)[segments[segments.length - 1]] = value;
229
+ }
230
+
231
+ // Encode/decode segment arrays to/from URL query keys. Each segment is
232
+ // percent-encoded so a literal `.` inside a key name won't collide with the
233
+ // nesting separator. `encodeURIComponent` leaves `.` alone, so we replace
234
+ // it manually to `%2E`. The encoded key still passes cleanly through
235
+ // `URLSearchParams` and vue-router.
236
+ function encodeKey(segments: string[]): string {
237
+ return segments
238
+ .map((s) => encodeURIComponent(s).replace(/\./g, "%2E"))
239
+ .join(".");
240
+ }
241
+
242
+ function decodeKey(key: string): string[] {
243
+ return key.split(".").map(decodeURIComponent);
244
+ }
245
+
246
+ // Recursive merge used to layer defaults onto the cloned current params.
247
+ // Descends into matching containers (objects and arrays) so sibling
248
+ // branches survive. URL overrides bypass this and use `setAtSegments`
249
+ // directly, since a comma-encoded leaf array must replace its slot
250
+ // wholesale rather than merge element-wise with the default.
251
+ function deepMerge(
252
+ target: Record<string, unknown> | unknown[],
253
+ source: Record<string, unknown> | unknown[],
254
+ stopPaths?: Set<string>,
255
+ prefix: string[] = [],
256
+ ): Record<string, unknown> | unknown[] {
257
+ for (const [k, v] of Object.entries(source)) {
258
+ const slot = (target as Record<string, unknown>)[k];
259
+ const path = stopPaths ? [...prefix, k] : prefix;
260
+ const atStop = stopPaths?.has(path.join("."));
261
+ if (!atStop && isContainer(v) && isContainer(slot)) {
262
+ deepMerge(slot, v, stopPaths, path);
263
+ } else {
264
+ // Codec paths are atomic: overwrite the target subtree with source's
265
+ // value instead of merging the two variants field-by-field (which
266
+ // would produce a malformed hybrid for tagged unions).
267
+ (target as Record<string, unknown>)[k] = v;
268
+ }
269
+ }
270
+ return target;
271
+ }
272
+
77
273
  export function paramsToQuery<T extends object>(
78
274
  params: T,
79
275
  defaults: T,
276
+ codecs?: ParamCodecs,
80
277
  ): Record<string, string> {
81
278
  const query: Record<string, string> = {};
82
- for (const key of Object.keys(defaults) as (keyof T & string)[]) {
83
- const serialized = serialize(params[key]);
84
- const defaultSerialized = serialize(defaults[key]);
279
+ const stopPaths = codecs ? new Set(Object.keys(codecs)) : undefined;
280
+ for (const [segments, defaultLeaf] of flattenLeaves(
281
+ defaults as Record<string, unknown>,
282
+ [],
283
+ stopPaths,
284
+ )) {
285
+ const paramLeaf = getAtSegments(params, segments);
286
+ if (paramLeaf === undefined) continue;
287
+ const codec = codecs?.[segments.join(".")];
288
+ const serializeFn = codec ? codec.serialize : serialize;
289
+ const serialized = serializeFn(paramLeaf);
290
+ const defaultSerialized = serializeFn(defaultLeaf);
85
291
  if (serialized !== defaultSerialized) {
86
- query[key] = serialized;
292
+ query[encodeKey(segments)] = serialized;
87
293
  }
88
294
  }
89
295
  return query;
@@ -92,13 +298,30 @@ export function paramsToQuery<T extends object>(
92
298
  export function queryToParams<T extends object>(
93
299
  query: Record<string, unknown>,
94
300
  defaults: T,
301
+ codecs?: ParamCodecs,
95
302
  ): Partial<T> {
96
303
  const result: Record<string, unknown> = {};
97
- const defaultsRecord = defaults as Record<string, unknown>;
304
+ const codecPaths = codecs ? new Set(Object.keys(codecs)) : undefined;
98
305
  for (const [key, raw] of Object.entries(query)) {
99
- if (!(key in defaultsRecord)) continue;
100
306
  if (typeof raw !== "string") continue;
101
- result[key] = deserialize(raw, defaultsRecord[key]);
307
+ const segments = decodeKey(key);
308
+ const pathStr = segments.join(".");
309
+ const codec = codecs?.[pathStr];
310
+ if (codec) {
311
+ try {
312
+ setAtSegments(result, segments, codec.deserialize(raw), defaults);
313
+ } catch {
314
+ // Malformed codec value — skip rather than corrupt the params.
315
+ }
316
+ continue;
317
+ }
318
+ // Drop descendants of a codec path; the codec owns the whole subtree.
319
+ if (codecPaths && pathIsInsideAnyCodec(segments, codecPaths)) continue;
320
+ const defaultLeaf = getAtSegments(defaults, segments);
321
+ // Drop unknown dotted keys: matches the flat behavior and keeps the
322
+ // params shape stable.
323
+ if (defaultLeaf === undefined) continue;
324
+ setAtSegments(result, segments, deserialize(raw, defaultLeaf), defaults);
102
325
  }
103
326
  return result as Partial<T>;
104
327
  }
@@ -127,22 +350,43 @@ function toGetter<T>(input: DefaultsInput<T>): () => T | undefined {
127
350
  return () => input as T;
128
351
  }
129
352
 
130
- function filterKeys<T extends object>(
353
+ // `include`/`ignore` entries are dotted strings using `.` as a JS-side path
354
+ // separator (one segment per key). A path is in scope iff it (or any
355
+ // ancestor) matches an include entry, or it (and all its ancestors) avoid
356
+ // every ignore entry. `include` wins when both are supplied.
357
+ function segmentsStartWith(path: string[], prefix: string[]): boolean {
358
+ if (prefix.length > path.length) return false;
359
+ for (let i = 0; i < prefix.length; i++) {
360
+ if (path[i] !== prefix[i]) return false;
361
+ }
362
+ return true;
363
+ }
364
+
365
+ function pathInScope(
366
+ path: string[],
367
+ include?: string[][],
368
+ ignore?: string[][],
369
+ ): boolean {
370
+ if (include) return include.some((entry) => segmentsStartWith(path, entry));
371
+ if (ignore) return !ignore.some((entry) => segmentsStartWith(path, entry));
372
+ return true;
373
+ }
374
+
375
+ function filterDefaults<T extends object>(
131
376
  obj: T,
132
- include?: (keyof T)[],
133
- ignore?: (keyof T)[],
377
+ include?: string[][],
378
+ ignore?: string[][],
379
+ stopPaths?: Set<string>,
134
380
  ): T {
135
381
  if (!include && !ignore) return obj;
136
- const src = obj as Record<string, unknown>;
137
382
  const out: Record<string, unknown> = {};
138
- for (const k of Object.keys(src)) {
139
- const key = k as keyof T;
140
- if (include) {
141
- if (include.includes(key)) out[k] = src[k];
142
- } else if (ignore) {
143
- if (!ignore.includes(key)) out[k] = src[k];
144
- } else {
145
- out[k] = src[k];
383
+ for (const [path, leaf] of flattenLeaves(
384
+ obj as Record<string, unknown>,
385
+ [],
386
+ stopPaths,
387
+ )) {
388
+ if (pathInScope(path, include, ignore)) {
389
+ setAtSegments(out, path, leaf, obj);
146
390
  }
147
391
  }
148
392
  return out as T;
@@ -151,7 +395,8 @@ function filterKeys<T extends object>(
151
395
  /**
152
396
  * Syncs a reactive params object with the URL query string. Only values
153
397
  * that differ from defaults appear in the URL. Accepts either a `ref()`
154
- * or a `reactive()` object.
398
+ * or a `reactive()` object. Nested objects are encoded as dotted keys
399
+ * (`nested.foo=5`); arrays of primitives remain comma-joined leaves.
155
400
  *
156
401
  * For async defaults (e.g. loaded from WASM/network), pass a getter or ref
157
402
  * that returns `undefined` until ready, and call `hydrate()` once defaults
@@ -171,11 +416,16 @@ export function useUrlParams<T extends object>(
171
416
  options: UrlParamsOptions<T> = {},
172
417
  ) {
173
418
  const debounceMs = options.debounceMs ?? 300;
174
- const { router, route, include, ignore } = options;
419
+ const { router, route, include, ignore, codecs } = options;
420
+ const includeSegments = include?.map((e) => String(e).split("."));
421
+ const ignoreSegments = ignore?.map((e) => String(e).split("."));
422
+ const codecPaths = codecs ? new Set(Object.keys(codecs)) : undefined;
175
423
  const getDefaults = toGetter<T>(defaults);
176
424
  const scopedDefaults = (): T | undefined => {
177
425
  const d = getDefaults();
178
- return d === undefined ? undefined : filterKeys(d, include, ignore);
426
+ return d === undefined
427
+ ? undefined
428
+ : filterDefaults(d, includeSegments, ignoreSegments, codecPaths);
179
429
  };
180
430
 
181
431
  function readQuery(): Record<string, unknown> {
@@ -195,18 +445,24 @@ export function useUrlParams<T extends object>(
195
445
  return isRef(params) ? params.value : params;
196
446
  }
197
447
 
198
- function apply(overrides: Partial<T>, d: T) {
448
+ function apply(overrides: Array<[string[], unknown]>, d: T) {
199
449
  // Layer order (later wins): current params -> defaults -> URL overrides.
200
450
  // Starting from current preserves keys outside the sync scope (i.e.
201
451
  // those filtered out by `include`/`ignore`), which matters when `params`
202
- // is a ref and the whole object gets replaced below. `toRaw` unwraps
203
- // reactive proxies so `structuredClone` doesn't choke.
204
- const current = toRaw(read());
205
- const merged = {
206
- ...structuredClone(current),
207
- ...structuredClone(toRaw(d)),
208
- ...overrides,
209
- } as T;
452
+ // is a ref and the whole object gets replaced below. Defaults deep-merge
453
+ // so sibling branches survive; URL overrides are applied at leaf
454
+ // precision so a leaf array like `tags=1,2` replaces the whole leaf
455
+ // instead of merging element-wise with the default array.
456
+ const current = structuredClone(toRaw(read())) as Record<string, unknown>;
457
+ deepMerge(
458
+ current,
459
+ structuredClone(toRaw(d)) as Record<string, unknown>,
460
+ codecPaths,
461
+ );
462
+ for (const [segments, value] of overrides) {
463
+ setAtSegments(current, segments, value, d);
464
+ }
465
+ const merged = current as T;
210
466
  if (isRef(params)) {
211
467
  params.value = merged;
212
468
  } else {
@@ -214,13 +470,36 @@ export function useUrlParams<T extends object>(
214
470
  }
215
471
  }
216
472
 
473
+ function readOverrides(d: T): Array<[string[], unknown]> {
474
+ const out: Array<[string[], unknown]> = [];
475
+ for (const [key, raw] of Object.entries(readQuery())) {
476
+ if (typeof raw !== "string") continue;
477
+ const segments = decodeKey(key);
478
+ const pathStr = segments.join(".");
479
+ const codec = codecs?.[pathStr];
480
+ if (codec) {
481
+ try {
482
+ out.push([segments, codec.deserialize(raw)]);
483
+ } catch {
484
+ // skip malformed codec values
485
+ }
486
+ continue;
487
+ }
488
+ if (codecPaths && pathIsInsideAnyCodec(segments, codecPaths)) continue;
489
+ const defaultLeaf = getAtSegments(d, segments);
490
+ if (defaultLeaf === undefined) continue;
491
+ out.push([segments, deserialize(raw, defaultLeaf)]);
492
+ }
493
+ return out;
494
+ }
495
+
217
496
  let hydrated = false;
218
497
 
219
498
  function hydrate(): boolean {
220
499
  const d = scopedDefaults();
221
500
  if (d === undefined) return false;
222
- const overrides = queryToParams(readQuery(), d);
223
- if (Object.keys(overrides).length > 0) apply(overrides, d);
501
+ const overrides = readOverrides(d);
502
+ if (overrides.length > 0) apply(overrides, d);
224
503
  hydrated = true;
225
504
  return true;
226
505
  }
@@ -232,8 +511,7 @@ export function useUrlParams<T extends object>(
232
511
  }
233
512
  const d = scopedDefaults();
234
513
  if (d === undefined) return;
235
- const overrides = queryToParams(readQuery(), d);
236
- apply(overrides, d);
514
+ apply(readOverrides(d), d);
237
515
  }
238
516
 
239
517
  onMounted(() => {
@@ -250,7 +528,7 @@ export function useUrlParams<T extends object>(
250
528
  debounceTimer = setTimeout(() => {
251
529
  const d = scopedDefaults();
252
530
  if (d === undefined) return;
253
- writeQuery(paramsToQuery(read(), d));
531
+ writeQuery(paramsToQuery(read(), d, codecs));
254
532
  }, debounceMs);
255
533
  },
256
534
  { deep: true },
@@ -281,7 +559,7 @@ export function useUrlParams<T extends object>(
281
559
  const { clearUrl = true } = opts;
282
560
  const d = scopedDefaults();
283
561
  if (d === undefined) return;
284
- apply({}, d);
562
+ apply([], d);
285
563
  if (clearUrl) {
286
564
  if (debounceTimer) clearTimeout(debounceTimer);
287
565
  writeQuery({});