@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.
- package/README.md +52 -0
- package/fesm2022/mmstack-primitives.mjs +511 -65
- package/fesm2022/mmstack-primitives.mjs.map +1 -1
- package/index.d.ts +400 -30
- package/package.json +1 -1
|
@@ -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
|
|
406
|
-
|
|
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
|
-
:
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
790
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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, {
|
|
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
|
-
|
|
2219
|
-
|
|
2565
|
+
const container = vivifyFn(v, idx);
|
|
2566
|
+
if (container === null || container === undefined)
|
|
2567
|
+
return container;
|
|
2220
2568
|
try {
|
|
2221
|
-
|
|
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
|
|
2575
|
+
return container;
|
|
2228
2576
|
}),
|
|
2229
2577
|
});
|
|
2230
|
-
const
|
|
2231
|
-
|
|
2232
|
-
|
|
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
|
|
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 (!
|
|
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
|
-
|
|
2320
|
-
|
|
2678
|
+
const container = vivifyFn(v, prop);
|
|
2679
|
+
if (container === null || container === undefined)
|
|
2680
|
+
return container;
|
|
2321
2681
|
try {
|
|
2322
|
-
|
|
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
|
|
2688
|
+
return container;
|
|
2329
2689
|
}),
|
|
2330
2690
|
});
|
|
2331
|
-
const
|
|
2332
|
-
|
|
2333
|
-
|
|
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
|