@cfasim-ui/shared 0.4.12 → 0.4.14

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.12",
3
+ "version": "0.4.14",
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> }) => {
@@ -599,4 +699,118 @@ describe("useUrlParams (composable)", () => {
599
699
  expect(params.a).toBe(0);
600
700
  });
601
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
+ });
602
816
  });
@@ -35,6 +35,43 @@ export type DefaultsInput<T> = T | Ref<T> | (() => T | undefined);
35
35
  */
36
36
  export type ParamPath<T> = keyof T | (string & {});
37
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
+
38
75
  export type UrlParamsOptions<T> = {
39
76
  debounceMs?: number;
40
77
  /**
@@ -55,6 +92,14 @@ export type UrlParamsOptions<T> = {
55
92
  * if `include` is provided.
56
93
  */
57
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;
58
103
  };
59
104
 
60
105
  export type ResetOptions = {
@@ -109,20 +154,42 @@ function isBranch(value: unknown): boolean {
109
154
  function* flattenLeaves(
110
155
  obj: Record<string, unknown> | unknown[],
111
156
  prefix: string[] = [],
157
+ stopPaths?: Set<string>,
112
158
  ): Generator<[string[], unknown]> {
113
159
  const entries: Array<[string, unknown]> = Array.isArray(obj)
114
160
  ? obj.map((v, i) => [String(i), v])
115
161
  : Object.entries(obj);
116
162
  for (const [k, v] of entries) {
117
163
  const path = [...prefix, k];
118
- if (isBranch(v)) {
119
- yield* flattenLeaves(v as Record<string, unknown> | unknown[], path);
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
+ );
120
173
  } else {
121
174
  yield [path, v];
122
175
  }
123
176
  }
124
177
  }
125
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
+
126
193
  function getAtSegments(obj: unknown, segments: string[]): unknown {
127
194
  let cur: unknown = obj;
128
195
  for (const p of segments) {
@@ -184,12 +251,19 @@ function decodeKey(key: string): string[] {
184
251
  function deepMerge(
185
252
  target: Record<string, unknown> | unknown[],
186
253
  source: Record<string, unknown> | unknown[],
254
+ stopPaths?: Set<string>,
255
+ prefix: string[] = [],
187
256
  ): Record<string, unknown> | unknown[] {
188
257
  for (const [k, v] of Object.entries(source)) {
189
258
  const slot = (target as Record<string, unknown>)[k];
190
- if (isContainer(v) && isContainer(slot)) {
191
- deepMerge(slot, v);
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);
192
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).
193
267
  (target as Record<string, unknown>)[k] = v;
194
268
  }
195
269
  }
@@ -199,15 +273,21 @@ function deepMerge(
199
273
  export function paramsToQuery<T extends object>(
200
274
  params: T,
201
275
  defaults: T,
276
+ codecs?: ParamCodecs,
202
277
  ): Record<string, string> {
203
278
  const query: Record<string, string> = {};
279
+ const stopPaths = codecs ? new Set(Object.keys(codecs)) : undefined;
204
280
  for (const [segments, defaultLeaf] of flattenLeaves(
205
281
  defaults as Record<string, unknown>,
282
+ [],
283
+ stopPaths,
206
284
  )) {
207
285
  const paramLeaf = getAtSegments(params, segments);
208
286
  if (paramLeaf === undefined) continue;
209
- const serialized = serialize(paramLeaf);
210
- const defaultSerialized = serialize(defaultLeaf);
287
+ const codec = codecs?.[segments.join(".")];
288
+ const serializeFn = codec ? codec.serialize : serialize;
289
+ const serialized = serializeFn(paramLeaf);
290
+ const defaultSerialized = serializeFn(defaultLeaf);
211
291
  if (serialized !== defaultSerialized) {
212
292
  query[encodeKey(segments)] = serialized;
213
293
  }
@@ -218,11 +298,25 @@ export function paramsToQuery<T extends object>(
218
298
  export function queryToParams<T extends object>(
219
299
  query: Record<string, unknown>,
220
300
  defaults: T,
301
+ codecs?: ParamCodecs,
221
302
  ): Partial<T> {
222
303
  const result: Record<string, unknown> = {};
304
+ const codecPaths = codecs ? new Set(Object.keys(codecs)) : undefined;
223
305
  for (const [key, raw] of Object.entries(query)) {
224
306
  if (typeof raw !== "string") continue;
225
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;
226
320
  const defaultLeaf = getAtSegments(defaults, segments);
227
321
  // Drop unknown dotted keys: matches the flat behavior and keeps the
228
322
  // params shape stable.
@@ -282,10 +376,15 @@ function filterDefaults<T extends object>(
282
376
  obj: T,
283
377
  include?: string[][],
284
378
  ignore?: string[][],
379
+ stopPaths?: Set<string>,
285
380
  ): T {
286
381
  if (!include && !ignore) return obj;
287
382
  const out: Record<string, unknown> = {};
288
- for (const [path, leaf] of flattenLeaves(obj as Record<string, unknown>)) {
383
+ for (const [path, leaf] of flattenLeaves(
384
+ obj as Record<string, unknown>,
385
+ [],
386
+ stopPaths,
387
+ )) {
289
388
  if (pathInScope(path, include, ignore)) {
290
389
  setAtSegments(out, path, leaf, obj);
291
390
  }
@@ -317,15 +416,16 @@ export function useUrlParams<T extends object>(
317
416
  options: UrlParamsOptions<T> = {},
318
417
  ) {
319
418
  const debounceMs = options.debounceMs ?? 300;
320
- const { router, route, include, ignore } = options;
419
+ const { router, route, include, ignore, codecs } = options;
321
420
  const includeSegments = include?.map((e) => String(e).split("."));
322
421
  const ignoreSegments = ignore?.map((e) => String(e).split("."));
422
+ const codecPaths = codecs ? new Set(Object.keys(codecs)) : undefined;
323
423
  const getDefaults = toGetter<T>(defaults);
324
424
  const scopedDefaults = (): T | undefined => {
325
425
  const d = getDefaults();
326
426
  return d === undefined
327
427
  ? undefined
328
- : filterDefaults(d, includeSegments, ignoreSegments);
428
+ : filterDefaults(d, includeSegments, ignoreSegments, codecPaths);
329
429
  };
330
430
 
331
431
  function readQuery(): Record<string, unknown> {
@@ -354,7 +454,11 @@ export function useUrlParams<T extends object>(
354
454
  // precision so a leaf array like `tags=1,2` replaces the whole leaf
355
455
  // instead of merging element-wise with the default array.
356
456
  const current = structuredClone(toRaw(read())) as Record<string, unknown>;
357
- deepMerge(current, structuredClone(toRaw(d)) as Record<string, unknown>);
457
+ deepMerge(
458
+ current,
459
+ structuredClone(toRaw(d)) as Record<string, unknown>,
460
+ codecPaths,
461
+ );
358
462
  for (const [segments, value] of overrides) {
359
463
  setAtSegments(current, segments, value, d);
360
464
  }
@@ -371,6 +475,17 @@ export function useUrlParams<T extends object>(
371
475
  for (const [key, raw] of Object.entries(readQuery())) {
372
476
  if (typeof raw !== "string") continue;
373
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;
374
489
  const defaultLeaf = getAtSegments(d, segments);
375
490
  if (defaultLeaf === undefined) continue;
376
491
  out.push([segments, deserialize(raw, defaultLeaf)]);
@@ -413,7 +528,7 @@ export function useUrlParams<T extends object>(
413
528
  debounceTimer = setTimeout(() => {
414
529
  const d = scopedDefaults();
415
530
  if (d === undefined) return;
416
- writeQuery(paramsToQuery(read(), d));
531
+ writeQuery(paramsToQuery(read(), d, codecs));
417
532
  }, debounceMs);
418
533
  },
419
534
  { deep: true },