@mmstack/primitives 20.6.1 → 20.7.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 +575 -249
- package/fesm2022/mmstack-primitives.mjs.map +1 -1
- package/index.d.ts +128 -107
- package/package.json +2 -2
|
@@ -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) => {
|
|
@@ -266,7 +273,9 @@ class MmActivity {
|
|
|
266
273
|
});
|
|
267
274
|
}
|
|
268
275
|
for (const node of this.view.rootNodes) {
|
|
269
|
-
|
|
276
|
+
// covers HTML and SVG roots; text/comment roots can't be styled — their CD is still
|
|
277
|
+
// detached, but prefer an element root for true visual hiding
|
|
278
|
+
if (node instanceof HTMLElement || node instanceof SVGElement)
|
|
270
279
|
node.style.display = visible ? '' : 'none';
|
|
271
280
|
}
|
|
272
281
|
if (visible)
|
|
@@ -345,14 +354,25 @@ function resolvePause(opt) {
|
|
|
345
354
|
if (pause === false)
|
|
346
355
|
return null;
|
|
347
356
|
const run = (fn) => opt?.injector ? runInInjectionContext(opt.injector, fn) : fn();
|
|
357
|
+
// `inject` requires an injection context even with `optional: true`. A bare
|
|
358
|
+
// `pausableSignal(0)` (documented as "like `signal`") must degrade to the unwrapped
|
|
359
|
+
// primitive outside DI, not throw NG0203 — so injection failures fall back gracefully.
|
|
360
|
+
const tryRun = (fn, fallback) => {
|
|
361
|
+
try {
|
|
362
|
+
return run(fn);
|
|
363
|
+
}
|
|
364
|
+
catch {
|
|
365
|
+
return fallback;
|
|
366
|
+
}
|
|
367
|
+
};
|
|
348
368
|
const onServer = () => typeof pause === 'function' && !opt?.injector
|
|
349
369
|
? typeof globalThis.window === 'undefined'
|
|
350
|
-
:
|
|
370
|
+
: tryRun(() => isPlatformServer(inject(PLATFORM_ID, { optional: true }) ?? 'browser'), typeof globalThis.window === 'undefined');
|
|
351
371
|
if (typeof pause === 'function')
|
|
352
372
|
return onServer() ? null : pause;
|
|
353
373
|
if (onServer())
|
|
354
374
|
return null;
|
|
355
|
-
const paused =
|
|
375
|
+
const paused = tryRun(() => inject(PAUSED_CONTEXT, { optional: true }), null);
|
|
356
376
|
if (!paused) {
|
|
357
377
|
if (explicit === true && isDevMode())
|
|
358
378
|
console.warn('[pausable] `pause: true` but no PAUSED_CONTEXT in scope — not pausing. Provide one via an ' +
|
|
@@ -381,8 +401,9 @@ function pausableEffect(effectFn, options) {
|
|
|
381
401
|
/**
|
|
382
402
|
* Like `signal`, but pausable. While paused, READS hold the last value; writes still land on the
|
|
383
403
|
* underlying signal and surface on resume. Built on the `keepPrevious`/`hold` shape — a
|
|
384
|
-
* `linkedSignal` gated on the pause predicate, with `set`/`update
|
|
385
|
-
*
|
|
404
|
+
* `linkedSignal` gated on the pause predicate, with `set`/`update` forwarded to the source signal.
|
|
405
|
+
* `asReadonly()` returns the held (gated) view, so both views of the signal agree while paused.
|
|
406
|
+
* With no `pause` option it defaults to the ambient `PAUSED_CONTEXT`; `pause: false`
|
|
386
407
|
* makes it a plain `signal` — no `linkedSignal` is created.
|
|
387
408
|
*
|
|
388
409
|
* NOTE: while paused, `set(x)` followed by a read returns the *held* (pre-pause) value, not `x` — the
|
|
@@ -403,7 +424,8 @@ function pausableSignal(initialValue, options) {
|
|
|
403
424
|
}]));
|
|
404
425
|
read.set = src.set;
|
|
405
426
|
read.update = src.update;
|
|
406
|
-
|
|
427
|
+
// NOTE: `asReadonly` deliberately stays the linkedSignal's own (the held view) — the
|
|
428
|
+
// source's readonly view would show live values while the signal itself shows held ones.
|
|
407
429
|
return read;
|
|
408
430
|
}
|
|
409
431
|
/**
|
|
@@ -444,8 +466,12 @@ function mutable(initial, opt) {
|
|
|
444
466
|
const internalUpdate = sig.update;
|
|
445
467
|
sig.mutate = (updater) => {
|
|
446
468
|
cnt++;
|
|
447
|
-
|
|
448
|
-
|
|
469
|
+
try {
|
|
470
|
+
internalUpdate(updater);
|
|
471
|
+
}
|
|
472
|
+
finally {
|
|
473
|
+
cnt--;
|
|
474
|
+
}
|
|
449
475
|
};
|
|
450
476
|
sig.inline = (updater) => {
|
|
451
477
|
sig.mutate((prev) => {
|
|
@@ -532,7 +558,7 @@ function createNoopScope() {
|
|
|
532
558
|
hold: (value) => value,
|
|
533
559
|
};
|
|
534
560
|
}
|
|
535
|
-
const TRANSITION_SCOPE = new InjectionToken('@mmstack/
|
|
561
|
+
const TRANSITION_SCOPE = new InjectionToken('@mmstack/primitives:transition-scope');
|
|
536
562
|
/** Provide a fresh transition scope at a boundary so its subtree's resources are tracked independently. */
|
|
537
563
|
function provideTransitionScope() {
|
|
538
564
|
return { provide: TRANSITION_SCOPE, useFactory: createTransitionScope };
|
|
@@ -541,12 +567,53 @@ function injectTransitionScope() {
|
|
|
541
567
|
const scope = inject(TRANSITION_SCOPE, { optional: true });
|
|
542
568
|
if (!scope) {
|
|
543
569
|
if (isDevMode())
|
|
544
|
-
console.warn('[mmstack/
|
|
570
|
+
console.warn('[mmstack/primitives] No transition scope in context — registration/tracking here is a no-op. ' +
|
|
545
571
|
'Use a <mm-suspense> boundary or provideTransitionScope() in an ancestor.');
|
|
546
572
|
return createNoopScope();
|
|
547
573
|
}
|
|
548
574
|
return scope;
|
|
549
575
|
}
|
|
576
|
+
function createForwardingScope() {
|
|
577
|
+
const own = createTransitionScope();
|
|
578
|
+
const target = signal(null, ...(ngDevMode ? [{ debugName: "target" }] : []));
|
|
579
|
+
const eff = () => target() ?? own;
|
|
580
|
+
const owners = new Map();
|
|
581
|
+
return {
|
|
582
|
+
setTarget: (t) => target.set(t),
|
|
583
|
+
resources: computed(() => eff().resources()),
|
|
584
|
+
pending: computed(() => eff().pending()),
|
|
585
|
+
suspended: (type) => eff().suspended(type),
|
|
586
|
+
add: (ref, opt) => {
|
|
587
|
+
const t = untracked(target) ?? own;
|
|
588
|
+
owners.set(ref, t);
|
|
589
|
+
t.add(ref, opt);
|
|
590
|
+
},
|
|
591
|
+
remove: (ref) => {
|
|
592
|
+
const t = owners.get(ref) ?? untracked(target) ?? own;
|
|
593
|
+
t.remove(ref);
|
|
594
|
+
owners.delete(ref);
|
|
595
|
+
},
|
|
596
|
+
commit: (value) => linkedSignal({
|
|
597
|
+
source: () => ({ v: value(), settled: !eff().pending() }),
|
|
598
|
+
computation: (curr, prev) => curr.settled || prev === undefined ? curr.v : prev.value,
|
|
599
|
+
}),
|
|
600
|
+
holding: computed(() => eff().holding()),
|
|
601
|
+
beginHold: () => (untracked(target) ?? own).beginHold(),
|
|
602
|
+
endHold: () => (untracked(target) ?? own).endHold(),
|
|
603
|
+
hold: (value) => linkedSignal({
|
|
604
|
+
source: () => ({ v: value(), held: eff().holding() }),
|
|
605
|
+
computation: (curr, prev) => prev !== undefined && curr.held ? prev.value : curr.v,
|
|
606
|
+
}),
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
/** Provide a forwarding transition scope at a boundary (used by the transition outlet). */
|
|
610
|
+
function provideForwardingTransitionScope() {
|
|
611
|
+
return { provide: TRANSITION_SCOPE, useFactory: createForwardingScope };
|
|
612
|
+
}
|
|
613
|
+
/** Read the transition scope reachable from `injector`, or null if none is provided there. */
|
|
614
|
+
function getTransitionScope(injector) {
|
|
615
|
+
return injector.get(TRANSITION_SCOPE, null);
|
|
616
|
+
}
|
|
550
617
|
/**
|
|
551
618
|
* Returns a register function bound to the nearest transition scope: it adds a resource
|
|
552
619
|
* to the scope and removes it when the caller's injection context is destroyed. Pass any
|
|
@@ -575,6 +642,11 @@ function registerResource(res, opt) {
|
|
|
575
642
|
*
|
|
576
643
|
* Must be called in an injection context. This is the *async* generalization (Tier 2): it adds
|
|
577
644
|
* no rendering cost and needs no fork — holding direct/sync readers is a separate, deferred tier.
|
|
645
|
+
*
|
|
646
|
+
* Caveat: work must go in flight by the first post-write render to be awaited. A loader that
|
|
647
|
+
* starts later (a debounced request signal, a chained/deferred resource) is not attributable to
|
|
648
|
+
* this transition — the no-async fallback will have already resolved `done`. Trigger such work
|
|
649
|
+
* eagerly inside `fn`, or coordinate it separately.
|
|
578
650
|
*/
|
|
579
651
|
function injectStartTransition() {
|
|
580
652
|
const scope = injectTransitionScope();
|
|
@@ -724,6 +796,11 @@ function runInTransaction(txn, fn) {
|
|
|
724
796
|
* The writes land on LIVE state immediately (so derived variables and connector requests see the
|
|
725
797
|
* new values and refetch); only the *display* is held, via `scope.hold`. Must run in an injection
|
|
726
798
|
* context.
|
|
799
|
+
*
|
|
800
|
+
* Caveat: work must go in flight by the first post-write render to be part of the transaction. A
|
|
801
|
+
* loader that starts later (a debounced request signal, a chained/deferred resource) is not
|
|
802
|
+
* attributable to it — the no-async fallback will have already committed and released the hold,
|
|
803
|
+
* after which `abort()` is a no-op. Trigger such work eagerly inside `fn`.
|
|
727
804
|
*/
|
|
728
805
|
function injectStartTransaction() {
|
|
729
806
|
const scope = injectTransitionScope();
|
|
@@ -733,7 +810,15 @@ function injectStartTransaction() {
|
|
|
733
810
|
// Hold BEFORE the writes, so the display freezes at pre-transaction values.
|
|
734
811
|
scope.beginHold();
|
|
735
812
|
let finished = false;
|
|
813
|
+
// eslint-disable-next-line prefer-const -- assigned in try/catch, but needs to be declared here for the `finally` block to see it
|
|
736
814
|
let watcher;
|
|
815
|
+
let resolveDone;
|
|
816
|
+
const done = new Promise((resolve) => {
|
|
817
|
+
resolveDone = resolve;
|
|
818
|
+
});
|
|
819
|
+
// Every exit path funnels through here, so `done` always settles — including `abort()`
|
|
820
|
+
// and a throwing transaction body (which would otherwise leak the hold forever and
|
|
821
|
+
// freeze the boundary with no recovery).
|
|
737
822
|
const finish = (restore) => {
|
|
738
823
|
if (finished)
|
|
739
824
|
return;
|
|
@@ -744,27 +829,28 @@ function injectStartTransaction() {
|
|
|
744
829
|
else
|
|
745
830
|
txn.clear();
|
|
746
831
|
scope.endHold();
|
|
832
|
+
resolveDone();
|
|
747
833
|
};
|
|
748
|
-
|
|
834
|
+
try {
|
|
835
|
+
runInTransaction(txn, fn);
|
|
836
|
+
}
|
|
837
|
+
catch (e) {
|
|
838
|
+
finish(true);
|
|
839
|
+
throw e;
|
|
840
|
+
}
|
|
749
841
|
let sawPending = false;
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
if (!sawPending && !untracked(scope.pending)) {
|
|
763
|
-
finish(false);
|
|
764
|
-
resolve();
|
|
765
|
-
}
|
|
766
|
-
}, { injector });
|
|
767
|
-
});
|
|
842
|
+
watcher = effect(() => {
|
|
843
|
+
const p = scope.pending();
|
|
844
|
+
if (p)
|
|
845
|
+
sawPending = true;
|
|
846
|
+
if (sawPending && !p)
|
|
847
|
+
finish(false);
|
|
848
|
+
}, { injector });
|
|
849
|
+
// no-async fallback: if nothing ever went in flight, settle once the writes are processed.
|
|
850
|
+
afterNextRender(() => {
|
|
851
|
+
if (!sawPending && !untracked(scope.pending))
|
|
852
|
+
finish(false);
|
|
853
|
+
}, { injector });
|
|
768
854
|
return {
|
|
769
855
|
pending: scope.pending,
|
|
770
856
|
done,
|
|
@@ -773,6 +859,17 @@ function injectStartTransaction() {
|
|
|
773
859
|
};
|
|
774
860
|
}
|
|
775
861
|
|
|
862
|
+
/**
|
|
863
|
+
* @internal
|
|
864
|
+
*/
|
|
865
|
+
function getSignalEquality(sig) {
|
|
866
|
+
const internal = sig[SIGNAL];
|
|
867
|
+
if (internal && typeof internal.equal === 'function') {
|
|
868
|
+
return internal.equal;
|
|
869
|
+
}
|
|
870
|
+
return Object.is; // Default equality check
|
|
871
|
+
}
|
|
872
|
+
|
|
776
873
|
/**
|
|
777
874
|
* Converts a read-only `Signal` into a `WritableSignal` by providing custom `set` and, optionally, `update` functions.
|
|
778
875
|
* This can be useful for creating controlled write access to a signal that is otherwise read-only.
|
|
@@ -871,6 +968,7 @@ function debounced(initial, opt) {
|
|
|
871
968
|
* ```
|
|
872
969
|
*/
|
|
873
970
|
function debounce(source, opt) {
|
|
971
|
+
const eq = opt?.equal ?? getSignalEquality(source);
|
|
874
972
|
const ms = opt?.ms ?? 0;
|
|
875
973
|
const trigger = signal(false, ...(ngDevMode ? [{ debugName: "trigger" }] : []));
|
|
876
974
|
let timeout;
|
|
@@ -885,25 +983,25 @@ function debounce(source, opt) {
|
|
|
885
983
|
catch {
|
|
886
984
|
// not in injection context & no destroyRef provided opting out of cleanup
|
|
887
985
|
}
|
|
888
|
-
const
|
|
986
|
+
const set = (next) => {
|
|
987
|
+
const isEqual = eq(untracked(source), next);
|
|
988
|
+
if (!timeout && isEqual)
|
|
989
|
+
return; // nothing to do
|
|
889
990
|
if (timeout)
|
|
890
|
-
clearTimeout(timeout);
|
|
891
|
-
|
|
991
|
+
clearTimeout(timeout); // clear pending
|
|
992
|
+
if (!isEqual)
|
|
993
|
+
source.set(next);
|
|
892
994
|
timeout = setTimeout(() => {
|
|
995
|
+
timeout = undefined;
|
|
893
996
|
trigger.update((c) => !c);
|
|
894
997
|
}, ms);
|
|
895
998
|
};
|
|
896
|
-
const
|
|
897
|
-
triggerFn(value);
|
|
898
|
-
};
|
|
899
|
-
const update = (fn) => {
|
|
900
|
-
triggerFn(fn(untracked(source)));
|
|
901
|
-
};
|
|
999
|
+
const update = (fn) => set(fn(untracked(source)));
|
|
902
1000
|
const writable = toWritable(computed(() => {
|
|
903
1001
|
trigger();
|
|
904
1002
|
return untracked(source);
|
|
905
1003
|
}, opt), set, update);
|
|
906
|
-
writable.original = source;
|
|
1004
|
+
writable.original = source.asReadonly();
|
|
907
1005
|
return writable;
|
|
908
1006
|
}
|
|
909
1007
|
|
|
@@ -1074,8 +1172,18 @@ function derived(source, optOrKey, opt) {
|
|
|
1074
1172
|
if (isMutable(source)) {
|
|
1075
1173
|
sig.mutate = (updater) => {
|
|
1076
1174
|
cnt++;
|
|
1077
|
-
|
|
1078
|
-
|
|
1175
|
+
try {
|
|
1176
|
+
sig.update(updater);
|
|
1177
|
+
// The wrapped computed evaluates its `equal` lazily — at the next read, which would
|
|
1178
|
+
// normally happen after `cnt` has already dropped back to 0. For a reference-stable
|
|
1179
|
+
// mutation that read compares the same object to itself and the version never bumps,
|
|
1180
|
+
// so dependents are never notified. Reading here, while equality is still suppressed,
|
|
1181
|
+
// forces the recompute (and version bump) inside the mutate window.
|
|
1182
|
+
untracked(sig);
|
|
1183
|
+
}
|
|
1184
|
+
finally {
|
|
1185
|
+
cnt--;
|
|
1186
|
+
}
|
|
1079
1187
|
};
|
|
1080
1188
|
sig.inline = (updater) => {
|
|
1081
1189
|
sig.mutate((prev) => {
|
|
@@ -1157,20 +1265,48 @@ function createSetter(source) {
|
|
|
1157
1265
|
}
|
|
1158
1266
|
|
|
1159
1267
|
function keepPrevious(src, opt) {
|
|
1268
|
+
const mutableSrc = isWritableSignal(src) && isMutable(src);
|
|
1269
|
+
// For a mutable source the linkedSignal's equality must be suppressible: a forwarded
|
|
1270
|
+
// `mutate` keeps the same reference, which default equality would otherwise swallow.
|
|
1271
|
+
let cnt = 0;
|
|
1272
|
+
const baseEqual = opt?.equal;
|
|
1273
|
+
const equal = mutableSrc
|
|
1274
|
+
? (a, b) => cnt > 0 ? false : baseEqual ? baseEqual(a, b) : Object.is(a, b)
|
|
1275
|
+
: baseEqual;
|
|
1160
1276
|
const persisted = linkedSignal(...(ngDevMode ? [{ debugName: "persisted", ...opt,
|
|
1161
1277
|
source: () => src(),
|
|
1162
|
-
computation: (next, prev) => next === undefined && prev !== undefined ? prev.value : next
|
|
1278
|
+
computation: (next, prev) => next === undefined && prev !== undefined ? prev.value : next,
|
|
1279
|
+
equal }] : [{
|
|
1163
1280
|
...opt,
|
|
1164
1281
|
source: () => src(),
|
|
1165
1282
|
computation: (next, prev) => next === undefined && prev !== undefined ? prev.value : next,
|
|
1283
|
+
equal,
|
|
1166
1284
|
}]));
|
|
1167
1285
|
if (isWritableSignal(src)) {
|
|
1168
1286
|
persisted.set = src.set;
|
|
1169
1287
|
persisted.update = src.update;
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1288
|
+
// NOTE: `asReadonly` deliberately stays the linkedSignal's own — returning the
|
|
1289
|
+
// source's readonly view would reintroduce the `undefined` flashes this wrapper exists
|
|
1290
|
+
// to prevent.
|
|
1291
|
+
if (mutableSrc) {
|
|
1292
|
+
persisted.mutate = (updater) => {
|
|
1293
|
+
cnt++;
|
|
1294
|
+
try {
|
|
1295
|
+
src.mutate(updater);
|
|
1296
|
+
// force the recompute while equality is suppressed, so the reference-stable
|
|
1297
|
+
// mutation bumps the wrapper's version (see derived.ts for the same pattern)
|
|
1298
|
+
untracked(persisted);
|
|
1299
|
+
}
|
|
1300
|
+
finally {
|
|
1301
|
+
cnt--;
|
|
1302
|
+
}
|
|
1303
|
+
};
|
|
1304
|
+
persisted.inline = (updater) => {
|
|
1305
|
+
persisted.mutate((prev) => {
|
|
1306
|
+
updater(prev);
|
|
1307
|
+
return prev;
|
|
1308
|
+
});
|
|
1309
|
+
};
|
|
1174
1310
|
}
|
|
1175
1311
|
if (isDerivation(src)) {
|
|
1176
1312
|
persisted.from = src.from;
|
|
@@ -1201,13 +1337,18 @@ function indexArray(source, map, opt = {}) {
|
|
|
1201
1337
|
: toWritable(data, () => {
|
|
1202
1338
|
// noop
|
|
1203
1339
|
});
|
|
1340
|
+
// copy before defaulting `equal` — assigning onto `opt` would mutate a caller-owned
|
|
1341
|
+
// (possibly shared/reused) options object
|
|
1204
1342
|
if (isWritableSignal(data) && isMutable(data) && !opt.equal) {
|
|
1205
|
-
opt
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1343
|
+
opt = {
|
|
1344
|
+
...opt,
|
|
1345
|
+
equal: (a, b) => {
|
|
1346
|
+
if (typeof a !== typeof b)
|
|
1347
|
+
return false;
|
|
1348
|
+
if (typeof a === 'object' || typeof a === 'function')
|
|
1349
|
+
return false;
|
|
1350
|
+
return a === b;
|
|
1351
|
+
},
|
|
1211
1352
|
};
|
|
1212
1353
|
}
|
|
1213
1354
|
return linkedSignal({
|
|
@@ -1401,8 +1542,17 @@ function pooledKeys(src) {
|
|
|
1401
1542
|
for (const k in val)
|
|
1402
1543
|
if (Object.prototype.hasOwnProperty.call(val, k))
|
|
1403
1544
|
spare.add(k);
|
|
1404
|
-
if (active.size === spare.size
|
|
1405
|
-
|
|
1545
|
+
if (active.size === spare.size) {
|
|
1546
|
+
let subset = true;
|
|
1547
|
+
for (const k of active) {
|
|
1548
|
+
if (!spare.has(k)) {
|
|
1549
|
+
subset = false;
|
|
1550
|
+
break;
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
if (subset)
|
|
1554
|
+
return active;
|
|
1555
|
+
}
|
|
1406
1556
|
const temp = active;
|
|
1407
1557
|
active = spare;
|
|
1408
1558
|
spare = temp;
|
|
@@ -1502,7 +1652,7 @@ const filter = (predicate) => (src) => linkedSignal({
|
|
|
1502
1652
|
computation: (next, prev) => {
|
|
1503
1653
|
if (predicate(next))
|
|
1504
1654
|
return next;
|
|
1505
|
-
return prev?.
|
|
1655
|
+
return prev?.value;
|
|
1506
1656
|
},
|
|
1507
1657
|
});
|
|
1508
1658
|
/**
|
|
@@ -1538,7 +1688,7 @@ const tap = (fn, injector) => (src) => {
|
|
|
1538
1688
|
*/
|
|
1539
1689
|
const filterWith = (predicate, initial) => (src) => linkedSignal({
|
|
1540
1690
|
source: src,
|
|
1541
|
-
computation: (next, prev) => predicate(next) ? next :
|
|
1691
|
+
computation: (next, prev) => predicate(next) ? next : prev ? prev.value : initial,
|
|
1542
1692
|
});
|
|
1543
1693
|
/**
|
|
1544
1694
|
* Emit `initial` on the first read, then mirror the source on every subsequent
|
|
@@ -1587,7 +1737,7 @@ const pairwise = () => (src) => linkedSignal({
|
|
|
1587
1737
|
*/
|
|
1588
1738
|
const scan = (reducer, seed) => (src) => linkedSignal({
|
|
1589
1739
|
source: src,
|
|
1590
|
-
computation: (next, prev) => reducer(prev
|
|
1740
|
+
computation: (next, prev) => reducer(prev ? prev.value : seed, next),
|
|
1591
1741
|
});
|
|
1592
1742
|
|
|
1593
1743
|
/**
|
|
@@ -1638,7 +1788,7 @@ function pipeable(signal) {
|
|
|
1638
1788
|
return internal;
|
|
1639
1789
|
}
|
|
1640
1790
|
/**
|
|
1641
|
-
* Create a new **writable** signal and return it as a `
|
|
1791
|
+
* Create a new **writable** signal and return it as a `PipeableSignal`.
|
|
1642
1792
|
*
|
|
1643
1793
|
* The returned value is a `WritableSignal<T>` with `.set`, `.update`, `.asReadonly`
|
|
1644
1794
|
* still available (via intersection type), plus a chainable `.pipe(...)`.
|
|
@@ -1742,6 +1892,20 @@ function pooledMap(optOrComputation, signalOpt) {
|
|
|
1742
1892
|
return pooled(toPooledOptions(optOrComputation, createEmptyMap, resetClearable, signalOpt));
|
|
1743
1893
|
}
|
|
1744
1894
|
|
|
1895
|
+
/**
|
|
1896
|
+
* @internal Run a sensor factory inside `injector` when provided, else in the ambient
|
|
1897
|
+
* injection context. Keeps every sensor's escape hatch identical and in one place.
|
|
1898
|
+
*/
|
|
1899
|
+
function runInSensorContext(injector, fn) {
|
|
1900
|
+
return injector ? runInInjectionContext(injector, fn) : fn();
|
|
1901
|
+
}
|
|
1902
|
+
/**
|
|
1903
|
+
* @internal Normalize the legacy positional `debugName: string` form into {@link SensorRunOptions}.
|
|
1904
|
+
*/
|
|
1905
|
+
function coerceSensorOptions(opt) {
|
|
1906
|
+
return typeof opt === 'string' ? { debugName: opt } : (opt ?? {});
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1745
1909
|
const EVENTS = [
|
|
1746
1910
|
'chargingchange',
|
|
1747
1911
|
'levelchange',
|
|
@@ -1763,7 +1927,11 @@ const EVENTS = [
|
|
|
1763
1927
|
* });
|
|
1764
1928
|
* ```
|
|
1765
1929
|
*/
|
|
1766
|
-
function batteryStatus(
|
|
1930
|
+
function batteryStatus(opt) {
|
|
1931
|
+
const { debugName = 'batteryStatus', injector } = coerceSensorOptions(opt);
|
|
1932
|
+
return runInSensorContext(injector, () => createBatteryStatus(debugName));
|
|
1933
|
+
}
|
|
1934
|
+
function createBatteryStatus(debugName) {
|
|
1767
1935
|
if (isPlatformServer(inject(PLATFORM_ID)) ||
|
|
1768
1936
|
typeof navigator === 'undefined' ||
|
|
1769
1937
|
typeof navigator.getBattery !== 'function') {
|
|
@@ -1772,7 +1940,9 @@ function batteryStatus(debugName = 'batteryStatus') {
|
|
|
1772
1940
|
const state = signal(null, ...(ngDevMode ? [{ debugName: "state", debugName }] : [{ debugName }]));
|
|
1773
1941
|
const abortController = new AbortController();
|
|
1774
1942
|
inject(DestroyRef).onDestroy(() => abortController.abort());
|
|
1775
|
-
navigator
|
|
1943
|
+
navigator
|
|
1944
|
+
.getBattery()
|
|
1945
|
+
.then((battery) => {
|
|
1776
1946
|
if (abortController.signal.aborted)
|
|
1777
1947
|
return;
|
|
1778
1948
|
const read = () => ({
|
|
@@ -1788,6 +1958,10 @@ function batteryStatus(debugName = 'batteryStatus') {
|
|
|
1788
1958
|
signal: abortController.signal,
|
|
1789
1959
|
});
|
|
1790
1960
|
}
|
|
1961
|
+
})
|
|
1962
|
+
.catch(() => {
|
|
1963
|
+
// getBattery() rejects (NotAllowedError) when the `battery` permissions-policy is
|
|
1964
|
+
// disallowed, e.g. in cross-origin iframes — stay `null`, same as unsupported.
|
|
1791
1965
|
});
|
|
1792
1966
|
return state.asReadonly();
|
|
1793
1967
|
}
|
|
@@ -1803,7 +1977,11 @@ function batteryStatus(debugName = 'batteryStatus') {
|
|
|
1803
1977
|
* in browsers that gate it. Errors from `navigator.clipboard.readText` are
|
|
1804
1978
|
* swallowed silently to keep the signal value stable.
|
|
1805
1979
|
*/
|
|
1806
|
-
function clipboard(
|
|
1980
|
+
function clipboard(opt) {
|
|
1981
|
+
const { debugName = 'clipboard', injector } = coerceSensorOptions(opt);
|
|
1982
|
+
return runInSensorContext(injector, () => createClipboard(debugName));
|
|
1983
|
+
}
|
|
1984
|
+
function createClipboard(debugName) {
|
|
1807
1985
|
if (isPlatformServer(inject(PLATFORM_ID)) ||
|
|
1808
1986
|
typeof navigator === 'undefined' ||
|
|
1809
1987
|
!navigator.clipboard) {
|
|
@@ -1853,7 +2031,13 @@ function observerSupported$1() {
|
|
|
1853
2031
|
* });
|
|
1854
2032
|
* ```
|
|
1855
2033
|
*/
|
|
1856
|
-
function elementSize(target
|
|
2034
|
+
function elementSize(target, opt) {
|
|
2035
|
+
return runInSensorContext(opt?.injector, () =>
|
|
2036
|
+
// the host-element default must resolve INSIDE the sensor context, not as a
|
|
2037
|
+
// parameter default (which would run before the injector wrapper)
|
|
2038
|
+
createElementSize(target ?? inject(ElementRef), opt));
|
|
2039
|
+
}
|
|
2040
|
+
function createElementSize(target, opt) {
|
|
1857
2041
|
const getElement = () => {
|
|
1858
2042
|
if (isSignal(target)) {
|
|
1859
2043
|
try {
|
|
@@ -1867,8 +2051,8 @@ function elementSize(target = inject(ElementRef), opt) {
|
|
|
1867
2051
|
return target instanceof ElementRef ? target.nativeElement : target;
|
|
1868
2052
|
};
|
|
1869
2053
|
const resolveInitialValue = () => {
|
|
1870
|
-
|
|
1871
|
-
|
|
2054
|
+
// measuring needs only getBoundingClientRect — ResizeObserver support gates
|
|
2055
|
+
// live updates, not the initial read
|
|
1872
2056
|
const el = getElement();
|
|
1873
2057
|
if (el && el.getBoundingClientRect) {
|
|
1874
2058
|
const rect = el.getBoundingClientRect();
|
|
@@ -1986,7 +2170,13 @@ function observerSupported() {
|
|
|
1986
2170
|
* }
|
|
1987
2171
|
* ```
|
|
1988
2172
|
*/
|
|
1989
|
-
function elementVisibility(target
|
|
2173
|
+
function elementVisibility(target, opt) {
|
|
2174
|
+
return runInSensorContext(opt?.injector, () =>
|
|
2175
|
+
// the host-element default must resolve INSIDE the sensor context, not as a
|
|
2176
|
+
// parameter default (which would run before the injector wrapper)
|
|
2177
|
+
createElementVisibility(target ?? inject(ElementRef), opt));
|
|
2178
|
+
}
|
|
2179
|
+
function createElementVisibility(target, opt) {
|
|
1990
2180
|
if (isPlatformServer(inject(PLATFORM_ID)) || !observerSupported()) {
|
|
1991
2181
|
const base = computed(() => undefined, {
|
|
1992
2182
|
debugName: opt?.debugName,
|
|
@@ -2054,11 +2244,18 @@ function unwrap$1(target) {
|
|
|
2054
2244
|
* }
|
|
2055
2245
|
* ```
|
|
2056
2246
|
*/
|
|
2057
|
-
function focusWithin(target
|
|
2247
|
+
function focusWithin(target, opt) {
|
|
2248
|
+
return runInSensorContext(opt?.injector, () =>
|
|
2249
|
+
// the host-element default must resolve INSIDE the sensor context, not as a
|
|
2250
|
+
// parameter default (which would run before the injector wrapper)
|
|
2251
|
+
createFocusWithin(target ?? inject(ElementRef), opt));
|
|
2252
|
+
}
|
|
2253
|
+
function createFocusWithin(target, opt) {
|
|
2254
|
+
const debugName = opt?.debugName ?? 'focusWithin';
|
|
2058
2255
|
if (isPlatformServer(inject(PLATFORM_ID))) {
|
|
2059
|
-
return computed(() => false, { debugName
|
|
2256
|
+
return computed(() => false, { debugName });
|
|
2060
2257
|
}
|
|
2061
|
-
const state = signal(false, { debugName:
|
|
2258
|
+
const state = signal(false, ...(ngDevMode ? [{ debugName: "state", debugName }] : [{ debugName }]));
|
|
2062
2259
|
const attach = (el) => {
|
|
2063
2260
|
state.set(el.contains(document.activeElement));
|
|
2064
2261
|
const abortController = new AbortController();
|
|
@@ -2106,6 +2303,9 @@ function focusWithin(target = inject(ElementRef)) {
|
|
|
2106
2303
|
* ```
|
|
2107
2304
|
*/
|
|
2108
2305
|
function geolocation(opt) {
|
|
2306
|
+
return runInSensorContext(opt?.injector, () => createGeolocation(opt));
|
|
2307
|
+
}
|
|
2308
|
+
function createGeolocation(opt) {
|
|
2109
2309
|
if (isPlatformServer(inject(PLATFORM_ID)) || typeof navigator === 'undefined' || !navigator.geolocation) {
|
|
2110
2310
|
const sig = computed(() => null, {
|
|
2111
2311
|
debugName: opt?.debugName ?? 'geolocation',
|
|
@@ -2165,6 +2365,9 @@ const serverDate$1 = new Date();
|
|
|
2165
2365
|
* ```
|
|
2166
2366
|
*/
|
|
2167
2367
|
function idle(opt) {
|
|
2368
|
+
return runInSensorContext(opt?.injector, () => createIdle(opt));
|
|
2369
|
+
}
|
|
2370
|
+
function createIdle(opt) {
|
|
2168
2371
|
if (isPlatformServer(inject(PLATFORM_ID))) {
|
|
2169
2372
|
const sig = computed(() => false, {
|
|
2170
2373
|
debugName: opt?.debugName ?? 'idle',
|
|
@@ -2254,7 +2457,11 @@ function idle(opt) {
|
|
|
2254
2457
|
* }
|
|
2255
2458
|
* ```
|
|
2256
2459
|
*/
|
|
2257
|
-
function mediaQuery(query,
|
|
2460
|
+
function mediaQuery(query, opt) {
|
|
2461
|
+
const { debugName = 'mediaQuery', injector } = coerceSensorOptions(opt);
|
|
2462
|
+
return runInSensorContext(injector, () => createMediaQuery(query, debugName));
|
|
2463
|
+
}
|
|
2464
|
+
function createMediaQuery(query, debugName) {
|
|
2258
2465
|
if (isPlatformServer(inject(PLATFORM_ID)) ||
|
|
2259
2466
|
typeof window === 'undefined' ||
|
|
2260
2467
|
typeof window.matchMedia !== 'function' // jsdom doesn't implement matchMedia
|
|
@@ -2292,8 +2499,8 @@ function mediaQuery(query, debugName = 'mediaQuery') {
|
|
|
2292
2499
|
* });
|
|
2293
2500
|
* ```
|
|
2294
2501
|
*/
|
|
2295
|
-
function prefersDarkMode(
|
|
2296
|
-
return mediaQuery('(prefers-color-scheme: dark)',
|
|
2502
|
+
function prefersDarkMode(opt) {
|
|
2503
|
+
return mediaQuery('(prefers-color-scheme: dark)', opt);
|
|
2297
2504
|
}
|
|
2298
2505
|
/**
|
|
2299
2506
|
* Creates a read-only signal that tracks the user's OS/browser preference
|
|
@@ -2320,8 +2527,8 @@ function prefersDarkMode(debugName) {
|
|
|
2320
2527
|
* });
|
|
2321
2528
|
* ```
|
|
2322
2529
|
*/
|
|
2323
|
-
function prefersReducedMotion(
|
|
2324
|
-
return mediaQuery('(prefers-reduced-motion: reduce)',
|
|
2530
|
+
function prefersReducedMotion(opt) {
|
|
2531
|
+
return mediaQuery('(prefers-reduced-motion: reduce)', opt);
|
|
2325
2532
|
}
|
|
2326
2533
|
|
|
2327
2534
|
/**
|
|
@@ -2370,6 +2577,7 @@ function throttled(initial, opt) {
|
|
|
2370
2577
|
* // after the 500ms cooldown.
|
|
2371
2578
|
*/
|
|
2372
2579
|
function throttle(source, opt) {
|
|
2580
|
+
const eq = opt?.equal ?? getSignalEquality(source);
|
|
2373
2581
|
const ms = opt?.ms ?? 0;
|
|
2374
2582
|
const leading = opt?.leading ?? false;
|
|
2375
2583
|
const trailing = opt?.trailing ?? true;
|
|
@@ -2395,31 +2603,32 @@ function throttle(source, opt) {
|
|
|
2395
2603
|
fire();
|
|
2396
2604
|
else
|
|
2397
2605
|
pendingTrailing = trailing;
|
|
2398
|
-
|
|
2606
|
+
const onWindowEnd = () => {
|
|
2399
2607
|
timeout = undefined;
|
|
2400
2608
|
if (trailing && pendingTrailing) {
|
|
2401
2609
|
pendingTrailing = false;
|
|
2402
2610
|
fire();
|
|
2611
|
+
timeout = setTimeout(onWindowEnd, ms);
|
|
2403
2612
|
}
|
|
2404
|
-
}
|
|
2613
|
+
};
|
|
2614
|
+
timeout = setTimeout(onWindowEnd, ms);
|
|
2405
2615
|
return;
|
|
2406
2616
|
}
|
|
2407
2617
|
if (trailing)
|
|
2408
2618
|
pendingTrailing = true;
|
|
2409
2619
|
};
|
|
2410
|
-
const set = (
|
|
2411
|
-
source
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
const update = (fn) => {
|
|
2415
|
-
source.update(fn);
|
|
2620
|
+
const set = (next) => {
|
|
2621
|
+
if (eq(untracked(source), next))
|
|
2622
|
+
return;
|
|
2623
|
+
source.set(next);
|
|
2416
2624
|
tick();
|
|
2417
2625
|
};
|
|
2626
|
+
const update = (fn) => set(fn(untracked(source)));
|
|
2418
2627
|
const writable = toWritable(computed(() => {
|
|
2419
2628
|
trigger();
|
|
2420
2629
|
return untracked(source);
|
|
2421
2630
|
}, opt), set, update);
|
|
2422
|
-
writable.original = source;
|
|
2631
|
+
writable.original = source.asReadonly();
|
|
2423
2632
|
return writable;
|
|
2424
2633
|
}
|
|
2425
2634
|
|
|
@@ -2456,6 +2665,9 @@ function throttle(source, opt) {
|
|
|
2456
2665
|
* ```
|
|
2457
2666
|
*/
|
|
2458
2667
|
function mousePosition(opt) {
|
|
2668
|
+
return runInSensorContext(opt?.injector, () => createMousePosition(opt));
|
|
2669
|
+
}
|
|
2670
|
+
function createMousePosition(opt) {
|
|
2459
2671
|
if (isPlatformServer(inject(PLATFORM_ID))) {
|
|
2460
2672
|
const base = computed(() => ({
|
|
2461
2673
|
x: 0,
|
|
@@ -2467,8 +2679,12 @@ function mousePosition(opt) {
|
|
|
2467
2679
|
return base;
|
|
2468
2680
|
}
|
|
2469
2681
|
const { target = window, coordinateSpace = 'client', touch = false, debugName = 'mousePosition', throttle = 100, } = opt ?? {};
|
|
2470
|
-
const
|
|
2471
|
-
|
|
2682
|
+
const resolve = (t) => {
|
|
2683
|
+
if (!t)
|
|
2684
|
+
return null;
|
|
2685
|
+
return t instanceof ElementRef ? t.nativeElement : t;
|
|
2686
|
+
};
|
|
2687
|
+
if (!isSignal(target) && !resolve(target)) {
|
|
2472
2688
|
if (isDevMode())
|
|
2473
2689
|
console.warn('mousePosition: Target element not found.');
|
|
2474
2690
|
const base = computed(() => ({
|
|
@@ -2491,7 +2707,7 @@ function mousePosition(opt) {
|
|
|
2491
2707
|
x = coordinateSpace === 'page' ? event.pageX : event.clientX;
|
|
2492
2708
|
y = coordinateSpace === 'page' ? event.pageY : event.clientY;
|
|
2493
2709
|
}
|
|
2494
|
-
else if (event.touches
|
|
2710
|
+
else if (event.touches?.length > 0) {
|
|
2495
2711
|
const firstTouch = event.touches[0];
|
|
2496
2712
|
x = coordinateSpace === 'page' ? firstTouch.pageX : firstTouch.clientX;
|
|
2497
2713
|
y = coordinateSpace === 'page' ? firstTouch.pageY : firstTouch.clientY;
|
|
@@ -2501,16 +2717,36 @@ function mousePosition(opt) {
|
|
|
2501
2717
|
}
|
|
2502
2718
|
pos.set({ x, y });
|
|
2503
2719
|
};
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2720
|
+
// passive: the handler never calls preventDefault, and a non-passive touchmove on
|
|
2721
|
+
// window forces the browser to wait on JS before scrolling (scroll jank on touch)
|
|
2722
|
+
const attach = (el) => {
|
|
2723
|
+
const controller = new AbortController();
|
|
2724
|
+
el.addEventListener('mousemove', updatePosition, {
|
|
2725
|
+
passive: true,
|
|
2726
|
+
signal: controller.signal,
|
|
2727
|
+
});
|
|
2510
2728
|
if (touch) {
|
|
2511
|
-
|
|
2729
|
+
el.addEventListener('touchmove', updatePosition, {
|
|
2730
|
+
passive: true,
|
|
2731
|
+
signal: controller.signal,
|
|
2732
|
+
});
|
|
2512
2733
|
}
|
|
2513
|
-
|
|
2734
|
+
return () => controller.abort();
|
|
2735
|
+
};
|
|
2736
|
+
if (isSignal(target)) {
|
|
2737
|
+
// re-attach whenever the signal resolves to a (new) element — covers viewChild
|
|
2738
|
+
effect((cleanup) => {
|
|
2739
|
+
const el = resolve(target());
|
|
2740
|
+
if (!el)
|
|
2741
|
+
return;
|
|
2742
|
+
cleanup(attach(el));
|
|
2743
|
+
});
|
|
2744
|
+
}
|
|
2745
|
+
else {
|
|
2746
|
+
const el = resolve(target);
|
|
2747
|
+
if (el)
|
|
2748
|
+
inject(DestroyRef).onDestroy(attach(el));
|
|
2749
|
+
}
|
|
2514
2750
|
const base = pos.asReadonly();
|
|
2515
2751
|
base.unthrottled = pos.original;
|
|
2516
2752
|
return base;
|
|
@@ -2524,7 +2760,8 @@ const serverDate = new Date();
|
|
|
2524
2760
|
* An additional `since` signal is attached, tracking when the status last changed.
|
|
2525
2761
|
* It's SSR-safe and automatically cleans up its event listeners.
|
|
2526
2762
|
*
|
|
2527
|
-
* @param
|
|
2763
|
+
* @param opt Optional debug name for the signal, or a {@link SensorRunOptions} object
|
|
2764
|
+
* (with an optional `injector` for creation outside an injection context).
|
|
2528
2765
|
* @returns A `NetworkStatusSignal` instance.
|
|
2529
2766
|
*
|
|
2530
2767
|
* @example
|
|
@@ -2535,7 +2772,11 @@ const serverDate = new Date();
|
|
|
2535
2772
|
* });
|
|
2536
2773
|
* ```
|
|
2537
2774
|
*/
|
|
2538
|
-
function networkStatus(
|
|
2775
|
+
function networkStatus(opt) {
|
|
2776
|
+
const { debugName = 'networkStatus', injector } = coerceSensorOptions(opt);
|
|
2777
|
+
return runInSensorContext(injector, () => createNetworkStatus(debugName));
|
|
2778
|
+
}
|
|
2779
|
+
function createNetworkStatus(debugName) {
|
|
2539
2780
|
if (isPlatformServer(inject(PLATFORM_ID))) {
|
|
2540
2781
|
const sig = computed(() => true, {
|
|
2541
2782
|
debugName,
|
|
@@ -2585,7 +2826,11 @@ const SSR_FALLBACK = {
|
|
|
2585
2826
|
* });
|
|
2586
2827
|
* ```
|
|
2587
2828
|
*/
|
|
2588
|
-
function orientation(
|
|
2829
|
+
function orientation(opt) {
|
|
2830
|
+
const { debugName = 'orientation', injector } = coerceSensorOptions(opt);
|
|
2831
|
+
return runInSensorContext(injector, () => createOrientation(debugName));
|
|
2832
|
+
}
|
|
2833
|
+
function createOrientation(debugName) {
|
|
2589
2834
|
if (isPlatformServer(inject(PLATFORM_ID)) ||
|
|
2590
2835
|
typeof screen === 'undefined' ||
|
|
2591
2836
|
!screen.orientation) {
|
|
@@ -2615,7 +2860,8 @@ function orientation(debugName = 'orientation') {
|
|
|
2615
2860
|
* The primitive is SSR-safe and automatically cleans up its event listeners
|
|
2616
2861
|
* when the creating context is destroyed.
|
|
2617
2862
|
*
|
|
2618
|
-
* @param
|
|
2863
|
+
* @param opt Optional debug name for the signal, or a {@link SensorRunOptions} object
|
|
2864
|
+
* (with an optional `injector` for creation outside an injection context).
|
|
2619
2865
|
* @returns A read-only `Signal<DocumentVisibilityState>`. On the server,
|
|
2620
2866
|
* it returns a static signal with a value of `'visible'`.
|
|
2621
2867
|
*
|
|
@@ -2643,7 +2889,11 @@ function orientation(debugName = 'orientation') {
|
|
|
2643
2889
|
* }
|
|
2644
2890
|
* ```
|
|
2645
2891
|
*/
|
|
2646
|
-
function pageVisibility(
|
|
2892
|
+
function pageVisibility(opt) {
|
|
2893
|
+
const { debugName = 'pageVisibility', injector } = coerceSensorOptions(opt);
|
|
2894
|
+
return runInSensorContext(injector, () => createPageVisibility(debugName));
|
|
2895
|
+
}
|
|
2896
|
+
function createPageVisibility(debugName) {
|
|
2647
2897
|
if (isPlatformServer(inject(PLATFORM_ID))) {
|
|
2648
2898
|
return computed(() => 'visible', { debugName });
|
|
2649
2899
|
}
|
|
@@ -2675,31 +2925,25 @@ function pageVisibility(debugName = 'pageVisibility') {
|
|
|
2675
2925
|
* selector: 'app-scroll-tracker',
|
|
2676
2926
|
* template: `
|
|
2677
2927
|
* <p>Window Scroll: X: {{ windowScroll().x }}, Y: {{ windowScroll().y }}</p>
|
|
2678
|
-
* <
|
|
2679
|
-
* <div style="height: 400px; width: 400px;">Scroll me!</div>
|
|
2680
|
-
* </div>
|
|
2681
|
-
* @if (divScroll()) {
|
|
2682
|
-
* <p>Div Scroll: X: {{ divScroll().x }}, Y: {{ divScroll().y }}</p>
|
|
2683
|
-
* }
|
|
2928
|
+
* <p>Host Scroll: X: {{ hostScroll().x }}, Y: {{ hostScroll().y }}</p>
|
|
2684
2929
|
* `
|
|
2685
2930
|
* })
|
|
2686
2931
|
* export class ScrollTrackerComponent {
|
|
2687
2932
|
* readonly windowScroll = scrollPosition(); // Defaults to window
|
|
2933
|
+
* // Signal targets (e.g. viewChild) attach once the element exists:
|
|
2688
2934
|
* readonly scrollableDiv = viewChild<ElementRef<HTMLDivElement>>('scrollableDiv');
|
|
2689
|
-
* readonly divScroll = scrollPosition({ target: this.scrollableDiv
|
|
2935
|
+
* readonly divScroll = scrollPosition({ target: this.scrollableDiv });
|
|
2690
2936
|
*
|
|
2691
2937
|
* constructor() {
|
|
2692
|
-
* effect(() =>
|
|
2693
|
-
* console.log('Window scrolled to:', this.windowScroll());
|
|
2694
|
-
* if (this.divScroll()) {
|
|
2695
|
-
* console.log('Div scrolled to:', this.divScroll());
|
|
2696
|
-
* }
|
|
2697
|
-
* });
|
|
2938
|
+
* effect(() => console.log('Window scrolled to:', this.windowScroll()));
|
|
2698
2939
|
* }
|
|
2699
2940
|
* }
|
|
2700
2941
|
* ```
|
|
2701
2942
|
*/
|
|
2702
2943
|
function scrollPosition(opt) {
|
|
2944
|
+
return runInSensorContext(opt?.injector, () => createScrollPosition(opt));
|
|
2945
|
+
}
|
|
2946
|
+
function createScrollPosition(opt) {
|
|
2703
2947
|
if (isPlatformServer(inject(PLATFORM_ID))) {
|
|
2704
2948
|
const base = computed(() => ({
|
|
2705
2949
|
x: 0,
|
|
@@ -2711,40 +2955,44 @@ function scrollPosition(opt) {
|
|
|
2711
2955
|
return base;
|
|
2712
2956
|
}
|
|
2713
2957
|
const { target = window, throttle = 100, debugName = 'scrollPosition', } = opt || {};
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
};
|
|
2729
|
-
};
|
|
2730
|
-
}
|
|
2731
|
-
else {
|
|
2732
|
-
element = target;
|
|
2733
|
-
getScrollPosition = () => {
|
|
2734
|
-
return {
|
|
2735
|
-
x: target.scrollLeft,
|
|
2736
|
-
y: target.scrollTop,
|
|
2737
|
-
};
|
|
2738
|
-
};
|
|
2739
|
-
}
|
|
2740
|
-
const state = throttled(getScrollPosition(), {
|
|
2958
|
+
const resolve = (t) => {
|
|
2959
|
+
if (!t)
|
|
2960
|
+
return null;
|
|
2961
|
+
return t instanceof ElementRef ? t.nativeElement : t;
|
|
2962
|
+
};
|
|
2963
|
+
const isWindow = (el) => el.window === el;
|
|
2964
|
+
const readPosition = (el) => isWindow(el)
|
|
2965
|
+
? {
|
|
2966
|
+
x: el.scrollX ?? el.pageXOffset ?? 0,
|
|
2967
|
+
y: el.scrollY ?? el.pageYOffset ?? 0,
|
|
2968
|
+
}
|
|
2969
|
+
: { x: el.scrollLeft, y: el.scrollTop };
|
|
2970
|
+
const initial = resolve(isSignal(target) ? untracked(target) : target);
|
|
2971
|
+
const state = throttled(initial ? readPosition(initial) : { x: 0, y: 0 }, {
|
|
2741
2972
|
debugName,
|
|
2742
2973
|
equal: (a, b) => a.x === b.x && a.y === b.y,
|
|
2743
2974
|
ms: throttle,
|
|
2744
2975
|
});
|
|
2745
|
-
|
|
2746
|
-
|
|
2747
|
-
|
|
2976
|
+
if (isSignal(target)) {
|
|
2977
|
+
// re-attach whenever the signal resolves to a (new) element — covers viewChild
|
|
2978
|
+
effect((cleanup) => {
|
|
2979
|
+
const el = resolve(target());
|
|
2980
|
+
if (!el)
|
|
2981
|
+
return;
|
|
2982
|
+
state.set(readPosition(el)); // sync to the new element immediately
|
|
2983
|
+
const onScroll = () => state.set(readPosition(el));
|
|
2984
|
+
el.addEventListener('scroll', onScroll, { passive: true });
|
|
2985
|
+
cleanup(() => el.removeEventListener('scroll', onScroll));
|
|
2986
|
+
});
|
|
2987
|
+
}
|
|
2988
|
+
else {
|
|
2989
|
+
const el = resolve(target);
|
|
2990
|
+
if (el) {
|
|
2991
|
+
const onScroll = () => state.set(readPosition(el));
|
|
2992
|
+
el.addEventListener('scroll', onScroll, { passive: true });
|
|
2993
|
+
inject(DestroyRef).onDestroy(() => el.removeEventListener('scroll', onScroll));
|
|
2994
|
+
}
|
|
2995
|
+
}
|
|
2748
2996
|
const base = state.asReadonly();
|
|
2749
2997
|
base.unthrottled = state.original;
|
|
2750
2998
|
return base;
|
|
@@ -2792,6 +3040,9 @@ function scrollPosition(opt) {
|
|
|
2792
3040
|
* ```
|
|
2793
3041
|
*/
|
|
2794
3042
|
function windowSize(opt) {
|
|
3043
|
+
return runInSensorContext(opt?.injector, () => createWindowSize(opt));
|
|
3044
|
+
}
|
|
3045
|
+
function createWindowSize(opt) {
|
|
2795
3046
|
if (isPlatformServer(inject(PLATFORM_ID))) {
|
|
2796
3047
|
const base = computed(() => ({
|
|
2797
3048
|
width: 1024,
|
|
@@ -2828,17 +3079,19 @@ function sensor(type, options) {
|
|
|
2828
3079
|
case 'mousePosition':
|
|
2829
3080
|
return mousePosition(opts);
|
|
2830
3081
|
case 'networkStatus':
|
|
2831
|
-
return networkStatus(opts
|
|
3082
|
+
return networkStatus(opts);
|
|
2832
3083
|
case 'pageVisibility':
|
|
2833
|
-
return pageVisibility(opts
|
|
3084
|
+
return pageVisibility(opts);
|
|
2834
3085
|
case 'darkMode':
|
|
2835
3086
|
case 'dark-mode':
|
|
2836
|
-
return prefersDarkMode(opts
|
|
3087
|
+
return prefersDarkMode(opts);
|
|
2837
3088
|
case 'reducedMotion':
|
|
2838
3089
|
case 'reduced-motion':
|
|
2839
|
-
return prefersReducedMotion(opts
|
|
3090
|
+
return prefersReducedMotion(opts);
|
|
2840
3091
|
case 'mediaQuery':
|
|
2841
|
-
|
|
3092
|
+
if (typeof opts?.query !== 'string')
|
|
3093
|
+
throw new Error(`sensor('mediaQuery') requires a 'query' option, e.g. sensor('mediaQuery', { query: '(min-width: 1024px)' })`);
|
|
3094
|
+
return mediaQuery(opts.query, opts);
|
|
2842
3095
|
case 'windowSize':
|
|
2843
3096
|
return windowSize(opts);
|
|
2844
3097
|
case 'scrollPosition':
|
|
@@ -2850,15 +3103,15 @@ function sensor(type, options) {
|
|
|
2850
3103
|
case 'geolocation':
|
|
2851
3104
|
return geolocation(opts);
|
|
2852
3105
|
case 'clipboard':
|
|
2853
|
-
return clipboard(opts
|
|
3106
|
+
return clipboard(opts);
|
|
2854
3107
|
case 'orientation':
|
|
2855
|
-
return orientation(opts
|
|
3108
|
+
return orientation(opts);
|
|
2856
3109
|
case 'batteryStatus':
|
|
2857
|
-
return batteryStatus(opts
|
|
3110
|
+
return batteryStatus(opts);
|
|
2858
3111
|
case 'idle':
|
|
2859
3112
|
return idle(opts);
|
|
2860
3113
|
case 'focusWithin':
|
|
2861
|
-
return focusWithin(opts?.target);
|
|
3114
|
+
return focusWithin(opts?.target, opts);
|
|
2862
3115
|
default:
|
|
2863
3116
|
throw new Error(`Unknown sensor type: ${type}`);
|
|
2864
3117
|
}
|
|
@@ -2912,16 +3165,24 @@ function signalFromEvent(target, eventName, initial, projectOrOpt, maybeOpt) {
|
|
|
2912
3165
|
else
|
|
2913
3166
|
state.set(event);
|
|
2914
3167
|
};
|
|
2915
|
-
const { destroyRef: providedDestroyRef,
|
|
3168
|
+
const { destroyRef: providedDestroyRef,
|
|
3169
|
+
// strip non-listener keys so they don't leak into addEventListener options
|
|
3170
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
3171
|
+
injector: _injector,
|
|
3172
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
3173
|
+
debugName: _debugName, ...listenerOpts } = opt ?? {};
|
|
2916
3174
|
if (isSignal(target)) {
|
|
2917
3175
|
const targetSig = target;
|
|
2918
|
-
effect((cleanup) => {
|
|
3176
|
+
const effectRef = effect((cleanup) => {
|
|
2919
3177
|
const resolved = unwrap(targetSig());
|
|
2920
3178
|
if (!resolved)
|
|
2921
3179
|
return;
|
|
2922
3180
|
resolved.addEventListener(eventName, handler, listenerOpts);
|
|
2923
3181
|
cleanup(() => resolved.removeEventListener(eventName, handler, listenerOpts));
|
|
2924
|
-
}, { injector });
|
|
3182
|
+
}, ...(ngDevMode ? [{ debugName: "effectRef", injector }] : [{ injector }]));
|
|
3183
|
+
// honor an explicit destroyRef for signal targets too — the effect would otherwise
|
|
3184
|
+
// only follow the injector's lifetime, contradicting the documented option
|
|
3185
|
+
providedDestroyRef?.onDestroy(() => effectRef.destroy());
|
|
2925
3186
|
}
|
|
2926
3187
|
else {
|
|
2927
3188
|
const resolved = unwrap(target);
|
|
@@ -3007,7 +3268,8 @@ function alwaysFalse() {
|
|
|
3007
3268
|
* @internal Attaches a lazy, memoized leaf probe to a store node. The probe (`() => boolean`)
|
|
3008
3269
|
* closes over the node's value signal and its (stable) vivify setting, building the backing
|
|
3009
3270
|
* `computed` on first call so leaf-ness tracks the live value reactively without taxing every
|
|
3010
|
-
* node access.
|
|
3271
|
+
* node access. Under `noUnionLeaves` the caller promises shapes never flip, so the probe is
|
|
3272
|
+
* resolved once from the first sample and frozen as a constant. Idempotent.
|
|
3011
3273
|
*/
|
|
3012
3274
|
function markAsLeaf(sig, value, vivifyEnabled, noUnionLeaves) {
|
|
3013
3275
|
if (typeof sig[LEAF] !== 'function') {
|
|
@@ -3015,13 +3277,11 @@ function markAsLeaf(sig, value, vivifyEnabled, noUnionLeaves) {
|
|
|
3015
3277
|
const probe = () => {
|
|
3016
3278
|
if (memo)
|
|
3017
3279
|
return memo();
|
|
3018
|
-
|
|
3019
|
-
|
|
3020
|
-
|
|
3021
|
-
|
|
3022
|
-
|
|
3023
|
-
: alwaysFalse
|
|
3024
|
-
: computed(() => isLeafValue(value(), vivifyEnabled));
|
|
3280
|
+
memo = noUnionLeaves
|
|
3281
|
+
? isLeafValue(untracked(value), vivifyEnabled)
|
|
3282
|
+
? alwaysTrue
|
|
3283
|
+
: alwaysFalse
|
|
3284
|
+
: computed(() => isLeafValue(value(), vivifyEnabled));
|
|
3025
3285
|
return memo();
|
|
3026
3286
|
};
|
|
3027
3287
|
Object.defineProperty(sig, LEAF, {
|
|
@@ -3109,6 +3369,40 @@ function resolveVivify(sample, option) {
|
|
|
3109
3369
|
function hasOwnKey(value, key) {
|
|
3110
3370
|
return value != null && Object.hasOwn(value, key);
|
|
3111
3371
|
}
|
|
3372
|
+
/**
|
|
3373
|
+
* @internal
|
|
3374
|
+
* Builds the `onChange` for the fallback (non-record container) derivation branch. For an
|
|
3375
|
+
* immutable source the container is copied before the write — returning the same mutated
|
|
3376
|
+
* reference would let the source's equality cut propagation (leaving child signals permanently
|
|
3377
|
+
* stale) and alias the caller's original object, breaking the structural-sharing contract
|
|
3378
|
+
* `forkStore` relies on. For a mutable source the write goes through `mutate`, so the chain's
|
|
3379
|
+
* force-notify engages (plain `update` with the same reference would never notify).
|
|
3380
|
+
*/
|
|
3381
|
+
function createFallbackOnChange(target, prop, vivifyFn, isMutableSource) {
|
|
3382
|
+
const write = (newValue) => (v) => {
|
|
3383
|
+
const container = vivifyFn(v, prop);
|
|
3384
|
+
if (container === null || container === undefined)
|
|
3385
|
+
return container;
|
|
3386
|
+
const next = isMutableSource
|
|
3387
|
+
? container
|
|
3388
|
+
: Array.isArray(container)
|
|
3389
|
+
? container.slice()
|
|
3390
|
+
: isRecord(container)
|
|
3391
|
+
? { ...container }
|
|
3392
|
+
: container; // non-plain leaf (Date/class instance): legacy in-place attempt
|
|
3393
|
+
try {
|
|
3394
|
+
next[prop] = newValue;
|
|
3395
|
+
}
|
|
3396
|
+
catch (e) {
|
|
3397
|
+
if (isDevMode())
|
|
3398
|
+
console.error(`[store] Failed to set property "${String(prop)}"`, e);
|
|
3399
|
+
}
|
|
3400
|
+
return next;
|
|
3401
|
+
};
|
|
3402
|
+
return isMutableSource
|
|
3403
|
+
? (newValue) => target.mutate(write(newValue))
|
|
3404
|
+
: (newValue) => target.update(write(newValue));
|
|
3405
|
+
}
|
|
3112
3406
|
/**
|
|
3113
3407
|
* @internal
|
|
3114
3408
|
* Makes an array store
|
|
@@ -3131,7 +3425,9 @@ function toArrayStore(source, injector, vivify, noUnionLeaves = false) {
|
|
|
3131
3425
|
const idx = +prop;
|
|
3132
3426
|
return idx >= 0 && idx < untracked(lengthSignal);
|
|
3133
3427
|
}
|
|
3134
|
-
|
|
3428
|
+
const v = untracked(source);
|
|
3429
|
+
// nullish node values are routinely descended with vivify on — `in` must not throw
|
|
3430
|
+
return v == null ? false : Reflect.has(v, prop);
|
|
3135
3431
|
},
|
|
3136
3432
|
ownKeys() {
|
|
3137
3433
|
const v = untracked(source);
|
|
@@ -3168,7 +3464,9 @@ function toArrayStore(source, injector, vivify, noUnionLeaves = false) {
|
|
|
3168
3464
|
return lengthSignal;
|
|
3169
3465
|
if (prop === Symbol.iterator) {
|
|
3170
3466
|
return function* () {
|
|
3171
|
-
|
|
3467
|
+
// read length reactively: a spread/for-of inside a computed/effect must re-run
|
|
3468
|
+
// when items are added or removed, not only when already-read elements change
|
|
3469
|
+
for (let i = 0; i < lengthSignal(); i++) {
|
|
3172
3470
|
yield receiver[i];
|
|
3173
3471
|
}
|
|
3174
3472
|
};
|
|
@@ -3207,19 +3505,8 @@ function toArrayStore(source, injector, vivify, noUnionLeaves = false) {
|
|
|
3207
3505
|
})
|
|
3208
3506
|
: derived(target, {
|
|
3209
3507
|
from: (v) => v?.[idx],
|
|
3210
|
-
onChange: (
|
|
3211
|
-
|
|
3212
|
-
if (container === null || container === undefined)
|
|
3213
|
-
return container;
|
|
3214
|
-
try {
|
|
3215
|
-
container[idx] = newValue;
|
|
3216
|
-
}
|
|
3217
|
-
catch (e) {
|
|
3218
|
-
if (isDevMode())
|
|
3219
|
-
console.error(`[store] Failed to set property "${String(idx)}"`, e);
|
|
3220
|
-
}
|
|
3221
|
-
return container;
|
|
3222
|
-
}),
|
|
3508
|
+
onChange: createFallbackOnChange(target, idx, vivifyFn, isMutableSource),
|
|
3509
|
+
equal: equalFn,
|
|
3223
3510
|
});
|
|
3224
3511
|
const childSample = untracked(computation);
|
|
3225
3512
|
const childVivify = resolveVivify(childSample, vivify);
|
|
@@ -3239,6 +3526,13 @@ function toArrayStore(source, injector, vivify, noUnionLeaves = false) {
|
|
|
3239
3526
|
/**
|
|
3240
3527
|
* Converts a Signal into a deep-observable Store.
|
|
3241
3528
|
* Accessing nested properties returns a derived Signal of that path.
|
|
3529
|
+
*
|
|
3530
|
+
* @remarks
|
|
3531
|
+
* A child's *container kind* (array store vs object store) is resolved when the child is
|
|
3532
|
+
* first accessed and cached with the proxy. Leaf↔substore flips are tracked reactively, but a
|
|
3533
|
+
* union-typed node that later flips between an array and a record keeps its original trap set —
|
|
3534
|
+
* if you need that, re-model the union as `{ kind: ..., value: ... }` instead.
|
|
3535
|
+
*
|
|
3242
3536
|
* @example
|
|
3243
3537
|
* const state = store({ user: { name: 'John' } });
|
|
3244
3538
|
* const nameSignal = state.user.name; // WritableSignal<string>
|
|
@@ -3321,19 +3615,8 @@ function toStore(source, injector, vivify = false, noUnionLeaves = false) {
|
|
|
3321
3615
|
? derived(target, prop, { equal: equalFn, vivify: nodeVivify })
|
|
3322
3616
|
: derived(target, {
|
|
3323
3617
|
from: (v) => v?.[prop],
|
|
3324
|
-
onChange: (
|
|
3325
|
-
|
|
3326
|
-
if (container === null || container === undefined)
|
|
3327
|
-
return container;
|
|
3328
|
-
try {
|
|
3329
|
-
container[prop] = newValue;
|
|
3330
|
-
}
|
|
3331
|
-
catch (e) {
|
|
3332
|
-
if (isDevMode())
|
|
3333
|
-
console.error(`[store] Failed to set property "${String(prop)}"`, e);
|
|
3334
|
-
}
|
|
3335
|
-
return container;
|
|
3336
|
-
}),
|
|
3618
|
+
onChange: createFallbackOnChange(target, prop, vivifyFn, isMutableSource),
|
|
3619
|
+
equal: equalFn,
|
|
3337
3620
|
});
|
|
3338
3621
|
const childSample = untracked(computation);
|
|
3339
3622
|
const childVivify = resolveVivify(childSample, vivify);
|
|
@@ -3475,7 +3758,12 @@ function merge3(ancestor, mine, theirs) {
|
|
|
3475
3758
|
if (isPlainRecord(mine) && isPlainRecord(theirs) && isPlainRecord(ancestor)) {
|
|
3476
3759
|
const out = { ...theirs };
|
|
3477
3760
|
for (const key of new Set([...Object.keys(mine), ...Object.keys(theirs)])) {
|
|
3478
|
-
|
|
3761
|
+
const merged = merge3(ancestor[key], mine[key], theirs[key]);
|
|
3762
|
+
// a key deleted on the fork must commit as ABSENT, not as an explicit `undefined`
|
|
3763
|
+
if (merged === undefined && !(key in mine))
|
|
3764
|
+
delete out[key];
|
|
3765
|
+
else
|
|
3766
|
+
out[key] = merged;
|
|
3479
3767
|
}
|
|
3480
3768
|
return out;
|
|
3481
3769
|
}
|
|
@@ -3530,8 +3818,8 @@ const noopStore = {
|
|
|
3530
3818
|
*
|
|
3531
3819
|
* @template T The type of value held by the signal and stored (after serialization).
|
|
3532
3820
|
* @param fallback The default value of type `T` to use when no value is found in storage
|
|
3533
|
-
* or when deserialization fails.
|
|
3534
|
-
*
|
|
3821
|
+
* or when deserialization fails. A stored value (including a legitimate `null` for a
|
|
3822
|
+
* nullable `T`) always round-trips; the fallback only surfaces when the entry is absent.
|
|
3535
3823
|
* @param options Configuration options (`CreateStoredOptions<T>`). Requires at least the `key`.
|
|
3536
3824
|
* @returns A `StoredSignal<T>` instance. This signal behaves like a standard `WritableSignal<T>`,
|
|
3537
3825
|
* but its value is persisted. It includes a `.clear()` method to remove the item from storage
|
|
@@ -3544,7 +3832,8 @@ const noopStore = {
|
|
|
3544
3832
|
* - **Error Handling:** Catches and logs errors during serialization/deserialization in dev mode.
|
|
3545
3833
|
* - **Tab Sync:** If `syncTabs` is true, listens to `storage` events to keep the signal value
|
|
3546
3834
|
* consistent across browser tabs using the same key. Cleanup is handled automatically
|
|
3547
|
-
* using `DestroyRef`.
|
|
3835
|
+
* using `DestroyRef`. Web Storage only: the `storage` event never fires for custom `store`
|
|
3836
|
+
* adapters, so `syncTabs` has no effect with one.
|
|
3548
3837
|
* - **Removal:** Use the `.clear()` method on the returned signal to remove the item from storage.
|
|
3549
3838
|
* Setting the signal to the fallback value will store the fallback value, not remove the item.
|
|
3550
3839
|
*
|
|
@@ -3579,25 +3868,28 @@ function stored(fallback, { key, store: providedStore, serialize = JSON.stringif
|
|
|
3579
3868
|
: isSignal(key)
|
|
3580
3869
|
? key
|
|
3581
3870
|
: computed(key);
|
|
3871
|
+
// "no stored value" marker — distinct from `null`/`undefined`, so a nullable `T` can
|
|
3872
|
+
// round-trip a legitimate `null` through `set` instead of it acting like `clear()`
|
|
3873
|
+
const EMPTY = Symbol();
|
|
3582
3874
|
const getValue = (key) => {
|
|
3583
3875
|
const found = store.getItem(key);
|
|
3584
3876
|
if (found === null)
|
|
3585
|
-
return
|
|
3877
|
+
return EMPTY;
|
|
3586
3878
|
try {
|
|
3587
3879
|
const deserialized = deserialize(found);
|
|
3588
3880
|
if (!validate(deserialized))
|
|
3589
|
-
return
|
|
3881
|
+
return EMPTY;
|
|
3590
3882
|
return deserialized;
|
|
3591
3883
|
}
|
|
3592
3884
|
catch (err) {
|
|
3593
3885
|
if (isDevMode())
|
|
3594
3886
|
console.error(`Failed to parse stored value for key "${key}":`, err);
|
|
3595
|
-
return
|
|
3887
|
+
return EMPTY;
|
|
3596
3888
|
}
|
|
3597
3889
|
};
|
|
3598
3890
|
const storeValue = (key, value) => {
|
|
3599
3891
|
try {
|
|
3600
|
-
if (value ===
|
|
3892
|
+
if (value === EMPTY)
|
|
3601
3893
|
return store.removeItem(key);
|
|
3602
3894
|
const serialized = serialize(value);
|
|
3603
3895
|
store.setItem(key, serialized);
|
|
@@ -3614,17 +3906,17 @@ function stored(fallback, { key, store: providedStore, serialize = JSON.stringif
|
|
|
3614
3906
|
const initialKey = untracked(keySig);
|
|
3615
3907
|
const internal = signal(getValue(initialKey), ...(ngDevMode ? [{ debugName: "internal", ...opt,
|
|
3616
3908
|
equal: (a, b) => {
|
|
3617
|
-
if (a ===
|
|
3909
|
+
if (a === EMPTY && b === EMPTY)
|
|
3618
3910
|
return true;
|
|
3619
|
-
if (a ===
|
|
3911
|
+
if (a === EMPTY || b === EMPTY)
|
|
3620
3912
|
return false;
|
|
3621
3913
|
return equal(a, b);
|
|
3622
3914
|
} }] : [{
|
|
3623
3915
|
...opt,
|
|
3624
3916
|
equal: (a, b) => {
|
|
3625
|
-
if (a ===
|
|
3917
|
+
if (a === EMPTY && b === EMPTY)
|
|
3626
3918
|
return true;
|
|
3627
|
-
if (a ===
|
|
3919
|
+
if (a === EMPTY || b === EMPTY)
|
|
3628
3920
|
return false;
|
|
3629
3921
|
return equal(a, b);
|
|
3630
3922
|
},
|
|
@@ -3660,19 +3952,27 @@ function stored(fallback, { key, store: providedStore, serialize = JSON.stringif
|
|
|
3660
3952
|
if (syncTabs && !isServer) {
|
|
3661
3953
|
const destroyRef = inject(DestroyRef);
|
|
3662
3954
|
const sync = (e) => {
|
|
3955
|
+
// `storage` events only describe Web Storage — ignore events for a different
|
|
3956
|
+
// storage area (or any event when a custom adapter is configured), otherwise an
|
|
3957
|
+
// unrelated localStorage write with the same key string corrupts our state
|
|
3958
|
+
if (e.storageArea !== store)
|
|
3959
|
+
return;
|
|
3663
3960
|
if (e.key !== untracked(keySig))
|
|
3664
3961
|
return;
|
|
3665
3962
|
if (e.newValue === null)
|
|
3666
|
-
internal.set(
|
|
3963
|
+
internal.set(EMPTY);
|
|
3667
3964
|
else
|
|
3668
3965
|
internal.set(getValue(e.key));
|
|
3669
3966
|
};
|
|
3670
3967
|
window.addEventListener('storage', sync);
|
|
3671
3968
|
destroyRef.onDestroy(() => window.removeEventListener('storage', sync));
|
|
3672
3969
|
}
|
|
3673
|
-
const writable = toWritable(computed(() =>
|
|
3970
|
+
const writable = toWritable(computed(() => {
|
|
3971
|
+
const v = internal();
|
|
3972
|
+
return v === EMPTY ? fallback : v;
|
|
3973
|
+
}, opt), internal.set);
|
|
3674
3974
|
writable.clear = () => {
|
|
3675
|
-
internal.set(
|
|
3975
|
+
internal.set(EMPTY);
|
|
3676
3976
|
};
|
|
3677
3977
|
writable.key = keySig;
|
|
3678
3978
|
return writable;
|
|
@@ -3682,7 +3982,6 @@ class MessageBus {
|
|
|
3682
3982
|
channel = new BroadcastChannel('mmstack-tab-sync-bus');
|
|
3683
3983
|
listeners = new Map();
|
|
3684
3984
|
subscribe(id, listener) {
|
|
3685
|
-
this.unsubscribe(id); // Ensure no duplicate listeners
|
|
3686
3985
|
const wrapped = (ev) => {
|
|
3687
3986
|
try {
|
|
3688
3987
|
if (ev.data?.id === id)
|
|
@@ -3693,18 +3992,28 @@ class MessageBus {
|
|
|
3693
3992
|
}
|
|
3694
3993
|
};
|
|
3695
3994
|
this.channel.addEventListener('message', wrapped);
|
|
3696
|
-
this.listeners.
|
|
3995
|
+
let set = this.listeners.get(id);
|
|
3996
|
+
if (!set) {
|
|
3997
|
+
set = new Set();
|
|
3998
|
+
this.listeners.set(id, set);
|
|
3999
|
+
}
|
|
4000
|
+
set.add(wrapped);
|
|
3697
4001
|
return {
|
|
3698
|
-
unsub: (
|
|
3699
|
-
|
|
4002
|
+
unsub: () => {
|
|
4003
|
+
this.channel.removeEventListener('message', wrapped);
|
|
4004
|
+
const cur = this.listeners.get(id);
|
|
4005
|
+
if (!cur)
|
|
4006
|
+
return;
|
|
4007
|
+
cur.delete(wrapped);
|
|
4008
|
+
if (cur.size === 0)
|
|
4009
|
+
this.listeners.delete(id);
|
|
4010
|
+
},
|
|
4011
|
+
post: (value) => this.channel.postMessage({ id, value }),
|
|
3700
4012
|
};
|
|
3701
4013
|
}
|
|
3702
|
-
|
|
3703
|
-
|
|
3704
|
-
|
|
3705
|
-
return;
|
|
3706
|
-
this.channel.removeEventListener('message', listener);
|
|
3707
|
-
this.listeners.delete(id);
|
|
4014
|
+
ngOnDestroy() {
|
|
4015
|
+
this.channel.close();
|
|
4016
|
+
this.listeners.clear();
|
|
3708
4017
|
}
|
|
3709
4018
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.17", ngImport: i0, type: MessageBus, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
3710
4019
|
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.17", ngImport: i0, type: MessageBus, providedIn: 'root' });
|
|
@@ -3715,6 +4024,11 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.17", ngImpo
|
|
|
3715
4024
|
providedIn: 'root',
|
|
3716
4025
|
}]
|
|
3717
4026
|
}] });
|
|
4027
|
+
/**
|
|
4028
|
+
* @deprecated The generated id hashes the call-site stack line, which collides when a shared
|
|
4029
|
+
* helper calls {@link tabSync} for multiple signals and diverges across minified builds during
|
|
4030
|
+
* a rolling deploy. Pass an explicit `{ id }` instead.
|
|
4031
|
+
*/
|
|
3718
4032
|
function generateDeterministicID() {
|
|
3719
4033
|
const stack = new Error().stack;
|
|
3720
4034
|
if (stack) {
|
|
@@ -3752,10 +4066,8 @@ function generateDeterministicID() {
|
|
|
3752
4066
|
*
|
|
3753
4067
|
* @example
|
|
3754
4068
|
* ```typescript
|
|
3755
|
-
* //
|
|
3756
|
-
* const theme = tabSync(signal('dark'));
|
|
3757
|
-
*
|
|
3758
|
-
* // With explicit ID (recommended for production)
|
|
4069
|
+
* // With explicit ID (recommended)
|
|
4070
|
+
* const theme = tabSync(signal('dark'), { id: 'theme' });
|
|
3759
4071
|
* const userPrefs = tabSync(signal({ lang: 'en' }), { id: 'user-preferences' });
|
|
3760
4072
|
*
|
|
3761
4073
|
* // Changes in one tab will sync to all other tabs
|
|
@@ -3767,6 +4079,7 @@ function generateDeterministicID() {
|
|
|
3767
4079
|
* - Uses a single BroadcastChannel for all synchronized signals
|
|
3768
4080
|
* - Automatically cleans up listeners when the injection context is destroyed
|
|
3769
4081
|
* - Initial signal value after sync setup is not broadcasted to prevent loops
|
|
4082
|
+
* - Received values are not re-broadcast, so tabs never echo each other's updates
|
|
3770
4083
|
*
|
|
3771
4084
|
*/
|
|
3772
4085
|
function tabSync(sig, opt) {
|
|
@@ -3774,7 +4087,20 @@ function tabSync(sig, opt) {
|
|
|
3774
4087
|
return sig;
|
|
3775
4088
|
const id = typeof opt === 'string' ? opt : (opt?.id ?? generateDeterministicID());
|
|
3776
4089
|
const bus = inject(MessageBus);
|
|
3777
|
-
|
|
4090
|
+
// The last value applied from a remote tab. The outbound effect skips (exactly) the run
|
|
4091
|
+
// caused by that write — without this, an inbound object (a fresh structured clone, so
|
|
4092
|
+
// never reference-equal) would be re-posted, and two tabs would ping-pong forever.
|
|
4093
|
+
const NONE = Symbol();
|
|
4094
|
+
let received = NONE;
|
|
4095
|
+
const { unsub, post } = bus.subscribe(id, (next) => {
|
|
4096
|
+
const before = untracked(sig);
|
|
4097
|
+
received = next;
|
|
4098
|
+
sig.set(next);
|
|
4099
|
+
// Equality-suppressed write (e.g. an identical primitive): no effect run will follow,
|
|
4100
|
+
// so clear the marker — it must not swallow a later, genuinely local change.
|
|
4101
|
+
if (untracked(sig) === before)
|
|
4102
|
+
received = NONE;
|
|
4103
|
+
});
|
|
3778
4104
|
let first = false;
|
|
3779
4105
|
const effectRef = effect(() => {
|
|
3780
4106
|
const val = sig();
|
|
@@ -3782,6 +4108,11 @@ function tabSync(sig, opt) {
|
|
|
3782
4108
|
first = true;
|
|
3783
4109
|
return;
|
|
3784
4110
|
}
|
|
4111
|
+
if (val === received) {
|
|
4112
|
+
received = NONE;
|
|
4113
|
+
return;
|
|
4114
|
+
}
|
|
4115
|
+
received = NONE;
|
|
3785
4116
|
post(val);
|
|
3786
4117
|
}, ...(ngDevMode ? [{ debugName: "effectRef" }] : []));
|
|
3787
4118
|
inject(DestroyRef).onDestroy(() => {
|
|
@@ -3792,7 +4123,6 @@ function tabSync(sig, opt) {
|
|
|
3792
4123
|
}
|
|
3793
4124
|
|
|
3794
4125
|
function until(sourceSignal, predicate, options = {}) {
|
|
3795
|
-
const injector = options.injector ?? inject(Injector);
|
|
3796
4126
|
return new Promise((resolve, reject) => {
|
|
3797
4127
|
let effectRef;
|
|
3798
4128
|
let timeoutId;
|
|
@@ -3829,6 +4159,14 @@ function until(sourceSignal, predicate, options = {}) {
|
|
|
3829
4159
|
cleanupAndResolve(initialValue);
|
|
3830
4160
|
return;
|
|
3831
4161
|
}
|
|
4162
|
+
let injector;
|
|
4163
|
+
try {
|
|
4164
|
+
injector = options.injector ?? inject(Injector);
|
|
4165
|
+
}
|
|
4166
|
+
catch {
|
|
4167
|
+
cleanupAndReject('until: No injector available — provide options.injector when calling outside an injection context.');
|
|
4168
|
+
return;
|
|
4169
|
+
}
|
|
3832
4170
|
if (options?.timeout !== undefined) {
|
|
3833
4171
|
timeoutId = setTimeout(() => cleanupAndReject(`until: Timeout after ${options.timeout}ms.`), options.timeout);
|
|
3834
4172
|
}
|
|
@@ -3846,17 +4184,6 @@ function until(sourceSignal, predicate, options = {}) {
|
|
|
3846
4184
|
});
|
|
3847
4185
|
}
|
|
3848
4186
|
|
|
3849
|
-
/**
|
|
3850
|
-
* @interal
|
|
3851
|
-
*/
|
|
3852
|
-
function getSignalEquality(sig) {
|
|
3853
|
-
const internal = sig[SIGNAL];
|
|
3854
|
-
if (internal && typeof internal.equal === 'function') {
|
|
3855
|
-
return internal.equal;
|
|
3856
|
-
}
|
|
3857
|
-
return Object.is; // Default equality check
|
|
3858
|
-
}
|
|
3859
|
-
|
|
3860
4187
|
/**
|
|
3861
4188
|
* Enhances an existing `WritableSignal` by adding a complete undo/redo history
|
|
3862
4189
|
* stack and an API to control it.
|
|
@@ -3905,9 +4232,10 @@ function getSignalEquality(sig) {
|
|
|
3905
4232
|
* ```
|
|
3906
4233
|
*/
|
|
3907
4234
|
function withHistory(sourceOrValue, opt) {
|
|
3908
|
-
const equal =
|
|
3909
|
-
|
|
3910
|
-
|
|
4235
|
+
const equal = opt?.equal ??
|
|
4236
|
+
(isSignal(sourceOrValue)
|
|
4237
|
+
? getSignalEquality(sourceOrValue)
|
|
4238
|
+
: Object.is);
|
|
3911
4239
|
const source = isSignal(sourceOrValue)
|
|
3912
4240
|
? sourceOrValue
|
|
3913
4241
|
: signal(sourceOrValue);
|
|
@@ -3952,9 +4280,8 @@ function withHistory(sourceOrValue, opt) {
|
|
|
3952
4280
|
if (historyStack.length === 0)
|
|
3953
4281
|
return;
|
|
3954
4282
|
const valueForRedo = untracked(source);
|
|
3955
|
-
|
|
3956
|
-
|
|
3957
|
-
return;
|
|
4283
|
+
// length checked above — a legitimately `undefined` entry must still restore
|
|
4284
|
+
const valueToRestore = historyStack[historyStack.length - 1];
|
|
3958
4285
|
originalSet.call(source, valueToRestore);
|
|
3959
4286
|
history.inline((h) => h.pop());
|
|
3960
4287
|
redoArray.mutate((r) => {
|
|
@@ -3968,9 +4295,8 @@ function withHistory(sourceOrValue, opt) {
|
|
|
3968
4295
|
if (redoStack.length === 0)
|
|
3969
4296
|
return;
|
|
3970
4297
|
const valueForUndo = untracked(source);
|
|
3971
|
-
|
|
3972
|
-
|
|
3973
|
-
return;
|
|
4298
|
+
// length checked above — a legitimately `undefined` entry must still restore
|
|
4299
|
+
const valueToRestore = redoStack[redoStack.length - 1];
|
|
3974
4300
|
originalSet.call(source, valueToRestore);
|
|
3975
4301
|
redoArray.inline((r) => r.pop());
|
|
3976
4302
|
history.mutate((h) => {
|
|
@@ -3993,5 +4319,5 @@ function withHistory(sourceOrValue, opt) {
|
|
|
3993
4319
|
* Generated bundle index. Do not edit.
|
|
3994
4320
|
*/
|
|
3995
4321
|
|
|
3996
|
-
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 };
|
|
4322
|
+
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 };
|
|
3997
4323
|
//# sourceMappingURL=mmstack-primitives.mjs.map
|