@mmstack/primitives 20.5.6 → 20.5.8

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.
@@ -401,37 +401,158 @@ function isMutable(value) {
401
401
  return 'mutate' in value && typeof value.mutate === 'function';
402
402
  }
403
403
 
404
+ /**
405
+ * @internal
406
+ * Type guard for an array-index-like property key: a non-empty string that parses to a finite
407
+ * number (e.g. `'0'`, `'42'`). Used to choose array-vs-object shape during autovivification and
408
+ * deep store proxying.
409
+ */
410
+ function isIndexProp(prop) {
411
+ return typeof prop === 'string' && prop.trim() !== '' && !isNaN(+prop);
412
+ }
413
+
414
+ // Container resolvers used by createVivify: each returns the current value when present and
415
+ // only creates a new container when it is null/undefined.
416
+ function identity(x) {
417
+ return x;
418
+ }
419
+ function createArray(cur) {
420
+ if (cur === null || cur === undefined)
421
+ return [];
422
+ return cur;
423
+ }
424
+ function createObject(cur) {
425
+ if (cur === null || cur === undefined)
426
+ return {};
427
+ return cur;
428
+ }
429
+ function createAuto(cur, key) {
430
+ if (cur === null || cur === undefined) {
431
+ return typeof key === 'number' || isIndexProp(key)
432
+ ? []
433
+ : {};
434
+ }
435
+ return cur;
436
+ }
437
+ /**
438
+ * @internal
439
+ * Resolves a {@link Vivify} option into a {@link VivifyFn}. The returned function leaves a
440
+ * present value untouched and only creates a new container — object, array, or factory result —
441
+ * when the current value is `null`/`undefined`.
442
+ */
443
+ function createVivify(option) {
444
+ switch (option) {
445
+ case false:
446
+ return identity;
447
+ case 'array':
448
+ return createArray;
449
+ case 'object':
450
+ return createObject;
451
+ case 'auto':
452
+ case true:
453
+ return createAuto;
454
+ default:
455
+ return typeof option === 'function'
456
+ ? (cur) => cur === null || cur === undefined ? option() : cur
457
+ : identity;
458
+ }
459
+ }
460
+
461
+ function createMutableArrayUpdater(source, index, vivifyFn) {
462
+ return (next) => source.mutate((cur) => {
463
+ const vivified = vivifyFn(cur, index);
464
+ if (vivified === null || vivified === undefined)
465
+ return vivified;
466
+ vivified[index] = next;
467
+ return vivified;
468
+ });
469
+ }
470
+ function createImmutableArrayUpdater(source, index, vivifyFn) {
471
+ return (next) => source.update((cur) => {
472
+ const vivified = vivifyFn(cur, index)?.slice();
473
+ if (vivified === null || vivified === undefined)
474
+ return vivified;
475
+ vivified[index] = next;
476
+ return vivified;
477
+ });
478
+ }
479
+ function createMutableObjectUpdater(source, key, vivifyFn) {
480
+ return (next) => source.mutate((cur) => {
481
+ const vivified = vivifyFn(cur, key);
482
+ if (vivified === null || vivified === undefined)
483
+ return vivified;
484
+ vivified[key] = next;
485
+ return vivified;
486
+ });
487
+ }
488
+ function createImmutableObjectUpdater(source, key, vivifyFn) {
489
+ return (next) => source.update((cur) => {
490
+ const vivified = vivifyFn(cur, key);
491
+ if (vivified === null || vivified === undefined)
492
+ return vivified;
493
+ return { ...vivified, [key]: next };
494
+ });
495
+ }
496
+ function createUpdater(source, key, vivify) {
497
+ const sample = untracked(source);
498
+ // fast path for when vivification is off
499
+ if (!vivify) {
500
+ if (Array.isArray(sample) && typeof key === 'number') {
501
+ const idx = key;
502
+ return isMutable(source)
503
+ ? (next) => source.mutate((cur) => {
504
+ cur[idx] = next;
505
+ return cur;
506
+ })
507
+ : (next) => source.update((cur) => {
508
+ const copy = cur.slice();
509
+ copy[idx] = next;
510
+ return copy;
511
+ });
512
+ }
513
+ return isMutable(source)
514
+ ? (next) => source.mutate((cur) => {
515
+ cur[key] = next;
516
+ return cur;
517
+ })
518
+ : (next) => source.update((cur) => ({
519
+ ...cur,
520
+ [key]: next,
521
+ }));
522
+ }
523
+ const present = sample !== null && sample !== undefined;
524
+ const keyIsIndex = typeof key === 'number' || isIndexProp(key);
525
+ let vivifyOpt = vivify;
526
+ if (vivifyOpt === 'auto' || vivifyOpt === true) {
527
+ vivifyOpt = ((present ? Array.isArray(sample) : keyIsIndex) ? 'array' : 'object');
528
+ }
529
+ const vivifyFn = createVivify(vivifyOpt);
530
+ // Route to the array updater whenever the container is (or will be vivified as) an
531
+ // array, so the updater and the created container agree on shape for a nullish source.
532
+ const isArray = vivifyOpt === 'array'
533
+ ? keyIsIndex
534
+ : vivifyOpt === 'object'
535
+ ? false
536
+ : Array.isArray(sample) && typeof key === 'number';
537
+ if (isArray)
538
+ return isMutable(source)
539
+ ? createMutableArrayUpdater(source, key, vivifyFn)
540
+ : createImmutableArrayUpdater(source, key, vivifyFn);
541
+ return isMutable(source)
542
+ ? createMutableObjectUpdater(source, key, vivifyFn)
543
+ : createImmutableObjectUpdater(source, key, vivifyFn);
544
+ }
404
545
  function derived(source, optOrKey, opt) {
405
- const isArray = Array.isArray(untracked(source)) && typeof optOrKey === 'number';
406
- const from = typeof optOrKey === 'object' ? optOrKey.from : (v) => v[optOrKey];
546
+ const vivify = typeof optOrKey === 'object' ? false : (opt?.vivify ?? false);
547
+ // With vivification the source may legitimately be null/undefined
548
+ const from = typeof optOrKey === 'object'
549
+ ? optOrKey.from
550
+ : vivify
551
+ ? (v) => v?.[optOrKey]
552
+ : (v) => v[optOrKey];
407
553
  const onChange = typeof optOrKey === 'object'
408
554
  ? optOrKey.onChange
409
- : isArray
410
- ? isMutable(source)
411
- ? (next) => {
412
- source.mutate((cur) => {
413
- cur[optOrKey] = next;
414
- return cur;
415
- });
416
- }
417
- : (next) => {
418
- source.update((cur) => {
419
- const newArray = [...cur];
420
- newArray[optOrKey] = next;
421
- return newArray;
422
- });
423
- }
424
- : isMutable(source)
425
- ? (next) => {
426
- source.mutate((cur) => {
427
- cur[optOrKey] =
428
- next;
429
- return cur;
430
- });
431
- }
432
- : (next) => {
433
- source.update((cur) => ({ ...cur, [optOrKey]: next }));
434
- };
555
+ : createUpdater(source, optOrKey, vivify);
435
556
  const rest = typeof optOrKey === 'object' ? { ...optOrKey, ...opt } : opt;
436
557
  const baseEqual = rest?.equal ?? Object.is;
437
558
  let cnt = 0;
@@ -604,7 +725,30 @@ const mapArray = indexArray;
604
725
  * @param mapFn The mapping function. Receives the item and its index as a Signal.
605
726
  * @param options Optional configuration:
606
727
  * - `onDestroy`: A callback invoked when a mapped item is removed from the array.
728
+ * - `key`: A custom key extractor for identity matching (e.g. `(item) => item.id`)
729
+ * when item references change but conceptual identity is preserved.
607
730
  * @returns A `Signal<U[]>` containing the mapped array.
731
+ *
732
+ * @example
733
+ * ```ts
734
+ * const users = signal([
735
+ * { id: 1, name: 'Alice' },
736
+ * { id: 2, name: 'Bob' },
737
+ * ]);
738
+ *
739
+ * const rows = keyArray(
740
+ * users,
741
+ * (user, index) => ({
742
+ * label: computed(() => `#${index()} ${user.name}`),
743
+ * id: user.id,
744
+ * }),
745
+ * { key: (u) => u.id },
746
+ * );
747
+ *
748
+ * // Reordering users() rebuilds index signals only — `rows` entries
749
+ * // are matched by id and reused, not re-created.
750
+ * users.set([users()[1], users()[0]]);
751
+ * ```
608
752
  */
609
753
  function keyArray(source, mapFn, options = {}) {
610
754
  const sourceSignal = isSignal(source) ? source : computed(source);
@@ -761,15 +905,69 @@ function mapObject(source, mapFn, options = {}) {
761
905
  }).asReadonly();
762
906
  }
763
907
 
764
- /** Project with optional equality. Pure & sync. */
908
+ /**
909
+ * Synchronous projection of a signal value with optional `CreateSignalOptions`
910
+ * (custom `equal`, `debugName`, etc.). Equivalent to `map` plus the ability to
911
+ * pass signal options through to the underlying `computed()`.
912
+ *
913
+ * @example
914
+ * ```ts
915
+ * const user = piped({ id: 1, name: 'Alice' });
916
+ * const name = user.pipe(select((u) => u.name));
917
+ * name(); // 'Alice'
918
+ * ```
919
+ */
765
920
  const select = (projector, opt) => (src) => computed(() => projector(src()), opt);
766
- /** Combine with another signal using a projector. */
921
+ /**
922
+ * Combine the piped signal with another `Signal` using a projector. The result
923
+ * recomputes whenever either source changes.
924
+ *
925
+ * @example
926
+ * ```ts
927
+ * const price = piped(10);
928
+ * const quantity = signal(3);
929
+ * const total = price.pipe(combineWith(quantity, (p, q) => p * q));
930
+ * total(); // 30
931
+ * ```
932
+ */
767
933
  const combineWith = (other, project, opt) => (src) => computed(() => project(src(), other()), opt);
768
- /** Only re-emit when equal(prev, next) is false. */
934
+ /**
935
+ * Suppress emissions while consecutive values are considered equal. The
936
+ * comparator defaults to `Object.is`; pass a custom one for structural or
937
+ * key-based equality (e.g. compare by `id` only).
938
+ *
939
+ * @example
940
+ * ```ts
941
+ * const user = piped({ id: 1, lastSeen: Date.now() });
942
+ * const byId = user.pipe(distinct((a, b) => a.id === b.id));
943
+ * // byId only re-emits when `id` changes, not on every `lastSeen` update
944
+ * ```
945
+ */
769
946
  const distinct = (equal = Object.is) => (src) => computed(() => src(), { equal });
770
- /** map to new value */
947
+ /**
948
+ * Pure synchronous transform from input to output. Equivalent to a `computed()`
949
+ * that reads the source and returns `fn(value)`.
950
+ *
951
+ * @example
952
+ * ```ts
953
+ * const count = piped(2);
954
+ * const doubled = count.pipe(map((n) => n * 2));
955
+ * doubled(); // 4
956
+ * ```
957
+ */
771
958
  const map = (fn) => (src) => computed(() => fn(src()));
772
- /** filter values, keeping the last value if it was ever available, if first value is filtered will return undefined */
959
+ /**
960
+ * Keep only values that pass the predicate. The result holds the last passing
961
+ * value across emissions; before any value passes, the result is `undefined` —
962
+ * see {@link filterWith} when you need a non-`undefined` seed.
963
+ *
964
+ * @example
965
+ * ```ts
966
+ * const event = piped<MouseEvent | null>(null);
967
+ * const clicks = event.pipe(filter((e): e is MouseEvent => e?.type === 'click'));
968
+ * clicks(); // undefined until a click happens, then the last MouseEvent
969
+ * ```
970
+ */
773
971
  const filter = (predicate) => (src) => linkedSignal({
774
972
  source: src,
775
973
  computation: (next, prev) => {
@@ -778,7 +976,19 @@ const filter = (predicate) => (src) => linkedSignal({
778
976
  return prev?.source;
779
977
  },
780
978
  });
781
- /** tap into the value */
979
+ /**
980
+ * Run a side effect on every emission without altering the signal value. Wraps
981
+ * Angular's `effect()`, so it must run in an injection context or receive an
982
+ * explicit `injector`. Use for logging / analytics — not for setting other
983
+ * signals (that's what regular `effect()` is for).
984
+ *
985
+ * @example
986
+ * ```ts
987
+ * const count = piped(0);
988
+ * count.pipe(tap((n) => console.log('count:', n)));
989
+ * count.set(1); // logs 'count: 1'
990
+ * ```
991
+ */
782
992
  const tap = (fn, injector) => (src) => {
783
993
  effect(() => fn(src()), {
784
994
  injector,
@@ -786,24 +996,66 @@ const tap = (fn, injector) => (src) => {
786
996
  return src;
787
997
  };
788
998
  /**
789
- * Like {@link filter}, but emits `initial` until a value passes the predicate
790
- * for the first time. Avoids the `T | undefined` return type.
999
+ * Like {@link filter}, but emits `initial` until a value first passes the
1000
+ * predicate. Eliminates the `T | undefined` return type at the cost of an
1001
+ * explicit seed value.
1002
+ *
1003
+ * @example
1004
+ * ```ts
1005
+ * const event = piped<MouseEvent | null>(null);
1006
+ * const lastClick = event.pipe(filterWith((e) => e?.type === 'click', null));
1007
+ * lastClick(); // null until the first click, then the most recent click event
1008
+ * ```
791
1009
  */
792
1010
  const filterWith = (predicate, initial) => (src) => linkedSignal({
793
1011
  source: src,
794
1012
  computation: (next, prev) => predicate(next) ? next : (prev?.value ?? initial),
795
1013
  });
796
- /** Emits `initial` first, then mirrors source. */
1014
+ /**
1015
+ * Emit `initial` on the first read, then mirror the source on every subsequent
1016
+ * read. Useful for giving a pipeline a sensible seed value before the source
1017
+ * is ready (e.g. loading state).
1018
+ *
1019
+ * @example
1020
+ * ```ts
1021
+ * const data = piped<User | null>(null);
1022
+ * const view = data.pipe(startWith<User | null, 'loading'>('loading'));
1023
+ * view(); // 'loading' on first read, then User | null afterward
1024
+ * ```
1025
+ */
797
1026
  const startWith = (initial) => (src) => linkedSignal({
798
1027
  source: src,
799
1028
  computation: (next, prev) => (prev === undefined ? initial : next),
800
1029
  });
801
- /** Emits `[prev, curr]` pairs. The first emission has prev = undefined. */
1030
+ /**
1031
+ * Emit `[prev, curr]` tuples so consumers can react to transitions instead of
1032
+ * raw values. On the first emission `prev` is `undefined`.
1033
+ *
1034
+ * @example
1035
+ * ```ts
1036
+ * const count = piped(0);
1037
+ * const delta = count.pipe(pairwise(), map(([prev, curr]) => curr - (prev ?? 0)));
1038
+ * count.set(5);
1039
+ * delta(); // 5
1040
+ * ```
1041
+ */
802
1042
  const pairwise = () => (src) => linkedSignal({
803
1043
  source: src,
804
1044
  computation: (next, prev) => [prev?.source, next],
805
1045
  });
806
- /** Reduce-like accumulator across emissions. */
1046
+ /**
1047
+ * Reduce-like accumulator that folds each emission into a running result.
1048
+ * Behaves like `Array.prototype.reduce` but applied over time, with the
1049
+ * accumulator persisted across emissions.
1050
+ *
1051
+ * @example
1052
+ * ```ts
1053
+ * const delta = piped(0);
1054
+ * const total = delta.pipe(scan((acc, n) => acc + n, 0));
1055
+ * delta.set(5); // total() === 5
1056
+ * delta.set(3); // total() === 8
1057
+ * ```
1058
+ */
807
1059
  const scan = (reducer, seed) => (src) => linkedSignal({
808
1060
  source: src,
809
1061
  computation: (next, prev) => reducer(prev?.value ?? seed, next),
@@ -972,6 +1224,15 @@ const EVENTS = [
972
1224
  * Battery Status API. Returns `null` until the underlying `getBattery()`
973
1225
  * promise resolves, or permanently when the API is unsupported (Firefox /
974
1226
  * Safari at the time of writing). SSR-safe.
1227
+ *
1228
+ * @example
1229
+ * ```ts
1230
+ * const battery = batteryStatus();
1231
+ * effect(() => {
1232
+ * const b = battery();
1233
+ * if (b) console.log(`${Math.round(b.level * 100)}% • charging: ${b.charging}`);
1234
+ * });
1235
+ * ```
975
1236
  */
976
1237
  function batteryStatus(debugName = 'batteryStatus') {
977
1238
  if (isPlatformServer(inject(PLATFORM_ID)) ||
@@ -1254,6 +1515,15 @@ function unwrap$1(target) {
1254
1515
  * Defaults `target` to the current `ElementRef` so it can be used inline in a
1255
1516
  * component's `class` field. SSR-safe — returns a constant `false` signal on
1256
1517
  * the server.
1518
+ *
1519
+ * @example
1520
+ * ```ts
1521
+ * @Component({ ... })
1522
+ * class MenuComponent {
1523
+ * // Defaults to the host element — flips true when focus is inside.
1524
+ * readonly hasFocus = focusWithin();
1525
+ * }
1526
+ * ```
1257
1527
  */
1258
1528
  function focusWithin(target = inject(ElementRef)) {
1259
1529
  if (isPlatformServer(inject(PLATFORM_ID))) {
@@ -1356,6 +1626,14 @@ const serverDate$1 = new Date();
1356
1626
  * activity) resets the timer and flips the signal back to `false`.
1357
1627
  *
1358
1628
  * SSR-safe — always `false` with a frozen `since` date on the server.
1629
+ *
1630
+ * @example
1631
+ * ```ts
1632
+ * const isAway = idle({ ms: 30_000 });
1633
+ * effect(() => {
1634
+ * if (isAway()) console.log('idle since', isAway.since());
1635
+ * });
1636
+ * ```
1359
1637
  */
1360
1638
  function idle(opt) {
1361
1639
  if (isPlatformServer(inject(PLATFORM_ID))) {
@@ -1719,6 +1997,14 @@ const serverDate = new Date();
1719
1997
  *
1720
1998
  * @param debugName Optional debug name for the signal.
1721
1999
  * @returns A `NetworkStatusSignal` instance.
2000
+ *
2001
+ * @example
2002
+ * ```ts
2003
+ * const online = networkStatus();
2004
+ * effect(() => {
2005
+ * if (!online()) console.log('offline since', online.since());
2006
+ * });
2007
+ * ```
1722
2008
  */
1723
2009
  function networkStatus(debugName = 'networkStatus') {
1724
2010
  if (isPlatformServer(inject(PLATFORM_ID))) {
@@ -1760,6 +2046,15 @@ const SSR_FALLBACK = {
1760
2046
  *
1761
2047
  * SSR-safe — returns a constant `portrait-primary / 0°` signal on the server
1762
2048
  * and in environments without `screen.orientation` support.
2049
+ *
2050
+ * @example
2051
+ * ```ts
2052
+ * const screenOrientation = orientation();
2053
+ * effect(() => {
2054
+ * const { type, angle } = screenOrientation();
2055
+ * console.log(`${type} at ${angle}°`);
2056
+ * });
2057
+ * ```
1763
2058
  */
1764
2059
  function orientation(debugName = 'orientation') {
1765
2060
  if (isPlatformServer(inject(PLATFORM_ID)) ||
@@ -2039,6 +2334,27 @@ function sensor(type, options) {
2039
2334
  throw new Error(`Unknown sensor type: ${type}`);
2040
2335
  }
2041
2336
  }
2337
+ /**
2338
+ * Bulk sensor factory — creates several sensor signals at once and returns
2339
+ * them keyed by sensor type. Convenient when a single consumer needs to react
2340
+ * to multiple browser signals; for a single sensor prefer {@link sensor}
2341
+ * directly.
2342
+ *
2343
+ * @typeParam TType The union of sensor keys being requested.
2344
+ * @param track Array of sensor type keys to create.
2345
+ * @param opt Optional per-sensor options keyed by sensor type.
2346
+ * @returns A record `{ [key]: <SensorReturnType> }` for each requested key.
2347
+ *
2348
+ * @example
2349
+ * ```ts
2350
+ * const { windowSize, networkStatus } = sensors(
2351
+ * ['windowSize', 'networkStatus'],
2352
+ * { windowSize: { throttle: 200 } },
2353
+ * );
2354
+ *
2355
+ * effect(() => console.log(windowSize(), networkStatus()));
2356
+ * ```
2357
+ */
2042
2358
  function sensors(track, opt) {
2043
2359
  return track.reduce((result, key) => {
2044
2360
  result[key] = sensor(key, opt?.[key]);
@@ -2090,6 +2406,12 @@ function signalFromEvent(target, eventName, initial, projectOrOpt, maybeOpt) {
2090
2406
  }
2091
2407
 
2092
2408
  const IS_STORE = Symbol('MMSTACK::IS_STORE');
2409
+ const SCOPE_PARENT = Symbol('MMSTACK::SCOPE_PARENT');
2410
+ /**
2411
+ * @internal
2412
+ * Test-only handle on the proxy cache (deliberately NOT re-exported from the public barrel).
2413
+ * Maps a store's backing signal to its lazily-built child proxies, each held via a `WeakRef`.
2414
+ */
2093
2415
  const PROXY_CACHE = new WeakMap();
2094
2416
  const SIGNAL_FN_PROP = new Set([
2095
2417
  'set',
@@ -2098,6 +2420,11 @@ const SIGNAL_FN_PROP = new Set([
2098
2420
  'inline',
2099
2421
  'asReadonly',
2100
2422
  ]);
2423
+ /**
2424
+ * @internal
2425
+ * Test-only handle on the finalization registry (deliberately NOT re-exported from the public
2426
+ * barrel). Prunes a cache entry once its proxy is reclaimed by the GC.
2427
+ */
2101
2428
  const PROXY_CLEANUP = new FinalizationRegistry(({ target, prop }) => {
2102
2429
  const storeCache = PROXY_CACHE.get(target);
2103
2430
  if (storeCache)
@@ -2112,20 +2439,35 @@ function isStore(value) {
2112
2439
  value !== null &&
2113
2440
  value[IS_STORE] === true);
2114
2441
  }
2115
- function isIndexProp(prop) {
2116
- return typeof prop === 'string' && prop.trim() !== '' && !isNaN(+prop);
2117
- }
2118
2442
  function isRecord(value) {
2119
2443
  if (value === null || typeof value !== 'object')
2120
2444
  return false;
2121
2445
  const proto = Object.getPrototypeOf(value);
2122
2446
  return proto === Object.prototype || proto === null;
2123
2447
  }
2448
+ /**
2449
+ * @internal
2450
+ * Resolves the vivify shape for a node from its current value: a present record/array is a
2451
+ * certainty we keep (cached in the derivation, so it survives the value being nulled); an
2452
+ * unknown value (`null`/`undefined`) defers to the caller's option. Off stays off.
2453
+ */
2454
+ function resolveVivify(sample, option) {
2455
+ if (!option)
2456
+ return false;
2457
+ if (Array.isArray(sample))
2458
+ return 'array';
2459
+ if (isRecord(sample))
2460
+ return 'object';
2461
+ return 'auto';
2462
+ }
2463
+ function hasOwnKey(value, key) {
2464
+ return value != null && Object.hasOwn(value, key);
2465
+ }
2124
2466
  /**
2125
2467
  * @internal
2126
2468
  * Makes an array store
2127
2469
  */
2128
- function toArrayStore(source, injector) {
2470
+ function toArrayStore(source, injector, vivify) {
2129
2471
  if (isStore(source))
2130
2472
  return source;
2131
2473
  const isMutableSource = isMutable(source);
@@ -2205,31 +2547,39 @@ function toArrayStore(source, injector) {
2205
2547
  const value = untracked(target);
2206
2548
  const valueIsArray = Array.isArray(value);
2207
2549
  const valueIsRecord = isRecord(value);
2550
+ const nodeVivify = resolveVivify(value, vivify);
2551
+ const vivifyFn = createVivify(nodeVivify);
2208
2552
  const equalFn = (valueIsRecord || valueIsArray) &&
2209
2553
  isMutableSource &&
2210
2554
  typeof value[idx] === 'object'
2211
2555
  ? () => false
2212
2556
  : undefined;
2213
2557
  const computation = valueIsRecord
2214
- ? derived(target, idx, { equal: equalFn })
2558
+ ? derived(target, idx, {
2559
+ equal: equalFn,
2560
+ vivify: nodeVivify,
2561
+ })
2215
2562
  : derived(target, {
2216
2563
  from: (v) => v?.[idx],
2217
2564
  onChange: (newValue) => target.update((v) => {
2218
- if (v === null || v === undefined)
2219
- return v;
2565
+ const container = vivifyFn(v, idx);
2566
+ if (container === null || container === undefined)
2567
+ return container;
2220
2568
  try {
2221
- v[idx] = newValue;
2569
+ container[idx] = newValue;
2222
2570
  }
2223
2571
  catch (e) {
2224
2572
  if (isDevMode())
2225
2573
  console.error(`[store] Failed to set property "${String(idx)}"`, e);
2226
2574
  }
2227
- return v;
2575
+ return container;
2228
2576
  }),
2229
2577
  });
2230
- const proxy = Array.isArray(untracked(computation))
2231
- ? toArrayStore(computation, injector)
2232
- : toStore(computation, injector);
2578
+ const childSample = untracked(computation);
2579
+ const childVivify = resolveVivify(childSample, vivify);
2580
+ const proxy = Array.isArray(childSample)
2581
+ ? toArrayStore(computation, injector, childVivify)
2582
+ : toStore(computation, injector, childVivify);
2233
2583
  const ref = new WeakRef(proxy);
2234
2584
  storeCache.set(idx, ref);
2235
2585
  PROXY_CLEANUP.register(proxy, { target, prop: idx }, ref);
@@ -2246,7 +2596,7 @@ function toArrayStore(source, injector) {
2246
2596
  * const state = store({ user: { name: 'John' } });
2247
2597
  * const nameSignal = state.user.name; // WritableSignal<string>
2248
2598
  */
2249
- function toStore(source, injector) {
2599
+ function toStore(source, injector, vivify = false) {
2250
2600
  if (isStore(source))
2251
2601
  return source;
2252
2602
  if (!injector)
@@ -2256,7 +2606,8 @@ function toStore(source, injector) {
2256
2606
  : toWritable(source, () => {
2257
2607
  // noop
2258
2608
  });
2259
- const isMutableSource = isMutable(writableSource);
2609
+ const isWritableSource = isWritableSignal(source);
2610
+ const isMutableSource = isWritableSource && isMutable(writableSource);
2260
2611
  const s = new Proxy(writableSource, {
2261
2612
  has(_, prop) {
2262
2613
  return Reflect.has(untracked(source), prop);
@@ -2284,10 +2635,16 @@ function toStore(source, injector) {
2284
2635
  return true;
2285
2636
  if (prop === 'asReadonlyStore')
2286
2637
  return () => {
2287
- if (!isWritableSignal(source))
2638
+ if (!isWritableSource)
2288
2639
  return s;
2289
- return untracked(() => toStore(source.asReadonly(), injector));
2640
+ return untracked(() => toStore(source.asReadonly(), injector, vivify));
2290
2641
  };
2642
+ if (prop === 'extend')
2643
+ return (seed) => scopedStore(s, seed, isMutableSource
2644
+ ? 'mutable'
2645
+ : isWritableSource
2646
+ ? 'writable'
2647
+ : 'readonly', injector);
2291
2648
  if (typeof prop === 'symbol' || SIGNAL_FN_PROP.has(prop))
2292
2649
  return target[prop];
2293
2650
  let storeCache = PROXY_CACHE.get(target);
@@ -2306,31 +2663,36 @@ function toStore(source, injector) {
2306
2663
  const value = untracked(target);
2307
2664
  const valueIsRecord = isRecord(value);
2308
2665
  const valueIsArray = Array.isArray(value);
2666
+ const nodeVivify = resolveVivify(value, vivify);
2667
+ const vivifyFn = createVivify(nodeVivify);
2309
2668
  const equalFn = (valueIsRecord || valueIsArray) &&
2310
2669
  isMutableSource &&
2311
2670
  typeof value[prop] === 'object'
2312
2671
  ? () => false
2313
2672
  : undefined;
2314
2673
  const computation = valueIsRecord
2315
- ? derived(target, prop, { equal: equalFn })
2674
+ ? derived(target, prop, { equal: equalFn, vivify: nodeVivify })
2316
2675
  : derived(target, {
2317
2676
  from: (v) => v?.[prop],
2318
2677
  onChange: (newValue) => target.update((v) => {
2319
- if (v === null || v === undefined)
2320
- return v;
2678
+ const container = vivifyFn(v, prop);
2679
+ if (container === null || container === undefined)
2680
+ return container;
2321
2681
  try {
2322
- v[prop] = newValue;
2682
+ container[prop] = newValue;
2323
2683
  }
2324
2684
  catch (e) {
2325
2685
  if (isDevMode())
2326
2686
  console.error(`[store] Failed to set property "${String(prop)}"`, e);
2327
2687
  }
2328
- return v;
2688
+ return container;
2329
2689
  }),
2330
2690
  });
2331
- const proxy = Array.isArray(untracked(computation))
2332
- ? toArrayStore(computation, injector)
2333
- : toStore(computation, injector);
2691
+ const childSample = untracked(computation);
2692
+ const childVivify = resolveVivify(childSample, vivify);
2693
+ const proxy = Array.isArray(childSample)
2694
+ ? toArrayStore(computation, injector, childVivify)
2695
+ : toStore(computation, injector, childVivify);
2334
2696
  const ref = new WeakRef(proxy);
2335
2697
  storeCache.set(prop, ref);
2336
2698
  PROXY_CLEANUP.register(proxy, { target, prop }, ref);
@@ -2339,19 +2701,103 @@ function toStore(source, injector) {
2339
2701
  });
2340
2702
  return s;
2341
2703
  }
2704
+ /**
2705
+ * @internal
2706
+ * Backs `store.extend(...)`. Builds a scoped overlay over `parent`: the local layer (the seed
2707
+ * plus any keys created later) is its own signal and `parent` is its own signal, so the getter
2708
+ * routes each key by consulting BOTH — local first, then parent, else local (so a write to an
2709
+ * as-yet-unknown key lands locally). Inherited keys return the parent's own sub-store (shared
2710
+ * identity + two-way), while local keys never propagate upward. A merged `computed` is derived
2711
+ * only for whole-object reads / `has` / iteration — never for routing.
2712
+ */
2713
+ function scopedStore(parent, seed, kind, injector) {
2714
+ const local = isSignal(seed)
2715
+ ? toStore(seed, injector)
2716
+ : kind === 'mutable'
2717
+ ? mutableStore(seed, { injector })
2718
+ : kind === 'readonly'
2719
+ ? store(seed, { injector }).asReadonlyStore()
2720
+ : store(seed, { injector });
2721
+ const localValue = () => untracked(local);
2722
+ const parentValue = () => untracked(parent);
2723
+ const view = computed(() => ({
2724
+ ...parent(),
2725
+ ...local(),
2726
+ }), ...(ngDevMode ? [{ debugName: "view" }] : []));
2727
+ const splitSet = (next) => {
2728
+ const lv = localValue();
2729
+ const pv = parentValue();
2730
+ for (const key of Reflect.ownKeys(next)) {
2731
+ const layer = hasOwnKey(lv, key)
2732
+ ? local
2733
+ : hasOwnKey(pv, key)
2734
+ ? parent
2735
+ : local;
2736
+ layer[key].set(next[key]);
2737
+ }
2738
+ };
2739
+ const base = toWritable(view, kind === 'readonly' ? () => undefined : splitSet, undefined, { pure: false });
2740
+ if (kind === 'mutable') {
2741
+ base.mutate = (updater) => splitSet(updater(untracked(view)));
2742
+ base.inline = (updater) => base.mutate((prev) => {
2743
+ updater(prev);
2744
+ return prev;
2745
+ });
2746
+ }
2747
+ const scope = new Proxy(base, {
2748
+ get(target, prop) {
2749
+ if (prop === IS_STORE)
2750
+ return true;
2751
+ if (prop === SCOPE_PARENT)
2752
+ return parent;
2753
+ if (prop === 'extend')
2754
+ return (childSeed) => scopedStore(scope, childSeed, kind, injector);
2755
+ if (prop === 'asReadonlyStore')
2756
+ return () => toStore(computed(() => ({ ...parent(), ...local() })), injector);
2757
+ if (typeof prop === 'symbol' || SIGNAL_FN_PROP.has(prop))
2758
+ return target[prop];
2759
+ // Route by consulting both signals: local first, then parent, else local (new → local).
2760
+ if (hasOwnKey(localValue(), prop))
2761
+ return local[prop];
2762
+ if (hasOwnKey(parentValue(), prop))
2763
+ return parent[prop];
2764
+ return local[prop];
2765
+ },
2766
+ has(_, prop) {
2767
+ return hasOwnKey(localValue(), prop) || hasOwnKey(parentValue(), prop);
2768
+ },
2769
+ ownKeys() {
2770
+ return [
2771
+ ...new Set([
2772
+ ...Reflect.ownKeys(parentValue()),
2773
+ ...Reflect.ownKeys(localValue()),
2774
+ ]),
2775
+ ];
2776
+ },
2777
+ getOwnPropertyDescriptor(_, prop) {
2778
+ if (hasOwnKey(localValue(), prop) || hasOwnKey(parentValue(), prop))
2779
+ return { enumerable: true, configurable: true };
2780
+ return undefined;
2781
+ },
2782
+ getPrototypeOf() {
2783
+ return Object.prototype;
2784
+ },
2785
+ });
2786
+ return scope;
2787
+ }
2342
2788
  /**
2343
2789
  * Creates a WritableSignalStore from a value.
2344
2790
  * @see {@link toStore}
2345
2791
  */
2346
2792
  function store(value, opt) {
2347
- return toStore(signal(value, opt), opt?.injector);
2793
+ return toStore(signal(value, opt), opt?.injector, opt?.vivify ?? false);
2348
2794
  }
2349
2795
  /**
2350
2796
  * Creates a MutableSignalStore from a value.
2351
2797
  * @see {@link toStore}
2352
2798
  */
2353
2799
  function mutableStore(value, opt) {
2354
- return toStore(mutable(value, opt), opt?.injector);
2800
+ return toStore(mutable(value, opt), opt?.injector, opt?.vivify ?? false);
2355
2801
  }
2356
2802
 
2357
2803
  // Internal dummy store for server-side rendering