@mmstack/primitives 21.1.1 → 21.1.2
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/fesm2022/mmstack-primitives.mjs +528 -247
- package/fesm2022/mmstack-primitives.mjs.map +1 -1
- package/package.json +1 -1
- package/types/mmstack-primitives.d.ts +111 -104
|
@@ -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,7 +555,7 @@ 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
|
}
|
|
@@ -563,6 +589,11 @@ function registerResource(res, opt) {
|
|
|
563
589
|
*
|
|
564
590
|
* Must be called in an injection context. This is the *async* generalization (Tier 2): it adds
|
|
565
591
|
* no rendering cost and needs no fork — holding direct/sync readers is a separate, deferred tier.
|
|
592
|
+
*
|
|
593
|
+
* Caveat: work must go in flight by the first post-write render to be awaited. A loader that
|
|
594
|
+
* starts later (a debounced request signal, a chained/deferred resource) is not attributable to
|
|
595
|
+
* this transition — the no-async fallback will have already resolved `done`. Trigger such work
|
|
596
|
+
* eagerly inside `fn`, or coordinate it separately.
|
|
566
597
|
*/
|
|
567
598
|
function injectStartTransition() {
|
|
568
599
|
const scope = injectTransitionScope();
|
|
@@ -712,6 +743,11 @@ function runInTransaction(txn, fn) {
|
|
|
712
743
|
* The writes land on LIVE state immediately (so derived variables and connector requests see the
|
|
713
744
|
* new values and refetch); only the *display* is held, via `scope.hold`. Must run in an injection
|
|
714
745
|
* context.
|
|
746
|
+
*
|
|
747
|
+
* Caveat: work must go in flight by the first post-write render to be part of the transaction. A
|
|
748
|
+
* loader that starts later (a debounced request signal, a chained/deferred resource) is not
|
|
749
|
+
* attributable to it — the no-async fallback will have already committed and released the hold,
|
|
750
|
+
* after which `abort()` is a no-op. Trigger such work eagerly inside `fn`.
|
|
715
751
|
*/
|
|
716
752
|
function injectStartTransaction() {
|
|
717
753
|
const scope = injectTransitionScope();
|
|
@@ -721,7 +757,15 @@ function injectStartTransaction() {
|
|
|
721
757
|
// Hold BEFORE the writes, so the display freezes at pre-transaction values.
|
|
722
758
|
scope.beginHold();
|
|
723
759
|
let finished = false;
|
|
760
|
+
// eslint-disable-next-line prefer-const -- assigned in try/catch, but needs to be declared here for the `finally` block to see it
|
|
724
761
|
let watcher;
|
|
762
|
+
let resolveDone;
|
|
763
|
+
const done = new Promise((resolve) => {
|
|
764
|
+
resolveDone = resolve;
|
|
765
|
+
});
|
|
766
|
+
// Every exit path funnels through here, so `done` always settles — including `abort()`
|
|
767
|
+
// and a throwing transaction body (which would otherwise leak the hold forever and
|
|
768
|
+
// freeze the boundary with no recovery).
|
|
725
769
|
const finish = (restore) => {
|
|
726
770
|
if (finished)
|
|
727
771
|
return;
|
|
@@ -732,27 +776,28 @@ function injectStartTransaction() {
|
|
|
732
776
|
else
|
|
733
777
|
txn.clear();
|
|
734
778
|
scope.endHold();
|
|
779
|
+
resolveDone();
|
|
735
780
|
};
|
|
736
|
-
|
|
781
|
+
try {
|
|
782
|
+
runInTransaction(txn, fn);
|
|
783
|
+
}
|
|
784
|
+
catch (e) {
|
|
785
|
+
finish(true);
|
|
786
|
+
throw e;
|
|
787
|
+
}
|
|
737
788
|
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
|
-
});
|
|
789
|
+
watcher = effect(() => {
|
|
790
|
+
const p = scope.pending();
|
|
791
|
+
if (p)
|
|
792
|
+
sawPending = true;
|
|
793
|
+
if (sawPending && !p)
|
|
794
|
+
finish(false);
|
|
795
|
+
}, { injector });
|
|
796
|
+
// no-async fallback: if nothing ever went in flight, settle once the writes are processed.
|
|
797
|
+
afterNextRender(() => {
|
|
798
|
+
if (!sawPending && !untracked(scope.pending))
|
|
799
|
+
finish(false);
|
|
800
|
+
}, { injector });
|
|
756
801
|
return {
|
|
757
802
|
pending: scope.pending,
|
|
758
803
|
done,
|
|
@@ -761,6 +806,17 @@ function injectStartTransaction() {
|
|
|
761
806
|
};
|
|
762
807
|
}
|
|
763
808
|
|
|
809
|
+
/**
|
|
810
|
+
* @internal
|
|
811
|
+
*/
|
|
812
|
+
function getSignalEquality(sig) {
|
|
813
|
+
const internal = sig[SIGNAL];
|
|
814
|
+
if (internal && typeof internal.equal === 'function') {
|
|
815
|
+
return internal.equal;
|
|
816
|
+
}
|
|
817
|
+
return Object.is; // Default equality check
|
|
818
|
+
}
|
|
819
|
+
|
|
764
820
|
/**
|
|
765
821
|
* Converts a read-only `Signal` into a `WritableSignal` by providing custom `set` and, optionally, `update` functions.
|
|
766
822
|
* This can be useful for creating controlled write access to a signal that is otherwise read-only.
|
|
@@ -859,6 +915,7 @@ function debounced(initial, opt) {
|
|
|
859
915
|
* ```
|
|
860
916
|
*/
|
|
861
917
|
function debounce(source, opt) {
|
|
918
|
+
const eq = opt?.equal ?? getSignalEquality(source);
|
|
862
919
|
const ms = opt?.ms ?? 0;
|
|
863
920
|
const trigger = signal(false, ...(ngDevMode ? [{ debugName: "trigger" }] : /* istanbul ignore next */ []));
|
|
864
921
|
let timeout;
|
|
@@ -873,25 +930,25 @@ function debounce(source, opt) {
|
|
|
873
930
|
catch {
|
|
874
931
|
// not in injection context & no destroyRef provided opting out of cleanup
|
|
875
932
|
}
|
|
876
|
-
const
|
|
933
|
+
const set = (next) => {
|
|
934
|
+
const isEqual = eq(untracked(source), next);
|
|
935
|
+
if (!timeout && isEqual)
|
|
936
|
+
return; // nothing to do
|
|
877
937
|
if (timeout)
|
|
878
|
-
clearTimeout(timeout);
|
|
879
|
-
|
|
938
|
+
clearTimeout(timeout); // clear pending
|
|
939
|
+
if (!isEqual)
|
|
940
|
+
source.set(next);
|
|
880
941
|
timeout = setTimeout(() => {
|
|
942
|
+
timeout = undefined;
|
|
881
943
|
trigger.update((c) => !c);
|
|
882
944
|
}, ms);
|
|
883
945
|
};
|
|
884
|
-
const
|
|
885
|
-
triggerFn(value);
|
|
886
|
-
};
|
|
887
|
-
const update = (fn) => {
|
|
888
|
-
triggerFn(fn(untracked(source)));
|
|
889
|
-
};
|
|
946
|
+
const update = (fn) => set(fn(untracked(source)));
|
|
890
947
|
const writable = toWritable(computed(() => {
|
|
891
948
|
trigger();
|
|
892
949
|
return untracked(source);
|
|
893
950
|
}, opt), set, update);
|
|
894
|
-
writable.original = source;
|
|
951
|
+
writable.original = source.asReadonly();
|
|
895
952
|
return writable;
|
|
896
953
|
}
|
|
897
954
|
|
|
@@ -1062,8 +1119,18 @@ function derived(source, optOrKey, opt) {
|
|
|
1062
1119
|
if (isMutable(source)) {
|
|
1063
1120
|
sig.mutate = (updater) => {
|
|
1064
1121
|
cnt++;
|
|
1065
|
-
|
|
1066
|
-
|
|
1122
|
+
try {
|
|
1123
|
+
sig.update(updater);
|
|
1124
|
+
// The wrapped computed evaluates its `equal` lazily — at the next read, which would
|
|
1125
|
+
// normally happen after `cnt` has already dropped back to 0. For a reference-stable
|
|
1126
|
+
// mutation that read compares the same object to itself and the version never bumps,
|
|
1127
|
+
// so dependents are never notified. Reading here, while equality is still suppressed,
|
|
1128
|
+
// forces the recompute (and version bump) inside the mutate window.
|
|
1129
|
+
untracked(sig);
|
|
1130
|
+
}
|
|
1131
|
+
finally {
|
|
1132
|
+
cnt--;
|
|
1133
|
+
}
|
|
1067
1134
|
};
|
|
1068
1135
|
sig.inline = (updater) => {
|
|
1069
1136
|
sig.mutate((prev) => {
|
|
@@ -1119,16 +1186,43 @@ function isDerivation(sig) {
|
|
|
1119
1186
|
}
|
|
1120
1187
|
|
|
1121
1188
|
function keepPrevious(src, opt) {
|
|
1189
|
+
const mutableSrc = isWritableSignal$2(src) && isMutable(src);
|
|
1190
|
+
// For a mutable source the linkedSignal's equality must be suppressible: a forwarded
|
|
1191
|
+
// `mutate` keeps the same reference, which default equality would otherwise swallow.
|
|
1192
|
+
let cnt = 0;
|
|
1193
|
+
const baseEqual = opt?.equal;
|
|
1194
|
+
const equal = mutableSrc
|
|
1195
|
+
? (a, b) => cnt > 0 ? false : baseEqual ? baseEqual(a, b) : Object.is(a, b)
|
|
1196
|
+
: baseEqual;
|
|
1122
1197
|
const persisted = linkedSignal({ ...(ngDevMode ? { debugName: "persisted" } : /* istanbul ignore next */ {}), ...opt,
|
|
1123
1198
|
source: () => src(),
|
|
1124
|
-
computation: (next, prev) => next === undefined && prev !== undefined ? prev.value : next
|
|
1199
|
+
computation: (next, prev) => next === undefined && prev !== undefined ? prev.value : next,
|
|
1200
|
+
equal });
|
|
1125
1201
|
if (isWritableSignal$2(src)) {
|
|
1126
1202
|
persisted.set = src.set;
|
|
1127
1203
|
persisted.update = src.update;
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1204
|
+
// NOTE: `asReadonly` deliberately stays the linkedSignal's own — returning the
|
|
1205
|
+
// source's readonly view would reintroduce the `undefined` flashes this wrapper exists
|
|
1206
|
+
// to prevent.
|
|
1207
|
+
if (mutableSrc) {
|
|
1208
|
+
persisted.mutate = (updater) => {
|
|
1209
|
+
cnt++;
|
|
1210
|
+
try {
|
|
1211
|
+
src.mutate(updater);
|
|
1212
|
+
// force the recompute while equality is suppressed, so the reference-stable
|
|
1213
|
+
// mutation bumps the wrapper's version (see derived.ts for the same pattern)
|
|
1214
|
+
untracked(persisted);
|
|
1215
|
+
}
|
|
1216
|
+
finally {
|
|
1217
|
+
cnt--;
|
|
1218
|
+
}
|
|
1219
|
+
};
|
|
1220
|
+
persisted.inline = (updater) => {
|
|
1221
|
+
persisted.mutate((prev) => {
|
|
1222
|
+
updater(prev);
|
|
1223
|
+
return prev;
|
|
1224
|
+
});
|
|
1225
|
+
};
|
|
1132
1226
|
}
|
|
1133
1227
|
if (isDerivation(src)) {
|
|
1134
1228
|
persisted.from = src.from;
|
|
@@ -1185,13 +1279,18 @@ function indexArray(source, map, opt = {}) {
|
|
|
1185
1279
|
: toWritable(data, () => {
|
|
1186
1280
|
// noop
|
|
1187
1281
|
});
|
|
1282
|
+
// copy before defaulting `equal` — assigning onto `opt` would mutate a caller-owned
|
|
1283
|
+
// (possibly shared/reused) options object
|
|
1188
1284
|
if (isWritableSignal$1(data) && isMutable(data) && !opt.equal) {
|
|
1189
|
-
opt
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1285
|
+
opt = {
|
|
1286
|
+
...opt,
|
|
1287
|
+
equal: (a, b) => {
|
|
1288
|
+
if (typeof a !== typeof b)
|
|
1289
|
+
return false;
|
|
1290
|
+
if (typeof a === 'object' || typeof a === 'function')
|
|
1291
|
+
return false;
|
|
1292
|
+
return a === b;
|
|
1293
|
+
},
|
|
1195
1294
|
};
|
|
1196
1295
|
}
|
|
1197
1296
|
return linkedSignal({
|
|
@@ -1385,8 +1484,17 @@ function pooledKeys(src) {
|
|
|
1385
1484
|
for (const k in val)
|
|
1386
1485
|
if (Object.prototype.hasOwnProperty.call(val, k))
|
|
1387
1486
|
spare.add(k);
|
|
1388
|
-
if (active.size === spare.size
|
|
1389
|
-
|
|
1487
|
+
if (active.size === spare.size) {
|
|
1488
|
+
let subset = true;
|
|
1489
|
+
for (const k of active) {
|
|
1490
|
+
if (!spare.has(k)) {
|
|
1491
|
+
subset = false;
|
|
1492
|
+
break;
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
if (subset)
|
|
1496
|
+
return active;
|
|
1497
|
+
}
|
|
1390
1498
|
const temp = active;
|
|
1391
1499
|
active = spare;
|
|
1392
1500
|
spare = temp;
|
|
@@ -1486,7 +1594,7 @@ const filter = (predicate) => (src) => linkedSignal({
|
|
|
1486
1594
|
computation: (next, prev) => {
|
|
1487
1595
|
if (predicate(next))
|
|
1488
1596
|
return next;
|
|
1489
|
-
return prev?.
|
|
1597
|
+
return prev?.value;
|
|
1490
1598
|
},
|
|
1491
1599
|
});
|
|
1492
1600
|
/**
|
|
@@ -1522,7 +1630,7 @@ const tap = (fn, injector) => (src) => {
|
|
|
1522
1630
|
*/
|
|
1523
1631
|
const filterWith = (predicate, initial) => (src) => linkedSignal({
|
|
1524
1632
|
source: src,
|
|
1525
|
-
computation: (next, prev) => predicate(next) ? next :
|
|
1633
|
+
computation: (next, prev) => predicate(next) ? next : prev ? prev.value : initial,
|
|
1526
1634
|
});
|
|
1527
1635
|
/**
|
|
1528
1636
|
* Emit `initial` on the first read, then mirror the source on every subsequent
|
|
@@ -1571,7 +1679,7 @@ const pairwise = () => (src) => linkedSignal({
|
|
|
1571
1679
|
*/
|
|
1572
1680
|
const scan = (reducer, seed) => (src) => linkedSignal({
|
|
1573
1681
|
source: src,
|
|
1574
|
-
computation: (next, prev) => reducer(prev
|
|
1682
|
+
computation: (next, prev) => reducer(prev ? prev.value : seed, next),
|
|
1575
1683
|
});
|
|
1576
1684
|
|
|
1577
1685
|
/**
|
|
@@ -1622,7 +1730,7 @@ function pipeable(signal) {
|
|
|
1622
1730
|
return internal;
|
|
1623
1731
|
}
|
|
1624
1732
|
/**
|
|
1625
|
-
* Create a new **writable** signal and return it as a `
|
|
1733
|
+
* Create a new **writable** signal and return it as a `PipeableSignal`.
|
|
1626
1734
|
*
|
|
1627
1735
|
* The returned value is a `WritableSignal<T>` with `.set`, `.update`, `.asReadonly`
|
|
1628
1736
|
* still available (via intersection type), plus a chainable `.pipe(...)`.
|
|
@@ -1726,6 +1834,20 @@ function pooledMap(optOrComputation, signalOpt) {
|
|
|
1726
1834
|
return pooled(toPooledOptions(optOrComputation, createEmptyMap, resetClearable, signalOpt));
|
|
1727
1835
|
}
|
|
1728
1836
|
|
|
1837
|
+
/**
|
|
1838
|
+
* @internal Run a sensor factory inside `injector` when provided, else in the ambient
|
|
1839
|
+
* injection context. Keeps every sensor's escape hatch identical and in one place.
|
|
1840
|
+
*/
|
|
1841
|
+
function runInSensorContext(injector, fn) {
|
|
1842
|
+
return injector ? runInInjectionContext(injector, fn) : fn();
|
|
1843
|
+
}
|
|
1844
|
+
/**
|
|
1845
|
+
* @internal Normalize the legacy positional `debugName: string` form into {@link SensorRunOptions}.
|
|
1846
|
+
*/
|
|
1847
|
+
function coerceSensorOptions(opt) {
|
|
1848
|
+
return typeof opt === 'string' ? { debugName: opt } : (opt ?? {});
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1729
1851
|
const EVENTS = [
|
|
1730
1852
|
'chargingchange',
|
|
1731
1853
|
'levelchange',
|
|
@@ -1747,7 +1869,11 @@ const EVENTS = [
|
|
|
1747
1869
|
* });
|
|
1748
1870
|
* ```
|
|
1749
1871
|
*/
|
|
1750
|
-
function batteryStatus(
|
|
1872
|
+
function batteryStatus(opt) {
|
|
1873
|
+
const { debugName = 'batteryStatus', injector } = coerceSensorOptions(opt);
|
|
1874
|
+
return runInSensorContext(injector, () => createBatteryStatus(debugName));
|
|
1875
|
+
}
|
|
1876
|
+
function createBatteryStatus(debugName) {
|
|
1751
1877
|
if (isPlatformServer(inject(PLATFORM_ID)) ||
|
|
1752
1878
|
typeof navigator === 'undefined' ||
|
|
1753
1879
|
typeof navigator.getBattery !== 'function') {
|
|
@@ -1756,7 +1882,9 @@ function batteryStatus(debugName = 'batteryStatus') {
|
|
|
1756
1882
|
const state = signal(null, { ...(ngDevMode ? { debugName: "state" } : /* istanbul ignore next */ {}), debugName });
|
|
1757
1883
|
const abortController = new AbortController();
|
|
1758
1884
|
inject(DestroyRef).onDestroy(() => abortController.abort());
|
|
1759
|
-
navigator
|
|
1885
|
+
navigator
|
|
1886
|
+
.getBattery()
|
|
1887
|
+
.then((battery) => {
|
|
1760
1888
|
if (abortController.signal.aborted)
|
|
1761
1889
|
return;
|
|
1762
1890
|
const read = () => ({
|
|
@@ -1772,6 +1900,10 @@ function batteryStatus(debugName = 'batteryStatus') {
|
|
|
1772
1900
|
signal: abortController.signal,
|
|
1773
1901
|
});
|
|
1774
1902
|
}
|
|
1903
|
+
})
|
|
1904
|
+
.catch(() => {
|
|
1905
|
+
// getBattery() rejects (NotAllowedError) when the `battery` permissions-policy is
|
|
1906
|
+
// disallowed, e.g. in cross-origin iframes — stay `null`, same as unsupported.
|
|
1775
1907
|
});
|
|
1776
1908
|
return state.asReadonly();
|
|
1777
1909
|
}
|
|
@@ -1787,7 +1919,11 @@ function batteryStatus(debugName = 'batteryStatus') {
|
|
|
1787
1919
|
* in browsers that gate it. Errors from `navigator.clipboard.readText` are
|
|
1788
1920
|
* swallowed silently to keep the signal value stable.
|
|
1789
1921
|
*/
|
|
1790
|
-
function clipboard(
|
|
1922
|
+
function clipboard(opt) {
|
|
1923
|
+
const { debugName = 'clipboard', injector } = coerceSensorOptions(opt);
|
|
1924
|
+
return runInSensorContext(injector, () => createClipboard(debugName));
|
|
1925
|
+
}
|
|
1926
|
+
function createClipboard(debugName) {
|
|
1791
1927
|
if (isPlatformServer(inject(PLATFORM_ID)) ||
|
|
1792
1928
|
typeof navigator === 'undefined' ||
|
|
1793
1929
|
!navigator.clipboard) {
|
|
@@ -1837,7 +1973,13 @@ function observerSupported$1() {
|
|
|
1837
1973
|
* });
|
|
1838
1974
|
* ```
|
|
1839
1975
|
*/
|
|
1840
|
-
function elementSize(target
|
|
1976
|
+
function elementSize(target, opt) {
|
|
1977
|
+
return runInSensorContext(opt?.injector, () =>
|
|
1978
|
+
// the host-element default must resolve INSIDE the sensor context, not as a
|
|
1979
|
+
// parameter default (which would run before the injector wrapper)
|
|
1980
|
+
createElementSize(target ?? inject(ElementRef), opt));
|
|
1981
|
+
}
|
|
1982
|
+
function createElementSize(target, opt) {
|
|
1841
1983
|
const getElement = () => {
|
|
1842
1984
|
if (isSignal(target)) {
|
|
1843
1985
|
try {
|
|
@@ -1851,8 +1993,8 @@ function elementSize(target = inject(ElementRef), opt) {
|
|
|
1851
1993
|
return target instanceof ElementRef ? target.nativeElement : target;
|
|
1852
1994
|
};
|
|
1853
1995
|
const resolveInitialValue = () => {
|
|
1854
|
-
|
|
1855
|
-
|
|
1996
|
+
// measuring needs only getBoundingClientRect — ResizeObserver support gates
|
|
1997
|
+
// live updates, not the initial read
|
|
1856
1998
|
const el = getElement();
|
|
1857
1999
|
if (el && el.getBoundingClientRect) {
|
|
1858
2000
|
const rect = el.getBoundingClientRect();
|
|
@@ -1970,7 +2112,13 @@ function observerSupported() {
|
|
|
1970
2112
|
* }
|
|
1971
2113
|
* ```
|
|
1972
2114
|
*/
|
|
1973
|
-
function elementVisibility(target
|
|
2115
|
+
function elementVisibility(target, opt) {
|
|
2116
|
+
return runInSensorContext(opt?.injector, () =>
|
|
2117
|
+
// the host-element default must resolve INSIDE the sensor context, not as a
|
|
2118
|
+
// parameter default (which would run before the injector wrapper)
|
|
2119
|
+
createElementVisibility(target ?? inject(ElementRef), opt));
|
|
2120
|
+
}
|
|
2121
|
+
function createElementVisibility(target, opt) {
|
|
1974
2122
|
if (isPlatformServer(inject(PLATFORM_ID)) || !observerSupported()) {
|
|
1975
2123
|
const base = computed(() => undefined, {
|
|
1976
2124
|
debugName: opt?.debugName,
|
|
@@ -2038,11 +2186,18 @@ function unwrap$1(target) {
|
|
|
2038
2186
|
* }
|
|
2039
2187
|
* ```
|
|
2040
2188
|
*/
|
|
2041
|
-
function focusWithin(target
|
|
2189
|
+
function focusWithin(target, opt) {
|
|
2190
|
+
return runInSensorContext(opt?.injector, () =>
|
|
2191
|
+
// the host-element default must resolve INSIDE the sensor context, not as a
|
|
2192
|
+
// parameter default (which would run before the injector wrapper)
|
|
2193
|
+
createFocusWithin(target ?? inject(ElementRef), opt));
|
|
2194
|
+
}
|
|
2195
|
+
function createFocusWithin(target, opt) {
|
|
2196
|
+
const debugName = opt?.debugName ?? 'focusWithin';
|
|
2042
2197
|
if (isPlatformServer(inject(PLATFORM_ID))) {
|
|
2043
|
-
return computed(() => false, { debugName
|
|
2198
|
+
return computed(() => false, { debugName });
|
|
2044
2199
|
}
|
|
2045
|
-
const state = signal(false, { debugName:
|
|
2200
|
+
const state = signal(false, { ...(ngDevMode ? { debugName: "state" } : /* istanbul ignore next */ {}), debugName });
|
|
2046
2201
|
const attach = (el) => {
|
|
2047
2202
|
state.set(el.contains(document.activeElement));
|
|
2048
2203
|
const abortController = new AbortController();
|
|
@@ -2090,6 +2245,9 @@ function focusWithin(target = inject(ElementRef)) {
|
|
|
2090
2245
|
* ```
|
|
2091
2246
|
*/
|
|
2092
2247
|
function geolocation(opt) {
|
|
2248
|
+
return runInSensorContext(opt?.injector, () => createGeolocation(opt));
|
|
2249
|
+
}
|
|
2250
|
+
function createGeolocation(opt) {
|
|
2093
2251
|
if (isPlatformServer(inject(PLATFORM_ID)) || typeof navigator === 'undefined' || !navigator.geolocation) {
|
|
2094
2252
|
const sig = computed(() => null, {
|
|
2095
2253
|
debugName: opt?.debugName ?? 'geolocation',
|
|
@@ -2149,6 +2307,9 @@ const serverDate$1 = new Date();
|
|
|
2149
2307
|
* ```
|
|
2150
2308
|
*/
|
|
2151
2309
|
function idle(opt) {
|
|
2310
|
+
return runInSensorContext(opt?.injector, () => createIdle(opt));
|
|
2311
|
+
}
|
|
2312
|
+
function createIdle(opt) {
|
|
2152
2313
|
if (isPlatformServer(inject(PLATFORM_ID))) {
|
|
2153
2314
|
const sig = computed(() => false, {
|
|
2154
2315
|
debugName: opt?.debugName ?? 'idle',
|
|
@@ -2238,7 +2399,11 @@ function idle(opt) {
|
|
|
2238
2399
|
* }
|
|
2239
2400
|
* ```
|
|
2240
2401
|
*/
|
|
2241
|
-
function mediaQuery(query,
|
|
2402
|
+
function mediaQuery(query, opt) {
|
|
2403
|
+
const { debugName = 'mediaQuery', injector } = coerceSensorOptions(opt);
|
|
2404
|
+
return runInSensorContext(injector, () => createMediaQuery(query, debugName));
|
|
2405
|
+
}
|
|
2406
|
+
function createMediaQuery(query, debugName) {
|
|
2242
2407
|
if (isPlatformServer(inject(PLATFORM_ID)) ||
|
|
2243
2408
|
typeof window === 'undefined' ||
|
|
2244
2409
|
typeof window.matchMedia !== 'function' // jsdom doesn't implement matchMedia
|
|
@@ -2276,8 +2441,8 @@ function mediaQuery(query, debugName = 'mediaQuery') {
|
|
|
2276
2441
|
* });
|
|
2277
2442
|
* ```
|
|
2278
2443
|
*/
|
|
2279
|
-
function prefersDarkMode(
|
|
2280
|
-
return mediaQuery('(prefers-color-scheme: dark)',
|
|
2444
|
+
function prefersDarkMode(opt) {
|
|
2445
|
+
return mediaQuery('(prefers-color-scheme: dark)', opt);
|
|
2281
2446
|
}
|
|
2282
2447
|
/**
|
|
2283
2448
|
* Creates a read-only signal that tracks the user's OS/browser preference
|
|
@@ -2304,8 +2469,8 @@ function prefersDarkMode(debugName) {
|
|
|
2304
2469
|
* });
|
|
2305
2470
|
* ```
|
|
2306
2471
|
*/
|
|
2307
|
-
function prefersReducedMotion(
|
|
2308
|
-
return mediaQuery('(prefers-reduced-motion: reduce)',
|
|
2472
|
+
function prefersReducedMotion(opt) {
|
|
2473
|
+
return mediaQuery('(prefers-reduced-motion: reduce)', opt);
|
|
2309
2474
|
}
|
|
2310
2475
|
|
|
2311
2476
|
/**
|
|
@@ -2354,6 +2519,7 @@ function throttled(initial, opt) {
|
|
|
2354
2519
|
* // after the 500ms cooldown.
|
|
2355
2520
|
*/
|
|
2356
2521
|
function throttle(source, opt) {
|
|
2522
|
+
const eq = opt?.equal ?? getSignalEquality(source);
|
|
2357
2523
|
const ms = opt?.ms ?? 0;
|
|
2358
2524
|
const leading = opt?.leading ?? false;
|
|
2359
2525
|
const trailing = opt?.trailing ?? true;
|
|
@@ -2379,31 +2545,32 @@ function throttle(source, opt) {
|
|
|
2379
2545
|
fire();
|
|
2380
2546
|
else
|
|
2381
2547
|
pendingTrailing = trailing;
|
|
2382
|
-
|
|
2548
|
+
const onWindowEnd = () => {
|
|
2383
2549
|
timeout = undefined;
|
|
2384
2550
|
if (trailing && pendingTrailing) {
|
|
2385
2551
|
pendingTrailing = false;
|
|
2386
2552
|
fire();
|
|
2553
|
+
timeout = setTimeout(onWindowEnd, ms);
|
|
2387
2554
|
}
|
|
2388
|
-
}
|
|
2555
|
+
};
|
|
2556
|
+
timeout = setTimeout(onWindowEnd, ms);
|
|
2389
2557
|
return;
|
|
2390
2558
|
}
|
|
2391
2559
|
if (trailing)
|
|
2392
2560
|
pendingTrailing = true;
|
|
2393
2561
|
};
|
|
2394
|
-
const set = (
|
|
2395
|
-
source
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
const update = (fn) => {
|
|
2399
|
-
source.update(fn);
|
|
2562
|
+
const set = (next) => {
|
|
2563
|
+
if (eq(untracked(source), next))
|
|
2564
|
+
return;
|
|
2565
|
+
source.set(next);
|
|
2400
2566
|
tick();
|
|
2401
2567
|
};
|
|
2568
|
+
const update = (fn) => set(fn(untracked(source)));
|
|
2402
2569
|
const writable = toWritable(computed(() => {
|
|
2403
2570
|
trigger();
|
|
2404
2571
|
return untracked(source);
|
|
2405
2572
|
}, opt), set, update);
|
|
2406
|
-
writable.original = source;
|
|
2573
|
+
writable.original = source.asReadonly();
|
|
2407
2574
|
return writable;
|
|
2408
2575
|
}
|
|
2409
2576
|
|
|
@@ -2440,6 +2607,9 @@ function throttle(source, opt) {
|
|
|
2440
2607
|
* ```
|
|
2441
2608
|
*/
|
|
2442
2609
|
function mousePosition(opt) {
|
|
2610
|
+
return runInSensorContext(opt?.injector, () => createMousePosition(opt));
|
|
2611
|
+
}
|
|
2612
|
+
function createMousePosition(opt) {
|
|
2443
2613
|
if (isPlatformServer(inject(PLATFORM_ID))) {
|
|
2444
2614
|
const base = computed(() => ({
|
|
2445
2615
|
x: 0,
|
|
@@ -2451,8 +2621,12 @@ function mousePosition(opt) {
|
|
|
2451
2621
|
return base;
|
|
2452
2622
|
}
|
|
2453
2623
|
const { target = window, coordinateSpace = 'client', touch = false, debugName = 'mousePosition', throttle = 100, } = opt ?? {};
|
|
2454
|
-
const
|
|
2455
|
-
|
|
2624
|
+
const resolve = (t) => {
|
|
2625
|
+
if (!t)
|
|
2626
|
+
return null;
|
|
2627
|
+
return t instanceof ElementRef ? t.nativeElement : t;
|
|
2628
|
+
};
|
|
2629
|
+
if (!isSignal(target) && !resolve(target)) {
|
|
2456
2630
|
if (isDevMode())
|
|
2457
2631
|
console.warn('mousePosition: Target element not found.');
|
|
2458
2632
|
const base = computed(() => ({
|
|
@@ -2475,7 +2649,7 @@ function mousePosition(opt) {
|
|
|
2475
2649
|
x = coordinateSpace === 'page' ? event.pageX : event.clientX;
|
|
2476
2650
|
y = coordinateSpace === 'page' ? event.pageY : event.clientY;
|
|
2477
2651
|
}
|
|
2478
|
-
else if (event.touches
|
|
2652
|
+
else if (event.touches?.length > 0) {
|
|
2479
2653
|
const firstTouch = event.touches[0];
|
|
2480
2654
|
x = coordinateSpace === 'page' ? firstTouch.pageX : firstTouch.clientX;
|
|
2481
2655
|
y = coordinateSpace === 'page' ? firstTouch.pageY : firstTouch.clientY;
|
|
@@ -2485,16 +2659,36 @@ function mousePosition(opt) {
|
|
|
2485
2659
|
}
|
|
2486
2660
|
pos.set({ x, y });
|
|
2487
2661
|
};
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
|
|
2662
|
+
// passive: the handler never calls preventDefault, and a non-passive touchmove on
|
|
2663
|
+
// window forces the browser to wait on JS before scrolling (scroll jank on touch)
|
|
2664
|
+
const attach = (el) => {
|
|
2665
|
+
const controller = new AbortController();
|
|
2666
|
+
el.addEventListener('mousemove', updatePosition, {
|
|
2667
|
+
passive: true,
|
|
2668
|
+
signal: controller.signal,
|
|
2669
|
+
});
|
|
2494
2670
|
if (touch) {
|
|
2495
|
-
|
|
2671
|
+
el.addEventListener('touchmove', updatePosition, {
|
|
2672
|
+
passive: true,
|
|
2673
|
+
signal: controller.signal,
|
|
2674
|
+
});
|
|
2496
2675
|
}
|
|
2497
|
-
|
|
2676
|
+
return () => controller.abort();
|
|
2677
|
+
};
|
|
2678
|
+
if (isSignal(target)) {
|
|
2679
|
+
// re-attach whenever the signal resolves to a (new) element — covers viewChild
|
|
2680
|
+
effect((cleanup) => {
|
|
2681
|
+
const el = resolve(target());
|
|
2682
|
+
if (!el)
|
|
2683
|
+
return;
|
|
2684
|
+
cleanup(attach(el));
|
|
2685
|
+
});
|
|
2686
|
+
}
|
|
2687
|
+
else {
|
|
2688
|
+
const el = resolve(target);
|
|
2689
|
+
if (el)
|
|
2690
|
+
inject(DestroyRef).onDestroy(attach(el));
|
|
2691
|
+
}
|
|
2498
2692
|
const base = pos.asReadonly();
|
|
2499
2693
|
base.unthrottled = pos.original;
|
|
2500
2694
|
return base;
|
|
@@ -2508,7 +2702,8 @@ const serverDate = new Date();
|
|
|
2508
2702
|
* An additional `since` signal is attached, tracking when the status last changed.
|
|
2509
2703
|
* It's SSR-safe and automatically cleans up its event listeners.
|
|
2510
2704
|
*
|
|
2511
|
-
* @param
|
|
2705
|
+
* @param opt Optional debug name for the signal, or a {@link SensorRunOptions} object
|
|
2706
|
+
* (with an optional `injector` for creation outside an injection context).
|
|
2512
2707
|
* @returns A `NetworkStatusSignal` instance.
|
|
2513
2708
|
*
|
|
2514
2709
|
* @example
|
|
@@ -2519,7 +2714,11 @@ const serverDate = new Date();
|
|
|
2519
2714
|
* });
|
|
2520
2715
|
* ```
|
|
2521
2716
|
*/
|
|
2522
|
-
function networkStatus(
|
|
2717
|
+
function networkStatus(opt) {
|
|
2718
|
+
const { debugName = 'networkStatus', injector } = coerceSensorOptions(opt);
|
|
2719
|
+
return runInSensorContext(injector, () => createNetworkStatus(debugName));
|
|
2720
|
+
}
|
|
2721
|
+
function createNetworkStatus(debugName) {
|
|
2523
2722
|
if (isPlatformServer(inject(PLATFORM_ID))) {
|
|
2524
2723
|
const sig = computed(() => true, {
|
|
2525
2724
|
debugName,
|
|
@@ -2567,7 +2766,11 @@ const SSR_FALLBACK = {
|
|
|
2567
2766
|
* });
|
|
2568
2767
|
* ```
|
|
2569
2768
|
*/
|
|
2570
|
-
function orientation(
|
|
2769
|
+
function orientation(opt) {
|
|
2770
|
+
const { debugName = 'orientation', injector } = coerceSensorOptions(opt);
|
|
2771
|
+
return runInSensorContext(injector, () => createOrientation(debugName));
|
|
2772
|
+
}
|
|
2773
|
+
function createOrientation(debugName) {
|
|
2571
2774
|
if (isPlatformServer(inject(PLATFORM_ID)) ||
|
|
2572
2775
|
typeof screen === 'undefined' ||
|
|
2573
2776
|
!screen.orientation) {
|
|
@@ -2594,7 +2797,8 @@ function orientation(debugName = 'orientation') {
|
|
|
2594
2797
|
* The primitive is SSR-safe and automatically cleans up its event listeners
|
|
2595
2798
|
* when the creating context is destroyed.
|
|
2596
2799
|
*
|
|
2597
|
-
* @param
|
|
2800
|
+
* @param opt Optional debug name for the signal, or a {@link SensorRunOptions} object
|
|
2801
|
+
* (with an optional `injector` for creation outside an injection context).
|
|
2598
2802
|
* @returns A read-only `Signal<DocumentVisibilityState>`. On the server,
|
|
2599
2803
|
* it returns a static signal with a value of `'visible'`.
|
|
2600
2804
|
*
|
|
@@ -2622,7 +2826,11 @@ function orientation(debugName = 'orientation') {
|
|
|
2622
2826
|
* }
|
|
2623
2827
|
* ```
|
|
2624
2828
|
*/
|
|
2625
|
-
function pageVisibility(
|
|
2829
|
+
function pageVisibility(opt) {
|
|
2830
|
+
const { debugName = 'pageVisibility', injector } = coerceSensorOptions(opt);
|
|
2831
|
+
return runInSensorContext(injector, () => createPageVisibility(debugName));
|
|
2832
|
+
}
|
|
2833
|
+
function createPageVisibility(debugName) {
|
|
2626
2834
|
if (isPlatformServer(inject(PLATFORM_ID))) {
|
|
2627
2835
|
return computed(() => 'visible', { debugName });
|
|
2628
2836
|
}
|
|
@@ -2654,31 +2862,25 @@ function pageVisibility(debugName = 'pageVisibility') {
|
|
|
2654
2862
|
* selector: 'app-scroll-tracker',
|
|
2655
2863
|
* template: `
|
|
2656
2864
|
* <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
|
-
* }
|
|
2865
|
+
* <p>Host Scroll: X: {{ hostScroll().x }}, Y: {{ hostScroll().y }}</p>
|
|
2663
2866
|
* `
|
|
2664
2867
|
* })
|
|
2665
2868
|
* export class ScrollTrackerComponent {
|
|
2666
2869
|
* readonly windowScroll = scrollPosition(); // Defaults to window
|
|
2870
|
+
* // Signal targets (e.g. viewChild) attach once the element exists:
|
|
2667
2871
|
* readonly scrollableDiv = viewChild<ElementRef<HTMLDivElement>>('scrollableDiv');
|
|
2668
|
-
* readonly divScroll = scrollPosition({ target: this.scrollableDiv
|
|
2872
|
+
* readonly divScroll = scrollPosition({ target: this.scrollableDiv });
|
|
2669
2873
|
*
|
|
2670
2874
|
* 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
|
-
* });
|
|
2875
|
+
* effect(() => console.log('Window scrolled to:', this.windowScroll()));
|
|
2677
2876
|
* }
|
|
2678
2877
|
* }
|
|
2679
2878
|
* ```
|
|
2680
2879
|
*/
|
|
2681
2880
|
function scrollPosition(opt) {
|
|
2881
|
+
return runInSensorContext(opt?.injector, () => createScrollPosition(opt));
|
|
2882
|
+
}
|
|
2883
|
+
function createScrollPosition(opt) {
|
|
2682
2884
|
if (isPlatformServer(inject(PLATFORM_ID))) {
|
|
2683
2885
|
const base = computed(() => ({
|
|
2684
2886
|
x: 0,
|
|
@@ -2690,43 +2892,44 @@ function scrollPosition(opt) {
|
|
|
2690
2892
|
return base;
|
|
2691
2893
|
}
|
|
2692
2894
|
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(), {
|
|
2895
|
+
const resolve = (t) => {
|
|
2896
|
+
if (!t)
|
|
2897
|
+
return null;
|
|
2898
|
+
return t instanceof ElementRef ? t.nativeElement : t;
|
|
2899
|
+
};
|
|
2900
|
+
const isWindow = (el) => el.window === el;
|
|
2901
|
+
const readPosition = (el) => isWindow(el)
|
|
2902
|
+
? {
|
|
2903
|
+
x: el.scrollX ?? el.pageXOffset ?? 0,
|
|
2904
|
+
y: el.scrollY ?? el.pageYOffset ?? 0,
|
|
2905
|
+
}
|
|
2906
|
+
: { x: el.scrollLeft, y: el.scrollTop };
|
|
2907
|
+
const initial = resolve(isSignal(target) ? untracked(target) : target);
|
|
2908
|
+
const state = throttled(initial ? readPosition(initial) : { x: 0, y: 0 }, {
|
|
2723
2909
|
debugName,
|
|
2724
2910
|
equal: (a, b) => a.x === b.x && a.y === b.y,
|
|
2725
2911
|
ms: throttle,
|
|
2726
2912
|
});
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
2913
|
+
if (isSignal(target)) {
|
|
2914
|
+
// re-attach whenever the signal resolves to a (new) element — covers viewChild
|
|
2915
|
+
effect((cleanup) => {
|
|
2916
|
+
const el = resolve(target());
|
|
2917
|
+
if (!el)
|
|
2918
|
+
return;
|
|
2919
|
+
state.set(readPosition(el)); // sync to the new element immediately
|
|
2920
|
+
const onScroll = () => state.set(readPosition(el));
|
|
2921
|
+
el.addEventListener('scroll', onScroll, { passive: true });
|
|
2922
|
+
cleanup(() => el.removeEventListener('scroll', onScroll));
|
|
2923
|
+
});
|
|
2924
|
+
}
|
|
2925
|
+
else {
|
|
2926
|
+
const el = resolve(target);
|
|
2927
|
+
if (el) {
|
|
2928
|
+
const onScroll = () => state.set(readPosition(el));
|
|
2929
|
+
el.addEventListener('scroll', onScroll, { passive: true });
|
|
2930
|
+
inject(DestroyRef).onDestroy(() => el.removeEventListener('scroll', onScroll));
|
|
2931
|
+
}
|
|
2932
|
+
}
|
|
2730
2933
|
const base = state.asReadonly();
|
|
2731
2934
|
base.unthrottled = state.original;
|
|
2732
2935
|
return base;
|
|
@@ -2774,6 +2977,9 @@ function scrollPosition(opt) {
|
|
|
2774
2977
|
* ```
|
|
2775
2978
|
*/
|
|
2776
2979
|
function windowSize(opt) {
|
|
2980
|
+
return runInSensorContext(opt?.injector, () => createWindowSize(opt));
|
|
2981
|
+
}
|
|
2982
|
+
function createWindowSize(opt) {
|
|
2777
2983
|
if (isPlatformServer(inject(PLATFORM_ID))) {
|
|
2778
2984
|
const base = computed(() => ({
|
|
2779
2985
|
width: 1024,
|
|
@@ -2810,17 +3016,19 @@ function sensor(type, options) {
|
|
|
2810
3016
|
case 'mousePosition':
|
|
2811
3017
|
return mousePosition(opts);
|
|
2812
3018
|
case 'networkStatus':
|
|
2813
|
-
return networkStatus(opts
|
|
3019
|
+
return networkStatus(opts);
|
|
2814
3020
|
case 'pageVisibility':
|
|
2815
|
-
return pageVisibility(opts
|
|
3021
|
+
return pageVisibility(opts);
|
|
2816
3022
|
case 'darkMode':
|
|
2817
3023
|
case 'dark-mode':
|
|
2818
|
-
return prefersDarkMode(opts
|
|
3024
|
+
return prefersDarkMode(opts);
|
|
2819
3025
|
case 'reducedMotion':
|
|
2820
3026
|
case 'reduced-motion':
|
|
2821
|
-
return prefersReducedMotion(opts
|
|
3027
|
+
return prefersReducedMotion(opts);
|
|
2822
3028
|
case 'mediaQuery':
|
|
2823
|
-
|
|
3029
|
+
if (typeof opts?.query !== 'string')
|
|
3030
|
+
throw new Error(`sensor('mediaQuery') requires a 'query' option, e.g. sensor('mediaQuery', { query: '(min-width: 1024px)' })`);
|
|
3031
|
+
return mediaQuery(opts.query, opts);
|
|
2824
3032
|
case 'windowSize':
|
|
2825
3033
|
return windowSize(opts);
|
|
2826
3034
|
case 'scrollPosition':
|
|
@@ -2832,15 +3040,15 @@ function sensor(type, options) {
|
|
|
2832
3040
|
case 'geolocation':
|
|
2833
3041
|
return geolocation(opts);
|
|
2834
3042
|
case 'clipboard':
|
|
2835
|
-
return clipboard(opts
|
|
3043
|
+
return clipboard(opts);
|
|
2836
3044
|
case 'orientation':
|
|
2837
|
-
return orientation(opts
|
|
3045
|
+
return orientation(opts);
|
|
2838
3046
|
case 'batteryStatus':
|
|
2839
|
-
return batteryStatus(opts
|
|
3047
|
+
return batteryStatus(opts);
|
|
2840
3048
|
case 'idle':
|
|
2841
3049
|
return idle(opts);
|
|
2842
3050
|
case 'focusWithin':
|
|
2843
|
-
return focusWithin(opts?.target);
|
|
3051
|
+
return focusWithin(opts?.target, opts);
|
|
2844
3052
|
default:
|
|
2845
3053
|
throw new Error(`Unknown sensor type: ${type}`);
|
|
2846
3054
|
}
|
|
@@ -2894,16 +3102,24 @@ function signalFromEvent(target, eventName, initial, projectOrOpt, maybeOpt) {
|
|
|
2894
3102
|
else
|
|
2895
3103
|
state.set(event);
|
|
2896
3104
|
};
|
|
2897
|
-
const { destroyRef: providedDestroyRef,
|
|
3105
|
+
const { destroyRef: providedDestroyRef,
|
|
3106
|
+
// strip non-listener keys so they don't leak into addEventListener options
|
|
3107
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
3108
|
+
injector: _injector,
|
|
3109
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
3110
|
+
debugName: _debugName, ...listenerOpts } = opt ?? {};
|
|
2898
3111
|
if (isSignal(target)) {
|
|
2899
3112
|
const targetSig = target;
|
|
2900
|
-
effect((cleanup) => {
|
|
3113
|
+
const effectRef = effect((cleanup) => {
|
|
2901
3114
|
const resolved = unwrap(targetSig());
|
|
2902
3115
|
if (!resolved)
|
|
2903
3116
|
return;
|
|
2904
3117
|
resolved.addEventListener(eventName, handler, listenerOpts);
|
|
2905
3118
|
cleanup(() => resolved.removeEventListener(eventName, handler, listenerOpts));
|
|
2906
|
-
}, { injector });
|
|
3119
|
+
}, { ...(ngDevMode ? { debugName: "effectRef" } : /* istanbul ignore next */ {}), injector });
|
|
3120
|
+
// honor an explicit destroyRef for signal targets too — the effect would otherwise
|
|
3121
|
+
// only follow the injector's lifetime, contradicting the documented option
|
|
3122
|
+
providedDestroyRef?.onDestroy(() => effectRef.destroy());
|
|
2907
3123
|
}
|
|
2908
3124
|
else {
|
|
2909
3125
|
const resolved = unwrap(target);
|
|
@@ -2992,7 +3208,8 @@ function alwaysFalse() {
|
|
|
2992
3208
|
* @internal Attaches a lazy, memoized leaf probe to a store node. The probe (`() => boolean`)
|
|
2993
3209
|
* closes over the node's value signal and its (stable) vivify setting, building the backing
|
|
2994
3210
|
* `computed` on first call so leaf-ness tracks the live value reactively without taxing every
|
|
2995
|
-
* node access.
|
|
3211
|
+
* node access. Under `noUnionLeaves` the caller promises shapes never flip, so the probe is
|
|
3212
|
+
* resolved once from the first sample and frozen as a constant. Idempotent.
|
|
2996
3213
|
*/
|
|
2997
3214
|
function markAsLeaf(sig, value, vivifyEnabled, noUnionLeaves) {
|
|
2998
3215
|
if (typeof sig[LEAF] !== 'function') {
|
|
@@ -3000,13 +3217,11 @@ function markAsLeaf(sig, value, vivifyEnabled, noUnionLeaves) {
|
|
|
3000
3217
|
const probe = () => {
|
|
3001
3218
|
if (memo)
|
|
3002
3219
|
return memo();
|
|
3003
|
-
|
|
3004
|
-
|
|
3005
|
-
|
|
3006
|
-
|
|
3007
|
-
|
|
3008
|
-
: alwaysFalse
|
|
3009
|
-
: computed(() => isLeafValue(value(), vivifyEnabled));
|
|
3220
|
+
memo = noUnionLeaves
|
|
3221
|
+
? isLeafValue(untracked(value), vivifyEnabled)
|
|
3222
|
+
? alwaysTrue
|
|
3223
|
+
: alwaysFalse
|
|
3224
|
+
: computed(() => isLeafValue(value(), vivifyEnabled));
|
|
3010
3225
|
return memo();
|
|
3011
3226
|
};
|
|
3012
3227
|
Object.defineProperty(sig, LEAF, {
|
|
@@ -3094,6 +3309,40 @@ function resolveVivify(sample, option) {
|
|
|
3094
3309
|
function hasOwnKey(value, key) {
|
|
3095
3310
|
return value != null && Object.hasOwn(value, key);
|
|
3096
3311
|
}
|
|
3312
|
+
/**
|
|
3313
|
+
* @internal
|
|
3314
|
+
* Builds the `onChange` for the fallback (non-record container) derivation branch. For an
|
|
3315
|
+
* immutable source the container is copied before the write — returning the same mutated
|
|
3316
|
+
* reference would let the source's equality cut propagation (leaving child signals permanently
|
|
3317
|
+
* stale) and alias the caller's original object, breaking the structural-sharing contract
|
|
3318
|
+
* `forkStore` relies on. For a mutable source the write goes through `mutate`, so the chain's
|
|
3319
|
+
* force-notify engages (plain `update` with the same reference would never notify).
|
|
3320
|
+
*/
|
|
3321
|
+
function createFallbackOnChange(target, prop, vivifyFn, isMutableSource) {
|
|
3322
|
+
const write = (newValue) => (v) => {
|
|
3323
|
+
const container = vivifyFn(v, prop);
|
|
3324
|
+
if (container === null || container === undefined)
|
|
3325
|
+
return container;
|
|
3326
|
+
const next = isMutableSource
|
|
3327
|
+
? container
|
|
3328
|
+
: Array.isArray(container)
|
|
3329
|
+
? container.slice()
|
|
3330
|
+
: isRecord(container)
|
|
3331
|
+
? { ...container }
|
|
3332
|
+
: container; // non-plain leaf (Date/class instance): legacy in-place attempt
|
|
3333
|
+
try {
|
|
3334
|
+
next[prop] = newValue;
|
|
3335
|
+
}
|
|
3336
|
+
catch (e) {
|
|
3337
|
+
if (isDevMode())
|
|
3338
|
+
console.error(`[store] Failed to set property "${String(prop)}"`, e);
|
|
3339
|
+
}
|
|
3340
|
+
return next;
|
|
3341
|
+
};
|
|
3342
|
+
return isMutableSource
|
|
3343
|
+
? (newValue) => target.mutate(write(newValue))
|
|
3344
|
+
: (newValue) => target.update(write(newValue));
|
|
3345
|
+
}
|
|
3097
3346
|
/**
|
|
3098
3347
|
* @internal
|
|
3099
3348
|
* Makes an array store
|
|
@@ -3116,7 +3365,9 @@ function toArrayStore(source, injector, vivify, noUnionLeaves = false) {
|
|
|
3116
3365
|
const idx = +prop;
|
|
3117
3366
|
return idx >= 0 && idx < untracked(lengthSignal);
|
|
3118
3367
|
}
|
|
3119
|
-
|
|
3368
|
+
const v = untracked(source);
|
|
3369
|
+
// nullish node values are routinely descended with vivify on — `in` must not throw
|
|
3370
|
+
return v == null ? false : Reflect.has(v, prop);
|
|
3120
3371
|
},
|
|
3121
3372
|
ownKeys() {
|
|
3122
3373
|
const v = untracked(source);
|
|
@@ -3153,7 +3404,9 @@ function toArrayStore(source, injector, vivify, noUnionLeaves = false) {
|
|
|
3153
3404
|
return lengthSignal;
|
|
3154
3405
|
if (prop === Symbol.iterator) {
|
|
3155
3406
|
return function* () {
|
|
3156
|
-
|
|
3407
|
+
// read length reactively: a spread/for-of inside a computed/effect must re-run
|
|
3408
|
+
// when items are added or removed, not only when already-read elements change
|
|
3409
|
+
for (let i = 0; i < lengthSignal(); i++) {
|
|
3157
3410
|
yield receiver[i];
|
|
3158
3411
|
}
|
|
3159
3412
|
};
|
|
@@ -3192,19 +3445,8 @@ function toArrayStore(source, injector, vivify, noUnionLeaves = false) {
|
|
|
3192
3445
|
})
|
|
3193
3446
|
: derived(target, {
|
|
3194
3447
|
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
|
-
}),
|
|
3448
|
+
onChange: createFallbackOnChange(target, idx, vivifyFn, isMutableSource),
|
|
3449
|
+
equal: equalFn,
|
|
3208
3450
|
});
|
|
3209
3451
|
const childSample = untracked(computation);
|
|
3210
3452
|
const childVivify = resolveVivify(childSample, vivify);
|
|
@@ -3224,6 +3466,13 @@ function toArrayStore(source, injector, vivify, noUnionLeaves = false) {
|
|
|
3224
3466
|
/**
|
|
3225
3467
|
* Converts a Signal into a deep-observable Store.
|
|
3226
3468
|
* Accessing nested properties returns a derived Signal of that path.
|
|
3469
|
+
*
|
|
3470
|
+
* @remarks
|
|
3471
|
+
* A child's *container kind* (array store vs object store) is resolved when the child is
|
|
3472
|
+
* first accessed and cached with the proxy. Leaf↔substore flips are tracked reactively, but a
|
|
3473
|
+
* union-typed node that later flips between an array and a record keeps its original trap set —
|
|
3474
|
+
* if you need that, re-model the union as `{ kind: ..., value: ... }` instead.
|
|
3475
|
+
*
|
|
3227
3476
|
* @example
|
|
3228
3477
|
* const state = store({ user: { name: 'John' } });
|
|
3229
3478
|
* const nameSignal = state.user.name; // WritableSignal<string>
|
|
@@ -3306,19 +3555,8 @@ function toStore(source, injector, vivify = false, noUnionLeaves = false) {
|
|
|
3306
3555
|
? derived(target, prop, { equal: equalFn, vivify: nodeVivify })
|
|
3307
3556
|
: derived(target, {
|
|
3308
3557
|
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
|
-
}),
|
|
3558
|
+
onChange: createFallbackOnChange(target, prop, vivifyFn, isMutableSource),
|
|
3559
|
+
equal: equalFn,
|
|
3322
3560
|
});
|
|
3323
3561
|
const childSample = untracked(computation);
|
|
3324
3562
|
const childVivify = resolveVivify(childSample, vivify);
|
|
@@ -3460,7 +3698,12 @@ function merge3(ancestor, mine, theirs) {
|
|
|
3460
3698
|
if (isPlainRecord(mine) && isPlainRecord(theirs) && isPlainRecord(ancestor)) {
|
|
3461
3699
|
const out = { ...theirs };
|
|
3462
3700
|
for (const key of new Set([...Object.keys(mine), ...Object.keys(theirs)])) {
|
|
3463
|
-
|
|
3701
|
+
const merged = merge3(ancestor[key], mine[key], theirs[key]);
|
|
3702
|
+
// a key deleted on the fork must commit as ABSENT, not as an explicit `undefined`
|
|
3703
|
+
if (merged === undefined && !(key in mine))
|
|
3704
|
+
delete out[key];
|
|
3705
|
+
else
|
|
3706
|
+
out[key] = merged;
|
|
3464
3707
|
}
|
|
3465
3708
|
return out;
|
|
3466
3709
|
}
|
|
@@ -3512,8 +3755,8 @@ const noopStore = {
|
|
|
3512
3755
|
*
|
|
3513
3756
|
* @template T The type of value held by the signal and stored (after serialization).
|
|
3514
3757
|
* @param fallback The default value of type `T` to use when no value is found in storage
|
|
3515
|
-
* or when deserialization fails.
|
|
3516
|
-
*
|
|
3758
|
+
* or when deserialization fails. A stored value (including a legitimate `null` for a
|
|
3759
|
+
* nullable `T`) always round-trips; the fallback only surfaces when the entry is absent.
|
|
3517
3760
|
* @param options Configuration options (`CreateStoredOptions<T>`). Requires at least the `key`.
|
|
3518
3761
|
* @returns A `StoredSignal<T>` instance. This signal behaves like a standard `WritableSignal<T>`,
|
|
3519
3762
|
* but its value is persisted. It includes a `.clear()` method to remove the item from storage
|
|
@@ -3526,7 +3769,8 @@ const noopStore = {
|
|
|
3526
3769
|
* - **Error Handling:** Catches and logs errors during serialization/deserialization in dev mode.
|
|
3527
3770
|
* - **Tab Sync:** If `syncTabs` is true, listens to `storage` events to keep the signal value
|
|
3528
3771
|
* consistent across browser tabs using the same key. Cleanup is handled automatically
|
|
3529
|
-
* using `DestroyRef`.
|
|
3772
|
+
* using `DestroyRef`. Web Storage only: the `storage` event never fires for custom `store`
|
|
3773
|
+
* adapters, so `syncTabs` has no effect with one.
|
|
3530
3774
|
* - **Removal:** Use the `.clear()` method on the returned signal to remove the item from storage.
|
|
3531
3775
|
* Setting the signal to the fallback value will store the fallback value, not remove the item.
|
|
3532
3776
|
*
|
|
@@ -3561,25 +3805,28 @@ function stored(fallback, { key, store: providedStore, serialize = JSON.stringif
|
|
|
3561
3805
|
: isSignal(key)
|
|
3562
3806
|
? key
|
|
3563
3807
|
: computed(key);
|
|
3808
|
+
// "no stored value" marker — distinct from `null`/`undefined`, so a nullable `T` can
|
|
3809
|
+
// round-trip a legitimate `null` through `set` instead of it acting like `clear()`
|
|
3810
|
+
const EMPTY = Symbol();
|
|
3564
3811
|
const getValue = (key) => {
|
|
3565
3812
|
const found = store.getItem(key);
|
|
3566
3813
|
if (found === null)
|
|
3567
|
-
return
|
|
3814
|
+
return EMPTY;
|
|
3568
3815
|
try {
|
|
3569
3816
|
const deserialized = deserialize(found);
|
|
3570
3817
|
if (!validate(deserialized))
|
|
3571
|
-
return
|
|
3818
|
+
return EMPTY;
|
|
3572
3819
|
return deserialized;
|
|
3573
3820
|
}
|
|
3574
3821
|
catch (err) {
|
|
3575
3822
|
if (isDevMode())
|
|
3576
3823
|
console.error(`Failed to parse stored value for key "${key}":`, err);
|
|
3577
|
-
return
|
|
3824
|
+
return EMPTY;
|
|
3578
3825
|
}
|
|
3579
3826
|
};
|
|
3580
3827
|
const storeValue = (key, value) => {
|
|
3581
3828
|
try {
|
|
3582
|
-
if (value ===
|
|
3829
|
+
if (value === EMPTY)
|
|
3583
3830
|
return store.removeItem(key);
|
|
3584
3831
|
const serialized = serialize(value);
|
|
3585
3832
|
store.setItem(key, serialized);
|
|
@@ -3596,9 +3843,9 @@ function stored(fallback, { key, store: providedStore, serialize = JSON.stringif
|
|
|
3596
3843
|
const initialKey = untracked(keySig);
|
|
3597
3844
|
const internal = signal(getValue(initialKey), { ...(ngDevMode ? { debugName: "internal" } : /* istanbul ignore next */ {}), ...opt,
|
|
3598
3845
|
equal: (a, b) => {
|
|
3599
|
-
if (a ===
|
|
3846
|
+
if (a === EMPTY && b === EMPTY)
|
|
3600
3847
|
return true;
|
|
3601
|
-
if (a ===
|
|
3848
|
+
if (a === EMPTY || b === EMPTY)
|
|
3602
3849
|
return false;
|
|
3603
3850
|
return equal(a, b);
|
|
3604
3851
|
} });
|
|
@@ -3633,19 +3880,27 @@ function stored(fallback, { key, store: providedStore, serialize = JSON.stringif
|
|
|
3633
3880
|
if (syncTabs && !isServer) {
|
|
3634
3881
|
const destroyRef = inject(DestroyRef);
|
|
3635
3882
|
const sync = (e) => {
|
|
3883
|
+
// `storage` events only describe Web Storage — ignore events for a different
|
|
3884
|
+
// storage area (or any event when a custom adapter is configured), otherwise an
|
|
3885
|
+
// unrelated localStorage write with the same key string corrupts our state
|
|
3886
|
+
if (e.storageArea !== store)
|
|
3887
|
+
return;
|
|
3636
3888
|
if (e.key !== untracked(keySig))
|
|
3637
3889
|
return;
|
|
3638
3890
|
if (e.newValue === null)
|
|
3639
|
-
internal.set(
|
|
3891
|
+
internal.set(EMPTY);
|
|
3640
3892
|
else
|
|
3641
3893
|
internal.set(getValue(e.key));
|
|
3642
3894
|
};
|
|
3643
3895
|
window.addEventListener('storage', sync);
|
|
3644
3896
|
destroyRef.onDestroy(() => window.removeEventListener('storage', sync));
|
|
3645
3897
|
}
|
|
3646
|
-
const writable = toWritable(computed(() =>
|
|
3898
|
+
const writable = toWritable(computed(() => {
|
|
3899
|
+
const v = internal();
|
|
3900
|
+
return v === EMPTY ? fallback : v;
|
|
3901
|
+
}, opt), internal.set);
|
|
3647
3902
|
writable.clear = () => {
|
|
3648
|
-
internal.set(
|
|
3903
|
+
internal.set(EMPTY);
|
|
3649
3904
|
};
|
|
3650
3905
|
writable.key = keySig;
|
|
3651
3906
|
return writable;
|
|
@@ -3655,7 +3910,6 @@ class MessageBus {
|
|
|
3655
3910
|
channel = new BroadcastChannel('mmstack-tab-sync-bus');
|
|
3656
3911
|
listeners = new Map();
|
|
3657
3912
|
subscribe(id, listener) {
|
|
3658
|
-
this.unsubscribe(id); // Ensure no duplicate listeners
|
|
3659
3913
|
const wrapped = (ev) => {
|
|
3660
3914
|
try {
|
|
3661
3915
|
if (ev.data?.id === id)
|
|
@@ -3666,18 +3920,28 @@ class MessageBus {
|
|
|
3666
3920
|
}
|
|
3667
3921
|
};
|
|
3668
3922
|
this.channel.addEventListener('message', wrapped);
|
|
3669
|
-
this.listeners.
|
|
3923
|
+
let set = this.listeners.get(id);
|
|
3924
|
+
if (!set) {
|
|
3925
|
+
set = new Set();
|
|
3926
|
+
this.listeners.set(id, set);
|
|
3927
|
+
}
|
|
3928
|
+
set.add(wrapped);
|
|
3670
3929
|
return {
|
|
3671
|
-
unsub: (
|
|
3672
|
-
|
|
3930
|
+
unsub: () => {
|
|
3931
|
+
this.channel.removeEventListener('message', wrapped);
|
|
3932
|
+
const cur = this.listeners.get(id);
|
|
3933
|
+
if (!cur)
|
|
3934
|
+
return;
|
|
3935
|
+
cur.delete(wrapped);
|
|
3936
|
+
if (cur.size === 0)
|
|
3937
|
+
this.listeners.delete(id);
|
|
3938
|
+
},
|
|
3939
|
+
post: (value) => this.channel.postMessage({ id, value }),
|
|
3673
3940
|
};
|
|
3674
3941
|
}
|
|
3675
|
-
|
|
3676
|
-
|
|
3677
|
-
|
|
3678
|
-
return;
|
|
3679
|
-
this.channel.removeEventListener('message', listener);
|
|
3680
|
-
this.listeners.delete(id);
|
|
3942
|
+
ngOnDestroy() {
|
|
3943
|
+
this.channel.close();
|
|
3944
|
+
this.listeners.clear();
|
|
3681
3945
|
}
|
|
3682
3946
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: MessageBus, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
3683
3947
|
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: MessageBus, providedIn: 'root' });
|
|
@@ -3688,6 +3952,11 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
|
|
|
3688
3952
|
providedIn: 'root',
|
|
3689
3953
|
}]
|
|
3690
3954
|
}] });
|
|
3955
|
+
/**
|
|
3956
|
+
* @deprecated The generated id hashes the call-site stack line, which collides when a shared
|
|
3957
|
+
* helper calls {@link tabSync} for multiple signals and diverges across minified builds during
|
|
3958
|
+
* a rolling deploy. Pass an explicit `{ id }` instead.
|
|
3959
|
+
*/
|
|
3691
3960
|
function generateDeterministicID() {
|
|
3692
3961
|
const stack = new Error().stack;
|
|
3693
3962
|
if (stack) {
|
|
@@ -3725,10 +3994,8 @@ function generateDeterministicID() {
|
|
|
3725
3994
|
*
|
|
3726
3995
|
* @example
|
|
3727
3996
|
* ```typescript
|
|
3728
|
-
* //
|
|
3729
|
-
* const theme = tabSync(signal('dark'));
|
|
3730
|
-
*
|
|
3731
|
-
* // With explicit ID (recommended for production)
|
|
3997
|
+
* // With explicit ID (recommended)
|
|
3998
|
+
* const theme = tabSync(signal('dark'), { id: 'theme' });
|
|
3732
3999
|
* const userPrefs = tabSync(signal({ lang: 'en' }), { id: 'user-preferences' });
|
|
3733
4000
|
*
|
|
3734
4001
|
* // Changes in one tab will sync to all other tabs
|
|
@@ -3740,6 +4007,7 @@ function generateDeterministicID() {
|
|
|
3740
4007
|
* - Uses a single BroadcastChannel for all synchronized signals
|
|
3741
4008
|
* - Automatically cleans up listeners when the injection context is destroyed
|
|
3742
4009
|
* - Initial signal value after sync setup is not broadcasted to prevent loops
|
|
4010
|
+
* - Received values are not re-broadcast, so tabs never echo each other's updates
|
|
3743
4011
|
*
|
|
3744
4012
|
*/
|
|
3745
4013
|
function tabSync(sig, opt) {
|
|
@@ -3747,7 +4015,20 @@ function tabSync(sig, opt) {
|
|
|
3747
4015
|
return sig;
|
|
3748
4016
|
const id = typeof opt === 'string' ? opt : (opt?.id ?? generateDeterministicID());
|
|
3749
4017
|
const bus = inject(MessageBus);
|
|
3750
|
-
|
|
4018
|
+
// The last value applied from a remote tab. The outbound effect skips (exactly) the run
|
|
4019
|
+
// caused by that write — without this, an inbound object (a fresh structured clone, so
|
|
4020
|
+
// never reference-equal) would be re-posted, and two tabs would ping-pong forever.
|
|
4021
|
+
const NONE = Symbol();
|
|
4022
|
+
let received = NONE;
|
|
4023
|
+
const { unsub, post } = bus.subscribe(id, (next) => {
|
|
4024
|
+
const before = untracked(sig);
|
|
4025
|
+
received = next;
|
|
4026
|
+
sig.set(next);
|
|
4027
|
+
// Equality-suppressed write (e.g. an identical primitive): no effect run will follow,
|
|
4028
|
+
// so clear the marker — it must not swallow a later, genuinely local change.
|
|
4029
|
+
if (untracked(sig) === before)
|
|
4030
|
+
received = NONE;
|
|
4031
|
+
});
|
|
3751
4032
|
let first = false;
|
|
3752
4033
|
const effectRef = effect(() => {
|
|
3753
4034
|
const val = sig();
|
|
@@ -3755,6 +4036,11 @@ function tabSync(sig, opt) {
|
|
|
3755
4036
|
first = true;
|
|
3756
4037
|
return;
|
|
3757
4038
|
}
|
|
4039
|
+
if (val === received) {
|
|
4040
|
+
received = NONE;
|
|
4041
|
+
return;
|
|
4042
|
+
}
|
|
4043
|
+
received = NONE;
|
|
3758
4044
|
post(val);
|
|
3759
4045
|
}, ...(ngDevMode ? [{ debugName: "effectRef" }] : /* istanbul ignore next */ []));
|
|
3760
4046
|
inject(DestroyRef).onDestroy(() => {
|
|
@@ -3765,7 +4051,6 @@ function tabSync(sig, opt) {
|
|
|
3765
4051
|
}
|
|
3766
4052
|
|
|
3767
4053
|
function until(sourceSignal, predicate, options = {}) {
|
|
3768
|
-
const injector = options.injector ?? inject(Injector);
|
|
3769
4054
|
return new Promise((resolve, reject) => {
|
|
3770
4055
|
let effectRef;
|
|
3771
4056
|
let timeoutId;
|
|
@@ -3802,6 +4087,14 @@ function until(sourceSignal, predicate, options = {}) {
|
|
|
3802
4087
|
cleanupAndResolve(initialValue);
|
|
3803
4088
|
return;
|
|
3804
4089
|
}
|
|
4090
|
+
let injector;
|
|
4091
|
+
try {
|
|
4092
|
+
injector = options.injector ?? inject(Injector);
|
|
4093
|
+
}
|
|
4094
|
+
catch {
|
|
4095
|
+
cleanupAndReject('until: No injector available — provide options.injector when calling outside an injection context.');
|
|
4096
|
+
return;
|
|
4097
|
+
}
|
|
3805
4098
|
if (options?.timeout !== undefined) {
|
|
3806
4099
|
timeoutId = setTimeout(() => cleanupAndReject(`until: Timeout after ${options.timeout}ms.`), options.timeout);
|
|
3807
4100
|
}
|
|
@@ -3819,17 +4112,6 @@ function until(sourceSignal, predicate, options = {}) {
|
|
|
3819
4112
|
});
|
|
3820
4113
|
}
|
|
3821
4114
|
|
|
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
4115
|
/**
|
|
3834
4116
|
* Enhances an existing `WritableSignal` by adding a complete undo/redo history
|
|
3835
4117
|
* stack and an API to control it.
|
|
@@ -3878,9 +4160,10 @@ function getSignalEquality(sig) {
|
|
|
3878
4160
|
* ```
|
|
3879
4161
|
*/
|
|
3880
4162
|
function withHistory(sourceOrValue, opt) {
|
|
3881
|
-
const equal =
|
|
3882
|
-
|
|
3883
|
-
|
|
4163
|
+
const equal = opt?.equal ??
|
|
4164
|
+
(isSignal(sourceOrValue)
|
|
4165
|
+
? getSignalEquality(sourceOrValue)
|
|
4166
|
+
: Object.is);
|
|
3884
4167
|
const source = isSignal(sourceOrValue)
|
|
3885
4168
|
? sourceOrValue
|
|
3886
4169
|
: signal(sourceOrValue);
|
|
@@ -3925,9 +4208,8 @@ function withHistory(sourceOrValue, opt) {
|
|
|
3925
4208
|
if (historyStack.length === 0)
|
|
3926
4209
|
return;
|
|
3927
4210
|
const valueForRedo = untracked(source);
|
|
3928
|
-
|
|
3929
|
-
|
|
3930
|
-
return;
|
|
4211
|
+
// length checked above — a legitimately `undefined` entry must still restore
|
|
4212
|
+
const valueToRestore = historyStack[historyStack.length - 1];
|
|
3931
4213
|
originalSet.call(source, valueToRestore);
|
|
3932
4214
|
history.inline((h) => h.pop());
|
|
3933
4215
|
redoArray.mutate((r) => {
|
|
@@ -3941,9 +4223,8 @@ function withHistory(sourceOrValue, opt) {
|
|
|
3941
4223
|
if (redoStack.length === 0)
|
|
3942
4224
|
return;
|
|
3943
4225
|
const valueForUndo = untracked(source);
|
|
3944
|
-
|
|
3945
|
-
|
|
3946
|
-
return;
|
|
4226
|
+
// length checked above — a legitimately `undefined` entry must still restore
|
|
4227
|
+
const valueToRestore = redoStack[redoStack.length - 1];
|
|
3947
4228
|
originalSet.call(source, valueToRestore);
|
|
3948
4229
|
redoArray.inline((r) => r.pop());
|
|
3949
4230
|
history.mutate((h) => {
|