@mmstack/primitives 21.1.1 → 21.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/fesm2022/mmstack-primitives.mjs +572 -250
- package/fesm2022/mmstack-primitives.mjs.map +1 -1
- package/package.json +2 -2
- package/types/mmstack-primitives.d.ts +128 -107
|
@@ -80,7 +80,7 @@ function popFrame() {
|
|
|
80
80
|
* ]);
|
|
81
81
|
*
|
|
82
82
|
* // The fine-grained mapped list
|
|
83
|
-
* const mappedUsers =
|
|
83
|
+
* const mappedUsers = indexArray(
|
|
84
84
|
* users,
|
|
85
85
|
* (userSignal, index) => {
|
|
86
86
|
* // 1. Create a fine-grained SIDE EFFECT for *this item*
|
|
@@ -101,7 +101,7 @@ function popFrame() {
|
|
|
101
101
|
* };
|
|
102
102
|
* },
|
|
103
103
|
* {
|
|
104
|
-
* // 3. Tell
|
|
104
|
+
* // 3. Tell indexArray HOW to clean up when an item is removed, this needs to be manual as it's not a nestedEffect itself
|
|
105
105
|
* onDestroy: (mappedItem) => {
|
|
106
106
|
* mappedItem.destroyEffect();
|
|
107
107
|
* }
|
|
@@ -158,7 +158,7 @@ function nestedEffect(effectFn, options) {
|
|
|
158
158
|
/**
|
|
159
159
|
* Creates a new `Signal` that processes an array of items in time-sliced chunks. This is useful for handling large lists without blocking the main thread.
|
|
160
160
|
*
|
|
161
|
-
* The returned signal will initially contain the first `chunkSize` items from the source array. It will then schedule updates to include additional chunks of items based on the specified `
|
|
161
|
+
* The returned signal will initially contain the first `chunkSize` items from the source array. It will then schedule updates to include additional chunks of items based on the specified `delay`.
|
|
162
162
|
*
|
|
163
163
|
* @template T The type of items in the array.
|
|
164
164
|
* @param source A `Signal` or a function that returns an array of items to be processed in chunks.
|
|
@@ -167,16 +167,23 @@ function nestedEffect(effectFn, options) {
|
|
|
167
167
|
*
|
|
168
168
|
* @example
|
|
169
169
|
* const largeList = signal(Array.from({ length: 1000 }, (_, i) => i));
|
|
170
|
-
* const chunkedList = chunked(largeList, { chunkSize: 100,
|
|
170
|
+
* const chunkedList = chunked(largeList, { chunkSize: 100, delay: 100 });
|
|
171
171
|
*/
|
|
172
172
|
function chunked(source, options) {
|
|
173
173
|
const { chunkSize = 50, delay = 'frame', equal, injector } = options || {};
|
|
174
174
|
let delayFn;
|
|
175
175
|
if (delay === 'frame') {
|
|
176
|
-
delayFn =
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
176
|
+
delayFn =
|
|
177
|
+
typeof requestAnimationFrame === 'function'
|
|
178
|
+
? (callback) => {
|
|
179
|
+
const num = requestAnimationFrame(callback);
|
|
180
|
+
return () => cancelAnimationFrame(num);
|
|
181
|
+
}
|
|
182
|
+
: // SSR: no requestAnimationFrame — approximate a frame with a timeout
|
|
183
|
+
(cb) => {
|
|
184
|
+
const num = setTimeout(cb, 16);
|
|
185
|
+
return () => clearTimeout(num);
|
|
186
|
+
};
|
|
180
187
|
}
|
|
181
188
|
else if (delay === 'microtask') {
|
|
182
189
|
delayFn = (cb) => {
|
|
@@ -262,7 +269,9 @@ class MmActivity {
|
|
|
262
269
|
});
|
|
263
270
|
}
|
|
264
271
|
for (const node of this.view.rootNodes) {
|
|
265
|
-
|
|
272
|
+
// covers HTML and SVG roots; text/comment roots can't be styled — their CD is still
|
|
273
|
+
// detached, but prefer an element root for true visual hiding
|
|
274
|
+
if (node instanceof HTMLElement || node instanceof SVGElement)
|
|
266
275
|
node.style.display = visible ? '' : 'none';
|
|
267
276
|
}
|
|
268
277
|
if (visible)
|
|
@@ -341,14 +350,25 @@ function resolvePause(opt) {
|
|
|
341
350
|
if (pause === false)
|
|
342
351
|
return null;
|
|
343
352
|
const run = (fn) => opt?.injector ? runInInjectionContext(opt.injector, fn) : fn();
|
|
353
|
+
// `inject` requires an injection context even with `optional: true`. A bare
|
|
354
|
+
// `pausableSignal(0)` (documented as "like `signal`") must degrade to the unwrapped
|
|
355
|
+
// primitive outside DI, not throw NG0203 — so injection failures fall back gracefully.
|
|
356
|
+
const tryRun = (fn, fallback) => {
|
|
357
|
+
try {
|
|
358
|
+
return run(fn);
|
|
359
|
+
}
|
|
360
|
+
catch {
|
|
361
|
+
return fallback;
|
|
362
|
+
}
|
|
363
|
+
};
|
|
344
364
|
const onServer = () => typeof pause === 'function' && !opt?.injector
|
|
345
365
|
? typeof globalThis.window === 'undefined'
|
|
346
|
-
:
|
|
366
|
+
: tryRun(() => isPlatformServer(inject(PLATFORM_ID, { optional: true }) ?? 'browser'), typeof globalThis.window === 'undefined');
|
|
347
367
|
if (typeof pause === 'function')
|
|
348
368
|
return onServer() ? null : pause;
|
|
349
369
|
if (onServer())
|
|
350
370
|
return null;
|
|
351
|
-
const paused =
|
|
371
|
+
const paused = tryRun(() => inject(PAUSED_CONTEXT, { optional: true }), null);
|
|
352
372
|
if (!paused) {
|
|
353
373
|
if (explicit === true && isDevMode())
|
|
354
374
|
console.warn('[pausable] `pause: true` but no PAUSED_CONTEXT in scope — not pausing. Provide one via an ' +
|
|
@@ -377,8 +397,9 @@ function pausableEffect(effectFn, options) {
|
|
|
377
397
|
/**
|
|
378
398
|
* Like `signal`, but pausable. While paused, READS hold the last value; writes still land on the
|
|
379
399
|
* underlying signal and surface on resume. Built on the `keepPrevious`/`hold` shape — a
|
|
380
|
-
* `linkedSignal` gated on the pause predicate, with `set`/`update
|
|
381
|
-
*
|
|
400
|
+
* `linkedSignal` gated on the pause predicate, with `set`/`update` forwarded to the source signal.
|
|
401
|
+
* `asReadonly()` returns the held (gated) view, so both views of the signal agree while paused.
|
|
402
|
+
* With no `pause` option it defaults to the ambient `PAUSED_CONTEXT`; `pause: false`
|
|
382
403
|
* makes it a plain `signal` — no `linkedSignal` is created.
|
|
383
404
|
*
|
|
384
405
|
* NOTE: while paused, `set(x)` followed by a read returns the *held* (pre-pause) value, not `x` — the
|
|
@@ -395,7 +416,8 @@ function pausableSignal(initialValue, options) {
|
|
|
395
416
|
equal: options?.equal });
|
|
396
417
|
read.set = src.set;
|
|
397
418
|
read.update = src.update;
|
|
398
|
-
|
|
419
|
+
// NOTE: `asReadonly` deliberately stays the linkedSignal's own (the held view) — the
|
|
420
|
+
// source's readonly view would show live values while the signal itself shows held ones.
|
|
399
421
|
return read;
|
|
400
422
|
}
|
|
401
423
|
/**
|
|
@@ -432,8 +454,12 @@ function mutable(initial, opt) {
|
|
|
432
454
|
const internalUpdate = sig.update;
|
|
433
455
|
sig.mutate = (updater) => {
|
|
434
456
|
cnt++;
|
|
435
|
-
|
|
436
|
-
|
|
457
|
+
try {
|
|
458
|
+
internalUpdate(updater);
|
|
459
|
+
}
|
|
460
|
+
finally {
|
|
461
|
+
cnt--;
|
|
462
|
+
}
|
|
437
463
|
};
|
|
438
464
|
sig.inline = (updater) => {
|
|
439
465
|
sig.mutate((prev) => {
|
|
@@ -520,7 +546,7 @@ function createNoopScope() {
|
|
|
520
546
|
hold: (value) => value,
|
|
521
547
|
};
|
|
522
548
|
}
|
|
523
|
-
const TRANSITION_SCOPE = new InjectionToken('@mmstack/
|
|
549
|
+
const TRANSITION_SCOPE = new InjectionToken('@mmstack/primitives:transition-scope');
|
|
524
550
|
/** Provide a fresh transition scope at a boundary so its subtree's resources are tracked independently. */
|
|
525
551
|
function provideTransitionScope() {
|
|
526
552
|
return { provide: TRANSITION_SCOPE, useFactory: createTransitionScope };
|
|
@@ -529,12 +555,53 @@ function injectTransitionScope() {
|
|
|
529
555
|
const scope = inject(TRANSITION_SCOPE, { optional: true });
|
|
530
556
|
if (!scope) {
|
|
531
557
|
if (isDevMode())
|
|
532
|
-
console.warn('[mmstack/
|
|
558
|
+
console.warn('[mmstack/primitives] No transition scope in context — registration/tracking here is a no-op. ' +
|
|
533
559
|
'Use a <mm-suspense> boundary or provideTransitionScope() in an ancestor.');
|
|
534
560
|
return createNoopScope();
|
|
535
561
|
}
|
|
536
562
|
return scope;
|
|
537
563
|
}
|
|
564
|
+
function createForwardingScope() {
|
|
565
|
+
const own = createTransitionScope();
|
|
566
|
+
const target = signal(null, ...(ngDevMode ? [{ debugName: "target" }] : /* istanbul ignore next */ []));
|
|
567
|
+
const eff = () => target() ?? own;
|
|
568
|
+
const owners = new Map();
|
|
569
|
+
return {
|
|
570
|
+
setTarget: (t) => target.set(t),
|
|
571
|
+
resources: computed(() => eff().resources()),
|
|
572
|
+
pending: computed(() => eff().pending()),
|
|
573
|
+
suspended: (type) => eff().suspended(type),
|
|
574
|
+
add: (ref, opt) => {
|
|
575
|
+
const t = untracked(target) ?? own;
|
|
576
|
+
owners.set(ref, t);
|
|
577
|
+
t.add(ref, opt);
|
|
578
|
+
},
|
|
579
|
+
remove: (ref) => {
|
|
580
|
+
const t = owners.get(ref) ?? untracked(target) ?? own;
|
|
581
|
+
t.remove(ref);
|
|
582
|
+
owners.delete(ref);
|
|
583
|
+
},
|
|
584
|
+
commit: (value) => linkedSignal({
|
|
585
|
+
source: () => ({ v: value(), settled: !eff().pending() }),
|
|
586
|
+
computation: (curr, prev) => curr.settled || prev === undefined ? curr.v : prev.value,
|
|
587
|
+
}),
|
|
588
|
+
holding: computed(() => eff().holding()),
|
|
589
|
+
beginHold: () => (untracked(target) ?? own).beginHold(),
|
|
590
|
+
endHold: () => (untracked(target) ?? own).endHold(),
|
|
591
|
+
hold: (value) => linkedSignal({
|
|
592
|
+
source: () => ({ v: value(), held: eff().holding() }),
|
|
593
|
+
computation: (curr, prev) => prev !== undefined && curr.held ? prev.value : curr.v,
|
|
594
|
+
}),
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
/** Provide a forwarding transition scope at a boundary (used by the transition outlet). */
|
|
598
|
+
function provideForwardingTransitionScope() {
|
|
599
|
+
return { provide: TRANSITION_SCOPE, useFactory: createForwardingScope };
|
|
600
|
+
}
|
|
601
|
+
/** Read the transition scope reachable from `injector`, or null if none is provided there. */
|
|
602
|
+
function getTransitionScope(injector) {
|
|
603
|
+
return injector.get(TRANSITION_SCOPE, null);
|
|
604
|
+
}
|
|
538
605
|
/**
|
|
539
606
|
* Returns a register function bound to the nearest transition scope: it adds a resource
|
|
540
607
|
* to the scope and removes it when the caller's injection context is destroyed. Pass any
|
|
@@ -563,6 +630,11 @@ function registerResource(res, opt) {
|
|
|
563
630
|
*
|
|
564
631
|
* Must be called in an injection context. This is the *async* generalization (Tier 2): it adds
|
|
565
632
|
* no rendering cost and needs no fork — holding direct/sync readers is a separate, deferred tier.
|
|
633
|
+
*
|
|
634
|
+
* Caveat: work must go in flight by the first post-write render to be awaited. A loader that
|
|
635
|
+
* starts later (a debounced request signal, a chained/deferred resource) is not attributable to
|
|
636
|
+
* this transition — the no-async fallback will have already resolved `done`. Trigger such work
|
|
637
|
+
* eagerly inside `fn`, or coordinate it separately.
|
|
566
638
|
*/
|
|
567
639
|
function injectStartTransition() {
|
|
568
640
|
const scope = injectTransitionScope();
|
|
@@ -712,6 +784,11 @@ function runInTransaction(txn, fn) {
|
|
|
712
784
|
* The writes land on LIVE state immediately (so derived variables and connector requests see the
|
|
713
785
|
* new values and refetch); only the *display* is held, via `scope.hold`. Must run in an injection
|
|
714
786
|
* context.
|
|
787
|
+
*
|
|
788
|
+
* Caveat: work must go in flight by the first post-write render to be part of the transaction. A
|
|
789
|
+
* loader that starts later (a debounced request signal, a chained/deferred resource) is not
|
|
790
|
+
* attributable to it — the no-async fallback will have already committed and released the hold,
|
|
791
|
+
* after which `abort()` is a no-op. Trigger such work eagerly inside `fn`.
|
|
715
792
|
*/
|
|
716
793
|
function injectStartTransaction() {
|
|
717
794
|
const scope = injectTransitionScope();
|
|
@@ -721,7 +798,15 @@ function injectStartTransaction() {
|
|
|
721
798
|
// Hold BEFORE the writes, so the display freezes at pre-transaction values.
|
|
722
799
|
scope.beginHold();
|
|
723
800
|
let finished = false;
|
|
801
|
+
// eslint-disable-next-line prefer-const -- assigned in try/catch, but needs to be declared here for the `finally` block to see it
|
|
724
802
|
let watcher;
|
|
803
|
+
let resolveDone;
|
|
804
|
+
const done = new Promise((resolve) => {
|
|
805
|
+
resolveDone = resolve;
|
|
806
|
+
});
|
|
807
|
+
// Every exit path funnels through here, so `done` always settles — including `abort()`
|
|
808
|
+
// and a throwing transaction body (which would otherwise leak the hold forever and
|
|
809
|
+
// freeze the boundary with no recovery).
|
|
725
810
|
const finish = (restore) => {
|
|
726
811
|
if (finished)
|
|
727
812
|
return;
|
|
@@ -732,27 +817,28 @@ function injectStartTransaction() {
|
|
|
732
817
|
else
|
|
733
818
|
txn.clear();
|
|
734
819
|
scope.endHold();
|
|
820
|
+
resolveDone();
|
|
735
821
|
};
|
|
736
|
-
|
|
822
|
+
try {
|
|
823
|
+
runInTransaction(txn, fn);
|
|
824
|
+
}
|
|
825
|
+
catch (e) {
|
|
826
|
+
finish(true);
|
|
827
|
+
throw e;
|
|
828
|
+
}
|
|
737
829
|
let sawPending = false;
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
if (!sawPending && !untracked(scope.pending)) {
|
|
751
|
-
finish(false);
|
|
752
|
-
resolve();
|
|
753
|
-
}
|
|
754
|
-
}, { injector });
|
|
755
|
-
});
|
|
830
|
+
watcher = effect(() => {
|
|
831
|
+
const p = scope.pending();
|
|
832
|
+
if (p)
|
|
833
|
+
sawPending = true;
|
|
834
|
+
if (sawPending && !p)
|
|
835
|
+
finish(false);
|
|
836
|
+
}, { injector });
|
|
837
|
+
// no-async fallback: if nothing ever went in flight, settle once the writes are processed.
|
|
838
|
+
afterNextRender(() => {
|
|
839
|
+
if (!sawPending && !untracked(scope.pending))
|
|
840
|
+
finish(false);
|
|
841
|
+
}, { injector });
|
|
756
842
|
return {
|
|
757
843
|
pending: scope.pending,
|
|
758
844
|
done,
|
|
@@ -761,6 +847,17 @@ function injectStartTransaction() {
|
|
|
761
847
|
};
|
|
762
848
|
}
|
|
763
849
|
|
|
850
|
+
/**
|
|
851
|
+
* @internal
|
|
852
|
+
*/
|
|
853
|
+
function getSignalEquality(sig) {
|
|
854
|
+
const internal = sig[SIGNAL];
|
|
855
|
+
if (internal && typeof internal.equal === 'function') {
|
|
856
|
+
return internal.equal;
|
|
857
|
+
}
|
|
858
|
+
return Object.is; // Default equality check
|
|
859
|
+
}
|
|
860
|
+
|
|
764
861
|
/**
|
|
765
862
|
* Converts a read-only `Signal` into a `WritableSignal` by providing custom `set` and, optionally, `update` functions.
|
|
766
863
|
* This can be useful for creating controlled write access to a signal that is otherwise read-only.
|
|
@@ -859,6 +956,7 @@ function debounced(initial, opt) {
|
|
|
859
956
|
* ```
|
|
860
957
|
*/
|
|
861
958
|
function debounce(source, opt) {
|
|
959
|
+
const eq = opt?.equal ?? getSignalEquality(source);
|
|
862
960
|
const ms = opt?.ms ?? 0;
|
|
863
961
|
const trigger = signal(false, ...(ngDevMode ? [{ debugName: "trigger" }] : /* istanbul ignore next */ []));
|
|
864
962
|
let timeout;
|
|
@@ -873,25 +971,25 @@ function debounce(source, opt) {
|
|
|
873
971
|
catch {
|
|
874
972
|
// not in injection context & no destroyRef provided opting out of cleanup
|
|
875
973
|
}
|
|
876
|
-
const
|
|
974
|
+
const set = (next) => {
|
|
975
|
+
const isEqual = eq(untracked(source), next);
|
|
976
|
+
if (!timeout && isEqual)
|
|
977
|
+
return; // nothing to do
|
|
877
978
|
if (timeout)
|
|
878
|
-
clearTimeout(timeout);
|
|
879
|
-
|
|
979
|
+
clearTimeout(timeout); // clear pending
|
|
980
|
+
if (!isEqual)
|
|
981
|
+
source.set(next);
|
|
880
982
|
timeout = setTimeout(() => {
|
|
983
|
+
timeout = undefined;
|
|
881
984
|
trigger.update((c) => !c);
|
|
882
985
|
}, ms);
|
|
883
986
|
};
|
|
884
|
-
const
|
|
885
|
-
triggerFn(value);
|
|
886
|
-
};
|
|
887
|
-
const update = (fn) => {
|
|
888
|
-
triggerFn(fn(untracked(source)));
|
|
889
|
-
};
|
|
987
|
+
const update = (fn) => set(fn(untracked(source)));
|
|
890
988
|
const writable = toWritable(computed(() => {
|
|
891
989
|
trigger();
|
|
892
990
|
return untracked(source);
|
|
893
991
|
}, opt), set, update);
|
|
894
|
-
writable.original = source;
|
|
992
|
+
writable.original = source.asReadonly();
|
|
895
993
|
return writable;
|
|
896
994
|
}
|
|
897
995
|
|
|
@@ -1062,8 +1160,18 @@ function derived(source, optOrKey, opt) {
|
|
|
1062
1160
|
if (isMutable(source)) {
|
|
1063
1161
|
sig.mutate = (updater) => {
|
|
1064
1162
|
cnt++;
|
|
1065
|
-
|
|
1066
|
-
|
|
1163
|
+
try {
|
|
1164
|
+
sig.update(updater);
|
|
1165
|
+
// The wrapped computed evaluates its `equal` lazily — at the next read, which would
|
|
1166
|
+
// normally happen after `cnt` has already dropped back to 0. For a reference-stable
|
|
1167
|
+
// mutation that read compares the same object to itself and the version never bumps,
|
|
1168
|
+
// so dependents are never notified. Reading here, while equality is still suppressed,
|
|
1169
|
+
// forces the recompute (and version bump) inside the mutate window.
|
|
1170
|
+
untracked(sig);
|
|
1171
|
+
}
|
|
1172
|
+
finally {
|
|
1173
|
+
cnt--;
|
|
1174
|
+
}
|
|
1067
1175
|
};
|
|
1068
1176
|
sig.inline = (updater) => {
|
|
1069
1177
|
sig.mutate((prev) => {
|
|
@@ -1119,16 +1227,43 @@ function isDerivation(sig) {
|
|
|
1119
1227
|
}
|
|
1120
1228
|
|
|
1121
1229
|
function keepPrevious(src, opt) {
|
|
1230
|
+
const mutableSrc = isWritableSignal$2(src) && isMutable(src);
|
|
1231
|
+
// For a mutable source the linkedSignal's equality must be suppressible: a forwarded
|
|
1232
|
+
// `mutate` keeps the same reference, which default equality would otherwise swallow.
|
|
1233
|
+
let cnt = 0;
|
|
1234
|
+
const baseEqual = opt?.equal;
|
|
1235
|
+
const equal = mutableSrc
|
|
1236
|
+
? (a, b) => cnt > 0 ? false : baseEqual ? baseEqual(a, b) : Object.is(a, b)
|
|
1237
|
+
: baseEqual;
|
|
1122
1238
|
const persisted = linkedSignal({ ...(ngDevMode ? { debugName: "persisted" } : /* istanbul ignore next */ {}), ...opt,
|
|
1123
1239
|
source: () => src(),
|
|
1124
|
-
computation: (next, prev) => next === undefined && prev !== undefined ? prev.value : next
|
|
1240
|
+
computation: (next, prev) => next === undefined && prev !== undefined ? prev.value : next,
|
|
1241
|
+
equal });
|
|
1125
1242
|
if (isWritableSignal$2(src)) {
|
|
1126
1243
|
persisted.set = src.set;
|
|
1127
1244
|
persisted.update = src.update;
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1245
|
+
// NOTE: `asReadonly` deliberately stays the linkedSignal's own — returning the
|
|
1246
|
+
// source's readonly view would reintroduce the `undefined` flashes this wrapper exists
|
|
1247
|
+
// to prevent.
|
|
1248
|
+
if (mutableSrc) {
|
|
1249
|
+
persisted.mutate = (updater) => {
|
|
1250
|
+
cnt++;
|
|
1251
|
+
try {
|
|
1252
|
+
src.mutate(updater);
|
|
1253
|
+
// force the recompute while equality is suppressed, so the reference-stable
|
|
1254
|
+
// mutation bumps the wrapper's version (see derived.ts for the same pattern)
|
|
1255
|
+
untracked(persisted);
|
|
1256
|
+
}
|
|
1257
|
+
finally {
|
|
1258
|
+
cnt--;
|
|
1259
|
+
}
|
|
1260
|
+
};
|
|
1261
|
+
persisted.inline = (updater) => {
|
|
1262
|
+
persisted.mutate((prev) => {
|
|
1263
|
+
updater(prev);
|
|
1264
|
+
return prev;
|
|
1265
|
+
});
|
|
1266
|
+
};
|
|
1132
1267
|
}
|
|
1133
1268
|
if (isDerivation(src)) {
|
|
1134
1269
|
persisted.from = src.from;
|
|
@@ -1185,13 +1320,18 @@ function indexArray(source, map, opt = {}) {
|
|
|
1185
1320
|
: toWritable(data, () => {
|
|
1186
1321
|
// noop
|
|
1187
1322
|
});
|
|
1323
|
+
// copy before defaulting `equal` — assigning onto `opt` would mutate a caller-owned
|
|
1324
|
+
// (possibly shared/reused) options object
|
|
1188
1325
|
if (isWritableSignal$1(data) && isMutable(data) && !opt.equal) {
|
|
1189
|
-
opt
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1326
|
+
opt = {
|
|
1327
|
+
...opt,
|
|
1328
|
+
equal: (a, b) => {
|
|
1329
|
+
if (typeof a !== typeof b)
|
|
1330
|
+
return false;
|
|
1331
|
+
if (typeof a === 'object' || typeof a === 'function')
|
|
1332
|
+
return false;
|
|
1333
|
+
return a === b;
|
|
1334
|
+
},
|
|
1195
1335
|
};
|
|
1196
1336
|
}
|
|
1197
1337
|
return linkedSignal({
|
|
@@ -1385,8 +1525,17 @@ function pooledKeys(src) {
|
|
|
1385
1525
|
for (const k in val)
|
|
1386
1526
|
if (Object.prototype.hasOwnProperty.call(val, k))
|
|
1387
1527
|
spare.add(k);
|
|
1388
|
-
if (active.size === spare.size
|
|
1389
|
-
|
|
1528
|
+
if (active.size === spare.size) {
|
|
1529
|
+
let subset = true;
|
|
1530
|
+
for (const k of active) {
|
|
1531
|
+
if (!spare.has(k)) {
|
|
1532
|
+
subset = false;
|
|
1533
|
+
break;
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
if (subset)
|
|
1537
|
+
return active;
|
|
1538
|
+
}
|
|
1390
1539
|
const temp = active;
|
|
1391
1540
|
active = spare;
|
|
1392
1541
|
spare = temp;
|
|
@@ -1486,7 +1635,7 @@ const filter = (predicate) => (src) => linkedSignal({
|
|
|
1486
1635
|
computation: (next, prev) => {
|
|
1487
1636
|
if (predicate(next))
|
|
1488
1637
|
return next;
|
|
1489
|
-
return prev?.
|
|
1638
|
+
return prev?.value;
|
|
1490
1639
|
},
|
|
1491
1640
|
});
|
|
1492
1641
|
/**
|
|
@@ -1522,7 +1671,7 @@ const tap = (fn, injector) => (src) => {
|
|
|
1522
1671
|
*/
|
|
1523
1672
|
const filterWith = (predicate, initial) => (src) => linkedSignal({
|
|
1524
1673
|
source: src,
|
|
1525
|
-
computation: (next, prev) => predicate(next) ? next :
|
|
1674
|
+
computation: (next, prev) => predicate(next) ? next : prev ? prev.value : initial,
|
|
1526
1675
|
});
|
|
1527
1676
|
/**
|
|
1528
1677
|
* Emit `initial` on the first read, then mirror the source on every subsequent
|
|
@@ -1571,7 +1720,7 @@ const pairwise = () => (src) => linkedSignal({
|
|
|
1571
1720
|
*/
|
|
1572
1721
|
const scan = (reducer, seed) => (src) => linkedSignal({
|
|
1573
1722
|
source: src,
|
|
1574
|
-
computation: (next, prev) => reducer(prev
|
|
1723
|
+
computation: (next, prev) => reducer(prev ? prev.value : seed, next),
|
|
1575
1724
|
});
|
|
1576
1725
|
|
|
1577
1726
|
/**
|
|
@@ -1622,7 +1771,7 @@ function pipeable(signal) {
|
|
|
1622
1771
|
return internal;
|
|
1623
1772
|
}
|
|
1624
1773
|
/**
|
|
1625
|
-
* Create a new **writable** signal and return it as a `
|
|
1774
|
+
* Create a new **writable** signal and return it as a `PipeableSignal`.
|
|
1626
1775
|
*
|
|
1627
1776
|
* The returned value is a `WritableSignal<T>` with `.set`, `.update`, `.asReadonly`
|
|
1628
1777
|
* still available (via intersection type), plus a chainable `.pipe(...)`.
|
|
@@ -1726,6 +1875,20 @@ function pooledMap(optOrComputation, signalOpt) {
|
|
|
1726
1875
|
return pooled(toPooledOptions(optOrComputation, createEmptyMap, resetClearable, signalOpt));
|
|
1727
1876
|
}
|
|
1728
1877
|
|
|
1878
|
+
/**
|
|
1879
|
+
* @internal Run a sensor factory inside `injector` when provided, else in the ambient
|
|
1880
|
+
* injection context. Keeps every sensor's escape hatch identical and in one place.
|
|
1881
|
+
*/
|
|
1882
|
+
function runInSensorContext(injector, fn) {
|
|
1883
|
+
return injector ? runInInjectionContext(injector, fn) : fn();
|
|
1884
|
+
}
|
|
1885
|
+
/**
|
|
1886
|
+
* @internal Normalize the legacy positional `debugName: string` form into {@link SensorRunOptions}.
|
|
1887
|
+
*/
|
|
1888
|
+
function coerceSensorOptions(opt) {
|
|
1889
|
+
return typeof opt === 'string' ? { debugName: opt } : (opt ?? {});
|
|
1890
|
+
}
|
|
1891
|
+
|
|
1729
1892
|
const EVENTS = [
|
|
1730
1893
|
'chargingchange',
|
|
1731
1894
|
'levelchange',
|
|
@@ -1747,7 +1910,11 @@ const EVENTS = [
|
|
|
1747
1910
|
* });
|
|
1748
1911
|
* ```
|
|
1749
1912
|
*/
|
|
1750
|
-
function batteryStatus(
|
|
1913
|
+
function batteryStatus(opt) {
|
|
1914
|
+
const { debugName = 'batteryStatus', injector } = coerceSensorOptions(opt);
|
|
1915
|
+
return runInSensorContext(injector, () => createBatteryStatus(debugName));
|
|
1916
|
+
}
|
|
1917
|
+
function createBatteryStatus(debugName) {
|
|
1751
1918
|
if (isPlatformServer(inject(PLATFORM_ID)) ||
|
|
1752
1919
|
typeof navigator === 'undefined' ||
|
|
1753
1920
|
typeof navigator.getBattery !== 'function') {
|
|
@@ -1756,7 +1923,9 @@ function batteryStatus(debugName = 'batteryStatus') {
|
|
|
1756
1923
|
const state = signal(null, { ...(ngDevMode ? { debugName: "state" } : /* istanbul ignore next */ {}), debugName });
|
|
1757
1924
|
const abortController = new AbortController();
|
|
1758
1925
|
inject(DestroyRef).onDestroy(() => abortController.abort());
|
|
1759
|
-
navigator
|
|
1926
|
+
navigator
|
|
1927
|
+
.getBattery()
|
|
1928
|
+
.then((battery) => {
|
|
1760
1929
|
if (abortController.signal.aborted)
|
|
1761
1930
|
return;
|
|
1762
1931
|
const read = () => ({
|
|
@@ -1772,6 +1941,10 @@ function batteryStatus(debugName = 'batteryStatus') {
|
|
|
1772
1941
|
signal: abortController.signal,
|
|
1773
1942
|
});
|
|
1774
1943
|
}
|
|
1944
|
+
})
|
|
1945
|
+
.catch(() => {
|
|
1946
|
+
// getBattery() rejects (NotAllowedError) when the `battery` permissions-policy is
|
|
1947
|
+
// disallowed, e.g. in cross-origin iframes — stay `null`, same as unsupported.
|
|
1775
1948
|
});
|
|
1776
1949
|
return state.asReadonly();
|
|
1777
1950
|
}
|
|
@@ -1787,7 +1960,11 @@ function batteryStatus(debugName = 'batteryStatus') {
|
|
|
1787
1960
|
* in browsers that gate it. Errors from `navigator.clipboard.readText` are
|
|
1788
1961
|
* swallowed silently to keep the signal value stable.
|
|
1789
1962
|
*/
|
|
1790
|
-
function clipboard(
|
|
1963
|
+
function clipboard(opt) {
|
|
1964
|
+
const { debugName = 'clipboard', injector } = coerceSensorOptions(opt);
|
|
1965
|
+
return runInSensorContext(injector, () => createClipboard(debugName));
|
|
1966
|
+
}
|
|
1967
|
+
function createClipboard(debugName) {
|
|
1791
1968
|
if (isPlatformServer(inject(PLATFORM_ID)) ||
|
|
1792
1969
|
typeof navigator === 'undefined' ||
|
|
1793
1970
|
!navigator.clipboard) {
|
|
@@ -1837,7 +2014,13 @@ function observerSupported$1() {
|
|
|
1837
2014
|
* });
|
|
1838
2015
|
* ```
|
|
1839
2016
|
*/
|
|
1840
|
-
function elementSize(target
|
|
2017
|
+
function elementSize(target, opt) {
|
|
2018
|
+
return runInSensorContext(opt?.injector, () =>
|
|
2019
|
+
// the host-element default must resolve INSIDE the sensor context, not as a
|
|
2020
|
+
// parameter default (which would run before the injector wrapper)
|
|
2021
|
+
createElementSize(target ?? inject(ElementRef), opt));
|
|
2022
|
+
}
|
|
2023
|
+
function createElementSize(target, opt) {
|
|
1841
2024
|
const getElement = () => {
|
|
1842
2025
|
if (isSignal(target)) {
|
|
1843
2026
|
try {
|
|
@@ -1851,8 +2034,8 @@ function elementSize(target = inject(ElementRef), opt) {
|
|
|
1851
2034
|
return target instanceof ElementRef ? target.nativeElement : target;
|
|
1852
2035
|
};
|
|
1853
2036
|
const resolveInitialValue = () => {
|
|
1854
|
-
|
|
1855
|
-
|
|
2037
|
+
// measuring needs only getBoundingClientRect — ResizeObserver support gates
|
|
2038
|
+
// live updates, not the initial read
|
|
1856
2039
|
const el = getElement();
|
|
1857
2040
|
if (el && el.getBoundingClientRect) {
|
|
1858
2041
|
const rect = el.getBoundingClientRect();
|
|
@@ -1970,7 +2153,13 @@ function observerSupported() {
|
|
|
1970
2153
|
* }
|
|
1971
2154
|
* ```
|
|
1972
2155
|
*/
|
|
1973
|
-
function elementVisibility(target
|
|
2156
|
+
function elementVisibility(target, opt) {
|
|
2157
|
+
return runInSensorContext(opt?.injector, () =>
|
|
2158
|
+
// the host-element default must resolve INSIDE the sensor context, not as a
|
|
2159
|
+
// parameter default (which would run before the injector wrapper)
|
|
2160
|
+
createElementVisibility(target ?? inject(ElementRef), opt));
|
|
2161
|
+
}
|
|
2162
|
+
function createElementVisibility(target, opt) {
|
|
1974
2163
|
if (isPlatformServer(inject(PLATFORM_ID)) || !observerSupported()) {
|
|
1975
2164
|
const base = computed(() => undefined, {
|
|
1976
2165
|
debugName: opt?.debugName,
|
|
@@ -2038,11 +2227,18 @@ function unwrap$1(target) {
|
|
|
2038
2227
|
* }
|
|
2039
2228
|
* ```
|
|
2040
2229
|
*/
|
|
2041
|
-
function focusWithin(target
|
|
2230
|
+
function focusWithin(target, opt) {
|
|
2231
|
+
return runInSensorContext(opt?.injector, () =>
|
|
2232
|
+
// the host-element default must resolve INSIDE the sensor context, not as a
|
|
2233
|
+
// parameter default (which would run before the injector wrapper)
|
|
2234
|
+
createFocusWithin(target ?? inject(ElementRef), opt));
|
|
2235
|
+
}
|
|
2236
|
+
function createFocusWithin(target, opt) {
|
|
2237
|
+
const debugName = opt?.debugName ?? 'focusWithin';
|
|
2042
2238
|
if (isPlatformServer(inject(PLATFORM_ID))) {
|
|
2043
|
-
return computed(() => false, { debugName
|
|
2239
|
+
return computed(() => false, { debugName });
|
|
2044
2240
|
}
|
|
2045
|
-
const state = signal(false, { debugName:
|
|
2241
|
+
const state = signal(false, { ...(ngDevMode ? { debugName: "state" } : /* istanbul ignore next */ {}), debugName });
|
|
2046
2242
|
const attach = (el) => {
|
|
2047
2243
|
state.set(el.contains(document.activeElement));
|
|
2048
2244
|
const abortController = new AbortController();
|
|
@@ -2090,6 +2286,9 @@ function focusWithin(target = inject(ElementRef)) {
|
|
|
2090
2286
|
* ```
|
|
2091
2287
|
*/
|
|
2092
2288
|
function geolocation(opt) {
|
|
2289
|
+
return runInSensorContext(opt?.injector, () => createGeolocation(opt));
|
|
2290
|
+
}
|
|
2291
|
+
function createGeolocation(opt) {
|
|
2093
2292
|
if (isPlatformServer(inject(PLATFORM_ID)) || typeof navigator === 'undefined' || !navigator.geolocation) {
|
|
2094
2293
|
const sig = computed(() => null, {
|
|
2095
2294
|
debugName: opt?.debugName ?? 'geolocation',
|
|
@@ -2149,6 +2348,9 @@ const serverDate$1 = new Date();
|
|
|
2149
2348
|
* ```
|
|
2150
2349
|
*/
|
|
2151
2350
|
function idle(opt) {
|
|
2351
|
+
return runInSensorContext(opt?.injector, () => createIdle(opt));
|
|
2352
|
+
}
|
|
2353
|
+
function createIdle(opt) {
|
|
2152
2354
|
if (isPlatformServer(inject(PLATFORM_ID))) {
|
|
2153
2355
|
const sig = computed(() => false, {
|
|
2154
2356
|
debugName: opt?.debugName ?? 'idle',
|
|
@@ -2238,7 +2440,11 @@ function idle(opt) {
|
|
|
2238
2440
|
* }
|
|
2239
2441
|
* ```
|
|
2240
2442
|
*/
|
|
2241
|
-
function mediaQuery(query,
|
|
2443
|
+
function mediaQuery(query, opt) {
|
|
2444
|
+
const { debugName = 'mediaQuery', injector } = coerceSensorOptions(opt);
|
|
2445
|
+
return runInSensorContext(injector, () => createMediaQuery(query, debugName));
|
|
2446
|
+
}
|
|
2447
|
+
function createMediaQuery(query, debugName) {
|
|
2242
2448
|
if (isPlatformServer(inject(PLATFORM_ID)) ||
|
|
2243
2449
|
typeof window === 'undefined' ||
|
|
2244
2450
|
typeof window.matchMedia !== 'function' // jsdom doesn't implement matchMedia
|
|
@@ -2276,8 +2482,8 @@ function mediaQuery(query, debugName = 'mediaQuery') {
|
|
|
2276
2482
|
* });
|
|
2277
2483
|
* ```
|
|
2278
2484
|
*/
|
|
2279
|
-
function prefersDarkMode(
|
|
2280
|
-
return mediaQuery('(prefers-color-scheme: dark)',
|
|
2485
|
+
function prefersDarkMode(opt) {
|
|
2486
|
+
return mediaQuery('(prefers-color-scheme: dark)', opt);
|
|
2281
2487
|
}
|
|
2282
2488
|
/**
|
|
2283
2489
|
* Creates a read-only signal that tracks the user's OS/browser preference
|
|
@@ -2304,8 +2510,8 @@ function prefersDarkMode(debugName) {
|
|
|
2304
2510
|
* });
|
|
2305
2511
|
* ```
|
|
2306
2512
|
*/
|
|
2307
|
-
function prefersReducedMotion(
|
|
2308
|
-
return mediaQuery('(prefers-reduced-motion: reduce)',
|
|
2513
|
+
function prefersReducedMotion(opt) {
|
|
2514
|
+
return mediaQuery('(prefers-reduced-motion: reduce)', opt);
|
|
2309
2515
|
}
|
|
2310
2516
|
|
|
2311
2517
|
/**
|
|
@@ -2354,6 +2560,7 @@ function throttled(initial, opt) {
|
|
|
2354
2560
|
* // after the 500ms cooldown.
|
|
2355
2561
|
*/
|
|
2356
2562
|
function throttle(source, opt) {
|
|
2563
|
+
const eq = opt?.equal ?? getSignalEquality(source);
|
|
2357
2564
|
const ms = opt?.ms ?? 0;
|
|
2358
2565
|
const leading = opt?.leading ?? false;
|
|
2359
2566
|
const trailing = opt?.trailing ?? true;
|
|
@@ -2379,31 +2586,32 @@ function throttle(source, opt) {
|
|
|
2379
2586
|
fire();
|
|
2380
2587
|
else
|
|
2381
2588
|
pendingTrailing = trailing;
|
|
2382
|
-
|
|
2589
|
+
const onWindowEnd = () => {
|
|
2383
2590
|
timeout = undefined;
|
|
2384
2591
|
if (trailing && pendingTrailing) {
|
|
2385
2592
|
pendingTrailing = false;
|
|
2386
2593
|
fire();
|
|
2594
|
+
timeout = setTimeout(onWindowEnd, ms);
|
|
2387
2595
|
}
|
|
2388
|
-
}
|
|
2596
|
+
};
|
|
2597
|
+
timeout = setTimeout(onWindowEnd, ms);
|
|
2389
2598
|
return;
|
|
2390
2599
|
}
|
|
2391
2600
|
if (trailing)
|
|
2392
2601
|
pendingTrailing = true;
|
|
2393
2602
|
};
|
|
2394
|
-
const set = (
|
|
2395
|
-
source
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
const update = (fn) => {
|
|
2399
|
-
source.update(fn);
|
|
2603
|
+
const set = (next) => {
|
|
2604
|
+
if (eq(untracked(source), next))
|
|
2605
|
+
return;
|
|
2606
|
+
source.set(next);
|
|
2400
2607
|
tick();
|
|
2401
2608
|
};
|
|
2609
|
+
const update = (fn) => set(fn(untracked(source)));
|
|
2402
2610
|
const writable = toWritable(computed(() => {
|
|
2403
2611
|
trigger();
|
|
2404
2612
|
return untracked(source);
|
|
2405
2613
|
}, opt), set, update);
|
|
2406
|
-
writable.original = source;
|
|
2614
|
+
writable.original = source.asReadonly();
|
|
2407
2615
|
return writable;
|
|
2408
2616
|
}
|
|
2409
2617
|
|
|
@@ -2440,6 +2648,9 @@ function throttle(source, opt) {
|
|
|
2440
2648
|
* ```
|
|
2441
2649
|
*/
|
|
2442
2650
|
function mousePosition(opt) {
|
|
2651
|
+
return runInSensorContext(opt?.injector, () => createMousePosition(opt));
|
|
2652
|
+
}
|
|
2653
|
+
function createMousePosition(opt) {
|
|
2443
2654
|
if (isPlatformServer(inject(PLATFORM_ID))) {
|
|
2444
2655
|
const base = computed(() => ({
|
|
2445
2656
|
x: 0,
|
|
@@ -2451,8 +2662,12 @@ function mousePosition(opt) {
|
|
|
2451
2662
|
return base;
|
|
2452
2663
|
}
|
|
2453
2664
|
const { target = window, coordinateSpace = 'client', touch = false, debugName = 'mousePosition', throttle = 100, } = opt ?? {};
|
|
2454
|
-
const
|
|
2455
|
-
|
|
2665
|
+
const resolve = (t) => {
|
|
2666
|
+
if (!t)
|
|
2667
|
+
return null;
|
|
2668
|
+
return t instanceof ElementRef ? t.nativeElement : t;
|
|
2669
|
+
};
|
|
2670
|
+
if (!isSignal(target) && !resolve(target)) {
|
|
2456
2671
|
if (isDevMode())
|
|
2457
2672
|
console.warn('mousePosition: Target element not found.');
|
|
2458
2673
|
const base = computed(() => ({
|
|
@@ -2475,7 +2690,7 @@ function mousePosition(opt) {
|
|
|
2475
2690
|
x = coordinateSpace === 'page' ? event.pageX : event.clientX;
|
|
2476
2691
|
y = coordinateSpace === 'page' ? event.pageY : event.clientY;
|
|
2477
2692
|
}
|
|
2478
|
-
else if (event.touches
|
|
2693
|
+
else if (event.touches?.length > 0) {
|
|
2479
2694
|
const firstTouch = event.touches[0];
|
|
2480
2695
|
x = coordinateSpace === 'page' ? firstTouch.pageX : firstTouch.clientX;
|
|
2481
2696
|
y = coordinateSpace === 'page' ? firstTouch.pageY : firstTouch.clientY;
|
|
@@ -2485,16 +2700,36 @@ function mousePosition(opt) {
|
|
|
2485
2700
|
}
|
|
2486
2701
|
pos.set({ x, y });
|
|
2487
2702
|
};
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
|
|
2703
|
+
// passive: the handler never calls preventDefault, and a non-passive touchmove on
|
|
2704
|
+
// window forces the browser to wait on JS before scrolling (scroll jank on touch)
|
|
2705
|
+
const attach = (el) => {
|
|
2706
|
+
const controller = new AbortController();
|
|
2707
|
+
el.addEventListener('mousemove', updatePosition, {
|
|
2708
|
+
passive: true,
|
|
2709
|
+
signal: controller.signal,
|
|
2710
|
+
});
|
|
2494
2711
|
if (touch) {
|
|
2495
|
-
|
|
2712
|
+
el.addEventListener('touchmove', updatePosition, {
|
|
2713
|
+
passive: true,
|
|
2714
|
+
signal: controller.signal,
|
|
2715
|
+
});
|
|
2496
2716
|
}
|
|
2497
|
-
|
|
2717
|
+
return () => controller.abort();
|
|
2718
|
+
};
|
|
2719
|
+
if (isSignal(target)) {
|
|
2720
|
+
// re-attach whenever the signal resolves to a (new) element — covers viewChild
|
|
2721
|
+
effect((cleanup) => {
|
|
2722
|
+
const el = resolve(target());
|
|
2723
|
+
if (!el)
|
|
2724
|
+
return;
|
|
2725
|
+
cleanup(attach(el));
|
|
2726
|
+
});
|
|
2727
|
+
}
|
|
2728
|
+
else {
|
|
2729
|
+
const el = resolve(target);
|
|
2730
|
+
if (el)
|
|
2731
|
+
inject(DestroyRef).onDestroy(attach(el));
|
|
2732
|
+
}
|
|
2498
2733
|
const base = pos.asReadonly();
|
|
2499
2734
|
base.unthrottled = pos.original;
|
|
2500
2735
|
return base;
|
|
@@ -2508,7 +2743,8 @@ const serverDate = new Date();
|
|
|
2508
2743
|
* An additional `since` signal is attached, tracking when the status last changed.
|
|
2509
2744
|
* It's SSR-safe and automatically cleans up its event listeners.
|
|
2510
2745
|
*
|
|
2511
|
-
* @param
|
|
2746
|
+
* @param opt Optional debug name for the signal, or a {@link SensorRunOptions} object
|
|
2747
|
+
* (with an optional `injector` for creation outside an injection context).
|
|
2512
2748
|
* @returns A `NetworkStatusSignal` instance.
|
|
2513
2749
|
*
|
|
2514
2750
|
* @example
|
|
@@ -2519,7 +2755,11 @@ const serverDate = new Date();
|
|
|
2519
2755
|
* });
|
|
2520
2756
|
* ```
|
|
2521
2757
|
*/
|
|
2522
|
-
function networkStatus(
|
|
2758
|
+
function networkStatus(opt) {
|
|
2759
|
+
const { debugName = 'networkStatus', injector } = coerceSensorOptions(opt);
|
|
2760
|
+
return runInSensorContext(injector, () => createNetworkStatus(debugName));
|
|
2761
|
+
}
|
|
2762
|
+
function createNetworkStatus(debugName) {
|
|
2523
2763
|
if (isPlatformServer(inject(PLATFORM_ID))) {
|
|
2524
2764
|
const sig = computed(() => true, {
|
|
2525
2765
|
debugName,
|
|
@@ -2567,7 +2807,11 @@ const SSR_FALLBACK = {
|
|
|
2567
2807
|
* });
|
|
2568
2808
|
* ```
|
|
2569
2809
|
*/
|
|
2570
|
-
function orientation(
|
|
2810
|
+
function orientation(opt) {
|
|
2811
|
+
const { debugName = 'orientation', injector } = coerceSensorOptions(opt);
|
|
2812
|
+
return runInSensorContext(injector, () => createOrientation(debugName));
|
|
2813
|
+
}
|
|
2814
|
+
function createOrientation(debugName) {
|
|
2571
2815
|
if (isPlatformServer(inject(PLATFORM_ID)) ||
|
|
2572
2816
|
typeof screen === 'undefined' ||
|
|
2573
2817
|
!screen.orientation) {
|
|
@@ -2594,7 +2838,8 @@ function orientation(debugName = 'orientation') {
|
|
|
2594
2838
|
* The primitive is SSR-safe and automatically cleans up its event listeners
|
|
2595
2839
|
* when the creating context is destroyed.
|
|
2596
2840
|
*
|
|
2597
|
-
* @param
|
|
2841
|
+
* @param opt Optional debug name for the signal, or a {@link SensorRunOptions} object
|
|
2842
|
+
* (with an optional `injector` for creation outside an injection context).
|
|
2598
2843
|
* @returns A read-only `Signal<DocumentVisibilityState>`. On the server,
|
|
2599
2844
|
* it returns a static signal with a value of `'visible'`.
|
|
2600
2845
|
*
|
|
@@ -2622,7 +2867,11 @@ function orientation(debugName = 'orientation') {
|
|
|
2622
2867
|
* }
|
|
2623
2868
|
* ```
|
|
2624
2869
|
*/
|
|
2625
|
-
function pageVisibility(
|
|
2870
|
+
function pageVisibility(opt) {
|
|
2871
|
+
const { debugName = 'pageVisibility', injector } = coerceSensorOptions(opt);
|
|
2872
|
+
return runInSensorContext(injector, () => createPageVisibility(debugName));
|
|
2873
|
+
}
|
|
2874
|
+
function createPageVisibility(debugName) {
|
|
2626
2875
|
if (isPlatformServer(inject(PLATFORM_ID))) {
|
|
2627
2876
|
return computed(() => 'visible', { debugName });
|
|
2628
2877
|
}
|
|
@@ -2654,31 +2903,25 @@ function pageVisibility(debugName = 'pageVisibility') {
|
|
|
2654
2903
|
* selector: 'app-scroll-tracker',
|
|
2655
2904
|
* template: `
|
|
2656
2905
|
* <p>Window Scroll: X: {{ windowScroll().x }}, Y: {{ windowScroll().y }}</p>
|
|
2657
|
-
* <
|
|
2658
|
-
* <div style="height: 400px; width: 400px;">Scroll me!</div>
|
|
2659
|
-
* </div>
|
|
2660
|
-
* @if (divScroll()) {
|
|
2661
|
-
* <p>Div Scroll: X: {{ divScroll().x }}, Y: {{ divScroll().y }}</p>
|
|
2662
|
-
* }
|
|
2906
|
+
* <p>Host Scroll: X: {{ hostScroll().x }}, Y: {{ hostScroll().y }}</p>
|
|
2663
2907
|
* `
|
|
2664
2908
|
* })
|
|
2665
2909
|
* export class ScrollTrackerComponent {
|
|
2666
2910
|
* readonly windowScroll = scrollPosition(); // Defaults to window
|
|
2911
|
+
* // Signal targets (e.g. viewChild) attach once the element exists:
|
|
2667
2912
|
* readonly scrollableDiv = viewChild<ElementRef<HTMLDivElement>>('scrollableDiv');
|
|
2668
|
-
* readonly divScroll = scrollPosition({ target: this.scrollableDiv
|
|
2913
|
+
* readonly divScroll = scrollPosition({ target: this.scrollableDiv });
|
|
2669
2914
|
*
|
|
2670
2915
|
* constructor() {
|
|
2671
|
-
* effect(() =>
|
|
2672
|
-
* console.log('Window scrolled to:', this.windowScroll());
|
|
2673
|
-
* if (this.divScroll()) {
|
|
2674
|
-
* console.log('Div scrolled to:', this.divScroll());
|
|
2675
|
-
* }
|
|
2676
|
-
* });
|
|
2916
|
+
* effect(() => console.log('Window scrolled to:', this.windowScroll()));
|
|
2677
2917
|
* }
|
|
2678
2918
|
* }
|
|
2679
2919
|
* ```
|
|
2680
2920
|
*/
|
|
2681
2921
|
function scrollPosition(opt) {
|
|
2922
|
+
return runInSensorContext(opt?.injector, () => createScrollPosition(opt));
|
|
2923
|
+
}
|
|
2924
|
+
function createScrollPosition(opt) {
|
|
2682
2925
|
if (isPlatformServer(inject(PLATFORM_ID))) {
|
|
2683
2926
|
const base = computed(() => ({
|
|
2684
2927
|
x: 0,
|
|
@@ -2690,43 +2933,44 @@ function scrollPosition(opt) {
|
|
|
2690
2933
|
return base;
|
|
2691
2934
|
}
|
|
2692
2935
|
const { target = globalThis.window, throttle = 100, debugName = 'scrollPosition', } = opt || {};
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
return {
|
|
2708
|
-
x: target.nativeElement.scrollLeft,
|
|
2709
|
-
y: target.nativeElement.scrollTop,
|
|
2710
|
-
};
|
|
2711
|
-
};
|
|
2712
|
-
}
|
|
2713
|
-
else {
|
|
2714
|
-
element = target;
|
|
2715
|
-
getScrollPosition = () => {
|
|
2716
|
-
return {
|
|
2717
|
-
x: target.scrollLeft,
|
|
2718
|
-
y: target.scrollTop,
|
|
2719
|
-
};
|
|
2720
|
-
};
|
|
2721
|
-
}
|
|
2722
|
-
const state = throttled(getScrollPosition(), {
|
|
2936
|
+
const resolve = (t) => {
|
|
2937
|
+
if (!t)
|
|
2938
|
+
return null;
|
|
2939
|
+
return t instanceof ElementRef ? t.nativeElement : t;
|
|
2940
|
+
};
|
|
2941
|
+
const isWindow = (el) => el.window === el;
|
|
2942
|
+
const readPosition = (el) => isWindow(el)
|
|
2943
|
+
? {
|
|
2944
|
+
x: el.scrollX ?? el.pageXOffset ?? 0,
|
|
2945
|
+
y: el.scrollY ?? el.pageYOffset ?? 0,
|
|
2946
|
+
}
|
|
2947
|
+
: { x: el.scrollLeft, y: el.scrollTop };
|
|
2948
|
+
const initial = resolve(isSignal(target) ? untracked(target) : target);
|
|
2949
|
+
const state = throttled(initial ? readPosition(initial) : { x: 0, y: 0 }, {
|
|
2723
2950
|
debugName,
|
|
2724
2951
|
equal: (a, b) => a.x === b.x && a.y === b.y,
|
|
2725
2952
|
ms: throttle,
|
|
2726
2953
|
});
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
2954
|
+
if (isSignal(target)) {
|
|
2955
|
+
// re-attach whenever the signal resolves to a (new) element — covers viewChild
|
|
2956
|
+
effect((cleanup) => {
|
|
2957
|
+
const el = resolve(target());
|
|
2958
|
+
if (!el)
|
|
2959
|
+
return;
|
|
2960
|
+
state.set(readPosition(el)); // sync to the new element immediately
|
|
2961
|
+
const onScroll = () => state.set(readPosition(el));
|
|
2962
|
+
el.addEventListener('scroll', onScroll, { passive: true });
|
|
2963
|
+
cleanup(() => el.removeEventListener('scroll', onScroll));
|
|
2964
|
+
});
|
|
2965
|
+
}
|
|
2966
|
+
else {
|
|
2967
|
+
const el = resolve(target);
|
|
2968
|
+
if (el) {
|
|
2969
|
+
const onScroll = () => state.set(readPosition(el));
|
|
2970
|
+
el.addEventListener('scroll', onScroll, { passive: true });
|
|
2971
|
+
inject(DestroyRef).onDestroy(() => el.removeEventListener('scroll', onScroll));
|
|
2972
|
+
}
|
|
2973
|
+
}
|
|
2730
2974
|
const base = state.asReadonly();
|
|
2731
2975
|
base.unthrottled = state.original;
|
|
2732
2976
|
return base;
|
|
@@ -2774,6 +3018,9 @@ function scrollPosition(opt) {
|
|
|
2774
3018
|
* ```
|
|
2775
3019
|
*/
|
|
2776
3020
|
function windowSize(opt) {
|
|
3021
|
+
return runInSensorContext(opt?.injector, () => createWindowSize(opt));
|
|
3022
|
+
}
|
|
3023
|
+
function createWindowSize(opt) {
|
|
2777
3024
|
if (isPlatformServer(inject(PLATFORM_ID))) {
|
|
2778
3025
|
const base = computed(() => ({
|
|
2779
3026
|
width: 1024,
|
|
@@ -2810,17 +3057,19 @@ function sensor(type, options) {
|
|
|
2810
3057
|
case 'mousePosition':
|
|
2811
3058
|
return mousePosition(opts);
|
|
2812
3059
|
case 'networkStatus':
|
|
2813
|
-
return networkStatus(opts
|
|
3060
|
+
return networkStatus(opts);
|
|
2814
3061
|
case 'pageVisibility':
|
|
2815
|
-
return pageVisibility(opts
|
|
3062
|
+
return pageVisibility(opts);
|
|
2816
3063
|
case 'darkMode':
|
|
2817
3064
|
case 'dark-mode':
|
|
2818
|
-
return prefersDarkMode(opts
|
|
3065
|
+
return prefersDarkMode(opts);
|
|
2819
3066
|
case 'reducedMotion':
|
|
2820
3067
|
case 'reduced-motion':
|
|
2821
|
-
return prefersReducedMotion(opts
|
|
3068
|
+
return prefersReducedMotion(opts);
|
|
2822
3069
|
case 'mediaQuery':
|
|
2823
|
-
|
|
3070
|
+
if (typeof opts?.query !== 'string')
|
|
3071
|
+
throw new Error(`sensor('mediaQuery') requires a 'query' option, e.g. sensor('mediaQuery', { query: '(min-width: 1024px)' })`);
|
|
3072
|
+
return mediaQuery(opts.query, opts);
|
|
2824
3073
|
case 'windowSize':
|
|
2825
3074
|
return windowSize(opts);
|
|
2826
3075
|
case 'scrollPosition':
|
|
@@ -2832,15 +3081,15 @@ function sensor(type, options) {
|
|
|
2832
3081
|
case 'geolocation':
|
|
2833
3082
|
return geolocation(opts);
|
|
2834
3083
|
case 'clipboard':
|
|
2835
|
-
return clipboard(opts
|
|
3084
|
+
return clipboard(opts);
|
|
2836
3085
|
case 'orientation':
|
|
2837
|
-
return orientation(opts
|
|
3086
|
+
return orientation(opts);
|
|
2838
3087
|
case 'batteryStatus':
|
|
2839
|
-
return batteryStatus(opts
|
|
3088
|
+
return batteryStatus(opts);
|
|
2840
3089
|
case 'idle':
|
|
2841
3090
|
return idle(opts);
|
|
2842
3091
|
case 'focusWithin':
|
|
2843
|
-
return focusWithin(opts?.target);
|
|
3092
|
+
return focusWithin(opts?.target, opts);
|
|
2844
3093
|
default:
|
|
2845
3094
|
throw new Error(`Unknown sensor type: ${type}`);
|
|
2846
3095
|
}
|
|
@@ -2894,16 +3143,24 @@ function signalFromEvent(target, eventName, initial, projectOrOpt, maybeOpt) {
|
|
|
2894
3143
|
else
|
|
2895
3144
|
state.set(event);
|
|
2896
3145
|
};
|
|
2897
|
-
const { destroyRef: providedDestroyRef,
|
|
3146
|
+
const { destroyRef: providedDestroyRef,
|
|
3147
|
+
// strip non-listener keys so they don't leak into addEventListener options
|
|
3148
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
3149
|
+
injector: _injector,
|
|
3150
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
3151
|
+
debugName: _debugName, ...listenerOpts } = opt ?? {};
|
|
2898
3152
|
if (isSignal(target)) {
|
|
2899
3153
|
const targetSig = target;
|
|
2900
|
-
effect((cleanup) => {
|
|
3154
|
+
const effectRef = effect((cleanup) => {
|
|
2901
3155
|
const resolved = unwrap(targetSig());
|
|
2902
3156
|
if (!resolved)
|
|
2903
3157
|
return;
|
|
2904
3158
|
resolved.addEventListener(eventName, handler, listenerOpts);
|
|
2905
3159
|
cleanup(() => resolved.removeEventListener(eventName, handler, listenerOpts));
|
|
2906
|
-
}, { injector });
|
|
3160
|
+
}, { ...(ngDevMode ? { debugName: "effectRef" } : /* istanbul ignore next */ {}), injector });
|
|
3161
|
+
// honor an explicit destroyRef for signal targets too — the effect would otherwise
|
|
3162
|
+
// only follow the injector's lifetime, contradicting the documented option
|
|
3163
|
+
providedDestroyRef?.onDestroy(() => effectRef.destroy());
|
|
2907
3164
|
}
|
|
2908
3165
|
else {
|
|
2909
3166
|
const resolved = unwrap(target);
|
|
@@ -2992,7 +3249,8 @@ function alwaysFalse() {
|
|
|
2992
3249
|
* @internal Attaches a lazy, memoized leaf probe to a store node. The probe (`() => boolean`)
|
|
2993
3250
|
* closes over the node's value signal and its (stable) vivify setting, building the backing
|
|
2994
3251
|
* `computed` on first call so leaf-ness tracks the live value reactively without taxing every
|
|
2995
|
-
* node access.
|
|
3252
|
+
* node access. Under `noUnionLeaves` the caller promises shapes never flip, so the probe is
|
|
3253
|
+
* resolved once from the first sample and frozen as a constant. Idempotent.
|
|
2996
3254
|
*/
|
|
2997
3255
|
function markAsLeaf(sig, value, vivifyEnabled, noUnionLeaves) {
|
|
2998
3256
|
if (typeof sig[LEAF] !== 'function') {
|
|
@@ -3000,13 +3258,11 @@ function markAsLeaf(sig, value, vivifyEnabled, noUnionLeaves) {
|
|
|
3000
3258
|
const probe = () => {
|
|
3001
3259
|
if (memo)
|
|
3002
3260
|
return memo();
|
|
3003
|
-
|
|
3004
|
-
|
|
3005
|
-
|
|
3006
|
-
|
|
3007
|
-
|
|
3008
|
-
: alwaysFalse
|
|
3009
|
-
: computed(() => isLeafValue(value(), vivifyEnabled));
|
|
3261
|
+
memo = noUnionLeaves
|
|
3262
|
+
? isLeafValue(untracked(value), vivifyEnabled)
|
|
3263
|
+
? alwaysTrue
|
|
3264
|
+
: alwaysFalse
|
|
3265
|
+
: computed(() => isLeafValue(value(), vivifyEnabled));
|
|
3010
3266
|
return memo();
|
|
3011
3267
|
};
|
|
3012
3268
|
Object.defineProperty(sig, LEAF, {
|
|
@@ -3094,6 +3350,40 @@ function resolveVivify(sample, option) {
|
|
|
3094
3350
|
function hasOwnKey(value, key) {
|
|
3095
3351
|
return value != null && Object.hasOwn(value, key);
|
|
3096
3352
|
}
|
|
3353
|
+
/**
|
|
3354
|
+
* @internal
|
|
3355
|
+
* Builds the `onChange` for the fallback (non-record container) derivation branch. For an
|
|
3356
|
+
* immutable source the container is copied before the write — returning the same mutated
|
|
3357
|
+
* reference would let the source's equality cut propagation (leaving child signals permanently
|
|
3358
|
+
* stale) and alias the caller's original object, breaking the structural-sharing contract
|
|
3359
|
+
* `forkStore` relies on. For a mutable source the write goes through `mutate`, so the chain's
|
|
3360
|
+
* force-notify engages (plain `update` with the same reference would never notify).
|
|
3361
|
+
*/
|
|
3362
|
+
function createFallbackOnChange(target, prop, vivifyFn, isMutableSource) {
|
|
3363
|
+
const write = (newValue) => (v) => {
|
|
3364
|
+
const container = vivifyFn(v, prop);
|
|
3365
|
+
if (container === null || container === undefined)
|
|
3366
|
+
return container;
|
|
3367
|
+
const next = isMutableSource
|
|
3368
|
+
? container
|
|
3369
|
+
: Array.isArray(container)
|
|
3370
|
+
? container.slice()
|
|
3371
|
+
: isRecord(container)
|
|
3372
|
+
? { ...container }
|
|
3373
|
+
: container; // non-plain leaf (Date/class instance): legacy in-place attempt
|
|
3374
|
+
try {
|
|
3375
|
+
next[prop] = newValue;
|
|
3376
|
+
}
|
|
3377
|
+
catch (e) {
|
|
3378
|
+
if (isDevMode())
|
|
3379
|
+
console.error(`[store] Failed to set property "${String(prop)}"`, e);
|
|
3380
|
+
}
|
|
3381
|
+
return next;
|
|
3382
|
+
};
|
|
3383
|
+
return isMutableSource
|
|
3384
|
+
? (newValue) => target.mutate(write(newValue))
|
|
3385
|
+
: (newValue) => target.update(write(newValue));
|
|
3386
|
+
}
|
|
3097
3387
|
/**
|
|
3098
3388
|
* @internal
|
|
3099
3389
|
* Makes an array store
|
|
@@ -3116,7 +3406,9 @@ function toArrayStore(source, injector, vivify, noUnionLeaves = false) {
|
|
|
3116
3406
|
const idx = +prop;
|
|
3117
3407
|
return idx >= 0 && idx < untracked(lengthSignal);
|
|
3118
3408
|
}
|
|
3119
|
-
|
|
3409
|
+
const v = untracked(source);
|
|
3410
|
+
// nullish node values are routinely descended with vivify on — `in` must not throw
|
|
3411
|
+
return v == null ? false : Reflect.has(v, prop);
|
|
3120
3412
|
},
|
|
3121
3413
|
ownKeys() {
|
|
3122
3414
|
const v = untracked(source);
|
|
@@ -3153,7 +3445,9 @@ function toArrayStore(source, injector, vivify, noUnionLeaves = false) {
|
|
|
3153
3445
|
return lengthSignal;
|
|
3154
3446
|
if (prop === Symbol.iterator) {
|
|
3155
3447
|
return function* () {
|
|
3156
|
-
|
|
3448
|
+
// read length reactively: a spread/for-of inside a computed/effect must re-run
|
|
3449
|
+
// when items are added or removed, not only when already-read elements change
|
|
3450
|
+
for (let i = 0; i < lengthSignal(); i++) {
|
|
3157
3451
|
yield receiver[i];
|
|
3158
3452
|
}
|
|
3159
3453
|
};
|
|
@@ -3192,19 +3486,8 @@ function toArrayStore(source, injector, vivify, noUnionLeaves = false) {
|
|
|
3192
3486
|
})
|
|
3193
3487
|
: derived(target, {
|
|
3194
3488
|
from: (v) => v?.[idx],
|
|
3195
|
-
onChange: (
|
|
3196
|
-
|
|
3197
|
-
if (container === null || container === undefined)
|
|
3198
|
-
return container;
|
|
3199
|
-
try {
|
|
3200
|
-
container[idx] = newValue;
|
|
3201
|
-
}
|
|
3202
|
-
catch (e) {
|
|
3203
|
-
if (isDevMode())
|
|
3204
|
-
console.error(`[store] Failed to set property "${String(idx)}"`, e);
|
|
3205
|
-
}
|
|
3206
|
-
return container;
|
|
3207
|
-
}),
|
|
3489
|
+
onChange: createFallbackOnChange(target, idx, vivifyFn, isMutableSource),
|
|
3490
|
+
equal: equalFn,
|
|
3208
3491
|
});
|
|
3209
3492
|
const childSample = untracked(computation);
|
|
3210
3493
|
const childVivify = resolveVivify(childSample, vivify);
|
|
@@ -3224,6 +3507,13 @@ function toArrayStore(source, injector, vivify, noUnionLeaves = false) {
|
|
|
3224
3507
|
/**
|
|
3225
3508
|
* Converts a Signal into a deep-observable Store.
|
|
3226
3509
|
* Accessing nested properties returns a derived Signal of that path.
|
|
3510
|
+
*
|
|
3511
|
+
* @remarks
|
|
3512
|
+
* A child's *container kind* (array store vs object store) is resolved when the child is
|
|
3513
|
+
* first accessed and cached with the proxy. Leaf↔substore flips are tracked reactively, but a
|
|
3514
|
+
* union-typed node that later flips between an array and a record keeps its original trap set —
|
|
3515
|
+
* if you need that, re-model the union as `{ kind: ..., value: ... }` instead.
|
|
3516
|
+
*
|
|
3227
3517
|
* @example
|
|
3228
3518
|
* const state = store({ user: { name: 'John' } });
|
|
3229
3519
|
* const nameSignal = state.user.name; // WritableSignal<string>
|
|
@@ -3306,19 +3596,8 @@ function toStore(source, injector, vivify = false, noUnionLeaves = false) {
|
|
|
3306
3596
|
? derived(target, prop, { equal: equalFn, vivify: nodeVivify })
|
|
3307
3597
|
: derived(target, {
|
|
3308
3598
|
from: (v) => v?.[prop],
|
|
3309
|
-
onChange: (
|
|
3310
|
-
|
|
3311
|
-
if (container === null || container === undefined)
|
|
3312
|
-
return container;
|
|
3313
|
-
try {
|
|
3314
|
-
container[prop] = newValue;
|
|
3315
|
-
}
|
|
3316
|
-
catch (e) {
|
|
3317
|
-
if (isDevMode())
|
|
3318
|
-
console.error(`[store] Failed to set property "${String(prop)}"`, e);
|
|
3319
|
-
}
|
|
3320
|
-
return container;
|
|
3321
|
-
}),
|
|
3599
|
+
onChange: createFallbackOnChange(target, prop, vivifyFn, isMutableSource),
|
|
3600
|
+
equal: equalFn,
|
|
3322
3601
|
});
|
|
3323
3602
|
const childSample = untracked(computation);
|
|
3324
3603
|
const childVivify = resolveVivify(childSample, vivify);
|
|
@@ -3460,7 +3739,12 @@ function merge3(ancestor, mine, theirs) {
|
|
|
3460
3739
|
if (isPlainRecord(mine) && isPlainRecord(theirs) && isPlainRecord(ancestor)) {
|
|
3461
3740
|
const out = { ...theirs };
|
|
3462
3741
|
for (const key of new Set([...Object.keys(mine), ...Object.keys(theirs)])) {
|
|
3463
|
-
|
|
3742
|
+
const merged = merge3(ancestor[key], mine[key], theirs[key]);
|
|
3743
|
+
// a key deleted on the fork must commit as ABSENT, not as an explicit `undefined`
|
|
3744
|
+
if (merged === undefined && !(key in mine))
|
|
3745
|
+
delete out[key];
|
|
3746
|
+
else
|
|
3747
|
+
out[key] = merged;
|
|
3464
3748
|
}
|
|
3465
3749
|
return out;
|
|
3466
3750
|
}
|
|
@@ -3512,8 +3796,8 @@ const noopStore = {
|
|
|
3512
3796
|
*
|
|
3513
3797
|
* @template T The type of value held by the signal and stored (after serialization).
|
|
3514
3798
|
* @param fallback The default value of type `T` to use when no value is found in storage
|
|
3515
|
-
* or when deserialization fails.
|
|
3516
|
-
*
|
|
3799
|
+
* or when deserialization fails. A stored value (including a legitimate `null` for a
|
|
3800
|
+
* nullable `T`) always round-trips; the fallback only surfaces when the entry is absent.
|
|
3517
3801
|
* @param options Configuration options (`CreateStoredOptions<T>`). Requires at least the `key`.
|
|
3518
3802
|
* @returns A `StoredSignal<T>` instance. This signal behaves like a standard `WritableSignal<T>`,
|
|
3519
3803
|
* but its value is persisted. It includes a `.clear()` method to remove the item from storage
|
|
@@ -3526,7 +3810,8 @@ const noopStore = {
|
|
|
3526
3810
|
* - **Error Handling:** Catches and logs errors during serialization/deserialization in dev mode.
|
|
3527
3811
|
* - **Tab Sync:** If `syncTabs` is true, listens to `storage` events to keep the signal value
|
|
3528
3812
|
* consistent across browser tabs using the same key. Cleanup is handled automatically
|
|
3529
|
-
* using `DestroyRef`.
|
|
3813
|
+
* using `DestroyRef`. Web Storage only: the `storage` event never fires for custom `store`
|
|
3814
|
+
* adapters, so `syncTabs` has no effect with one.
|
|
3530
3815
|
* - **Removal:** Use the `.clear()` method on the returned signal to remove the item from storage.
|
|
3531
3816
|
* Setting the signal to the fallback value will store the fallback value, not remove the item.
|
|
3532
3817
|
*
|
|
@@ -3561,25 +3846,28 @@ function stored(fallback, { key, store: providedStore, serialize = JSON.stringif
|
|
|
3561
3846
|
: isSignal(key)
|
|
3562
3847
|
? key
|
|
3563
3848
|
: computed(key);
|
|
3849
|
+
// "no stored value" marker — distinct from `null`/`undefined`, so a nullable `T` can
|
|
3850
|
+
// round-trip a legitimate `null` through `set` instead of it acting like `clear()`
|
|
3851
|
+
const EMPTY = Symbol();
|
|
3564
3852
|
const getValue = (key) => {
|
|
3565
3853
|
const found = store.getItem(key);
|
|
3566
3854
|
if (found === null)
|
|
3567
|
-
return
|
|
3855
|
+
return EMPTY;
|
|
3568
3856
|
try {
|
|
3569
3857
|
const deserialized = deserialize(found);
|
|
3570
3858
|
if (!validate(deserialized))
|
|
3571
|
-
return
|
|
3859
|
+
return EMPTY;
|
|
3572
3860
|
return deserialized;
|
|
3573
3861
|
}
|
|
3574
3862
|
catch (err) {
|
|
3575
3863
|
if (isDevMode())
|
|
3576
3864
|
console.error(`Failed to parse stored value for key "${key}":`, err);
|
|
3577
|
-
return
|
|
3865
|
+
return EMPTY;
|
|
3578
3866
|
}
|
|
3579
3867
|
};
|
|
3580
3868
|
const storeValue = (key, value) => {
|
|
3581
3869
|
try {
|
|
3582
|
-
if (value ===
|
|
3870
|
+
if (value === EMPTY)
|
|
3583
3871
|
return store.removeItem(key);
|
|
3584
3872
|
const serialized = serialize(value);
|
|
3585
3873
|
store.setItem(key, serialized);
|
|
@@ -3596,9 +3884,9 @@ function stored(fallback, { key, store: providedStore, serialize = JSON.stringif
|
|
|
3596
3884
|
const initialKey = untracked(keySig);
|
|
3597
3885
|
const internal = signal(getValue(initialKey), { ...(ngDevMode ? { debugName: "internal" } : /* istanbul ignore next */ {}), ...opt,
|
|
3598
3886
|
equal: (a, b) => {
|
|
3599
|
-
if (a ===
|
|
3887
|
+
if (a === EMPTY && b === EMPTY)
|
|
3600
3888
|
return true;
|
|
3601
|
-
if (a ===
|
|
3889
|
+
if (a === EMPTY || b === EMPTY)
|
|
3602
3890
|
return false;
|
|
3603
3891
|
return equal(a, b);
|
|
3604
3892
|
} });
|
|
@@ -3633,19 +3921,27 @@ function stored(fallback, { key, store: providedStore, serialize = JSON.stringif
|
|
|
3633
3921
|
if (syncTabs && !isServer) {
|
|
3634
3922
|
const destroyRef = inject(DestroyRef);
|
|
3635
3923
|
const sync = (e) => {
|
|
3924
|
+
// `storage` events only describe Web Storage — ignore events for a different
|
|
3925
|
+
// storage area (or any event when a custom adapter is configured), otherwise an
|
|
3926
|
+
// unrelated localStorage write with the same key string corrupts our state
|
|
3927
|
+
if (e.storageArea !== store)
|
|
3928
|
+
return;
|
|
3636
3929
|
if (e.key !== untracked(keySig))
|
|
3637
3930
|
return;
|
|
3638
3931
|
if (e.newValue === null)
|
|
3639
|
-
internal.set(
|
|
3932
|
+
internal.set(EMPTY);
|
|
3640
3933
|
else
|
|
3641
3934
|
internal.set(getValue(e.key));
|
|
3642
3935
|
};
|
|
3643
3936
|
window.addEventListener('storage', sync);
|
|
3644
3937
|
destroyRef.onDestroy(() => window.removeEventListener('storage', sync));
|
|
3645
3938
|
}
|
|
3646
|
-
const writable = toWritable(computed(() =>
|
|
3939
|
+
const writable = toWritable(computed(() => {
|
|
3940
|
+
const v = internal();
|
|
3941
|
+
return v === EMPTY ? fallback : v;
|
|
3942
|
+
}, opt), internal.set);
|
|
3647
3943
|
writable.clear = () => {
|
|
3648
|
-
internal.set(
|
|
3944
|
+
internal.set(EMPTY);
|
|
3649
3945
|
};
|
|
3650
3946
|
writable.key = keySig;
|
|
3651
3947
|
return writable;
|
|
@@ -3655,7 +3951,6 @@ class MessageBus {
|
|
|
3655
3951
|
channel = new BroadcastChannel('mmstack-tab-sync-bus');
|
|
3656
3952
|
listeners = new Map();
|
|
3657
3953
|
subscribe(id, listener) {
|
|
3658
|
-
this.unsubscribe(id); // Ensure no duplicate listeners
|
|
3659
3954
|
const wrapped = (ev) => {
|
|
3660
3955
|
try {
|
|
3661
3956
|
if (ev.data?.id === id)
|
|
@@ -3666,18 +3961,28 @@ class MessageBus {
|
|
|
3666
3961
|
}
|
|
3667
3962
|
};
|
|
3668
3963
|
this.channel.addEventListener('message', wrapped);
|
|
3669
|
-
this.listeners.
|
|
3964
|
+
let set = this.listeners.get(id);
|
|
3965
|
+
if (!set) {
|
|
3966
|
+
set = new Set();
|
|
3967
|
+
this.listeners.set(id, set);
|
|
3968
|
+
}
|
|
3969
|
+
set.add(wrapped);
|
|
3670
3970
|
return {
|
|
3671
|
-
unsub: (
|
|
3672
|
-
|
|
3971
|
+
unsub: () => {
|
|
3972
|
+
this.channel.removeEventListener('message', wrapped);
|
|
3973
|
+
const cur = this.listeners.get(id);
|
|
3974
|
+
if (!cur)
|
|
3975
|
+
return;
|
|
3976
|
+
cur.delete(wrapped);
|
|
3977
|
+
if (cur.size === 0)
|
|
3978
|
+
this.listeners.delete(id);
|
|
3979
|
+
},
|
|
3980
|
+
post: (value) => this.channel.postMessage({ id, value }),
|
|
3673
3981
|
};
|
|
3674
3982
|
}
|
|
3675
|
-
|
|
3676
|
-
|
|
3677
|
-
|
|
3678
|
-
return;
|
|
3679
|
-
this.channel.removeEventListener('message', listener);
|
|
3680
|
-
this.listeners.delete(id);
|
|
3983
|
+
ngOnDestroy() {
|
|
3984
|
+
this.channel.close();
|
|
3985
|
+
this.listeners.clear();
|
|
3681
3986
|
}
|
|
3682
3987
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: MessageBus, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
3683
3988
|
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: MessageBus, providedIn: 'root' });
|
|
@@ -3688,6 +3993,11 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
|
|
|
3688
3993
|
providedIn: 'root',
|
|
3689
3994
|
}]
|
|
3690
3995
|
}] });
|
|
3996
|
+
/**
|
|
3997
|
+
* @deprecated The generated id hashes the call-site stack line, which collides when a shared
|
|
3998
|
+
* helper calls {@link tabSync} for multiple signals and diverges across minified builds during
|
|
3999
|
+
* a rolling deploy. Pass an explicit `{ id }` instead.
|
|
4000
|
+
*/
|
|
3691
4001
|
function generateDeterministicID() {
|
|
3692
4002
|
const stack = new Error().stack;
|
|
3693
4003
|
if (stack) {
|
|
@@ -3725,10 +4035,8 @@ function generateDeterministicID() {
|
|
|
3725
4035
|
*
|
|
3726
4036
|
* @example
|
|
3727
4037
|
* ```typescript
|
|
3728
|
-
* //
|
|
3729
|
-
* const theme = tabSync(signal('dark'));
|
|
3730
|
-
*
|
|
3731
|
-
* // With explicit ID (recommended for production)
|
|
4038
|
+
* // With explicit ID (recommended)
|
|
4039
|
+
* const theme = tabSync(signal('dark'), { id: 'theme' });
|
|
3732
4040
|
* const userPrefs = tabSync(signal({ lang: 'en' }), { id: 'user-preferences' });
|
|
3733
4041
|
*
|
|
3734
4042
|
* // Changes in one tab will sync to all other tabs
|
|
@@ -3740,6 +4048,7 @@ function generateDeterministicID() {
|
|
|
3740
4048
|
* - Uses a single BroadcastChannel for all synchronized signals
|
|
3741
4049
|
* - Automatically cleans up listeners when the injection context is destroyed
|
|
3742
4050
|
* - Initial signal value after sync setup is not broadcasted to prevent loops
|
|
4051
|
+
* - Received values are not re-broadcast, so tabs never echo each other's updates
|
|
3743
4052
|
*
|
|
3744
4053
|
*/
|
|
3745
4054
|
function tabSync(sig, opt) {
|
|
@@ -3747,7 +4056,20 @@ function tabSync(sig, opt) {
|
|
|
3747
4056
|
return sig;
|
|
3748
4057
|
const id = typeof opt === 'string' ? opt : (opt?.id ?? generateDeterministicID());
|
|
3749
4058
|
const bus = inject(MessageBus);
|
|
3750
|
-
|
|
4059
|
+
// The last value applied from a remote tab. The outbound effect skips (exactly) the run
|
|
4060
|
+
// caused by that write — without this, an inbound object (a fresh structured clone, so
|
|
4061
|
+
// never reference-equal) would be re-posted, and two tabs would ping-pong forever.
|
|
4062
|
+
const NONE = Symbol();
|
|
4063
|
+
let received = NONE;
|
|
4064
|
+
const { unsub, post } = bus.subscribe(id, (next) => {
|
|
4065
|
+
const before = untracked(sig);
|
|
4066
|
+
received = next;
|
|
4067
|
+
sig.set(next);
|
|
4068
|
+
// Equality-suppressed write (e.g. an identical primitive): no effect run will follow,
|
|
4069
|
+
// so clear the marker — it must not swallow a later, genuinely local change.
|
|
4070
|
+
if (untracked(sig) === before)
|
|
4071
|
+
received = NONE;
|
|
4072
|
+
});
|
|
3751
4073
|
let first = false;
|
|
3752
4074
|
const effectRef = effect(() => {
|
|
3753
4075
|
const val = sig();
|
|
@@ -3755,6 +4077,11 @@ function tabSync(sig, opt) {
|
|
|
3755
4077
|
first = true;
|
|
3756
4078
|
return;
|
|
3757
4079
|
}
|
|
4080
|
+
if (val === received) {
|
|
4081
|
+
received = NONE;
|
|
4082
|
+
return;
|
|
4083
|
+
}
|
|
4084
|
+
received = NONE;
|
|
3758
4085
|
post(val);
|
|
3759
4086
|
}, ...(ngDevMode ? [{ debugName: "effectRef" }] : /* istanbul ignore next */ []));
|
|
3760
4087
|
inject(DestroyRef).onDestroy(() => {
|
|
@@ -3765,7 +4092,6 @@ function tabSync(sig, opt) {
|
|
|
3765
4092
|
}
|
|
3766
4093
|
|
|
3767
4094
|
function until(sourceSignal, predicate, options = {}) {
|
|
3768
|
-
const injector = options.injector ?? inject(Injector);
|
|
3769
4095
|
return new Promise((resolve, reject) => {
|
|
3770
4096
|
let effectRef;
|
|
3771
4097
|
let timeoutId;
|
|
@@ -3802,6 +4128,14 @@ function until(sourceSignal, predicate, options = {}) {
|
|
|
3802
4128
|
cleanupAndResolve(initialValue);
|
|
3803
4129
|
return;
|
|
3804
4130
|
}
|
|
4131
|
+
let injector;
|
|
4132
|
+
try {
|
|
4133
|
+
injector = options.injector ?? inject(Injector);
|
|
4134
|
+
}
|
|
4135
|
+
catch {
|
|
4136
|
+
cleanupAndReject('until: No injector available — provide options.injector when calling outside an injection context.');
|
|
4137
|
+
return;
|
|
4138
|
+
}
|
|
3805
4139
|
if (options?.timeout !== undefined) {
|
|
3806
4140
|
timeoutId = setTimeout(() => cleanupAndReject(`until: Timeout after ${options.timeout}ms.`), options.timeout);
|
|
3807
4141
|
}
|
|
@@ -3819,17 +4153,6 @@ function until(sourceSignal, predicate, options = {}) {
|
|
|
3819
4153
|
});
|
|
3820
4154
|
}
|
|
3821
4155
|
|
|
3822
|
-
/**
|
|
3823
|
-
* @interal
|
|
3824
|
-
*/
|
|
3825
|
-
function getSignalEquality(sig) {
|
|
3826
|
-
const internal = sig[SIGNAL];
|
|
3827
|
-
if (internal && typeof internal.equal === 'function') {
|
|
3828
|
-
return internal.equal;
|
|
3829
|
-
}
|
|
3830
|
-
return Object.is; // Default equality check
|
|
3831
|
-
}
|
|
3832
|
-
|
|
3833
4156
|
/**
|
|
3834
4157
|
* Enhances an existing `WritableSignal` by adding a complete undo/redo history
|
|
3835
4158
|
* stack and an API to control it.
|
|
@@ -3878,9 +4201,10 @@ function getSignalEquality(sig) {
|
|
|
3878
4201
|
* ```
|
|
3879
4202
|
*/
|
|
3880
4203
|
function withHistory(sourceOrValue, opt) {
|
|
3881
|
-
const equal =
|
|
3882
|
-
|
|
3883
|
-
|
|
4204
|
+
const equal = opt?.equal ??
|
|
4205
|
+
(isSignal(sourceOrValue)
|
|
4206
|
+
? getSignalEquality(sourceOrValue)
|
|
4207
|
+
: Object.is);
|
|
3884
4208
|
const source = isSignal(sourceOrValue)
|
|
3885
4209
|
? sourceOrValue
|
|
3886
4210
|
: signal(sourceOrValue);
|
|
@@ -3925,9 +4249,8 @@ function withHistory(sourceOrValue, opt) {
|
|
|
3925
4249
|
if (historyStack.length === 0)
|
|
3926
4250
|
return;
|
|
3927
4251
|
const valueForRedo = untracked(source);
|
|
3928
|
-
|
|
3929
|
-
|
|
3930
|
-
return;
|
|
4252
|
+
// length checked above — a legitimately `undefined` entry must still restore
|
|
4253
|
+
const valueToRestore = historyStack[historyStack.length - 1];
|
|
3931
4254
|
originalSet.call(source, valueToRestore);
|
|
3932
4255
|
history.inline((h) => h.pop());
|
|
3933
4256
|
redoArray.mutate((r) => {
|
|
@@ -3941,9 +4264,8 @@ function withHistory(sourceOrValue, opt) {
|
|
|
3941
4264
|
if (redoStack.length === 0)
|
|
3942
4265
|
return;
|
|
3943
4266
|
const valueForUndo = untracked(source);
|
|
3944
|
-
|
|
3945
|
-
|
|
3946
|
-
return;
|
|
4267
|
+
// length checked above — a legitimately `undefined` entry must still restore
|
|
4268
|
+
const valueToRestore = redoStack[redoStack.length - 1];
|
|
3947
4269
|
originalSet.call(source, valueToRestore);
|
|
3948
4270
|
redoArray.inline((r) => r.pop());
|
|
3949
4271
|
history.mutate((h) => {
|
|
@@ -3966,5 +4288,5 @@ function withHistory(sourceOrValue, opt) {
|
|
|
3966
4288
|
* Generated bundle index. Do not edit.
|
|
3967
4289
|
*/
|
|
3968
4290
|
|
|
3969
|
-
export { MmActivity, SuspenseBoundary, SuspenseBoundaryBase, UnscopedSuspenseBoundary, activeTransaction, batteryStatus, chunked, clipboard, combineWith, createTransaction, createTransitionScope, debounce, debounced, derived, distinct, elementSize, elementVisibility, filter, filterWith, focusWithin, forkStore, geolocation, holdUntilReady, idle, indexArray, injectPaused, injectRegisterResource, injectStartTransaction, injectStartTransition, injectTransitionScope, isDerivation, isLeaf, isMutable, isOpaque, isStore, keepPrevious, keyArray, map, mapArray, mapObject, mediaQuery, merge3, mousePosition, mutable, mutableStore, nestedEffect, networkStatus, opaque, orientation, pageVisibility, pairwise, pausableComputed, pausableEffect, pausableSignal, pipeable, piped, pooled, pooledArray, pooledMap, pooledSet, prefersDarkMode, prefersReducedMotion, providePaused, provideTransitionScope, registerResource, resolvePause, scan, scrollPosition, select, sensor, sensors, signalFromEvent, startWith, store, stored, tabSync, tap, throttle, throttled, toFakeDerivation, toFakeSignalDerivation, toStore, toWritable, until, windowSize, withHistory };
|
|
4291
|
+
export { MmActivity, SuspenseBoundary, SuspenseBoundaryBase, UnscopedSuspenseBoundary, activeTransaction, batteryStatus, chunked, clipboard, combineWith, createForwardingScope, createTransaction, createTransitionScope, debounce, debounced, derived, distinct, elementSize, elementVisibility, filter, filterWith, focusWithin, forkStore, geolocation, getTransitionScope, holdUntilReady, idle, indexArray, injectPaused, injectRegisterResource, injectStartTransaction, injectStartTransition, injectTransitionScope, isDerivation, isLeaf, isMutable, isOpaque, isStore, keepPrevious, keyArray, map, mapArray, mapObject, mediaQuery, merge3, mousePosition, mutable, mutableStore, nestedEffect, networkStatus, opaque, orientation, pageVisibility, pairwise, pausableComputed, pausableEffect, pausableSignal, pipeable, piped, pooled, pooledArray, pooledMap, pooledSet, prefersDarkMode, prefersReducedMotion, provideForwardingTransitionScope, providePaused, provideTransitionScope, registerResource, resolvePause, scan, scrollPosition, select, sensor, sensors, signalFromEvent, startWith, store, stored, tabSync, tap, throttle, throttled, toFakeDerivation, toFakeSignalDerivation, toStore, toWritable, until, windowSize, withHistory };
|
|
3970
4292
|
//# sourceMappingURL=mmstack-primitives.mjs.map
|