@mmstack/primitives 22.1.1 → 22.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) => {
|
|
@@ -263,7 +270,9 @@ class MmActivity {
|
|
|
263
270
|
});
|
|
264
271
|
}
|
|
265
272
|
for (const node of this.view.rootNodes) {
|
|
266
|
-
|
|
273
|
+
// covers HTML and SVG roots; text/comment roots can't be styled — their CD is still
|
|
274
|
+
// detached, but prefer an element root for true visual hiding
|
|
275
|
+
if (node instanceof HTMLElement || node instanceof SVGElement)
|
|
267
276
|
node.style.display = visible ? '' : 'none';
|
|
268
277
|
}
|
|
269
278
|
if (visible)
|
|
@@ -342,14 +351,25 @@ function resolvePause(opt) {
|
|
|
342
351
|
if (pause === false)
|
|
343
352
|
return null;
|
|
344
353
|
const run = (fn) => opt?.injector ? runInInjectionContext(opt.injector, fn) : fn();
|
|
354
|
+
// `inject` requires an injection context even with `optional: true`. A bare
|
|
355
|
+
// `pausableSignal(0)` (documented as "like `signal`") must degrade to the unwrapped
|
|
356
|
+
// primitive outside DI, not throw NG0203 — so injection failures fall back gracefully.
|
|
357
|
+
const tryRun = (fn, fallback) => {
|
|
358
|
+
try {
|
|
359
|
+
return run(fn);
|
|
360
|
+
}
|
|
361
|
+
catch {
|
|
362
|
+
return fallback;
|
|
363
|
+
}
|
|
364
|
+
};
|
|
345
365
|
const onServer = () => typeof pause === 'function' && !opt?.injector
|
|
346
366
|
? typeof globalThis.window === 'undefined'
|
|
347
|
-
:
|
|
367
|
+
: tryRun(() => isPlatformServer(inject(PLATFORM_ID, { optional: true }) ?? 'browser'), typeof globalThis.window === 'undefined');
|
|
348
368
|
if (typeof pause === 'function')
|
|
349
369
|
return onServer() ? null : pause;
|
|
350
370
|
if (onServer())
|
|
351
371
|
return null;
|
|
352
|
-
const paused =
|
|
372
|
+
const paused = tryRun(() => inject(PAUSED_CONTEXT, { optional: true }), null);
|
|
353
373
|
if (!paused) {
|
|
354
374
|
if (explicit === true && isDevMode())
|
|
355
375
|
console.warn('[pausable] `pause: true` but no PAUSED_CONTEXT in scope — not pausing. Provide one via an ' +
|
|
@@ -378,8 +398,9 @@ function pausableEffect(effectFn, options) {
|
|
|
378
398
|
/**
|
|
379
399
|
* Like `signal`, but pausable. While paused, READS hold the last value; writes still land on the
|
|
380
400
|
* underlying signal and surface on resume. Built on the `keepPrevious`/`hold` shape — a
|
|
381
|
-
* `linkedSignal` gated on the pause predicate, with `set`/`update
|
|
382
|
-
*
|
|
401
|
+
* `linkedSignal` gated on the pause predicate, with `set`/`update` forwarded to the source signal.
|
|
402
|
+
* `asReadonly()` returns the held (gated) view, so both views of the signal agree while paused.
|
|
403
|
+
* With no `pause` option it defaults to the ambient `PAUSED_CONTEXT`; `pause: false`
|
|
383
404
|
* makes it a plain `signal` — no `linkedSignal` is created.
|
|
384
405
|
*
|
|
385
406
|
* NOTE: while paused, `set(x)` followed by a read returns the *held* (pre-pause) value, not `x` — the
|
|
@@ -396,7 +417,8 @@ function pausableSignal(initialValue, options) {
|
|
|
396
417
|
equal: options?.equal });
|
|
397
418
|
read.set = src.set;
|
|
398
419
|
read.update = src.update;
|
|
399
|
-
|
|
420
|
+
// NOTE: `asReadonly` deliberately stays the linkedSignal's own (the held view) — the
|
|
421
|
+
// source's readonly view would show live values while the signal itself shows held ones.
|
|
400
422
|
return read;
|
|
401
423
|
}
|
|
402
424
|
/**
|
|
@@ -433,8 +455,12 @@ function mutable(initial, opt) {
|
|
|
433
455
|
const internalUpdate = sig.update;
|
|
434
456
|
sig.mutate = (updater) => {
|
|
435
457
|
cnt++;
|
|
436
|
-
|
|
437
|
-
|
|
458
|
+
try {
|
|
459
|
+
internalUpdate(updater);
|
|
460
|
+
}
|
|
461
|
+
finally {
|
|
462
|
+
cnt--;
|
|
463
|
+
}
|
|
438
464
|
};
|
|
439
465
|
sig.inline = (updater) => {
|
|
440
466
|
sig.mutate((prev) => {
|
|
@@ -524,7 +550,7 @@ function createNoopScope() {
|
|
|
524
550
|
hold: (value) => value,
|
|
525
551
|
};
|
|
526
552
|
}
|
|
527
|
-
const TRANSITION_SCOPE = new InjectionToken('@mmstack/
|
|
553
|
+
const TRANSITION_SCOPE = new InjectionToken('@mmstack/primitives:transition-scope');
|
|
528
554
|
/** Provide a fresh transition scope at a boundary so its subtree's resources are tracked independently. */
|
|
529
555
|
function provideTransitionScope() {
|
|
530
556
|
return { provide: TRANSITION_SCOPE, useFactory: createTransitionScope };
|
|
@@ -533,7 +559,7 @@ function injectTransitionScope() {
|
|
|
533
559
|
const scope = inject(TRANSITION_SCOPE, { optional: true });
|
|
534
560
|
if (!scope) {
|
|
535
561
|
if (isDevMode())
|
|
536
|
-
console.warn('[mmstack/
|
|
562
|
+
console.warn('[mmstack/primitives] No transition scope in context — registration/tracking here is a no-op. ' +
|
|
537
563
|
'Use a <mm-suspense> boundary or provideTransitionScope() in an ancestor.');
|
|
538
564
|
return createNoopScope();
|
|
539
565
|
}
|
|
@@ -567,6 +593,11 @@ function registerResource(res, opt) {
|
|
|
567
593
|
*
|
|
568
594
|
* Must be called in an injection context. This is the *async* generalization (Tier 2): it adds
|
|
569
595
|
* no rendering cost and needs no fork — holding direct/sync readers is a separate, deferred tier.
|
|
596
|
+
*
|
|
597
|
+
* Caveat: work must go in flight by the first post-write render to be awaited. A loader that
|
|
598
|
+
* starts later (a debounced request signal, a chained/deferred resource) is not attributable to
|
|
599
|
+
* this transition — the no-async fallback will have already resolved `done`. Trigger such work
|
|
600
|
+
* eagerly inside `fn`, or coordinate it separately.
|
|
570
601
|
*/
|
|
571
602
|
function injectStartTransition() {
|
|
572
603
|
const scope = injectTransitionScope();
|
|
@@ -718,6 +749,11 @@ function runInTransaction(txn, fn) {
|
|
|
718
749
|
* The writes land on LIVE state immediately (so derived variables and connector requests see the
|
|
719
750
|
* new values and refetch); only the *display* is held, via `scope.hold`. Must run in an injection
|
|
720
751
|
* context.
|
|
752
|
+
*
|
|
753
|
+
* Caveat: work must go in flight by the first post-write render to be part of the transaction. A
|
|
754
|
+
* loader that starts later (a debounced request signal, a chained/deferred resource) is not
|
|
755
|
+
* attributable to it — the no-async fallback will have already committed and released the hold,
|
|
756
|
+
* after which `abort()` is a no-op. Trigger such work eagerly inside `fn`.
|
|
721
757
|
*/
|
|
722
758
|
function injectStartTransaction() {
|
|
723
759
|
const scope = injectTransitionScope();
|
|
@@ -727,7 +763,15 @@ function injectStartTransaction() {
|
|
|
727
763
|
// Hold BEFORE the writes, so the display freezes at pre-transaction values.
|
|
728
764
|
scope.beginHold();
|
|
729
765
|
let finished = false;
|
|
766
|
+
// eslint-disable-next-line prefer-const -- assigned in try/catch, but needs to be declared here for the `finally` block to see it
|
|
730
767
|
let watcher;
|
|
768
|
+
let resolveDone;
|
|
769
|
+
const done = new Promise((resolve) => {
|
|
770
|
+
resolveDone = resolve;
|
|
771
|
+
});
|
|
772
|
+
// Every exit path funnels through here, so `done` always settles — including `abort()`
|
|
773
|
+
// and a throwing transaction body (which would otherwise leak the hold forever and
|
|
774
|
+
// freeze the boundary with no recovery).
|
|
731
775
|
const finish = (restore) => {
|
|
732
776
|
if (finished)
|
|
733
777
|
return;
|
|
@@ -738,27 +782,28 @@ function injectStartTransaction() {
|
|
|
738
782
|
else
|
|
739
783
|
txn.clear();
|
|
740
784
|
scope.endHold();
|
|
785
|
+
resolveDone();
|
|
741
786
|
};
|
|
742
|
-
|
|
787
|
+
try {
|
|
788
|
+
runInTransaction(txn, fn);
|
|
789
|
+
}
|
|
790
|
+
catch (e) {
|
|
791
|
+
finish(true);
|
|
792
|
+
throw e;
|
|
793
|
+
}
|
|
743
794
|
let sawPending = false;
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
if (!sawPending && !untracked(scope.pending)) {
|
|
757
|
-
finish(false);
|
|
758
|
-
resolve();
|
|
759
|
-
}
|
|
760
|
-
}, { injector });
|
|
761
|
-
});
|
|
795
|
+
watcher = effect(() => {
|
|
796
|
+
const p = scope.pending();
|
|
797
|
+
if (p)
|
|
798
|
+
sawPending = true;
|
|
799
|
+
if (sawPending && !p)
|
|
800
|
+
finish(false);
|
|
801
|
+
}, { injector });
|
|
802
|
+
// no-async fallback: if nothing ever went in flight, settle once the writes are processed.
|
|
803
|
+
afterNextRender(() => {
|
|
804
|
+
if (!sawPending && !untracked(scope.pending))
|
|
805
|
+
finish(false);
|
|
806
|
+
}, { injector });
|
|
762
807
|
return {
|
|
763
808
|
pending: scope.pending,
|
|
764
809
|
done,
|
|
@@ -767,6 +812,17 @@ function injectStartTransaction() {
|
|
|
767
812
|
};
|
|
768
813
|
}
|
|
769
814
|
|
|
815
|
+
/**
|
|
816
|
+
* @internal
|
|
817
|
+
*/
|
|
818
|
+
function getSignalEquality(sig) {
|
|
819
|
+
const internal = sig[SIGNAL];
|
|
820
|
+
if (internal && typeof internal.equal === 'function') {
|
|
821
|
+
return internal.equal;
|
|
822
|
+
}
|
|
823
|
+
return Object.is; // Default equality check
|
|
824
|
+
}
|
|
825
|
+
|
|
770
826
|
/**
|
|
771
827
|
* Converts a read-only `Signal` into a `WritableSignal` by providing custom `set` and, optionally, `update` functions.
|
|
772
828
|
* This can be useful for creating controlled write access to a signal that is otherwise read-only.
|
|
@@ -865,6 +921,7 @@ function debounced(initial, opt) {
|
|
|
865
921
|
* ```
|
|
866
922
|
*/
|
|
867
923
|
function debounce(source, opt) {
|
|
924
|
+
const eq = opt?.equal ?? getSignalEquality(source);
|
|
868
925
|
const ms = opt?.ms ?? 0;
|
|
869
926
|
const trigger = signal(false, /* @ts-ignore */
|
|
870
927
|
...(ngDevMode ? [{ debugName: "trigger" }] : /* istanbul ignore next */ []));
|
|
@@ -880,25 +937,25 @@ function debounce(source, opt) {
|
|
|
880
937
|
catch {
|
|
881
938
|
// not in injection context & no destroyRef provided opting out of cleanup
|
|
882
939
|
}
|
|
883
|
-
const
|
|
940
|
+
const set = (next) => {
|
|
941
|
+
const isEqual = eq(untracked(source), next);
|
|
942
|
+
if (!timeout && isEqual)
|
|
943
|
+
return; // nothing to do
|
|
884
944
|
if (timeout)
|
|
885
|
-
clearTimeout(timeout);
|
|
886
|
-
|
|
945
|
+
clearTimeout(timeout); // clear pending
|
|
946
|
+
if (!isEqual)
|
|
947
|
+
source.set(next);
|
|
887
948
|
timeout = setTimeout(() => {
|
|
949
|
+
timeout = undefined;
|
|
888
950
|
trigger.update((c) => !c);
|
|
889
951
|
}, ms);
|
|
890
952
|
};
|
|
891
|
-
const
|
|
892
|
-
triggerFn(value);
|
|
893
|
-
};
|
|
894
|
-
const update = (fn) => {
|
|
895
|
-
triggerFn(fn(untracked(source)));
|
|
896
|
-
};
|
|
953
|
+
const update = (fn) => set(fn(untracked(source)));
|
|
897
954
|
const writable = toWritable(computed(() => {
|
|
898
955
|
trigger();
|
|
899
956
|
return untracked(source);
|
|
900
957
|
}, opt), set, update);
|
|
901
|
-
writable.original = source;
|
|
958
|
+
writable.original = source.asReadonly();
|
|
902
959
|
return writable;
|
|
903
960
|
}
|
|
904
961
|
|
|
@@ -1069,8 +1126,18 @@ function derived(source, optOrKey, opt) {
|
|
|
1069
1126
|
if (isMutable(source)) {
|
|
1070
1127
|
sig.mutate = (updater) => {
|
|
1071
1128
|
cnt++;
|
|
1072
|
-
|
|
1073
|
-
|
|
1129
|
+
try {
|
|
1130
|
+
sig.update(updater);
|
|
1131
|
+
// The wrapped computed evaluates its `equal` lazily — at the next read, which would
|
|
1132
|
+
// normally happen after `cnt` has already dropped back to 0. For a reference-stable
|
|
1133
|
+
// mutation that read compares the same object to itself and the version never bumps,
|
|
1134
|
+
// so dependents are never notified. Reading here, while equality is still suppressed,
|
|
1135
|
+
// forces the recompute (and version bump) inside the mutate window.
|
|
1136
|
+
untracked(sig);
|
|
1137
|
+
}
|
|
1138
|
+
finally {
|
|
1139
|
+
cnt--;
|
|
1140
|
+
}
|
|
1074
1141
|
};
|
|
1075
1142
|
sig.inline = (updater) => {
|
|
1076
1143
|
sig.mutate((prev) => {
|
|
@@ -1126,16 +1193,43 @@ function isDerivation(sig) {
|
|
|
1126
1193
|
}
|
|
1127
1194
|
|
|
1128
1195
|
function keepPrevious(src, opt) {
|
|
1196
|
+
const mutableSrc = isWritableSignal$2(src) && isMutable(src);
|
|
1197
|
+
// For a mutable source the linkedSignal's equality must be suppressible: a forwarded
|
|
1198
|
+
// `mutate` keeps the same reference, which default equality would otherwise swallow.
|
|
1199
|
+
let cnt = 0;
|
|
1200
|
+
const baseEqual = opt?.equal;
|
|
1201
|
+
const equal = mutableSrc
|
|
1202
|
+
? (a, b) => cnt > 0 ? false : baseEqual ? baseEqual(a, b) : Object.is(a, b)
|
|
1203
|
+
: baseEqual;
|
|
1129
1204
|
const persisted = linkedSignal({ ...(ngDevMode ? { debugName: "persisted" } : /* istanbul ignore next */ {}), ...opt,
|
|
1130
1205
|
source: () => src(),
|
|
1131
|
-
computation: (next, prev) => next === undefined && prev !== undefined ? prev.value : next
|
|
1206
|
+
computation: (next, prev) => next === undefined && prev !== undefined ? prev.value : next,
|
|
1207
|
+
equal });
|
|
1132
1208
|
if (isWritableSignal$2(src)) {
|
|
1133
1209
|
persisted.set = src.set;
|
|
1134
1210
|
persisted.update = src.update;
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1211
|
+
// NOTE: `asReadonly` deliberately stays the linkedSignal's own — returning the
|
|
1212
|
+
// source's readonly view would reintroduce the `undefined` flashes this wrapper exists
|
|
1213
|
+
// to prevent.
|
|
1214
|
+
if (mutableSrc) {
|
|
1215
|
+
persisted.mutate = (updater) => {
|
|
1216
|
+
cnt++;
|
|
1217
|
+
try {
|
|
1218
|
+
src.mutate(updater);
|
|
1219
|
+
// force the recompute while equality is suppressed, so the reference-stable
|
|
1220
|
+
// mutation bumps the wrapper's version (see derived.ts for the same pattern)
|
|
1221
|
+
untracked(persisted);
|
|
1222
|
+
}
|
|
1223
|
+
finally {
|
|
1224
|
+
cnt--;
|
|
1225
|
+
}
|
|
1226
|
+
};
|
|
1227
|
+
persisted.inline = (updater) => {
|
|
1228
|
+
persisted.mutate((prev) => {
|
|
1229
|
+
updater(prev);
|
|
1230
|
+
return prev;
|
|
1231
|
+
});
|
|
1232
|
+
};
|
|
1139
1233
|
}
|
|
1140
1234
|
if (isDerivation(src)) {
|
|
1141
1235
|
persisted.from = src.from;
|
|
@@ -1193,13 +1287,18 @@ function indexArray(source, map, opt = {}) {
|
|
|
1193
1287
|
: toWritable(data, () => {
|
|
1194
1288
|
// noop
|
|
1195
1289
|
});
|
|
1290
|
+
// copy before defaulting `equal` — assigning onto `opt` would mutate a caller-owned
|
|
1291
|
+
// (possibly shared/reused) options object
|
|
1196
1292
|
if (isWritableSignal$1(data) && isMutable(data) && !opt.equal) {
|
|
1197
|
-
opt
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1293
|
+
opt = {
|
|
1294
|
+
...opt,
|
|
1295
|
+
equal: (a, b) => {
|
|
1296
|
+
if (typeof a !== typeof b)
|
|
1297
|
+
return false;
|
|
1298
|
+
if (typeof a === 'object' || typeof a === 'function')
|
|
1299
|
+
return false;
|
|
1300
|
+
return a === b;
|
|
1301
|
+
},
|
|
1203
1302
|
};
|
|
1204
1303
|
}
|
|
1205
1304
|
return linkedSignal({
|
|
@@ -1395,8 +1494,17 @@ function pooledKeys(src) {
|
|
|
1395
1494
|
for (const k in val)
|
|
1396
1495
|
if (Object.prototype.hasOwnProperty.call(val, k))
|
|
1397
1496
|
spare.add(k);
|
|
1398
|
-
if (active.size === spare.size
|
|
1399
|
-
|
|
1497
|
+
if (active.size === spare.size) {
|
|
1498
|
+
let subset = true;
|
|
1499
|
+
for (const k of active) {
|
|
1500
|
+
if (!spare.has(k)) {
|
|
1501
|
+
subset = false;
|
|
1502
|
+
break;
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
if (subset)
|
|
1506
|
+
return active;
|
|
1507
|
+
}
|
|
1400
1508
|
const temp = active;
|
|
1401
1509
|
active = spare;
|
|
1402
1510
|
spare = temp;
|
|
@@ -1496,7 +1604,7 @@ const filter = (predicate) => (src) => linkedSignal({
|
|
|
1496
1604
|
computation: (next, prev) => {
|
|
1497
1605
|
if (predicate(next))
|
|
1498
1606
|
return next;
|
|
1499
|
-
return prev?.
|
|
1607
|
+
return prev?.value;
|
|
1500
1608
|
},
|
|
1501
1609
|
});
|
|
1502
1610
|
/**
|
|
@@ -1532,7 +1640,7 @@ const tap = (fn, injector) => (src) => {
|
|
|
1532
1640
|
*/
|
|
1533
1641
|
const filterWith = (predicate, initial) => (src) => linkedSignal({
|
|
1534
1642
|
source: src,
|
|
1535
|
-
computation: (next, prev) => predicate(next) ? next :
|
|
1643
|
+
computation: (next, prev) => predicate(next) ? next : prev ? prev.value : initial,
|
|
1536
1644
|
});
|
|
1537
1645
|
/**
|
|
1538
1646
|
* Emit `initial` on the first read, then mirror the source on every subsequent
|
|
@@ -1581,7 +1689,7 @@ const pairwise = () => (src) => linkedSignal({
|
|
|
1581
1689
|
*/
|
|
1582
1690
|
const scan = (reducer, seed) => (src) => linkedSignal({
|
|
1583
1691
|
source: src,
|
|
1584
|
-
computation: (next, prev) => reducer(prev
|
|
1692
|
+
computation: (next, prev) => reducer(prev ? prev.value : seed, next),
|
|
1585
1693
|
});
|
|
1586
1694
|
|
|
1587
1695
|
/**
|
|
@@ -1632,7 +1740,7 @@ function pipeable(signal) {
|
|
|
1632
1740
|
return internal;
|
|
1633
1741
|
}
|
|
1634
1742
|
/**
|
|
1635
|
-
* Create a new **writable** signal and return it as a `
|
|
1743
|
+
* Create a new **writable** signal and return it as a `PipeableSignal`.
|
|
1636
1744
|
*
|
|
1637
1745
|
* The returned value is a `WritableSignal<T>` with `.set`, `.update`, `.asReadonly`
|
|
1638
1746
|
* still available (via intersection type), plus a chainable `.pipe(...)`.
|
|
@@ -1736,6 +1844,20 @@ function pooledMap(optOrComputation, signalOpt) {
|
|
|
1736
1844
|
return pooled(toPooledOptions(optOrComputation, createEmptyMap, resetClearable, signalOpt));
|
|
1737
1845
|
}
|
|
1738
1846
|
|
|
1847
|
+
/**
|
|
1848
|
+
* @internal Run a sensor factory inside `injector` when provided, else in the ambient
|
|
1849
|
+
* injection context. Keeps every sensor's escape hatch identical and in one place.
|
|
1850
|
+
*/
|
|
1851
|
+
function runInSensorContext(injector, fn) {
|
|
1852
|
+
return injector ? runInInjectionContext(injector, fn) : fn();
|
|
1853
|
+
}
|
|
1854
|
+
/**
|
|
1855
|
+
* @internal Normalize the legacy positional `debugName: string` form into {@link SensorRunOptions}.
|
|
1856
|
+
*/
|
|
1857
|
+
function coerceSensorOptions(opt) {
|
|
1858
|
+
return typeof opt === 'string' ? { debugName: opt } : (opt ?? {});
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1739
1861
|
const EVENTS = [
|
|
1740
1862
|
'chargingchange',
|
|
1741
1863
|
'levelchange',
|
|
@@ -1757,7 +1879,11 @@ const EVENTS = [
|
|
|
1757
1879
|
* });
|
|
1758
1880
|
* ```
|
|
1759
1881
|
*/
|
|
1760
|
-
function batteryStatus(
|
|
1882
|
+
function batteryStatus(opt) {
|
|
1883
|
+
const { debugName = 'batteryStatus', injector } = coerceSensorOptions(opt);
|
|
1884
|
+
return runInSensorContext(injector, () => createBatteryStatus(debugName));
|
|
1885
|
+
}
|
|
1886
|
+
function createBatteryStatus(debugName) {
|
|
1761
1887
|
if (isPlatformServer(inject(PLATFORM_ID)) ||
|
|
1762
1888
|
typeof navigator === 'undefined' ||
|
|
1763
1889
|
typeof navigator.getBattery !== 'function') {
|
|
@@ -1766,7 +1892,9 @@ function batteryStatus(debugName = 'batteryStatus') {
|
|
|
1766
1892
|
const state = signal(null, { ...(ngDevMode ? { debugName: "state" } : /* istanbul ignore next */ {}), debugName });
|
|
1767
1893
|
const abortController = new AbortController();
|
|
1768
1894
|
inject(DestroyRef).onDestroy(() => abortController.abort());
|
|
1769
|
-
navigator
|
|
1895
|
+
navigator
|
|
1896
|
+
.getBattery()
|
|
1897
|
+
.then((battery) => {
|
|
1770
1898
|
if (abortController.signal.aborted)
|
|
1771
1899
|
return;
|
|
1772
1900
|
const read = () => ({
|
|
@@ -1782,6 +1910,10 @@ function batteryStatus(debugName = 'batteryStatus') {
|
|
|
1782
1910
|
signal: abortController.signal,
|
|
1783
1911
|
});
|
|
1784
1912
|
}
|
|
1913
|
+
})
|
|
1914
|
+
.catch(() => {
|
|
1915
|
+
// getBattery() rejects (NotAllowedError) when the `battery` permissions-policy is
|
|
1916
|
+
// disallowed, e.g. in cross-origin iframes — stay `null`, same as unsupported.
|
|
1785
1917
|
});
|
|
1786
1918
|
return state.asReadonly();
|
|
1787
1919
|
}
|
|
@@ -1797,7 +1929,11 @@ function batteryStatus(debugName = 'batteryStatus') {
|
|
|
1797
1929
|
* in browsers that gate it. Errors from `navigator.clipboard.readText` are
|
|
1798
1930
|
* swallowed silently to keep the signal value stable.
|
|
1799
1931
|
*/
|
|
1800
|
-
function clipboard(
|
|
1932
|
+
function clipboard(opt) {
|
|
1933
|
+
const { debugName = 'clipboard', injector } = coerceSensorOptions(opt);
|
|
1934
|
+
return runInSensorContext(injector, () => createClipboard(debugName));
|
|
1935
|
+
}
|
|
1936
|
+
function createClipboard(debugName) {
|
|
1801
1937
|
if (isPlatformServer(inject(PLATFORM_ID)) ||
|
|
1802
1938
|
typeof navigator === 'undefined' ||
|
|
1803
1939
|
!navigator.clipboard) {
|
|
@@ -1849,7 +1985,13 @@ function observerSupported$1() {
|
|
|
1849
1985
|
* });
|
|
1850
1986
|
* ```
|
|
1851
1987
|
*/
|
|
1852
|
-
function elementSize(target
|
|
1988
|
+
function elementSize(target, opt) {
|
|
1989
|
+
return runInSensorContext(opt?.injector, () =>
|
|
1990
|
+
// the host-element default must resolve INSIDE the sensor context, not as a
|
|
1991
|
+
// parameter default (which would run before the injector wrapper)
|
|
1992
|
+
createElementSize(target ?? inject(ElementRef), opt));
|
|
1993
|
+
}
|
|
1994
|
+
function createElementSize(target, opt) {
|
|
1853
1995
|
const getElement = () => {
|
|
1854
1996
|
if (isSignal(target)) {
|
|
1855
1997
|
try {
|
|
@@ -1863,8 +2005,8 @@ function elementSize(target = inject(ElementRef), opt) {
|
|
|
1863
2005
|
return target instanceof ElementRef ? target.nativeElement : target;
|
|
1864
2006
|
};
|
|
1865
2007
|
const resolveInitialValue = () => {
|
|
1866
|
-
|
|
1867
|
-
|
|
2008
|
+
// measuring needs only getBoundingClientRect — ResizeObserver support gates
|
|
2009
|
+
// live updates, not the initial read
|
|
1868
2010
|
const el = getElement();
|
|
1869
2011
|
if (el && el.getBoundingClientRect) {
|
|
1870
2012
|
const rect = el.getBoundingClientRect();
|
|
@@ -1984,7 +2126,13 @@ function observerSupported() {
|
|
|
1984
2126
|
* }
|
|
1985
2127
|
* ```
|
|
1986
2128
|
*/
|
|
1987
|
-
function elementVisibility(target
|
|
2129
|
+
function elementVisibility(target, opt) {
|
|
2130
|
+
return runInSensorContext(opt?.injector, () =>
|
|
2131
|
+
// the host-element default must resolve INSIDE the sensor context, not as a
|
|
2132
|
+
// parameter default (which would run before the injector wrapper)
|
|
2133
|
+
createElementVisibility(target ?? inject(ElementRef), opt));
|
|
2134
|
+
}
|
|
2135
|
+
function createElementVisibility(target, opt) {
|
|
1988
2136
|
if (isPlatformServer(inject(PLATFORM_ID)) || !observerSupported()) {
|
|
1989
2137
|
const base = computed(() => undefined, {
|
|
1990
2138
|
debugName: opt?.debugName,
|
|
@@ -2054,11 +2202,18 @@ function unwrap$1(target) {
|
|
|
2054
2202
|
* }
|
|
2055
2203
|
* ```
|
|
2056
2204
|
*/
|
|
2057
|
-
function focusWithin(target
|
|
2205
|
+
function focusWithin(target, opt) {
|
|
2206
|
+
return runInSensorContext(opt?.injector, () =>
|
|
2207
|
+
// the host-element default must resolve INSIDE the sensor context, not as a
|
|
2208
|
+
// parameter default (which would run before the injector wrapper)
|
|
2209
|
+
createFocusWithin(target ?? inject(ElementRef), opt));
|
|
2210
|
+
}
|
|
2211
|
+
function createFocusWithin(target, opt) {
|
|
2212
|
+
const debugName = opt?.debugName ?? 'focusWithin';
|
|
2058
2213
|
if (isPlatformServer(inject(PLATFORM_ID))) {
|
|
2059
|
-
return computed(() => false, { debugName
|
|
2214
|
+
return computed(() => false, { debugName });
|
|
2060
2215
|
}
|
|
2061
|
-
const state = signal(false, { debugName:
|
|
2216
|
+
const state = signal(false, { ...(ngDevMode ? { debugName: "state" } : /* istanbul ignore next */ {}), debugName });
|
|
2062
2217
|
const attach = (el) => {
|
|
2063
2218
|
state.set(el.contains(document.activeElement));
|
|
2064
2219
|
const abortController = new AbortController();
|
|
@@ -2106,6 +2261,9 @@ function focusWithin(target = inject(ElementRef)) {
|
|
|
2106
2261
|
* ```
|
|
2107
2262
|
*/
|
|
2108
2263
|
function geolocation(opt) {
|
|
2264
|
+
return runInSensorContext(opt?.injector, () => createGeolocation(opt));
|
|
2265
|
+
}
|
|
2266
|
+
function createGeolocation(opt) {
|
|
2109
2267
|
if (isPlatformServer(inject(PLATFORM_ID)) || typeof navigator === 'undefined' || !navigator.geolocation) {
|
|
2110
2268
|
const sig = computed(() => null, {
|
|
2111
2269
|
debugName: opt?.debugName ?? 'geolocation',
|
|
@@ -2169,6 +2327,9 @@ const serverDate$1 = new Date();
|
|
|
2169
2327
|
* ```
|
|
2170
2328
|
*/
|
|
2171
2329
|
function idle(opt) {
|
|
2330
|
+
return runInSensorContext(opt?.injector, () => createIdle(opt));
|
|
2331
|
+
}
|
|
2332
|
+
function createIdle(opt) {
|
|
2172
2333
|
if (isPlatformServer(inject(PLATFORM_ID))) {
|
|
2173
2334
|
const sig = computed(() => false, {
|
|
2174
2335
|
debugName: opt?.debugName ?? 'idle',
|
|
@@ -2260,7 +2421,11 @@ function idle(opt) {
|
|
|
2260
2421
|
* }
|
|
2261
2422
|
* ```
|
|
2262
2423
|
*/
|
|
2263
|
-
function mediaQuery(query,
|
|
2424
|
+
function mediaQuery(query, opt) {
|
|
2425
|
+
const { debugName = 'mediaQuery', injector } = coerceSensorOptions(opt);
|
|
2426
|
+
return runInSensorContext(injector, () => createMediaQuery(query, debugName));
|
|
2427
|
+
}
|
|
2428
|
+
function createMediaQuery(query, debugName) {
|
|
2264
2429
|
if (isPlatformServer(inject(PLATFORM_ID)) ||
|
|
2265
2430
|
typeof window === 'undefined' ||
|
|
2266
2431
|
typeof window.matchMedia !== 'function' // jsdom doesn't implement matchMedia
|
|
@@ -2298,8 +2463,8 @@ function mediaQuery(query, debugName = 'mediaQuery') {
|
|
|
2298
2463
|
* });
|
|
2299
2464
|
* ```
|
|
2300
2465
|
*/
|
|
2301
|
-
function prefersDarkMode(
|
|
2302
|
-
return mediaQuery('(prefers-color-scheme: dark)',
|
|
2466
|
+
function prefersDarkMode(opt) {
|
|
2467
|
+
return mediaQuery('(prefers-color-scheme: dark)', opt);
|
|
2303
2468
|
}
|
|
2304
2469
|
/**
|
|
2305
2470
|
* Creates a read-only signal that tracks the user's OS/browser preference
|
|
@@ -2326,8 +2491,8 @@ function prefersDarkMode(debugName) {
|
|
|
2326
2491
|
* });
|
|
2327
2492
|
* ```
|
|
2328
2493
|
*/
|
|
2329
|
-
function prefersReducedMotion(
|
|
2330
|
-
return mediaQuery('(prefers-reduced-motion: reduce)',
|
|
2494
|
+
function prefersReducedMotion(opt) {
|
|
2495
|
+
return mediaQuery('(prefers-reduced-motion: reduce)', opt);
|
|
2331
2496
|
}
|
|
2332
2497
|
|
|
2333
2498
|
/**
|
|
@@ -2376,6 +2541,7 @@ function throttled(initial, opt) {
|
|
|
2376
2541
|
* // after the 500ms cooldown.
|
|
2377
2542
|
*/
|
|
2378
2543
|
function throttle(source, opt) {
|
|
2544
|
+
const eq = opt?.equal ?? getSignalEquality(source);
|
|
2379
2545
|
const ms = opt?.ms ?? 0;
|
|
2380
2546
|
const leading = opt?.leading ?? false;
|
|
2381
2547
|
const trailing = opt?.trailing ?? true;
|
|
@@ -2402,31 +2568,32 @@ function throttle(source, opt) {
|
|
|
2402
2568
|
fire();
|
|
2403
2569
|
else
|
|
2404
2570
|
pendingTrailing = trailing;
|
|
2405
|
-
|
|
2571
|
+
const onWindowEnd = () => {
|
|
2406
2572
|
timeout = undefined;
|
|
2407
2573
|
if (trailing && pendingTrailing) {
|
|
2408
2574
|
pendingTrailing = false;
|
|
2409
2575
|
fire();
|
|
2576
|
+
timeout = setTimeout(onWindowEnd, ms);
|
|
2410
2577
|
}
|
|
2411
|
-
}
|
|
2578
|
+
};
|
|
2579
|
+
timeout = setTimeout(onWindowEnd, ms);
|
|
2412
2580
|
return;
|
|
2413
2581
|
}
|
|
2414
2582
|
if (trailing)
|
|
2415
2583
|
pendingTrailing = true;
|
|
2416
2584
|
};
|
|
2417
|
-
const set = (
|
|
2418
|
-
source
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
const update = (fn) => {
|
|
2422
|
-
source.update(fn);
|
|
2585
|
+
const set = (next) => {
|
|
2586
|
+
if (eq(untracked(source), next))
|
|
2587
|
+
return;
|
|
2588
|
+
source.set(next);
|
|
2423
2589
|
tick();
|
|
2424
2590
|
};
|
|
2591
|
+
const update = (fn) => set(fn(untracked(source)));
|
|
2425
2592
|
const writable = toWritable(computed(() => {
|
|
2426
2593
|
trigger();
|
|
2427
2594
|
return untracked(source);
|
|
2428
2595
|
}, opt), set, update);
|
|
2429
|
-
writable.original = source;
|
|
2596
|
+
writable.original = source.asReadonly();
|
|
2430
2597
|
return writable;
|
|
2431
2598
|
}
|
|
2432
2599
|
|
|
@@ -2463,6 +2630,9 @@ function throttle(source, opt) {
|
|
|
2463
2630
|
* ```
|
|
2464
2631
|
*/
|
|
2465
2632
|
function mousePosition(opt) {
|
|
2633
|
+
return runInSensorContext(opt?.injector, () => createMousePosition(opt));
|
|
2634
|
+
}
|
|
2635
|
+
function createMousePosition(opt) {
|
|
2466
2636
|
if (isPlatformServer(inject(PLATFORM_ID))) {
|
|
2467
2637
|
const base = computed(() => ({
|
|
2468
2638
|
x: 0,
|
|
@@ -2474,8 +2644,12 @@ function mousePosition(opt) {
|
|
|
2474
2644
|
return base;
|
|
2475
2645
|
}
|
|
2476
2646
|
const { target = window, coordinateSpace = 'client', touch = false, debugName = 'mousePosition', throttle = 100, } = opt ?? {};
|
|
2477
|
-
const
|
|
2478
|
-
|
|
2647
|
+
const resolve = (t) => {
|
|
2648
|
+
if (!t)
|
|
2649
|
+
return null;
|
|
2650
|
+
return t instanceof ElementRef ? t.nativeElement : t;
|
|
2651
|
+
};
|
|
2652
|
+
if (!isSignal(target) && !resolve(target)) {
|
|
2479
2653
|
if (isDevMode())
|
|
2480
2654
|
console.warn('mousePosition: Target element not found.');
|
|
2481
2655
|
const base = computed(() => ({
|
|
@@ -2498,7 +2672,7 @@ function mousePosition(opt) {
|
|
|
2498
2672
|
x = coordinateSpace === 'page' ? event.pageX : event.clientX;
|
|
2499
2673
|
y = coordinateSpace === 'page' ? event.pageY : event.clientY;
|
|
2500
2674
|
}
|
|
2501
|
-
else if (event.touches
|
|
2675
|
+
else if (event.touches?.length > 0) {
|
|
2502
2676
|
const firstTouch = event.touches[0];
|
|
2503
2677
|
x = coordinateSpace === 'page' ? firstTouch.pageX : firstTouch.clientX;
|
|
2504
2678
|
y = coordinateSpace === 'page' ? firstTouch.pageY : firstTouch.clientY;
|
|
@@ -2508,16 +2682,36 @@ function mousePosition(opt) {
|
|
|
2508
2682
|
}
|
|
2509
2683
|
pos.set({ x, y });
|
|
2510
2684
|
};
|
|
2511
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
|
|
2685
|
+
// passive: the handler never calls preventDefault, and a non-passive touchmove on
|
|
2686
|
+
// window forces the browser to wait on JS before scrolling (scroll jank on touch)
|
|
2687
|
+
const attach = (el) => {
|
|
2688
|
+
const controller = new AbortController();
|
|
2689
|
+
el.addEventListener('mousemove', updatePosition, {
|
|
2690
|
+
passive: true,
|
|
2691
|
+
signal: controller.signal,
|
|
2692
|
+
});
|
|
2517
2693
|
if (touch) {
|
|
2518
|
-
|
|
2694
|
+
el.addEventListener('touchmove', updatePosition, {
|
|
2695
|
+
passive: true,
|
|
2696
|
+
signal: controller.signal,
|
|
2697
|
+
});
|
|
2519
2698
|
}
|
|
2520
|
-
|
|
2699
|
+
return () => controller.abort();
|
|
2700
|
+
};
|
|
2701
|
+
if (isSignal(target)) {
|
|
2702
|
+
// re-attach whenever the signal resolves to a (new) element — covers viewChild
|
|
2703
|
+
effect((cleanup) => {
|
|
2704
|
+
const el = resolve(target());
|
|
2705
|
+
if (!el)
|
|
2706
|
+
return;
|
|
2707
|
+
cleanup(attach(el));
|
|
2708
|
+
});
|
|
2709
|
+
}
|
|
2710
|
+
else {
|
|
2711
|
+
const el = resolve(target);
|
|
2712
|
+
if (el)
|
|
2713
|
+
inject(DestroyRef).onDestroy(attach(el));
|
|
2714
|
+
}
|
|
2521
2715
|
const base = pos.asReadonly();
|
|
2522
2716
|
base.unthrottled = pos.original;
|
|
2523
2717
|
return base;
|
|
@@ -2531,7 +2725,8 @@ const serverDate = new Date();
|
|
|
2531
2725
|
* An additional `since` signal is attached, tracking when the status last changed.
|
|
2532
2726
|
* It's SSR-safe and automatically cleans up its event listeners.
|
|
2533
2727
|
*
|
|
2534
|
-
* @param
|
|
2728
|
+
* @param opt Optional debug name for the signal, or a {@link SensorRunOptions} object
|
|
2729
|
+
* (with an optional `injector` for creation outside an injection context).
|
|
2535
2730
|
* @returns A `NetworkStatusSignal` instance.
|
|
2536
2731
|
*
|
|
2537
2732
|
* @example
|
|
@@ -2542,7 +2737,11 @@ const serverDate = new Date();
|
|
|
2542
2737
|
* });
|
|
2543
2738
|
* ```
|
|
2544
2739
|
*/
|
|
2545
|
-
function networkStatus(
|
|
2740
|
+
function networkStatus(opt) {
|
|
2741
|
+
const { debugName = 'networkStatus', injector } = coerceSensorOptions(opt);
|
|
2742
|
+
return runInSensorContext(injector, () => createNetworkStatus(debugName));
|
|
2743
|
+
}
|
|
2744
|
+
function createNetworkStatus(debugName) {
|
|
2546
2745
|
if (isPlatformServer(inject(PLATFORM_ID))) {
|
|
2547
2746
|
const sig = computed(() => true, {
|
|
2548
2747
|
debugName,
|
|
@@ -2592,7 +2791,11 @@ const SSR_FALLBACK = {
|
|
|
2592
2791
|
* });
|
|
2593
2792
|
* ```
|
|
2594
2793
|
*/
|
|
2595
|
-
function orientation(
|
|
2794
|
+
function orientation(opt) {
|
|
2795
|
+
const { debugName = 'orientation', injector } = coerceSensorOptions(opt);
|
|
2796
|
+
return runInSensorContext(injector, () => createOrientation(debugName));
|
|
2797
|
+
}
|
|
2798
|
+
function createOrientation(debugName) {
|
|
2596
2799
|
if (isPlatformServer(inject(PLATFORM_ID)) ||
|
|
2597
2800
|
typeof screen === 'undefined' ||
|
|
2598
2801
|
!screen.orientation) {
|
|
@@ -2619,7 +2822,8 @@ function orientation(debugName = 'orientation') {
|
|
|
2619
2822
|
* The primitive is SSR-safe and automatically cleans up its event listeners
|
|
2620
2823
|
* when the creating context is destroyed.
|
|
2621
2824
|
*
|
|
2622
|
-
* @param
|
|
2825
|
+
* @param opt Optional debug name for the signal, or a {@link SensorRunOptions} object
|
|
2826
|
+
* (with an optional `injector` for creation outside an injection context).
|
|
2623
2827
|
* @returns A read-only `Signal<DocumentVisibilityState>`. On the server,
|
|
2624
2828
|
* it returns a static signal with a value of `'visible'`.
|
|
2625
2829
|
*
|
|
@@ -2647,7 +2851,11 @@ function orientation(debugName = 'orientation') {
|
|
|
2647
2851
|
* }
|
|
2648
2852
|
* ```
|
|
2649
2853
|
*/
|
|
2650
|
-
function pageVisibility(
|
|
2854
|
+
function pageVisibility(opt) {
|
|
2855
|
+
const { debugName = 'pageVisibility', injector } = coerceSensorOptions(opt);
|
|
2856
|
+
return runInSensorContext(injector, () => createPageVisibility(debugName));
|
|
2857
|
+
}
|
|
2858
|
+
function createPageVisibility(debugName) {
|
|
2651
2859
|
if (isPlatformServer(inject(PLATFORM_ID))) {
|
|
2652
2860
|
return computed(() => 'visible', { debugName });
|
|
2653
2861
|
}
|
|
@@ -2679,31 +2887,25 @@ function pageVisibility(debugName = 'pageVisibility') {
|
|
|
2679
2887
|
* selector: 'app-scroll-tracker',
|
|
2680
2888
|
* template: `
|
|
2681
2889
|
* <p>Window Scroll: X: {{ windowScroll().x }}, Y: {{ windowScroll().y }}</p>
|
|
2682
|
-
* <
|
|
2683
|
-
* <div style="height: 400px; width: 400px;">Scroll me!</div>
|
|
2684
|
-
* </div>
|
|
2685
|
-
* @if (divScroll()) {
|
|
2686
|
-
* <p>Div Scroll: X: {{ divScroll().x }}, Y: {{ divScroll().y }}</p>
|
|
2687
|
-
* }
|
|
2890
|
+
* <p>Host Scroll: X: {{ hostScroll().x }}, Y: {{ hostScroll().y }}</p>
|
|
2688
2891
|
* `
|
|
2689
2892
|
* })
|
|
2690
2893
|
* export class ScrollTrackerComponent {
|
|
2691
2894
|
* readonly windowScroll = scrollPosition(); // Defaults to window
|
|
2895
|
+
* // Signal targets (e.g. viewChild) attach once the element exists:
|
|
2692
2896
|
* readonly scrollableDiv = viewChild<ElementRef<HTMLDivElement>>('scrollableDiv');
|
|
2693
|
-
* readonly divScroll = scrollPosition({ target: this.scrollableDiv
|
|
2897
|
+
* readonly divScroll = scrollPosition({ target: this.scrollableDiv });
|
|
2694
2898
|
*
|
|
2695
2899
|
* constructor() {
|
|
2696
|
-
* effect(() =>
|
|
2697
|
-
* console.log('Window scrolled to:', this.windowScroll());
|
|
2698
|
-
* if (this.divScroll()) {
|
|
2699
|
-
* console.log('Div scrolled to:', this.divScroll());
|
|
2700
|
-
* }
|
|
2701
|
-
* });
|
|
2900
|
+
* effect(() => console.log('Window scrolled to:', this.windowScroll()));
|
|
2702
2901
|
* }
|
|
2703
2902
|
* }
|
|
2704
2903
|
* ```
|
|
2705
2904
|
*/
|
|
2706
2905
|
function scrollPosition(opt) {
|
|
2906
|
+
return runInSensorContext(opt?.injector, () => createScrollPosition(opt));
|
|
2907
|
+
}
|
|
2908
|
+
function createScrollPosition(opt) {
|
|
2707
2909
|
if (isPlatformServer(inject(PLATFORM_ID))) {
|
|
2708
2910
|
const base = computed(() => ({
|
|
2709
2911
|
x: 0,
|
|
@@ -2715,43 +2917,44 @@ function scrollPosition(opt) {
|
|
|
2715
2917
|
return base;
|
|
2716
2918
|
}
|
|
2717
2919
|
const { target = globalThis.window, throttle = 100, debugName = 'scrollPosition', } = opt || {};
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
return {
|
|
2733
|
-
x: target.nativeElement.scrollLeft,
|
|
2734
|
-
y: target.nativeElement.scrollTop,
|
|
2735
|
-
};
|
|
2736
|
-
};
|
|
2737
|
-
}
|
|
2738
|
-
else {
|
|
2739
|
-
element = target;
|
|
2740
|
-
getScrollPosition = () => {
|
|
2741
|
-
return {
|
|
2742
|
-
x: target.scrollLeft,
|
|
2743
|
-
y: target.scrollTop,
|
|
2744
|
-
};
|
|
2745
|
-
};
|
|
2746
|
-
}
|
|
2747
|
-
const state = throttled(getScrollPosition(), {
|
|
2920
|
+
const resolve = (t) => {
|
|
2921
|
+
if (!t)
|
|
2922
|
+
return null;
|
|
2923
|
+
return t instanceof ElementRef ? t.nativeElement : t;
|
|
2924
|
+
};
|
|
2925
|
+
const isWindow = (el) => el.window === el;
|
|
2926
|
+
const readPosition = (el) => isWindow(el)
|
|
2927
|
+
? {
|
|
2928
|
+
x: el.scrollX ?? el.pageXOffset ?? 0,
|
|
2929
|
+
y: el.scrollY ?? el.pageYOffset ?? 0,
|
|
2930
|
+
}
|
|
2931
|
+
: { x: el.scrollLeft, y: el.scrollTop };
|
|
2932
|
+
const initial = resolve(isSignal(target) ? untracked(target) : target);
|
|
2933
|
+
const state = throttled(initial ? readPosition(initial) : { x: 0, y: 0 }, {
|
|
2748
2934
|
debugName,
|
|
2749
2935
|
equal: (a, b) => a.x === b.x && a.y === b.y,
|
|
2750
2936
|
ms: throttle,
|
|
2751
2937
|
});
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2938
|
+
if (isSignal(target)) {
|
|
2939
|
+
// re-attach whenever the signal resolves to a (new) element — covers viewChild
|
|
2940
|
+
effect((cleanup) => {
|
|
2941
|
+
const el = resolve(target());
|
|
2942
|
+
if (!el)
|
|
2943
|
+
return;
|
|
2944
|
+
state.set(readPosition(el)); // sync to the new element immediately
|
|
2945
|
+
const onScroll = () => state.set(readPosition(el));
|
|
2946
|
+
el.addEventListener('scroll', onScroll, { passive: true });
|
|
2947
|
+
cleanup(() => el.removeEventListener('scroll', onScroll));
|
|
2948
|
+
});
|
|
2949
|
+
}
|
|
2950
|
+
else {
|
|
2951
|
+
const el = resolve(target);
|
|
2952
|
+
if (el) {
|
|
2953
|
+
const onScroll = () => state.set(readPosition(el));
|
|
2954
|
+
el.addEventListener('scroll', onScroll, { passive: true });
|
|
2955
|
+
inject(DestroyRef).onDestroy(() => el.removeEventListener('scroll', onScroll));
|
|
2956
|
+
}
|
|
2957
|
+
}
|
|
2755
2958
|
const base = state.asReadonly();
|
|
2756
2959
|
base.unthrottled = state.original;
|
|
2757
2960
|
return base;
|
|
@@ -2799,6 +3002,9 @@ function scrollPosition(opt) {
|
|
|
2799
3002
|
* ```
|
|
2800
3003
|
*/
|
|
2801
3004
|
function windowSize(opt) {
|
|
3005
|
+
return runInSensorContext(opt?.injector, () => createWindowSize(opt));
|
|
3006
|
+
}
|
|
3007
|
+
function createWindowSize(opt) {
|
|
2802
3008
|
if (isPlatformServer(inject(PLATFORM_ID))) {
|
|
2803
3009
|
const base = computed(() => ({
|
|
2804
3010
|
width: 1024,
|
|
@@ -2835,17 +3041,19 @@ function sensor(type, options) {
|
|
|
2835
3041
|
case 'mousePosition':
|
|
2836
3042
|
return mousePosition(opts);
|
|
2837
3043
|
case 'networkStatus':
|
|
2838
|
-
return networkStatus(opts
|
|
3044
|
+
return networkStatus(opts);
|
|
2839
3045
|
case 'pageVisibility':
|
|
2840
|
-
return pageVisibility(opts
|
|
3046
|
+
return pageVisibility(opts);
|
|
2841
3047
|
case 'darkMode':
|
|
2842
3048
|
case 'dark-mode':
|
|
2843
|
-
return prefersDarkMode(opts
|
|
3049
|
+
return prefersDarkMode(opts);
|
|
2844
3050
|
case 'reducedMotion':
|
|
2845
3051
|
case 'reduced-motion':
|
|
2846
|
-
return prefersReducedMotion(opts
|
|
3052
|
+
return prefersReducedMotion(opts);
|
|
2847
3053
|
case 'mediaQuery':
|
|
2848
|
-
|
|
3054
|
+
if (typeof opts?.query !== 'string')
|
|
3055
|
+
throw new Error(`sensor('mediaQuery') requires a 'query' option, e.g. sensor('mediaQuery', { query: '(min-width: 1024px)' })`);
|
|
3056
|
+
return mediaQuery(opts.query, opts);
|
|
2849
3057
|
case 'windowSize':
|
|
2850
3058
|
return windowSize(opts);
|
|
2851
3059
|
case 'scrollPosition':
|
|
@@ -2857,15 +3065,15 @@ function sensor(type, options) {
|
|
|
2857
3065
|
case 'geolocation':
|
|
2858
3066
|
return geolocation(opts);
|
|
2859
3067
|
case 'clipboard':
|
|
2860
|
-
return clipboard(opts
|
|
3068
|
+
return clipboard(opts);
|
|
2861
3069
|
case 'orientation':
|
|
2862
|
-
return orientation(opts
|
|
3070
|
+
return orientation(opts);
|
|
2863
3071
|
case 'batteryStatus':
|
|
2864
|
-
return batteryStatus(opts
|
|
3072
|
+
return batteryStatus(opts);
|
|
2865
3073
|
case 'idle':
|
|
2866
3074
|
return idle(opts);
|
|
2867
3075
|
case 'focusWithin':
|
|
2868
|
-
return focusWithin(opts?.target);
|
|
3076
|
+
return focusWithin(opts?.target, opts);
|
|
2869
3077
|
default:
|
|
2870
3078
|
throw new Error(`Unknown sensor type: ${type}`);
|
|
2871
3079
|
}
|
|
@@ -2919,16 +3127,24 @@ function signalFromEvent(target, eventName, initial, projectOrOpt, maybeOpt) {
|
|
|
2919
3127
|
else
|
|
2920
3128
|
state.set(event);
|
|
2921
3129
|
};
|
|
2922
|
-
const { destroyRef: providedDestroyRef,
|
|
3130
|
+
const { destroyRef: providedDestroyRef,
|
|
3131
|
+
// strip non-listener keys so they don't leak into addEventListener options
|
|
3132
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
3133
|
+
injector: _injector,
|
|
3134
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
3135
|
+
debugName: _debugName, ...listenerOpts } = opt ?? {};
|
|
2923
3136
|
if (isSignal(target)) {
|
|
2924
3137
|
const targetSig = target;
|
|
2925
|
-
effect((cleanup) => {
|
|
3138
|
+
const effectRef = effect((cleanup) => {
|
|
2926
3139
|
const resolved = unwrap(targetSig());
|
|
2927
3140
|
if (!resolved)
|
|
2928
3141
|
return;
|
|
2929
3142
|
resolved.addEventListener(eventName, handler, listenerOpts);
|
|
2930
3143
|
cleanup(() => resolved.removeEventListener(eventName, handler, listenerOpts));
|
|
2931
|
-
}, { injector });
|
|
3144
|
+
}, { ...(ngDevMode ? { debugName: "effectRef" } : /* istanbul ignore next */ {}), injector });
|
|
3145
|
+
// honor an explicit destroyRef for signal targets too — the effect would otherwise
|
|
3146
|
+
// only follow the injector's lifetime, contradicting the documented option
|
|
3147
|
+
providedDestroyRef?.onDestroy(() => effectRef.destroy());
|
|
2932
3148
|
}
|
|
2933
3149
|
else {
|
|
2934
3150
|
const resolved = unwrap(target);
|
|
@@ -3017,7 +3233,8 @@ function alwaysFalse() {
|
|
|
3017
3233
|
* @internal Attaches a lazy, memoized leaf probe to a store node. The probe (`() => boolean`)
|
|
3018
3234
|
* closes over the node's value signal and its (stable) vivify setting, building the backing
|
|
3019
3235
|
* `computed` on first call so leaf-ness tracks the live value reactively without taxing every
|
|
3020
|
-
* node access.
|
|
3236
|
+
* node access. Under `noUnionLeaves` the caller promises shapes never flip, so the probe is
|
|
3237
|
+
* resolved once from the first sample and frozen as a constant. Idempotent.
|
|
3021
3238
|
*/
|
|
3022
3239
|
function markAsLeaf(sig, value, vivifyEnabled, noUnionLeaves) {
|
|
3023
3240
|
if (typeof sig[LEAF] !== 'function') {
|
|
@@ -3025,13 +3242,11 @@ function markAsLeaf(sig, value, vivifyEnabled, noUnionLeaves) {
|
|
|
3025
3242
|
const probe = () => {
|
|
3026
3243
|
if (memo)
|
|
3027
3244
|
return memo();
|
|
3028
|
-
|
|
3029
|
-
|
|
3030
|
-
|
|
3031
|
-
|
|
3032
|
-
|
|
3033
|
-
: alwaysFalse
|
|
3034
|
-
: computed(() => isLeafValue(value(), vivifyEnabled));
|
|
3245
|
+
memo = noUnionLeaves
|
|
3246
|
+
? isLeafValue(untracked(value), vivifyEnabled)
|
|
3247
|
+
? alwaysTrue
|
|
3248
|
+
: alwaysFalse
|
|
3249
|
+
: computed(() => isLeafValue(value(), vivifyEnabled));
|
|
3035
3250
|
return memo();
|
|
3036
3251
|
};
|
|
3037
3252
|
Object.defineProperty(sig, LEAF, {
|
|
@@ -3119,6 +3334,40 @@ function resolveVivify(sample, option) {
|
|
|
3119
3334
|
function hasOwnKey(value, key) {
|
|
3120
3335
|
return value != null && Object.hasOwn(value, key);
|
|
3121
3336
|
}
|
|
3337
|
+
/**
|
|
3338
|
+
* @internal
|
|
3339
|
+
* Builds the `onChange` for the fallback (non-record container) derivation branch. For an
|
|
3340
|
+
* immutable source the container is copied before the write — returning the same mutated
|
|
3341
|
+
* reference would let the source's equality cut propagation (leaving child signals permanently
|
|
3342
|
+
* stale) and alias the caller's original object, breaking the structural-sharing contract
|
|
3343
|
+
* `forkStore` relies on. For a mutable source the write goes through `mutate`, so the chain's
|
|
3344
|
+
* force-notify engages (plain `update` with the same reference would never notify).
|
|
3345
|
+
*/
|
|
3346
|
+
function createFallbackOnChange(target, prop, vivifyFn, isMutableSource) {
|
|
3347
|
+
const write = (newValue) => (v) => {
|
|
3348
|
+
const container = vivifyFn(v, prop);
|
|
3349
|
+
if (container === null || container === undefined)
|
|
3350
|
+
return container;
|
|
3351
|
+
const next = isMutableSource
|
|
3352
|
+
? container
|
|
3353
|
+
: Array.isArray(container)
|
|
3354
|
+
? container.slice()
|
|
3355
|
+
: isRecord(container)
|
|
3356
|
+
? { ...container }
|
|
3357
|
+
: container; // non-plain leaf (Date/class instance): legacy in-place attempt
|
|
3358
|
+
try {
|
|
3359
|
+
next[prop] = newValue;
|
|
3360
|
+
}
|
|
3361
|
+
catch (e) {
|
|
3362
|
+
if (isDevMode())
|
|
3363
|
+
console.error(`[store] Failed to set property "${String(prop)}"`, e);
|
|
3364
|
+
}
|
|
3365
|
+
return next;
|
|
3366
|
+
};
|
|
3367
|
+
return isMutableSource
|
|
3368
|
+
? (newValue) => target.mutate(write(newValue))
|
|
3369
|
+
: (newValue) => target.update(write(newValue));
|
|
3370
|
+
}
|
|
3122
3371
|
/**
|
|
3123
3372
|
* @internal
|
|
3124
3373
|
* Makes an array store
|
|
@@ -3142,7 +3391,9 @@ function toArrayStore(source, injector, vivify, noUnionLeaves = false) {
|
|
|
3142
3391
|
const idx = +prop;
|
|
3143
3392
|
return idx >= 0 && idx < untracked(lengthSignal);
|
|
3144
3393
|
}
|
|
3145
|
-
|
|
3394
|
+
const v = untracked(source);
|
|
3395
|
+
// nullish node values are routinely descended with vivify on — `in` must not throw
|
|
3396
|
+
return v == null ? false : Reflect.has(v, prop);
|
|
3146
3397
|
},
|
|
3147
3398
|
ownKeys() {
|
|
3148
3399
|
const v = untracked(source);
|
|
@@ -3179,7 +3430,9 @@ function toArrayStore(source, injector, vivify, noUnionLeaves = false) {
|
|
|
3179
3430
|
return lengthSignal;
|
|
3180
3431
|
if (prop === Symbol.iterator) {
|
|
3181
3432
|
return function* () {
|
|
3182
|
-
|
|
3433
|
+
// read length reactively: a spread/for-of inside a computed/effect must re-run
|
|
3434
|
+
// when items are added or removed, not only when already-read elements change
|
|
3435
|
+
for (let i = 0; i < lengthSignal(); i++) {
|
|
3183
3436
|
yield receiver[i];
|
|
3184
3437
|
}
|
|
3185
3438
|
};
|
|
@@ -3218,19 +3471,8 @@ function toArrayStore(source, injector, vivify, noUnionLeaves = false) {
|
|
|
3218
3471
|
})
|
|
3219
3472
|
: derived(target, {
|
|
3220
3473
|
from: (v) => v?.[idx],
|
|
3221
|
-
onChange: (
|
|
3222
|
-
|
|
3223
|
-
if (container === null || container === undefined)
|
|
3224
|
-
return container;
|
|
3225
|
-
try {
|
|
3226
|
-
container[idx] = newValue;
|
|
3227
|
-
}
|
|
3228
|
-
catch (e) {
|
|
3229
|
-
if (isDevMode())
|
|
3230
|
-
console.error(`[store] Failed to set property "${String(idx)}"`, e);
|
|
3231
|
-
}
|
|
3232
|
-
return container;
|
|
3233
|
-
}),
|
|
3474
|
+
onChange: createFallbackOnChange(target, idx, vivifyFn, isMutableSource),
|
|
3475
|
+
equal: equalFn,
|
|
3234
3476
|
});
|
|
3235
3477
|
const childSample = untracked(computation);
|
|
3236
3478
|
const childVivify = resolveVivify(childSample, vivify);
|
|
@@ -3250,6 +3492,13 @@ function toArrayStore(source, injector, vivify, noUnionLeaves = false) {
|
|
|
3250
3492
|
/**
|
|
3251
3493
|
* Converts a Signal into a deep-observable Store.
|
|
3252
3494
|
* Accessing nested properties returns a derived Signal of that path.
|
|
3495
|
+
*
|
|
3496
|
+
* @remarks
|
|
3497
|
+
* A child's *container kind* (array store vs object store) is resolved when the child is
|
|
3498
|
+
* first accessed and cached with the proxy. Leaf↔substore flips are tracked reactively, but a
|
|
3499
|
+
* union-typed node that later flips between an array and a record keeps its original trap set —
|
|
3500
|
+
* if you need that, re-model the union as `{ kind: ..., value: ... }` instead.
|
|
3501
|
+
*
|
|
3253
3502
|
* @example
|
|
3254
3503
|
* const state = store({ user: { name: 'John' } });
|
|
3255
3504
|
* const nameSignal = state.user.name; // WritableSignal<string>
|
|
@@ -3332,19 +3581,8 @@ function toStore(source, injector, vivify = false, noUnionLeaves = false) {
|
|
|
3332
3581
|
? derived(target, prop, { equal: equalFn, vivify: nodeVivify })
|
|
3333
3582
|
: derived(target, {
|
|
3334
3583
|
from: (v) => v?.[prop],
|
|
3335
|
-
onChange: (
|
|
3336
|
-
|
|
3337
|
-
if (container === null || container === undefined)
|
|
3338
|
-
return container;
|
|
3339
|
-
try {
|
|
3340
|
-
container[prop] = newValue;
|
|
3341
|
-
}
|
|
3342
|
-
catch (e) {
|
|
3343
|
-
if (isDevMode())
|
|
3344
|
-
console.error(`[store] Failed to set property "${String(prop)}"`, e);
|
|
3345
|
-
}
|
|
3346
|
-
return container;
|
|
3347
|
-
}),
|
|
3584
|
+
onChange: createFallbackOnChange(target, prop, vivifyFn, isMutableSource),
|
|
3585
|
+
equal: equalFn,
|
|
3348
3586
|
});
|
|
3349
3587
|
const childSample = untracked(computation);
|
|
3350
3588
|
const childVivify = resolveVivify(childSample, vivify);
|
|
@@ -3487,7 +3725,12 @@ function merge3(ancestor, mine, theirs) {
|
|
|
3487
3725
|
if (isPlainRecord(mine) && isPlainRecord(theirs) && isPlainRecord(ancestor)) {
|
|
3488
3726
|
const out = { ...theirs };
|
|
3489
3727
|
for (const key of new Set([...Object.keys(mine), ...Object.keys(theirs)])) {
|
|
3490
|
-
|
|
3728
|
+
const merged = merge3(ancestor[key], mine[key], theirs[key]);
|
|
3729
|
+
// a key deleted on the fork must commit as ABSENT, not as an explicit `undefined`
|
|
3730
|
+
if (merged === undefined && !(key in mine))
|
|
3731
|
+
delete out[key];
|
|
3732
|
+
else
|
|
3733
|
+
out[key] = merged;
|
|
3491
3734
|
}
|
|
3492
3735
|
return out;
|
|
3493
3736
|
}
|
|
@@ -3539,8 +3782,8 @@ const noopStore = {
|
|
|
3539
3782
|
*
|
|
3540
3783
|
* @template T The type of value held by the signal and stored (after serialization).
|
|
3541
3784
|
* @param fallback The default value of type `T` to use when no value is found in storage
|
|
3542
|
-
* or when deserialization fails.
|
|
3543
|
-
*
|
|
3785
|
+
* or when deserialization fails. A stored value (including a legitimate `null` for a
|
|
3786
|
+
* nullable `T`) always round-trips; the fallback only surfaces when the entry is absent.
|
|
3544
3787
|
* @param options Configuration options (`CreateStoredOptions<T>`). Requires at least the `key`.
|
|
3545
3788
|
* @returns A `StoredSignal<T>` instance. This signal behaves like a standard `WritableSignal<T>`,
|
|
3546
3789
|
* but its value is persisted. It includes a `.clear()` method to remove the item from storage
|
|
@@ -3553,7 +3796,8 @@ const noopStore = {
|
|
|
3553
3796
|
* - **Error Handling:** Catches and logs errors during serialization/deserialization in dev mode.
|
|
3554
3797
|
* - **Tab Sync:** If `syncTabs` is true, listens to `storage` events to keep the signal value
|
|
3555
3798
|
* consistent across browser tabs using the same key. Cleanup is handled automatically
|
|
3556
|
-
* using `DestroyRef`.
|
|
3799
|
+
* using `DestroyRef`. Web Storage only: the `storage` event never fires for custom `store`
|
|
3800
|
+
* adapters, so `syncTabs` has no effect with one.
|
|
3557
3801
|
* - **Removal:** Use the `.clear()` method on the returned signal to remove the item from storage.
|
|
3558
3802
|
* Setting the signal to the fallback value will store the fallback value, not remove the item.
|
|
3559
3803
|
*
|
|
@@ -3588,25 +3832,28 @@ function stored(fallback, { key, store: providedStore, serialize = JSON.stringif
|
|
|
3588
3832
|
: isSignal(key)
|
|
3589
3833
|
? key
|
|
3590
3834
|
: computed(key);
|
|
3835
|
+
// "no stored value" marker — distinct from `null`/`undefined`, so a nullable `T` can
|
|
3836
|
+
// round-trip a legitimate `null` through `set` instead of it acting like `clear()`
|
|
3837
|
+
const EMPTY = Symbol();
|
|
3591
3838
|
const getValue = (key) => {
|
|
3592
3839
|
const found = store.getItem(key);
|
|
3593
3840
|
if (found === null)
|
|
3594
|
-
return
|
|
3841
|
+
return EMPTY;
|
|
3595
3842
|
try {
|
|
3596
3843
|
const deserialized = deserialize(found);
|
|
3597
3844
|
if (!validate(deserialized))
|
|
3598
|
-
return
|
|
3845
|
+
return EMPTY;
|
|
3599
3846
|
return deserialized;
|
|
3600
3847
|
}
|
|
3601
3848
|
catch (err) {
|
|
3602
3849
|
if (isDevMode())
|
|
3603
3850
|
console.error(`Failed to parse stored value for key "${key}":`, err);
|
|
3604
|
-
return
|
|
3851
|
+
return EMPTY;
|
|
3605
3852
|
}
|
|
3606
3853
|
};
|
|
3607
3854
|
const storeValue = (key, value) => {
|
|
3608
3855
|
try {
|
|
3609
|
-
if (value ===
|
|
3856
|
+
if (value === EMPTY)
|
|
3610
3857
|
return store.removeItem(key);
|
|
3611
3858
|
const serialized = serialize(value);
|
|
3612
3859
|
store.setItem(key, serialized);
|
|
@@ -3623,9 +3870,9 @@ function stored(fallback, { key, store: providedStore, serialize = JSON.stringif
|
|
|
3623
3870
|
const initialKey = untracked(keySig);
|
|
3624
3871
|
const internal = signal(getValue(initialKey), { ...(ngDevMode ? { debugName: "internal" } : /* istanbul ignore next */ {}), ...opt,
|
|
3625
3872
|
equal: (a, b) => {
|
|
3626
|
-
if (a ===
|
|
3873
|
+
if (a === EMPTY && b === EMPTY)
|
|
3627
3874
|
return true;
|
|
3628
|
-
if (a ===
|
|
3875
|
+
if (a === EMPTY || b === EMPTY)
|
|
3629
3876
|
return false;
|
|
3630
3877
|
return equal(a, b);
|
|
3631
3878
|
} });
|
|
@@ -3660,19 +3907,27 @@ function stored(fallback, { key, store: providedStore, serialize = JSON.stringif
|
|
|
3660
3907
|
if (syncTabs && !isServer) {
|
|
3661
3908
|
const destroyRef = inject(DestroyRef);
|
|
3662
3909
|
const sync = (e) => {
|
|
3910
|
+
// `storage` events only describe Web Storage — ignore events for a different
|
|
3911
|
+
// storage area (or any event when a custom adapter is configured), otherwise an
|
|
3912
|
+
// unrelated localStorage write with the same key string corrupts our state
|
|
3913
|
+
if (e.storageArea !== store)
|
|
3914
|
+
return;
|
|
3663
3915
|
if (e.key !== untracked(keySig))
|
|
3664
3916
|
return;
|
|
3665
3917
|
if (e.newValue === null)
|
|
3666
|
-
internal.set(
|
|
3918
|
+
internal.set(EMPTY);
|
|
3667
3919
|
else
|
|
3668
3920
|
internal.set(getValue(e.key));
|
|
3669
3921
|
};
|
|
3670
3922
|
window.addEventListener('storage', sync);
|
|
3671
3923
|
destroyRef.onDestroy(() => window.removeEventListener('storage', sync));
|
|
3672
3924
|
}
|
|
3673
|
-
const writable = toWritable(computed(() =>
|
|
3925
|
+
const writable = toWritable(computed(() => {
|
|
3926
|
+
const v = internal();
|
|
3927
|
+
return v === EMPTY ? fallback : v;
|
|
3928
|
+
}, opt), internal.set);
|
|
3674
3929
|
writable.clear = () => {
|
|
3675
|
-
internal.set(
|
|
3930
|
+
internal.set(EMPTY);
|
|
3676
3931
|
};
|
|
3677
3932
|
writable.key = keySig;
|
|
3678
3933
|
return writable;
|
|
@@ -3682,7 +3937,6 @@ class MessageBus {
|
|
|
3682
3937
|
channel = new BroadcastChannel('mmstack-tab-sync-bus');
|
|
3683
3938
|
listeners = new Map();
|
|
3684
3939
|
subscribe(id, listener) {
|
|
3685
|
-
this.unsubscribe(id); // Ensure no duplicate listeners
|
|
3686
3940
|
const wrapped = (ev) => {
|
|
3687
3941
|
try {
|
|
3688
3942
|
if (ev.data?.id === id)
|
|
@@ -3693,18 +3947,28 @@ class MessageBus {
|
|
|
3693
3947
|
}
|
|
3694
3948
|
};
|
|
3695
3949
|
this.channel.addEventListener('message', wrapped);
|
|
3696
|
-
this.listeners.
|
|
3950
|
+
let set = this.listeners.get(id);
|
|
3951
|
+
if (!set) {
|
|
3952
|
+
set = new Set();
|
|
3953
|
+
this.listeners.set(id, set);
|
|
3954
|
+
}
|
|
3955
|
+
set.add(wrapped);
|
|
3697
3956
|
return {
|
|
3698
|
-
unsub: (
|
|
3699
|
-
|
|
3957
|
+
unsub: () => {
|
|
3958
|
+
this.channel.removeEventListener('message', wrapped);
|
|
3959
|
+
const cur = this.listeners.get(id);
|
|
3960
|
+
if (!cur)
|
|
3961
|
+
return;
|
|
3962
|
+
cur.delete(wrapped);
|
|
3963
|
+
if (cur.size === 0)
|
|
3964
|
+
this.listeners.delete(id);
|
|
3965
|
+
},
|
|
3966
|
+
post: (value) => this.channel.postMessage({ id, value }),
|
|
3700
3967
|
};
|
|
3701
3968
|
}
|
|
3702
|
-
|
|
3703
|
-
|
|
3704
|
-
|
|
3705
|
-
return;
|
|
3706
|
-
this.channel.removeEventListener('message', listener);
|
|
3707
|
-
this.listeners.delete(id);
|
|
3969
|
+
ngOnDestroy() {
|
|
3970
|
+
this.channel.close();
|
|
3971
|
+
this.listeners.clear();
|
|
3708
3972
|
}
|
|
3709
3973
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.0", ngImport: i0, type: MessageBus, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
3710
3974
|
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.0", ngImport: i0, type: MessageBus, providedIn: 'root' });
|
|
@@ -3715,6 +3979,11 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.0", ngImpor
|
|
|
3715
3979
|
providedIn: 'root',
|
|
3716
3980
|
}]
|
|
3717
3981
|
}] });
|
|
3982
|
+
/**
|
|
3983
|
+
* @deprecated The generated id hashes the call-site stack line, which collides when a shared
|
|
3984
|
+
* helper calls {@link tabSync} for multiple signals and diverges across minified builds during
|
|
3985
|
+
* a rolling deploy. Pass an explicit `{ id }` instead.
|
|
3986
|
+
*/
|
|
3718
3987
|
function generateDeterministicID() {
|
|
3719
3988
|
const stack = new Error().stack;
|
|
3720
3989
|
if (stack) {
|
|
@@ -3752,10 +4021,8 @@ function generateDeterministicID() {
|
|
|
3752
4021
|
*
|
|
3753
4022
|
* @example
|
|
3754
4023
|
* ```typescript
|
|
3755
|
-
* //
|
|
3756
|
-
* const theme = tabSync(signal('dark'));
|
|
3757
|
-
*
|
|
3758
|
-
* // With explicit ID (recommended for production)
|
|
4024
|
+
* // With explicit ID (recommended)
|
|
4025
|
+
* const theme = tabSync(signal('dark'), { id: 'theme' });
|
|
3759
4026
|
* const userPrefs = tabSync(signal({ lang: 'en' }), { id: 'user-preferences' });
|
|
3760
4027
|
*
|
|
3761
4028
|
* // Changes in one tab will sync to all other tabs
|
|
@@ -3767,6 +4034,7 @@ function generateDeterministicID() {
|
|
|
3767
4034
|
* - Uses a single BroadcastChannel for all synchronized signals
|
|
3768
4035
|
* - Automatically cleans up listeners when the injection context is destroyed
|
|
3769
4036
|
* - Initial signal value after sync setup is not broadcasted to prevent loops
|
|
4037
|
+
* - Received values are not re-broadcast, so tabs never echo each other's updates
|
|
3770
4038
|
*
|
|
3771
4039
|
*/
|
|
3772
4040
|
function tabSync(sig, opt) {
|
|
@@ -3774,7 +4042,20 @@ function tabSync(sig, opt) {
|
|
|
3774
4042
|
return sig;
|
|
3775
4043
|
const id = typeof opt === 'string' ? opt : (opt?.id ?? generateDeterministicID());
|
|
3776
4044
|
const bus = inject(MessageBus);
|
|
3777
|
-
|
|
4045
|
+
// The last value applied from a remote tab. The outbound effect skips (exactly) the run
|
|
4046
|
+
// caused by that write — without this, an inbound object (a fresh structured clone, so
|
|
4047
|
+
// never reference-equal) would be re-posted, and two tabs would ping-pong forever.
|
|
4048
|
+
const NONE = Symbol();
|
|
4049
|
+
let received = NONE;
|
|
4050
|
+
const { unsub, post } = bus.subscribe(id, (next) => {
|
|
4051
|
+
const before = untracked(sig);
|
|
4052
|
+
received = next;
|
|
4053
|
+
sig.set(next);
|
|
4054
|
+
// Equality-suppressed write (e.g. an identical primitive): no effect run will follow,
|
|
4055
|
+
// so clear the marker — it must not swallow a later, genuinely local change.
|
|
4056
|
+
if (untracked(sig) === before)
|
|
4057
|
+
received = NONE;
|
|
4058
|
+
});
|
|
3778
4059
|
let first = false;
|
|
3779
4060
|
const effectRef = effect(() => {
|
|
3780
4061
|
const val = sig();
|
|
@@ -3782,6 +4063,11 @@ function tabSync(sig, opt) {
|
|
|
3782
4063
|
first = true;
|
|
3783
4064
|
return;
|
|
3784
4065
|
}
|
|
4066
|
+
if (val === received) {
|
|
4067
|
+
received = NONE;
|
|
4068
|
+
return;
|
|
4069
|
+
}
|
|
4070
|
+
received = NONE;
|
|
3785
4071
|
post(val);
|
|
3786
4072
|
}, /* @ts-ignore */
|
|
3787
4073
|
...(ngDevMode ? [{ debugName: "effectRef" }] : /* istanbul ignore next */ []));
|
|
@@ -3793,7 +4079,6 @@ function tabSync(sig, opt) {
|
|
|
3793
4079
|
}
|
|
3794
4080
|
|
|
3795
4081
|
function until(sourceSignal, predicate, options = {}) {
|
|
3796
|
-
const injector = options.injector ?? inject(Injector);
|
|
3797
4082
|
return new Promise((resolve, reject) => {
|
|
3798
4083
|
let effectRef;
|
|
3799
4084
|
let timeoutId;
|
|
@@ -3830,6 +4115,14 @@ function until(sourceSignal, predicate, options = {}) {
|
|
|
3830
4115
|
cleanupAndResolve(initialValue);
|
|
3831
4116
|
return;
|
|
3832
4117
|
}
|
|
4118
|
+
let injector;
|
|
4119
|
+
try {
|
|
4120
|
+
injector = options.injector ?? inject(Injector);
|
|
4121
|
+
}
|
|
4122
|
+
catch {
|
|
4123
|
+
cleanupAndReject('until: No injector available — provide options.injector when calling outside an injection context.');
|
|
4124
|
+
return;
|
|
4125
|
+
}
|
|
3833
4126
|
if (options?.timeout !== undefined) {
|
|
3834
4127
|
timeoutId = setTimeout(() => cleanupAndReject(`until: Timeout after ${options.timeout}ms.`), options.timeout);
|
|
3835
4128
|
}
|
|
@@ -3847,17 +4140,6 @@ function until(sourceSignal, predicate, options = {}) {
|
|
|
3847
4140
|
});
|
|
3848
4141
|
}
|
|
3849
4142
|
|
|
3850
|
-
/**
|
|
3851
|
-
* @interal
|
|
3852
|
-
*/
|
|
3853
|
-
function getSignalEquality(sig) {
|
|
3854
|
-
const internal = sig[SIGNAL];
|
|
3855
|
-
if (internal && typeof internal.equal === 'function') {
|
|
3856
|
-
return internal.equal;
|
|
3857
|
-
}
|
|
3858
|
-
return Object.is; // Default equality check
|
|
3859
|
-
}
|
|
3860
|
-
|
|
3861
4143
|
/**
|
|
3862
4144
|
* Enhances an existing `WritableSignal` by adding a complete undo/redo history
|
|
3863
4145
|
* stack and an API to control it.
|
|
@@ -3906,9 +4188,10 @@ function getSignalEquality(sig) {
|
|
|
3906
4188
|
* ```
|
|
3907
4189
|
*/
|
|
3908
4190
|
function withHistory(sourceOrValue, opt) {
|
|
3909
|
-
const equal =
|
|
3910
|
-
|
|
3911
|
-
|
|
4191
|
+
const equal = opt?.equal ??
|
|
4192
|
+
(isSignal(sourceOrValue)
|
|
4193
|
+
? getSignalEquality(sourceOrValue)
|
|
4194
|
+
: Object.is);
|
|
3912
4195
|
const source = isSignal(sourceOrValue)
|
|
3913
4196
|
? sourceOrValue
|
|
3914
4197
|
: signal(sourceOrValue);
|
|
@@ -3953,9 +4236,8 @@ function withHistory(sourceOrValue, opt) {
|
|
|
3953
4236
|
if (historyStack.length === 0)
|
|
3954
4237
|
return;
|
|
3955
4238
|
const valueForRedo = untracked(source);
|
|
3956
|
-
|
|
3957
|
-
|
|
3958
|
-
return;
|
|
4239
|
+
// length checked above — a legitimately `undefined` entry must still restore
|
|
4240
|
+
const valueToRestore = historyStack[historyStack.length - 1];
|
|
3959
4241
|
originalSet.call(source, valueToRestore);
|
|
3960
4242
|
history.inline((h) => h.pop());
|
|
3961
4243
|
redoArray.mutate((r) => {
|
|
@@ -3969,9 +4251,8 @@ function withHistory(sourceOrValue, opt) {
|
|
|
3969
4251
|
if (redoStack.length === 0)
|
|
3970
4252
|
return;
|
|
3971
4253
|
const valueForUndo = untracked(source);
|
|
3972
|
-
|
|
3973
|
-
|
|
3974
|
-
return;
|
|
4254
|
+
// length checked above — a legitimately `undefined` entry must still restore
|
|
4255
|
+
const valueToRestore = redoStack[redoStack.length - 1];
|
|
3975
4256
|
originalSet.call(source, valueToRestore);
|
|
3976
4257
|
redoArray.inline((r) => r.pop());
|
|
3977
4258
|
history.mutate((h) => {
|