@mmstack/primitives 19.4.0 → 19.4.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/README.md +8 -5
- package/fesm2022/mmstack-primitives.mjs +526 -242
- package/fesm2022/mmstack-primitives.mjs.map +1 -1
- package/index.d.ts +2 -2
- package/lib/chunked.d.ts +2 -2
- package/lib/concurrent/pausable.d.ts +3 -2
- package/lib/concurrent/start-transition.d.ts +5 -0
- package/lib/concurrent/transaction.d.ts +5 -0
- package/lib/effect/index.d.ts +1 -0
- package/lib/get-signal-equality.d.ts +1 -1
- package/lib/mutable.d.ts +1 -1
- package/lib/pipeable/{pipeble.d.ts → pipeable.d.ts} +1 -1
- package/lib/pipeable/public_api.d.ts +1 -1
- package/lib/pipeable/types.d.ts +1 -1
- package/lib/sensors/battery-status.d.ts +2 -1
- package/lib/sensors/clipboard.d.ts +2 -1
- package/lib/sensors/element-size.d.ts +2 -4
- package/lib/sensors/element-visibility.d.ts +2 -4
- package/lib/sensors/focus-within.d.ts +2 -1
- package/lib/sensors/geolocation.d.ts +2 -3
- package/lib/sensors/idle.d.ts +6 -4
- package/lib/sensors/index.d.ts +1 -0
- package/lib/sensors/media-query.d.ts +4 -3
- package/lib/sensors/mouse-position.d.ts +6 -7
- package/lib/sensors/network-status.d.ts +8 -3
- package/lib/sensors/orientation.d.ts +9 -2
- package/lib/sensors/page-visibility.d.ts +4 -2
- package/lib/sensors/scroll-position.d.ts +10 -18
- package/lib/sensors/sensor-options.d.ts +25 -0
- package/lib/sensors/sensor.d.ts +15 -30
- package/lib/sensors/window-size.d.ts +3 -6
- package/lib/store/store.d.ts +4 -4
- package/lib/stored.d.ts +4 -3
- package/lib/{tabSync.d.ts → tab-sync.d.ts} +9 -4
- package/lib/throttled.d.ts +2 -0
- package/lib/to-writable.d.ts +4 -0
- package/package.json +1 -1
|
@@ -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) => {
|
|
@@ -264,7 +271,9 @@ class MmActivity {
|
|
|
264
271
|
});
|
|
265
272
|
}
|
|
266
273
|
for (const node of this.view.rootNodes) {
|
|
267
|
-
|
|
274
|
+
// covers HTML and SVG roots; text/comment roots can't be styled — their CD is still
|
|
275
|
+
// detached, but prefer an element root for true visual hiding
|
|
276
|
+
if (node instanceof HTMLElement || node instanceof SVGElement)
|
|
268
277
|
node.style.display = visible ? '' : 'none';
|
|
269
278
|
}
|
|
270
279
|
if (visible)
|
|
@@ -343,14 +352,25 @@ function resolvePause(opt) {
|
|
|
343
352
|
if (pause === false)
|
|
344
353
|
return null;
|
|
345
354
|
const run = (fn) => opt?.injector ? runInInjectionContext(opt.injector, fn) : fn();
|
|
355
|
+
// `inject` requires an injection context even with `optional: true`. A bare
|
|
356
|
+
// `pausableSignal(0)` (documented as "like `signal`") must degrade to the unwrapped
|
|
357
|
+
// primitive outside DI, not throw NG0203 — so injection failures fall back gracefully.
|
|
358
|
+
const tryRun = (fn, fallback) => {
|
|
359
|
+
try {
|
|
360
|
+
return run(fn);
|
|
361
|
+
}
|
|
362
|
+
catch {
|
|
363
|
+
return fallback;
|
|
364
|
+
}
|
|
365
|
+
};
|
|
346
366
|
const onServer = () => typeof pause === 'function' && !opt?.injector
|
|
347
367
|
? typeof globalThis.window === 'undefined'
|
|
348
|
-
:
|
|
368
|
+
: tryRun(() => isPlatformServer(inject(PLATFORM_ID, { optional: true }) ?? 'browser'), typeof globalThis.window === 'undefined');
|
|
349
369
|
if (typeof pause === 'function')
|
|
350
370
|
return onServer() ? null : pause;
|
|
351
371
|
if (onServer())
|
|
352
372
|
return null;
|
|
353
|
-
const paused =
|
|
373
|
+
const paused = tryRun(() => inject(PAUSED_CONTEXT, { optional: true }), null);
|
|
354
374
|
if (!paused) {
|
|
355
375
|
if (explicit === true && isDevMode())
|
|
356
376
|
console.warn('[pausable] `pause: true` but no PAUSED_CONTEXT in scope — not pausing. Provide one via an ' +
|
|
@@ -379,8 +399,9 @@ function pausableEffect(effectFn, options) {
|
|
|
379
399
|
/**
|
|
380
400
|
* Like `signal`, but pausable. While paused, READS hold the last value; writes still land on the
|
|
381
401
|
* underlying signal and surface on resume. Built on the `keepPrevious`/`hold` shape — a
|
|
382
|
-
* `linkedSignal` gated on the pause predicate, with `set`/`update
|
|
383
|
-
*
|
|
402
|
+
* `linkedSignal` gated on the pause predicate, with `set`/`update` forwarded to the source signal.
|
|
403
|
+
* `asReadonly()` returns the held (gated) view, so both views of the signal agree while paused.
|
|
404
|
+
* With no `pause` option it defaults to the ambient `PAUSED_CONTEXT`; `pause: false`
|
|
384
405
|
* makes it a plain `signal` — no `linkedSignal` is created.
|
|
385
406
|
*
|
|
386
407
|
* NOTE: while paused, `set(x)` followed by a read returns the *held* (pre-pause) value, not `x` — the
|
|
@@ -399,7 +420,8 @@ function pausableSignal(initialValue, options) {
|
|
|
399
420
|
});
|
|
400
421
|
read.set = src.set;
|
|
401
422
|
read.update = src.update;
|
|
402
|
-
|
|
423
|
+
// NOTE: `asReadonly` deliberately stays the linkedSignal's own (the held view) — the
|
|
424
|
+
// source's readonly view would show live values while the signal itself shows held ones.
|
|
403
425
|
return read;
|
|
404
426
|
}
|
|
405
427
|
/**
|
|
@@ -438,8 +460,12 @@ function mutable(initial, opt) {
|
|
|
438
460
|
const internalUpdate = sig.update;
|
|
439
461
|
sig.mutate = (updater) => {
|
|
440
462
|
cnt++;
|
|
441
|
-
|
|
442
|
-
|
|
463
|
+
try {
|
|
464
|
+
internalUpdate(updater);
|
|
465
|
+
}
|
|
466
|
+
finally {
|
|
467
|
+
cnt--;
|
|
468
|
+
}
|
|
443
469
|
};
|
|
444
470
|
sig.inline = (updater) => {
|
|
445
471
|
sig.mutate((prev) => {
|
|
@@ -526,7 +552,7 @@ function createNoopScope() {
|
|
|
526
552
|
hold: (value) => value,
|
|
527
553
|
};
|
|
528
554
|
}
|
|
529
|
-
const TRANSITION_SCOPE = new InjectionToken('@mmstack/
|
|
555
|
+
const TRANSITION_SCOPE = new InjectionToken('@mmstack/primitives:transition-scope');
|
|
530
556
|
/** Provide a fresh transition scope at a boundary so its subtree's resources are tracked independently. */
|
|
531
557
|
function provideTransitionScope() {
|
|
532
558
|
return { provide: TRANSITION_SCOPE, useFactory: createTransitionScope };
|
|
@@ -535,7 +561,7 @@ function injectTransitionScope() {
|
|
|
535
561
|
const scope = inject(TRANSITION_SCOPE, { optional: true });
|
|
536
562
|
if (!scope) {
|
|
537
563
|
if (isDevMode())
|
|
538
|
-
console.warn('[mmstack/
|
|
564
|
+
console.warn('[mmstack/primitives] No transition scope in context — registration/tracking here is a no-op. ' +
|
|
539
565
|
'Use a <mm-suspense> boundary or provideTransitionScope() in an ancestor.');
|
|
540
566
|
return createNoopScope();
|
|
541
567
|
}
|
|
@@ -569,6 +595,11 @@ function registerResource(res, opt) {
|
|
|
569
595
|
*
|
|
570
596
|
* Must be called in an injection context. This is the *async* generalization (Tier 2): it adds
|
|
571
597
|
* no rendering cost and needs no fork — holding direct/sync readers is a separate, deferred tier.
|
|
598
|
+
*
|
|
599
|
+
* Caveat: work must go in flight by the first post-write render to be awaited. A loader that
|
|
600
|
+
* starts later (a debounced request signal, a chained/deferred resource) is not attributable to
|
|
601
|
+
* this transition — the no-async fallback will have already resolved `done`. Trigger such work
|
|
602
|
+
* eagerly inside `fn`, or coordinate it separately.
|
|
572
603
|
*/
|
|
573
604
|
function injectStartTransition() {
|
|
574
605
|
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);
|
|
870
927
|
let timeout;
|
|
@@ -879,25 +936,25 @@ function debounce(source, opt) {
|
|
|
879
936
|
catch {
|
|
880
937
|
// not in injection context & no destroyRef provided opting out of cleanup
|
|
881
938
|
}
|
|
882
|
-
const
|
|
939
|
+
const set = (next) => {
|
|
940
|
+
const isEqual = eq(untracked(source), next);
|
|
941
|
+
if (!timeout && isEqual)
|
|
942
|
+
return; // nothing to do
|
|
883
943
|
if (timeout)
|
|
884
|
-
clearTimeout(timeout);
|
|
885
|
-
|
|
944
|
+
clearTimeout(timeout); // clear pending
|
|
945
|
+
if (!isEqual)
|
|
946
|
+
source.set(next);
|
|
886
947
|
timeout = setTimeout(() => {
|
|
948
|
+
timeout = undefined;
|
|
887
949
|
trigger.update((c) => !c);
|
|
888
950
|
}, ms);
|
|
889
951
|
};
|
|
890
|
-
const
|
|
891
|
-
triggerFn(value);
|
|
892
|
-
};
|
|
893
|
-
const update = (fn) => {
|
|
894
|
-
triggerFn(fn(untracked(source)));
|
|
895
|
-
};
|
|
952
|
+
const update = (fn) => set(fn(untracked(source)));
|
|
896
953
|
const writable = toWritable(computed(() => {
|
|
897
954
|
trigger();
|
|
898
955
|
return untracked(source);
|
|
899
956
|
}, opt), set, update);
|
|
900
|
-
writable.original = source;
|
|
957
|
+
writable.original = source.asReadonly();
|
|
901
958
|
return writable;
|
|
902
959
|
}
|
|
903
960
|
|
|
@@ -1068,8 +1125,18 @@ function derived(source, optOrKey, opt) {
|
|
|
1068
1125
|
if (isMutable(source)) {
|
|
1069
1126
|
sig.mutate = (updater) => {
|
|
1070
1127
|
cnt++;
|
|
1071
|
-
|
|
1072
|
-
|
|
1128
|
+
try {
|
|
1129
|
+
sig.update(updater);
|
|
1130
|
+
// The wrapped computed evaluates its `equal` lazily — at the next read, which would
|
|
1131
|
+
// normally happen after `cnt` has already dropped back to 0. For a reference-stable
|
|
1132
|
+
// mutation that read compares the same object to itself and the version never bumps,
|
|
1133
|
+
// so dependents are never notified. Reading here, while equality is still suppressed,
|
|
1134
|
+
// forces the recompute (and version bump) inside the mutate window.
|
|
1135
|
+
untracked(sig);
|
|
1136
|
+
}
|
|
1137
|
+
finally {
|
|
1138
|
+
cnt--;
|
|
1139
|
+
}
|
|
1073
1140
|
};
|
|
1074
1141
|
sig.inline = (updater) => {
|
|
1075
1142
|
sig.mutate((prev) => {
|
|
@@ -1151,18 +1218,45 @@ function createSetter(source) {
|
|
|
1151
1218
|
}
|
|
1152
1219
|
|
|
1153
1220
|
function keepPrevious(src, opt) {
|
|
1221
|
+
const mutableSrc = isWritableSignal$1(src) && isMutable(src);
|
|
1222
|
+
// For a mutable source the linkedSignal's equality must be suppressible: a forwarded
|
|
1223
|
+
// `mutate` keeps the same reference, which default equality would otherwise swallow.
|
|
1224
|
+
let cnt = 0;
|
|
1225
|
+
const baseEqual = opt?.equal;
|
|
1226
|
+
const equal = mutableSrc
|
|
1227
|
+
? (a, b) => cnt > 0 ? false : baseEqual ? baseEqual(a, b) : Object.is(a, b)
|
|
1228
|
+
: baseEqual;
|
|
1154
1229
|
const persisted = linkedSignal({
|
|
1155
1230
|
...opt,
|
|
1156
1231
|
source: () => src(),
|
|
1157
1232
|
computation: (next, prev) => next === undefined && prev !== undefined ? prev.value : next,
|
|
1233
|
+
equal,
|
|
1158
1234
|
});
|
|
1159
1235
|
if (isWritableSignal$1(src)) {
|
|
1160
1236
|
persisted.set = src.set;
|
|
1161
1237
|
persisted.update = src.update;
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1238
|
+
// NOTE: `asReadonly` deliberately stays the linkedSignal's own — returning the
|
|
1239
|
+
// source's readonly view would reintroduce the `undefined` flashes this wrapper exists
|
|
1240
|
+
// to prevent.
|
|
1241
|
+
if (mutableSrc) {
|
|
1242
|
+
persisted.mutate = (updater) => {
|
|
1243
|
+
cnt++;
|
|
1244
|
+
try {
|
|
1245
|
+
src.mutate(updater);
|
|
1246
|
+
// force the recompute while equality is suppressed, so the reference-stable
|
|
1247
|
+
// mutation bumps the wrapper's version (see derived.ts for the same pattern)
|
|
1248
|
+
untracked(persisted);
|
|
1249
|
+
}
|
|
1250
|
+
finally {
|
|
1251
|
+
cnt--;
|
|
1252
|
+
}
|
|
1253
|
+
};
|
|
1254
|
+
persisted.inline = (updater) => {
|
|
1255
|
+
persisted.mutate((prev) => {
|
|
1256
|
+
updater(prev);
|
|
1257
|
+
return prev;
|
|
1258
|
+
});
|
|
1259
|
+
};
|
|
1166
1260
|
}
|
|
1167
1261
|
if (isDerivation(src)) {
|
|
1168
1262
|
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({
|
|
@@ -1393,8 +1492,17 @@ function pooledKeys(src) {
|
|
|
1393
1492
|
for (const k in val)
|
|
1394
1493
|
if (Object.prototype.hasOwnProperty.call(val, k))
|
|
1395
1494
|
spare.add(k);
|
|
1396
|
-
if (active.size === spare.size
|
|
1397
|
-
|
|
1495
|
+
if (active.size === spare.size) {
|
|
1496
|
+
let subset = true;
|
|
1497
|
+
for (const k of active) {
|
|
1498
|
+
if (!spare.has(k)) {
|
|
1499
|
+
subset = false;
|
|
1500
|
+
break;
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
if (subset)
|
|
1504
|
+
return active;
|
|
1505
|
+
}
|
|
1398
1506
|
const temp = active;
|
|
1399
1507
|
active = spare;
|
|
1400
1508
|
spare = temp;
|
|
@@ -1494,7 +1602,7 @@ const filter = (predicate) => (src) => linkedSignal({
|
|
|
1494
1602
|
computation: (next, prev) => {
|
|
1495
1603
|
if (predicate(next))
|
|
1496
1604
|
return next;
|
|
1497
|
-
return prev?.
|
|
1605
|
+
return prev?.value;
|
|
1498
1606
|
},
|
|
1499
1607
|
});
|
|
1500
1608
|
/**
|
|
@@ -1530,7 +1638,7 @@ const tap = (fn, injector) => (src) => {
|
|
|
1530
1638
|
*/
|
|
1531
1639
|
const filterWith = (predicate, initial) => (src) => linkedSignal({
|
|
1532
1640
|
source: src,
|
|
1533
|
-
computation: (next, prev) => predicate(next) ? next :
|
|
1641
|
+
computation: (next, prev) => predicate(next) ? next : prev ? prev.value : initial,
|
|
1534
1642
|
});
|
|
1535
1643
|
/**
|
|
1536
1644
|
* Emit `initial` on the first read, then mirror the source on every subsequent
|
|
@@ -1579,7 +1687,7 @@ const pairwise = () => (src) => linkedSignal({
|
|
|
1579
1687
|
*/
|
|
1580
1688
|
const scan = (reducer, seed) => (src) => linkedSignal({
|
|
1581
1689
|
source: src,
|
|
1582
|
-
computation: (next, prev) => reducer(prev
|
|
1690
|
+
computation: (next, prev) => reducer(prev ? prev.value : seed, next),
|
|
1583
1691
|
});
|
|
1584
1692
|
|
|
1585
1693
|
/**
|
|
@@ -1630,7 +1738,7 @@ function pipeable(signal) {
|
|
|
1630
1738
|
return internal;
|
|
1631
1739
|
}
|
|
1632
1740
|
/**
|
|
1633
|
-
* Create a new **writable** signal and return it as a `
|
|
1741
|
+
* Create a new **writable** signal and return it as a `PipeableSignal`.
|
|
1634
1742
|
*
|
|
1635
1743
|
* The returned value is a `WritableSignal<T>` with `.set`, `.update`, `.asReadonly`
|
|
1636
1744
|
* still available (via intersection type), plus a chainable `.pipe(...)`.
|
|
@@ -1734,6 +1842,20 @@ function pooledMap(optOrComputation, signalOpt) {
|
|
|
1734
1842
|
return pooled(toPooledOptions(optOrComputation, createEmptyMap, resetClearable, signalOpt));
|
|
1735
1843
|
}
|
|
1736
1844
|
|
|
1845
|
+
/**
|
|
1846
|
+
* @internal Run a sensor factory inside `injector` when provided, else in the ambient
|
|
1847
|
+
* injection context. Keeps every sensor's escape hatch identical and in one place.
|
|
1848
|
+
*/
|
|
1849
|
+
function runInSensorContext(injector, fn) {
|
|
1850
|
+
return injector ? runInInjectionContext(injector, fn) : fn();
|
|
1851
|
+
}
|
|
1852
|
+
/**
|
|
1853
|
+
* @internal Normalize the legacy positional `debugName: string` form into {@link SensorRunOptions}.
|
|
1854
|
+
*/
|
|
1855
|
+
function coerceSensorOptions(opt) {
|
|
1856
|
+
return typeof opt === 'string' ? { debugName: opt } : (opt ?? {});
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1737
1859
|
const EVENTS = [
|
|
1738
1860
|
'chargingchange',
|
|
1739
1861
|
'levelchange',
|
|
@@ -1755,7 +1877,11 @@ const EVENTS = [
|
|
|
1755
1877
|
* });
|
|
1756
1878
|
* ```
|
|
1757
1879
|
*/
|
|
1758
|
-
function batteryStatus(
|
|
1880
|
+
function batteryStatus(opt) {
|
|
1881
|
+
const { debugName = 'batteryStatus', injector } = coerceSensorOptions(opt);
|
|
1882
|
+
return runInSensorContext(injector, () => createBatteryStatus(debugName));
|
|
1883
|
+
}
|
|
1884
|
+
function createBatteryStatus(debugName) {
|
|
1759
1885
|
if (isPlatformServer(inject(PLATFORM_ID)) ||
|
|
1760
1886
|
typeof navigator === 'undefined' ||
|
|
1761
1887
|
typeof navigator.getBattery !== 'function') {
|
|
@@ -1764,7 +1890,9 @@ function batteryStatus(debugName = 'batteryStatus') {
|
|
|
1764
1890
|
const state = signal(null, { debugName });
|
|
1765
1891
|
const abortController = new AbortController();
|
|
1766
1892
|
inject(DestroyRef).onDestroy(() => abortController.abort());
|
|
1767
|
-
navigator
|
|
1893
|
+
navigator
|
|
1894
|
+
.getBattery()
|
|
1895
|
+
.then((battery) => {
|
|
1768
1896
|
if (abortController.signal.aborted)
|
|
1769
1897
|
return;
|
|
1770
1898
|
const read = () => ({
|
|
@@ -1780,6 +1908,10 @@ function batteryStatus(debugName = 'batteryStatus') {
|
|
|
1780
1908
|
signal: abortController.signal,
|
|
1781
1909
|
});
|
|
1782
1910
|
}
|
|
1911
|
+
})
|
|
1912
|
+
.catch(() => {
|
|
1913
|
+
// getBattery() rejects (NotAllowedError) when the `battery` permissions-policy is
|
|
1914
|
+
// disallowed, e.g. in cross-origin iframes — stay `null`, same as unsupported.
|
|
1783
1915
|
});
|
|
1784
1916
|
return state.asReadonly();
|
|
1785
1917
|
}
|
|
@@ -1795,7 +1927,11 @@ function batteryStatus(debugName = 'batteryStatus') {
|
|
|
1795
1927
|
* in browsers that gate it. Errors from `navigator.clipboard.readText` are
|
|
1796
1928
|
* swallowed silently to keep the signal value stable.
|
|
1797
1929
|
*/
|
|
1798
|
-
function clipboard(
|
|
1930
|
+
function clipboard(opt) {
|
|
1931
|
+
const { debugName = 'clipboard', injector } = coerceSensorOptions(opt);
|
|
1932
|
+
return runInSensorContext(injector, () => createClipboard(debugName));
|
|
1933
|
+
}
|
|
1934
|
+
function createClipboard(debugName) {
|
|
1799
1935
|
if (isPlatformServer(inject(PLATFORM_ID)) ||
|
|
1800
1936
|
typeof navigator === 'undefined' ||
|
|
1801
1937
|
!navigator.clipboard) {
|
|
@@ -1845,7 +1981,13 @@ function observerSupported$1() {
|
|
|
1845
1981
|
* });
|
|
1846
1982
|
* ```
|
|
1847
1983
|
*/
|
|
1848
|
-
function elementSize(target
|
|
1984
|
+
function elementSize(target, opt) {
|
|
1985
|
+
return runInSensorContext(opt?.injector, () =>
|
|
1986
|
+
// the host-element default must resolve INSIDE the sensor context, not as a
|
|
1987
|
+
// parameter default (which would run before the injector wrapper)
|
|
1988
|
+
createElementSize(target ?? inject(ElementRef), opt));
|
|
1989
|
+
}
|
|
1990
|
+
function createElementSize(target, opt) {
|
|
1849
1991
|
const getElement = () => {
|
|
1850
1992
|
if (isSignal(target)) {
|
|
1851
1993
|
try {
|
|
@@ -1859,8 +2001,8 @@ function elementSize(target = inject(ElementRef), opt) {
|
|
|
1859
2001
|
return target instanceof ElementRef ? target.nativeElement : target;
|
|
1860
2002
|
};
|
|
1861
2003
|
const resolveInitialValue = () => {
|
|
1862
|
-
|
|
1863
|
-
|
|
2004
|
+
// measuring needs only getBoundingClientRect — ResizeObserver support gates
|
|
2005
|
+
// live updates, not the initial read
|
|
1864
2006
|
const el = getElement();
|
|
1865
2007
|
if (el && el.getBoundingClientRect) {
|
|
1866
2008
|
const rect = el.getBoundingClientRect();
|
|
@@ -1978,7 +2120,13 @@ function observerSupported() {
|
|
|
1978
2120
|
* }
|
|
1979
2121
|
* ```
|
|
1980
2122
|
*/
|
|
1981
|
-
function elementVisibility(target
|
|
2123
|
+
function elementVisibility(target, opt) {
|
|
2124
|
+
return runInSensorContext(opt?.injector, () =>
|
|
2125
|
+
// the host-element default must resolve INSIDE the sensor context, not as a
|
|
2126
|
+
// parameter default (which would run before the injector wrapper)
|
|
2127
|
+
createElementVisibility(target ?? inject(ElementRef), opt));
|
|
2128
|
+
}
|
|
2129
|
+
function createElementVisibility(target, opt) {
|
|
1982
2130
|
if (isPlatformServer(inject(PLATFORM_ID)) || !observerSupported()) {
|
|
1983
2131
|
const base = computed(() => undefined, {
|
|
1984
2132
|
debugName: opt?.debugName,
|
|
@@ -2046,11 +2194,18 @@ function unwrap$1(target) {
|
|
|
2046
2194
|
* }
|
|
2047
2195
|
* ```
|
|
2048
2196
|
*/
|
|
2049
|
-
function focusWithin(target
|
|
2197
|
+
function focusWithin(target, opt) {
|
|
2198
|
+
return runInSensorContext(opt?.injector, () =>
|
|
2199
|
+
// the host-element default must resolve INSIDE the sensor context, not as a
|
|
2200
|
+
// parameter default (which would run before the injector wrapper)
|
|
2201
|
+
createFocusWithin(target ?? inject(ElementRef), opt));
|
|
2202
|
+
}
|
|
2203
|
+
function createFocusWithin(target, opt) {
|
|
2204
|
+
const debugName = opt?.debugName ?? 'focusWithin';
|
|
2050
2205
|
if (isPlatformServer(inject(PLATFORM_ID))) {
|
|
2051
|
-
return computed(() => false, { debugName
|
|
2206
|
+
return computed(() => false, { debugName });
|
|
2052
2207
|
}
|
|
2053
|
-
const state = signal(false, { debugName
|
|
2208
|
+
const state = signal(false, { debugName });
|
|
2054
2209
|
const attach = (el) => {
|
|
2055
2210
|
state.set(el.contains(document.activeElement));
|
|
2056
2211
|
const abortController = new AbortController();
|
|
@@ -2098,6 +2253,9 @@ function focusWithin(target = inject(ElementRef)) {
|
|
|
2098
2253
|
* ```
|
|
2099
2254
|
*/
|
|
2100
2255
|
function geolocation(opt) {
|
|
2256
|
+
return runInSensorContext(opt?.injector, () => createGeolocation(opt));
|
|
2257
|
+
}
|
|
2258
|
+
function createGeolocation(opt) {
|
|
2101
2259
|
if (isPlatformServer(inject(PLATFORM_ID)) || typeof navigator === 'undefined' || !navigator.geolocation) {
|
|
2102
2260
|
const sig = computed(() => null, {
|
|
2103
2261
|
debugName: opt?.debugName ?? 'geolocation',
|
|
@@ -2157,6 +2315,9 @@ const serverDate$1 = new Date();
|
|
|
2157
2315
|
* ```
|
|
2158
2316
|
*/
|
|
2159
2317
|
function idle(opt) {
|
|
2318
|
+
return runInSensorContext(opt?.injector, () => createIdle(opt));
|
|
2319
|
+
}
|
|
2320
|
+
function createIdle(opt) {
|
|
2160
2321
|
if (isPlatformServer(inject(PLATFORM_ID))) {
|
|
2161
2322
|
const sig = computed(() => false, {
|
|
2162
2323
|
debugName: opt?.debugName ?? 'idle',
|
|
@@ -2246,7 +2407,11 @@ function idle(opt) {
|
|
|
2246
2407
|
* }
|
|
2247
2408
|
* ```
|
|
2248
2409
|
*/
|
|
2249
|
-
function mediaQuery(query,
|
|
2410
|
+
function mediaQuery(query, opt) {
|
|
2411
|
+
const { debugName = 'mediaQuery', injector } = coerceSensorOptions(opt);
|
|
2412
|
+
return runInSensorContext(injector, () => createMediaQuery(query, debugName));
|
|
2413
|
+
}
|
|
2414
|
+
function createMediaQuery(query, debugName) {
|
|
2250
2415
|
if (isPlatformServer(inject(PLATFORM_ID)) ||
|
|
2251
2416
|
typeof window === 'undefined' ||
|
|
2252
2417
|
typeof window.matchMedia !== 'function' // jsdom doesn't implement matchMedia
|
|
@@ -2284,8 +2449,8 @@ function mediaQuery(query, debugName = 'mediaQuery') {
|
|
|
2284
2449
|
* });
|
|
2285
2450
|
* ```
|
|
2286
2451
|
*/
|
|
2287
|
-
function prefersDarkMode(
|
|
2288
|
-
return mediaQuery('(prefers-color-scheme: dark)',
|
|
2452
|
+
function prefersDarkMode(opt) {
|
|
2453
|
+
return mediaQuery('(prefers-color-scheme: dark)', opt);
|
|
2289
2454
|
}
|
|
2290
2455
|
/**
|
|
2291
2456
|
* Creates a read-only signal that tracks the user's OS/browser preference
|
|
@@ -2312,8 +2477,8 @@ function prefersDarkMode(debugName) {
|
|
|
2312
2477
|
* });
|
|
2313
2478
|
* ```
|
|
2314
2479
|
*/
|
|
2315
|
-
function prefersReducedMotion(
|
|
2316
|
-
return mediaQuery('(prefers-reduced-motion: reduce)',
|
|
2480
|
+
function prefersReducedMotion(opt) {
|
|
2481
|
+
return mediaQuery('(prefers-reduced-motion: reduce)', opt);
|
|
2317
2482
|
}
|
|
2318
2483
|
|
|
2319
2484
|
/**
|
|
@@ -2362,6 +2527,7 @@ function throttled(initial, opt) {
|
|
|
2362
2527
|
* // after the 500ms cooldown.
|
|
2363
2528
|
*/
|
|
2364
2529
|
function throttle(source, opt) {
|
|
2530
|
+
const eq = opt?.equal ?? getSignalEquality(source);
|
|
2365
2531
|
const ms = opt?.ms ?? 0;
|
|
2366
2532
|
const leading = opt?.leading ?? false;
|
|
2367
2533
|
const trailing = opt?.trailing ?? true;
|
|
@@ -2387,31 +2553,32 @@ function throttle(source, opt) {
|
|
|
2387
2553
|
fire();
|
|
2388
2554
|
else
|
|
2389
2555
|
pendingTrailing = trailing;
|
|
2390
|
-
|
|
2556
|
+
const onWindowEnd = () => {
|
|
2391
2557
|
timeout = undefined;
|
|
2392
2558
|
if (trailing && pendingTrailing) {
|
|
2393
2559
|
pendingTrailing = false;
|
|
2394
2560
|
fire();
|
|
2561
|
+
timeout = setTimeout(onWindowEnd, ms);
|
|
2395
2562
|
}
|
|
2396
|
-
}
|
|
2563
|
+
};
|
|
2564
|
+
timeout = setTimeout(onWindowEnd, ms);
|
|
2397
2565
|
return;
|
|
2398
2566
|
}
|
|
2399
2567
|
if (trailing)
|
|
2400
2568
|
pendingTrailing = true;
|
|
2401
2569
|
};
|
|
2402
|
-
const set = (
|
|
2403
|
-
source
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
const update = (fn) => {
|
|
2407
|
-
source.update(fn);
|
|
2570
|
+
const set = (next) => {
|
|
2571
|
+
if (eq(untracked(source), next))
|
|
2572
|
+
return;
|
|
2573
|
+
source.set(next);
|
|
2408
2574
|
tick();
|
|
2409
2575
|
};
|
|
2576
|
+
const update = (fn) => set(fn(untracked(source)));
|
|
2410
2577
|
const writable = toWritable(computed(() => {
|
|
2411
2578
|
trigger();
|
|
2412
2579
|
return untracked(source);
|
|
2413
2580
|
}, opt), set, update);
|
|
2414
|
-
writable.original = source;
|
|
2581
|
+
writable.original = source.asReadonly();
|
|
2415
2582
|
return writable;
|
|
2416
2583
|
}
|
|
2417
2584
|
|
|
@@ -2448,6 +2615,9 @@ function throttle(source, opt) {
|
|
|
2448
2615
|
* ```
|
|
2449
2616
|
*/
|
|
2450
2617
|
function mousePosition(opt) {
|
|
2618
|
+
return runInSensorContext(opt?.injector, () => createMousePosition(opt));
|
|
2619
|
+
}
|
|
2620
|
+
function createMousePosition(opt) {
|
|
2451
2621
|
if (isPlatformServer(inject(PLATFORM_ID))) {
|
|
2452
2622
|
const base = computed(() => ({
|
|
2453
2623
|
x: 0,
|
|
@@ -2459,8 +2629,12 @@ function mousePosition(opt) {
|
|
|
2459
2629
|
return base;
|
|
2460
2630
|
}
|
|
2461
2631
|
const { target = window, coordinateSpace = 'client', touch = false, debugName = 'mousePosition', throttle = 100, } = opt ?? {};
|
|
2462
|
-
const
|
|
2463
|
-
|
|
2632
|
+
const resolve = (t) => {
|
|
2633
|
+
if (!t)
|
|
2634
|
+
return null;
|
|
2635
|
+
return t instanceof ElementRef ? t.nativeElement : t;
|
|
2636
|
+
};
|
|
2637
|
+
if (!isSignal(target) && !resolve(target)) {
|
|
2464
2638
|
if (isDevMode())
|
|
2465
2639
|
console.warn('mousePosition: Target element not found.');
|
|
2466
2640
|
const base = computed(() => ({
|
|
@@ -2483,7 +2657,7 @@ function mousePosition(opt) {
|
|
|
2483
2657
|
x = coordinateSpace === 'page' ? event.pageX : event.clientX;
|
|
2484
2658
|
y = coordinateSpace === 'page' ? event.pageY : event.clientY;
|
|
2485
2659
|
}
|
|
2486
|
-
else if (event.touches
|
|
2660
|
+
else if (event.touches?.length > 0) {
|
|
2487
2661
|
const firstTouch = event.touches[0];
|
|
2488
2662
|
x = coordinateSpace === 'page' ? firstTouch.pageX : firstTouch.clientX;
|
|
2489
2663
|
y = coordinateSpace === 'page' ? firstTouch.pageY : firstTouch.clientY;
|
|
@@ -2493,16 +2667,36 @@ function mousePosition(opt) {
|
|
|
2493
2667
|
}
|
|
2494
2668
|
pos.set({ x, y });
|
|
2495
2669
|
};
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
2670
|
+
// passive: the handler never calls preventDefault, and a non-passive touchmove on
|
|
2671
|
+
// window forces the browser to wait on JS before scrolling (scroll jank on touch)
|
|
2672
|
+
const attach = (el) => {
|
|
2673
|
+
const controller = new AbortController();
|
|
2674
|
+
el.addEventListener('mousemove', updatePosition, {
|
|
2675
|
+
passive: true,
|
|
2676
|
+
signal: controller.signal,
|
|
2677
|
+
});
|
|
2502
2678
|
if (touch) {
|
|
2503
|
-
|
|
2679
|
+
el.addEventListener('touchmove', updatePosition, {
|
|
2680
|
+
passive: true,
|
|
2681
|
+
signal: controller.signal,
|
|
2682
|
+
});
|
|
2504
2683
|
}
|
|
2505
|
-
|
|
2684
|
+
return () => controller.abort();
|
|
2685
|
+
};
|
|
2686
|
+
if (isSignal(target)) {
|
|
2687
|
+
// re-attach whenever the signal resolves to a (new) element — covers viewChild
|
|
2688
|
+
effect((cleanup) => {
|
|
2689
|
+
const el = resolve(target());
|
|
2690
|
+
if (!el)
|
|
2691
|
+
return;
|
|
2692
|
+
cleanup(attach(el));
|
|
2693
|
+
});
|
|
2694
|
+
}
|
|
2695
|
+
else {
|
|
2696
|
+
const el = resolve(target);
|
|
2697
|
+
if (el)
|
|
2698
|
+
inject(DestroyRef).onDestroy(attach(el));
|
|
2699
|
+
}
|
|
2506
2700
|
const base = pos.asReadonly();
|
|
2507
2701
|
base.unthrottled = pos.original;
|
|
2508
2702
|
return base;
|
|
@@ -2516,7 +2710,8 @@ const serverDate = new Date();
|
|
|
2516
2710
|
* An additional `since` signal is attached, tracking when the status last changed.
|
|
2517
2711
|
* It's SSR-safe and automatically cleans up its event listeners.
|
|
2518
2712
|
*
|
|
2519
|
-
* @param
|
|
2713
|
+
* @param opt Optional debug name for the signal, or a {@link SensorRunOptions} object
|
|
2714
|
+
* (with an optional `injector` for creation outside an injection context).
|
|
2520
2715
|
* @returns A `NetworkStatusSignal` instance.
|
|
2521
2716
|
*
|
|
2522
2717
|
* @example
|
|
@@ -2527,7 +2722,11 @@ const serverDate = new Date();
|
|
|
2527
2722
|
* });
|
|
2528
2723
|
* ```
|
|
2529
2724
|
*/
|
|
2530
|
-
function networkStatus(
|
|
2725
|
+
function networkStatus(opt) {
|
|
2726
|
+
const { debugName = 'networkStatus', injector } = coerceSensorOptions(opt);
|
|
2727
|
+
return runInSensorContext(injector, () => createNetworkStatus(debugName));
|
|
2728
|
+
}
|
|
2729
|
+
function createNetworkStatus(debugName) {
|
|
2531
2730
|
if (isPlatformServer(inject(PLATFORM_ID))) {
|
|
2532
2731
|
const sig = computed(() => true, {
|
|
2533
2732
|
debugName,
|
|
@@ -2577,7 +2776,11 @@ const SSR_FALLBACK = {
|
|
|
2577
2776
|
* });
|
|
2578
2777
|
* ```
|
|
2579
2778
|
*/
|
|
2580
|
-
function orientation(
|
|
2779
|
+
function orientation(opt) {
|
|
2780
|
+
const { debugName = 'orientation', injector } = coerceSensorOptions(opt);
|
|
2781
|
+
return runInSensorContext(injector, () => createOrientation(debugName));
|
|
2782
|
+
}
|
|
2783
|
+
function createOrientation(debugName) {
|
|
2581
2784
|
if (isPlatformServer(inject(PLATFORM_ID)) ||
|
|
2582
2785
|
typeof screen === 'undefined' ||
|
|
2583
2786
|
!screen.orientation) {
|
|
@@ -2606,7 +2809,8 @@ function orientation(debugName = 'orientation') {
|
|
|
2606
2809
|
* The primitive is SSR-safe and automatically cleans up its event listeners
|
|
2607
2810
|
* when the creating context is destroyed.
|
|
2608
2811
|
*
|
|
2609
|
-
* @param
|
|
2812
|
+
* @param opt Optional debug name for the signal, or a {@link SensorRunOptions} object
|
|
2813
|
+
* (with an optional `injector` for creation outside an injection context).
|
|
2610
2814
|
* @returns A read-only `Signal<DocumentVisibilityState>`. On the server,
|
|
2611
2815
|
* it returns a static signal with a value of `'visible'`.
|
|
2612
2816
|
*
|
|
@@ -2634,7 +2838,11 @@ function orientation(debugName = 'orientation') {
|
|
|
2634
2838
|
* }
|
|
2635
2839
|
* ```
|
|
2636
2840
|
*/
|
|
2637
|
-
function pageVisibility(
|
|
2841
|
+
function pageVisibility(opt) {
|
|
2842
|
+
const { debugName = 'pageVisibility', injector } = coerceSensorOptions(opt);
|
|
2843
|
+
return runInSensorContext(injector, () => createPageVisibility(debugName));
|
|
2844
|
+
}
|
|
2845
|
+
function createPageVisibility(debugName) {
|
|
2638
2846
|
if (isPlatformServer(inject(PLATFORM_ID))) {
|
|
2639
2847
|
return computed(() => 'visible', { debugName });
|
|
2640
2848
|
}
|
|
@@ -2666,31 +2874,25 @@ function pageVisibility(debugName = 'pageVisibility') {
|
|
|
2666
2874
|
* selector: 'app-scroll-tracker',
|
|
2667
2875
|
* template: `
|
|
2668
2876
|
* <p>Window Scroll: X: {{ windowScroll().x }}, Y: {{ windowScroll().y }}</p>
|
|
2669
|
-
* <
|
|
2670
|
-
* <div style="height: 400px; width: 400px;">Scroll me!</div>
|
|
2671
|
-
* </div>
|
|
2672
|
-
* @if (divScroll()) {
|
|
2673
|
-
* <p>Div Scroll: X: {{ divScroll().x }}, Y: {{ divScroll().y }}</p>
|
|
2674
|
-
* }
|
|
2877
|
+
* <p>Host Scroll: X: {{ hostScroll().x }}, Y: {{ hostScroll().y }}</p>
|
|
2675
2878
|
* `
|
|
2676
2879
|
* })
|
|
2677
2880
|
* export class ScrollTrackerComponent {
|
|
2678
2881
|
* readonly windowScroll = scrollPosition(); // Defaults to window
|
|
2882
|
+
* // Signal targets (e.g. viewChild) attach once the element exists:
|
|
2679
2883
|
* readonly scrollableDiv = viewChild<ElementRef<HTMLDivElement>>('scrollableDiv');
|
|
2680
|
-
* readonly divScroll = scrollPosition({ target: this.scrollableDiv
|
|
2884
|
+
* readonly divScroll = scrollPosition({ target: this.scrollableDiv });
|
|
2681
2885
|
*
|
|
2682
2886
|
* constructor() {
|
|
2683
|
-
* effect(() =>
|
|
2684
|
-
* console.log('Window scrolled to:', this.windowScroll());
|
|
2685
|
-
* if (this.divScroll()) {
|
|
2686
|
-
* console.log('Div scrolled to:', this.divScroll());
|
|
2687
|
-
* }
|
|
2688
|
-
* });
|
|
2887
|
+
* effect(() => console.log('Window scrolled to:', this.windowScroll()));
|
|
2689
2888
|
* }
|
|
2690
2889
|
* }
|
|
2691
2890
|
* ```
|
|
2692
2891
|
*/
|
|
2693
2892
|
function scrollPosition(opt) {
|
|
2893
|
+
return runInSensorContext(opt?.injector, () => createScrollPosition(opt));
|
|
2894
|
+
}
|
|
2895
|
+
function createScrollPosition(opt) {
|
|
2694
2896
|
if (isPlatformServer(inject(PLATFORM_ID))) {
|
|
2695
2897
|
const base = computed(() => ({
|
|
2696
2898
|
x: 0,
|
|
@@ -2702,40 +2904,44 @@ function scrollPosition(opt) {
|
|
|
2702
2904
|
return base;
|
|
2703
2905
|
}
|
|
2704
2906
|
const { target = window, throttle = 100, debugName = 'scrollPosition', } = opt || {};
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
};
|
|
2720
|
-
};
|
|
2721
|
-
}
|
|
2722
|
-
else {
|
|
2723
|
-
element = target;
|
|
2724
|
-
getScrollPosition = () => {
|
|
2725
|
-
return {
|
|
2726
|
-
x: target.scrollLeft,
|
|
2727
|
-
y: target.scrollTop,
|
|
2728
|
-
};
|
|
2729
|
-
};
|
|
2730
|
-
}
|
|
2731
|
-
const state = throttled(getScrollPosition(), {
|
|
2907
|
+
const resolve = (t) => {
|
|
2908
|
+
if (!t)
|
|
2909
|
+
return null;
|
|
2910
|
+
return t instanceof ElementRef ? t.nativeElement : t;
|
|
2911
|
+
};
|
|
2912
|
+
const isWindow = (el) => el.window === el;
|
|
2913
|
+
const readPosition = (el) => isWindow(el)
|
|
2914
|
+
? {
|
|
2915
|
+
x: el.scrollX ?? el.pageXOffset ?? 0,
|
|
2916
|
+
y: el.scrollY ?? el.pageYOffset ?? 0,
|
|
2917
|
+
}
|
|
2918
|
+
: { x: el.scrollLeft, y: el.scrollTop };
|
|
2919
|
+
const initial = resolve(isSignal(target) ? untracked(target) : target);
|
|
2920
|
+
const state = throttled(initial ? readPosition(initial) : { x: 0, y: 0 }, {
|
|
2732
2921
|
debugName,
|
|
2733
2922
|
equal: (a, b) => a.x === b.x && a.y === b.y,
|
|
2734
2923
|
ms: throttle,
|
|
2735
2924
|
});
|
|
2736
|
-
|
|
2737
|
-
|
|
2738
|
-
|
|
2925
|
+
if (isSignal(target)) {
|
|
2926
|
+
// re-attach whenever the signal resolves to a (new) element — covers viewChild
|
|
2927
|
+
effect((cleanup) => {
|
|
2928
|
+
const el = resolve(target());
|
|
2929
|
+
if (!el)
|
|
2930
|
+
return;
|
|
2931
|
+
state.set(readPosition(el)); // sync to the new element immediately
|
|
2932
|
+
const onScroll = () => state.set(readPosition(el));
|
|
2933
|
+
el.addEventListener('scroll', onScroll, { passive: true });
|
|
2934
|
+
cleanup(() => el.removeEventListener('scroll', onScroll));
|
|
2935
|
+
});
|
|
2936
|
+
}
|
|
2937
|
+
else {
|
|
2938
|
+
const el = resolve(target);
|
|
2939
|
+
if (el) {
|
|
2940
|
+
const onScroll = () => state.set(readPosition(el));
|
|
2941
|
+
el.addEventListener('scroll', onScroll, { passive: true });
|
|
2942
|
+
inject(DestroyRef).onDestroy(() => el.removeEventListener('scroll', onScroll));
|
|
2943
|
+
}
|
|
2944
|
+
}
|
|
2739
2945
|
const base = state.asReadonly();
|
|
2740
2946
|
base.unthrottled = state.original;
|
|
2741
2947
|
return base;
|
|
@@ -2783,6 +2989,9 @@ function scrollPosition(opt) {
|
|
|
2783
2989
|
* ```
|
|
2784
2990
|
*/
|
|
2785
2991
|
function windowSize(opt) {
|
|
2992
|
+
return runInSensorContext(opt?.injector, () => createWindowSize(opt));
|
|
2993
|
+
}
|
|
2994
|
+
function createWindowSize(opt) {
|
|
2786
2995
|
if (isPlatformServer(inject(PLATFORM_ID))) {
|
|
2787
2996
|
const base = computed(() => ({
|
|
2788
2997
|
width: 1024,
|
|
@@ -2819,17 +3028,19 @@ function sensor(type, options) {
|
|
|
2819
3028
|
case 'mousePosition':
|
|
2820
3029
|
return mousePosition(opts);
|
|
2821
3030
|
case 'networkStatus':
|
|
2822
|
-
return networkStatus(opts
|
|
3031
|
+
return networkStatus(opts);
|
|
2823
3032
|
case 'pageVisibility':
|
|
2824
|
-
return pageVisibility(opts
|
|
3033
|
+
return pageVisibility(opts);
|
|
2825
3034
|
case 'darkMode':
|
|
2826
3035
|
case 'dark-mode':
|
|
2827
|
-
return prefersDarkMode(opts
|
|
3036
|
+
return prefersDarkMode(opts);
|
|
2828
3037
|
case 'reducedMotion':
|
|
2829
3038
|
case 'reduced-motion':
|
|
2830
|
-
return prefersReducedMotion(opts
|
|
3039
|
+
return prefersReducedMotion(opts);
|
|
2831
3040
|
case 'mediaQuery':
|
|
2832
|
-
|
|
3041
|
+
if (typeof opts?.query !== 'string')
|
|
3042
|
+
throw new Error(`sensor('mediaQuery') requires a 'query' option, e.g. sensor('mediaQuery', { query: '(min-width: 1024px)' })`);
|
|
3043
|
+
return mediaQuery(opts.query, opts);
|
|
2833
3044
|
case 'windowSize':
|
|
2834
3045
|
return windowSize(opts);
|
|
2835
3046
|
case 'scrollPosition':
|
|
@@ -2841,15 +3052,15 @@ function sensor(type, options) {
|
|
|
2841
3052
|
case 'geolocation':
|
|
2842
3053
|
return geolocation(opts);
|
|
2843
3054
|
case 'clipboard':
|
|
2844
|
-
return clipboard(opts
|
|
3055
|
+
return clipboard(opts);
|
|
2845
3056
|
case 'orientation':
|
|
2846
|
-
return orientation(opts
|
|
3057
|
+
return orientation(opts);
|
|
2847
3058
|
case 'batteryStatus':
|
|
2848
|
-
return batteryStatus(opts
|
|
3059
|
+
return batteryStatus(opts);
|
|
2849
3060
|
case 'idle':
|
|
2850
3061
|
return idle(opts);
|
|
2851
3062
|
case 'focusWithin':
|
|
2852
|
-
return focusWithin(opts?.target);
|
|
3063
|
+
return focusWithin(opts?.target, opts);
|
|
2853
3064
|
default:
|
|
2854
3065
|
throw new Error(`Unknown sensor type: ${type}`);
|
|
2855
3066
|
}
|
|
@@ -2903,16 +3114,24 @@ function signalFromEvent(target, eventName, initial, projectOrOpt, maybeOpt) {
|
|
|
2903
3114
|
else
|
|
2904
3115
|
state.set(event);
|
|
2905
3116
|
};
|
|
2906
|
-
const { destroyRef: providedDestroyRef,
|
|
3117
|
+
const { destroyRef: providedDestroyRef,
|
|
3118
|
+
// strip non-listener keys so they don't leak into addEventListener options
|
|
3119
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
3120
|
+
injector: _injector,
|
|
3121
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
3122
|
+
debugName: _debugName, ...listenerOpts } = opt ?? {};
|
|
2907
3123
|
if (isSignal(target)) {
|
|
2908
3124
|
const targetSig = target;
|
|
2909
|
-
effect((cleanup) => {
|
|
3125
|
+
const effectRef = effect((cleanup) => {
|
|
2910
3126
|
const resolved = unwrap(targetSig());
|
|
2911
3127
|
if (!resolved)
|
|
2912
3128
|
return;
|
|
2913
3129
|
resolved.addEventListener(eventName, handler, listenerOpts);
|
|
2914
3130
|
cleanup(() => resolved.removeEventListener(eventName, handler, listenerOpts));
|
|
2915
3131
|
}, { injector });
|
|
3132
|
+
// honor an explicit destroyRef for signal targets too — the effect would otherwise
|
|
3133
|
+
// only follow the injector's lifetime, contradicting the documented option
|
|
3134
|
+
providedDestroyRef?.onDestroy(() => effectRef.destroy());
|
|
2916
3135
|
}
|
|
2917
3136
|
else {
|
|
2918
3137
|
const resolved = unwrap(target);
|
|
@@ -3001,7 +3220,8 @@ function alwaysFalse() {
|
|
|
3001
3220
|
* @internal Attaches a lazy, memoized leaf probe to a store node. The probe (`() => boolean`)
|
|
3002
3221
|
* closes over the node's value signal and its (stable) vivify setting, building the backing
|
|
3003
3222
|
* `computed` on first call so leaf-ness tracks the live value reactively without taxing every
|
|
3004
|
-
* node access.
|
|
3223
|
+
* node access. Under `noUnionLeaves` the caller promises shapes never flip, so the probe is
|
|
3224
|
+
* resolved once from the first sample and frozen as a constant. Idempotent.
|
|
3005
3225
|
*/
|
|
3006
3226
|
function markAsLeaf(sig, value, vivifyEnabled, noUnionLeaves) {
|
|
3007
3227
|
if (typeof sig[LEAF] !== 'function') {
|
|
@@ -3009,13 +3229,11 @@ function markAsLeaf(sig, value, vivifyEnabled, noUnionLeaves) {
|
|
|
3009
3229
|
const probe = () => {
|
|
3010
3230
|
if (memo)
|
|
3011
3231
|
return memo();
|
|
3012
|
-
|
|
3013
|
-
|
|
3014
|
-
|
|
3015
|
-
|
|
3016
|
-
|
|
3017
|
-
: alwaysFalse
|
|
3018
|
-
: computed(() => isLeafValue(value(), vivifyEnabled));
|
|
3232
|
+
memo = noUnionLeaves
|
|
3233
|
+
? isLeafValue(untracked(value), vivifyEnabled)
|
|
3234
|
+
? alwaysTrue
|
|
3235
|
+
: alwaysFalse
|
|
3236
|
+
: computed(() => isLeafValue(value(), vivifyEnabled));
|
|
3019
3237
|
return memo();
|
|
3020
3238
|
};
|
|
3021
3239
|
Object.defineProperty(sig, LEAF, {
|
|
@@ -3103,6 +3321,40 @@ function resolveVivify(sample, option) {
|
|
|
3103
3321
|
function hasOwnKey(value, key) {
|
|
3104
3322
|
return value != null && Object.hasOwn(value, key);
|
|
3105
3323
|
}
|
|
3324
|
+
/**
|
|
3325
|
+
* @internal
|
|
3326
|
+
* Builds the `onChange` for the fallback (non-record container) derivation branch. For an
|
|
3327
|
+
* immutable source the container is copied before the write — returning the same mutated
|
|
3328
|
+
* reference would let the source's equality cut propagation (leaving child signals permanently
|
|
3329
|
+
* stale) and alias the caller's original object, breaking the structural-sharing contract
|
|
3330
|
+
* `forkStore` relies on. For a mutable source the write goes through `mutate`, so the chain's
|
|
3331
|
+
* force-notify engages (plain `update` with the same reference would never notify).
|
|
3332
|
+
*/
|
|
3333
|
+
function createFallbackOnChange(target, prop, vivifyFn, isMutableSource) {
|
|
3334
|
+
const write = (newValue) => (v) => {
|
|
3335
|
+
const container = vivifyFn(v, prop);
|
|
3336
|
+
if (container === null || container === undefined)
|
|
3337
|
+
return container;
|
|
3338
|
+
const next = isMutableSource
|
|
3339
|
+
? container
|
|
3340
|
+
: Array.isArray(container)
|
|
3341
|
+
? container.slice()
|
|
3342
|
+
: isRecord(container)
|
|
3343
|
+
? { ...container }
|
|
3344
|
+
: container; // non-plain leaf (Date/class instance): legacy in-place attempt
|
|
3345
|
+
try {
|
|
3346
|
+
next[prop] = newValue;
|
|
3347
|
+
}
|
|
3348
|
+
catch (e) {
|
|
3349
|
+
if (isDevMode())
|
|
3350
|
+
console.error(`[store] Failed to set property "${String(prop)}"`, e);
|
|
3351
|
+
}
|
|
3352
|
+
return next;
|
|
3353
|
+
};
|
|
3354
|
+
return isMutableSource
|
|
3355
|
+
? (newValue) => target.mutate(write(newValue))
|
|
3356
|
+
: (newValue) => target.update(write(newValue));
|
|
3357
|
+
}
|
|
3106
3358
|
/**
|
|
3107
3359
|
* @internal
|
|
3108
3360
|
* Makes an array store
|
|
@@ -3125,7 +3377,9 @@ function toArrayStore(source, injector, vivify, noUnionLeaves = false) {
|
|
|
3125
3377
|
const idx = +prop;
|
|
3126
3378
|
return idx >= 0 && idx < untracked(lengthSignal);
|
|
3127
3379
|
}
|
|
3128
|
-
|
|
3380
|
+
const v = untracked(source);
|
|
3381
|
+
// nullish node values are routinely descended with vivify on — `in` must not throw
|
|
3382
|
+
return v == null ? false : Reflect.has(v, prop);
|
|
3129
3383
|
},
|
|
3130
3384
|
ownKeys() {
|
|
3131
3385
|
const v = untracked(source);
|
|
@@ -3162,7 +3416,9 @@ function toArrayStore(source, injector, vivify, noUnionLeaves = false) {
|
|
|
3162
3416
|
return lengthSignal;
|
|
3163
3417
|
if (prop === Symbol.iterator) {
|
|
3164
3418
|
return function* () {
|
|
3165
|
-
|
|
3419
|
+
// read length reactively: a spread/for-of inside a computed/effect must re-run
|
|
3420
|
+
// when items are added or removed, not only when already-read elements change
|
|
3421
|
+
for (let i = 0; i < lengthSignal(); i++) {
|
|
3166
3422
|
yield receiver[i];
|
|
3167
3423
|
}
|
|
3168
3424
|
};
|
|
@@ -3201,19 +3457,8 @@ function toArrayStore(source, injector, vivify, noUnionLeaves = false) {
|
|
|
3201
3457
|
})
|
|
3202
3458
|
: derived(target, {
|
|
3203
3459
|
from: (v) => v?.[idx],
|
|
3204
|
-
onChange: (
|
|
3205
|
-
|
|
3206
|
-
if (container === null || container === undefined)
|
|
3207
|
-
return container;
|
|
3208
|
-
try {
|
|
3209
|
-
container[idx] = newValue;
|
|
3210
|
-
}
|
|
3211
|
-
catch (e) {
|
|
3212
|
-
if (isDevMode())
|
|
3213
|
-
console.error(`[store] Failed to set property "${String(idx)}"`, e);
|
|
3214
|
-
}
|
|
3215
|
-
return container;
|
|
3216
|
-
}),
|
|
3460
|
+
onChange: createFallbackOnChange(target, idx, vivifyFn, isMutableSource),
|
|
3461
|
+
equal: equalFn,
|
|
3217
3462
|
});
|
|
3218
3463
|
const childSample = untracked(computation);
|
|
3219
3464
|
const childVivify = resolveVivify(childSample, vivify);
|
|
@@ -3233,6 +3478,13 @@ function toArrayStore(source, injector, vivify, noUnionLeaves = false) {
|
|
|
3233
3478
|
/**
|
|
3234
3479
|
* Converts a Signal into a deep-observable Store.
|
|
3235
3480
|
* Accessing nested properties returns a derived Signal of that path.
|
|
3481
|
+
*
|
|
3482
|
+
* @remarks
|
|
3483
|
+
* A child's *container kind* (array store vs object store) is resolved when the child is
|
|
3484
|
+
* first accessed and cached with the proxy. Leaf↔substore flips are tracked reactively, but a
|
|
3485
|
+
* union-typed node that later flips between an array and a record keeps its original trap set —
|
|
3486
|
+
* if you need that, re-model the union as `{ kind: ..., value: ... }` instead.
|
|
3487
|
+
*
|
|
3236
3488
|
* @example
|
|
3237
3489
|
* const state = store({ user: { name: 'John' } });
|
|
3238
3490
|
* const nameSignal = state.user.name; // WritableSignal<string>
|
|
@@ -3315,19 +3567,8 @@ function toStore(source, injector, vivify = false, noUnionLeaves = false) {
|
|
|
3315
3567
|
? derived(target, prop, { equal: equalFn, vivify: nodeVivify })
|
|
3316
3568
|
: derived(target, {
|
|
3317
3569
|
from: (v) => v?.[prop],
|
|
3318
|
-
onChange: (
|
|
3319
|
-
|
|
3320
|
-
if (container === null || container === undefined)
|
|
3321
|
-
return container;
|
|
3322
|
-
try {
|
|
3323
|
-
container[prop] = newValue;
|
|
3324
|
-
}
|
|
3325
|
-
catch (e) {
|
|
3326
|
-
if (isDevMode())
|
|
3327
|
-
console.error(`[store] Failed to set property "${String(prop)}"`, e);
|
|
3328
|
-
}
|
|
3329
|
-
return container;
|
|
3330
|
-
}),
|
|
3570
|
+
onChange: createFallbackOnChange(target, prop, vivifyFn, isMutableSource),
|
|
3571
|
+
equal: equalFn,
|
|
3331
3572
|
});
|
|
3332
3573
|
const childSample = untracked(computation);
|
|
3333
3574
|
const childVivify = resolveVivify(childSample, vivify);
|
|
@@ -3469,7 +3710,12 @@ function merge3(ancestor, mine, theirs) {
|
|
|
3469
3710
|
if (isPlainRecord(mine) && isPlainRecord(theirs) && isPlainRecord(ancestor)) {
|
|
3470
3711
|
const out = { ...theirs };
|
|
3471
3712
|
for (const key of new Set([...Object.keys(mine), ...Object.keys(theirs)])) {
|
|
3472
|
-
|
|
3713
|
+
const merged = merge3(ancestor[key], mine[key], theirs[key]);
|
|
3714
|
+
// a key deleted on the fork must commit as ABSENT, not as an explicit `undefined`
|
|
3715
|
+
if (merged === undefined && !(key in mine))
|
|
3716
|
+
delete out[key];
|
|
3717
|
+
else
|
|
3718
|
+
out[key] = merged;
|
|
3473
3719
|
}
|
|
3474
3720
|
return out;
|
|
3475
3721
|
}
|
|
@@ -3523,8 +3769,8 @@ const noopStore = {
|
|
|
3523
3769
|
*
|
|
3524
3770
|
* @template T The type of value held by the signal and stored (after serialization).
|
|
3525
3771
|
* @param fallback The default value of type `T` to use when no value is found in storage
|
|
3526
|
-
* or when deserialization fails.
|
|
3527
|
-
*
|
|
3772
|
+
* or when deserialization fails. A stored value (including a legitimate `null` for a
|
|
3773
|
+
* nullable `T`) always round-trips; the fallback only surfaces when the entry is absent.
|
|
3528
3774
|
* @param options Configuration options (`CreateStoredOptions<T>`). Requires at least the `key`.
|
|
3529
3775
|
* @returns A `StoredSignal<T>` instance. This signal behaves like a standard `WritableSignal<T>`,
|
|
3530
3776
|
* but its value is persisted. It includes a `.clear()` method to remove the item from storage
|
|
@@ -3537,7 +3783,8 @@ const noopStore = {
|
|
|
3537
3783
|
* - **Error Handling:** Catches and logs errors during serialization/deserialization in dev mode.
|
|
3538
3784
|
* - **Tab Sync:** If `syncTabs` is true, listens to `storage` events to keep the signal value
|
|
3539
3785
|
* consistent across browser tabs using the same key. Cleanup is handled automatically
|
|
3540
|
-
* using `DestroyRef`.
|
|
3786
|
+
* using `DestroyRef`. Web Storage only: the `storage` event never fires for custom `store`
|
|
3787
|
+
* adapters, so `syncTabs` has no effect with one.
|
|
3541
3788
|
* - **Removal:** Use the `.clear()` method on the returned signal to remove the item from storage.
|
|
3542
3789
|
* Setting the signal to the fallback value will store the fallback value, not remove the item.
|
|
3543
3790
|
*
|
|
@@ -3572,25 +3819,28 @@ function stored(fallback, { key, store: providedStore, serialize = JSON.stringif
|
|
|
3572
3819
|
: isSignal(key)
|
|
3573
3820
|
? key
|
|
3574
3821
|
: computed(key);
|
|
3822
|
+
// "no stored value" marker — distinct from `null`/`undefined`, so a nullable `T` can
|
|
3823
|
+
// round-trip a legitimate `null` through `set` instead of it acting like `clear()`
|
|
3824
|
+
const EMPTY = Symbol();
|
|
3575
3825
|
const getValue = (key) => {
|
|
3576
3826
|
const found = store.getItem(key);
|
|
3577
3827
|
if (found === null)
|
|
3578
|
-
return
|
|
3828
|
+
return EMPTY;
|
|
3579
3829
|
try {
|
|
3580
3830
|
const deserialized = deserialize(found);
|
|
3581
3831
|
if (!validate(deserialized))
|
|
3582
|
-
return
|
|
3832
|
+
return EMPTY;
|
|
3583
3833
|
return deserialized;
|
|
3584
3834
|
}
|
|
3585
3835
|
catch (err) {
|
|
3586
3836
|
if (isDevMode())
|
|
3587
3837
|
console.error(`Failed to parse stored value for key "${key}":`, err);
|
|
3588
|
-
return
|
|
3838
|
+
return EMPTY;
|
|
3589
3839
|
}
|
|
3590
3840
|
};
|
|
3591
3841
|
const storeValue = (key, value) => {
|
|
3592
3842
|
try {
|
|
3593
|
-
if (value ===
|
|
3843
|
+
if (value === EMPTY)
|
|
3594
3844
|
return store.removeItem(key);
|
|
3595
3845
|
const serialized = serialize(value);
|
|
3596
3846
|
store.setItem(key, serialized);
|
|
@@ -3608,9 +3858,9 @@ function stored(fallback, { key, store: providedStore, serialize = JSON.stringif
|
|
|
3608
3858
|
const internal = signal(getValue(initialKey), {
|
|
3609
3859
|
...opt,
|
|
3610
3860
|
equal: (a, b) => {
|
|
3611
|
-
if (a ===
|
|
3861
|
+
if (a === EMPTY && b === EMPTY)
|
|
3612
3862
|
return true;
|
|
3613
|
-
if (a ===
|
|
3863
|
+
if (a === EMPTY || b === EMPTY)
|
|
3614
3864
|
return false;
|
|
3615
3865
|
return equal(a, b);
|
|
3616
3866
|
},
|
|
@@ -3646,19 +3896,27 @@ function stored(fallback, { key, store: providedStore, serialize = JSON.stringif
|
|
|
3646
3896
|
if (syncTabs && !isServer) {
|
|
3647
3897
|
const destroyRef = inject(DestroyRef);
|
|
3648
3898
|
const sync = (e) => {
|
|
3899
|
+
// `storage` events only describe Web Storage — ignore events for a different
|
|
3900
|
+
// storage area (or any event when a custom adapter is configured), otherwise an
|
|
3901
|
+
// unrelated localStorage write with the same key string corrupts our state
|
|
3902
|
+
if (e.storageArea !== store)
|
|
3903
|
+
return;
|
|
3649
3904
|
if (e.key !== untracked(keySig))
|
|
3650
3905
|
return;
|
|
3651
3906
|
if (e.newValue === null)
|
|
3652
|
-
internal.set(
|
|
3907
|
+
internal.set(EMPTY);
|
|
3653
3908
|
else
|
|
3654
3909
|
internal.set(getValue(e.key));
|
|
3655
3910
|
};
|
|
3656
3911
|
window.addEventListener('storage', sync);
|
|
3657
3912
|
destroyRef.onDestroy(() => window.removeEventListener('storage', sync));
|
|
3658
3913
|
}
|
|
3659
|
-
const writable = toWritable(computed(() =>
|
|
3914
|
+
const writable = toWritable(computed(() => {
|
|
3915
|
+
const v = internal();
|
|
3916
|
+
return v === EMPTY ? fallback : v;
|
|
3917
|
+
}, opt), internal.set);
|
|
3660
3918
|
writable.clear = () => {
|
|
3661
|
-
internal.set(
|
|
3919
|
+
internal.set(EMPTY);
|
|
3662
3920
|
};
|
|
3663
3921
|
writable.key = keySig;
|
|
3664
3922
|
return writable;
|
|
@@ -3668,7 +3926,6 @@ class MessageBus {
|
|
|
3668
3926
|
channel = new BroadcastChannel('mmstack-tab-sync-bus');
|
|
3669
3927
|
listeners = new Map();
|
|
3670
3928
|
subscribe(id, listener) {
|
|
3671
|
-
this.unsubscribe(id); // Ensure no duplicate listeners
|
|
3672
3929
|
const wrapped = (ev) => {
|
|
3673
3930
|
try {
|
|
3674
3931
|
if (ev.data?.id === id)
|
|
@@ -3679,18 +3936,28 @@ class MessageBus {
|
|
|
3679
3936
|
}
|
|
3680
3937
|
};
|
|
3681
3938
|
this.channel.addEventListener('message', wrapped);
|
|
3682
|
-
this.listeners.
|
|
3939
|
+
let set = this.listeners.get(id);
|
|
3940
|
+
if (!set) {
|
|
3941
|
+
set = new Set();
|
|
3942
|
+
this.listeners.set(id, set);
|
|
3943
|
+
}
|
|
3944
|
+
set.add(wrapped);
|
|
3683
3945
|
return {
|
|
3684
|
-
unsub: (
|
|
3685
|
-
|
|
3946
|
+
unsub: () => {
|
|
3947
|
+
this.channel.removeEventListener('message', wrapped);
|
|
3948
|
+
const cur = this.listeners.get(id);
|
|
3949
|
+
if (!cur)
|
|
3950
|
+
return;
|
|
3951
|
+
cur.delete(wrapped);
|
|
3952
|
+
if (cur.size === 0)
|
|
3953
|
+
this.listeners.delete(id);
|
|
3954
|
+
},
|
|
3955
|
+
post: (value) => this.channel.postMessage({ id, value }),
|
|
3686
3956
|
};
|
|
3687
3957
|
}
|
|
3688
|
-
|
|
3689
|
-
|
|
3690
|
-
|
|
3691
|
-
return;
|
|
3692
|
-
this.channel.removeEventListener('message', listener);
|
|
3693
|
-
this.listeners.delete(id);
|
|
3958
|
+
ngOnDestroy() {
|
|
3959
|
+
this.channel.close();
|
|
3960
|
+
this.listeners.clear();
|
|
3694
3961
|
}
|
|
3695
3962
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.19", ngImport: i0, type: MessageBus, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
3696
3963
|
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.2.19", ngImport: i0, type: MessageBus, providedIn: 'root' });
|
|
@@ -3701,6 +3968,11 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.19", ngImpo
|
|
|
3701
3968
|
providedIn: 'root',
|
|
3702
3969
|
}]
|
|
3703
3970
|
}] });
|
|
3971
|
+
/**
|
|
3972
|
+
* @deprecated The generated id hashes the call-site stack line, which collides when a shared
|
|
3973
|
+
* helper calls {@link tabSync} for multiple signals and diverges across minified builds during
|
|
3974
|
+
* a rolling deploy. Pass an explicit `{ id }` instead.
|
|
3975
|
+
*/
|
|
3704
3976
|
function generateDeterministicID() {
|
|
3705
3977
|
const stack = new Error().stack;
|
|
3706
3978
|
if (stack) {
|
|
@@ -3738,10 +4010,8 @@ function generateDeterministicID() {
|
|
|
3738
4010
|
*
|
|
3739
4011
|
* @example
|
|
3740
4012
|
* ```typescript
|
|
3741
|
-
* //
|
|
3742
|
-
* const theme = tabSync(signal('dark'));
|
|
3743
|
-
*
|
|
3744
|
-
* // With explicit ID (recommended for production)
|
|
4013
|
+
* // With explicit ID (recommended)
|
|
4014
|
+
* const theme = tabSync(signal('dark'), { id: 'theme' });
|
|
3745
4015
|
* const userPrefs = tabSync(signal({ lang: 'en' }), { id: 'user-preferences' });
|
|
3746
4016
|
*
|
|
3747
4017
|
* // Changes in one tab will sync to all other tabs
|
|
@@ -3753,6 +4023,7 @@ function generateDeterministicID() {
|
|
|
3753
4023
|
* - Uses a single BroadcastChannel for all synchronized signals
|
|
3754
4024
|
* - Automatically cleans up listeners when the injection context is destroyed
|
|
3755
4025
|
* - Initial signal value after sync setup is not broadcasted to prevent loops
|
|
4026
|
+
* - Received values are not re-broadcast, so tabs never echo each other's updates
|
|
3756
4027
|
*
|
|
3757
4028
|
*/
|
|
3758
4029
|
function tabSync(sig, opt) {
|
|
@@ -3760,7 +4031,20 @@ function tabSync(sig, opt) {
|
|
|
3760
4031
|
return sig;
|
|
3761
4032
|
const id = typeof opt === 'string' ? opt : (opt?.id ?? generateDeterministicID());
|
|
3762
4033
|
const bus = inject(MessageBus);
|
|
3763
|
-
|
|
4034
|
+
// The last value applied from a remote tab. The outbound effect skips (exactly) the run
|
|
4035
|
+
// caused by that write — without this, an inbound object (a fresh structured clone, so
|
|
4036
|
+
// never reference-equal) would be re-posted, and two tabs would ping-pong forever.
|
|
4037
|
+
const NONE = Symbol();
|
|
4038
|
+
let received = NONE;
|
|
4039
|
+
const { unsub, post } = bus.subscribe(id, (next) => {
|
|
4040
|
+
const before = untracked(sig);
|
|
4041
|
+
received = next;
|
|
4042
|
+
sig.set(next);
|
|
4043
|
+
// Equality-suppressed write (e.g. an identical primitive): no effect run will follow,
|
|
4044
|
+
// so clear the marker — it must not swallow a later, genuinely local change.
|
|
4045
|
+
if (untracked(sig) === before)
|
|
4046
|
+
received = NONE;
|
|
4047
|
+
});
|
|
3764
4048
|
let first = false;
|
|
3765
4049
|
const effectRef = effect(() => {
|
|
3766
4050
|
const val = sig();
|
|
@@ -3768,6 +4052,11 @@ function tabSync(sig, opt) {
|
|
|
3768
4052
|
first = true;
|
|
3769
4053
|
return;
|
|
3770
4054
|
}
|
|
4055
|
+
if (val === received) {
|
|
4056
|
+
received = NONE;
|
|
4057
|
+
return;
|
|
4058
|
+
}
|
|
4059
|
+
received = NONE;
|
|
3771
4060
|
post(val);
|
|
3772
4061
|
});
|
|
3773
4062
|
inject(DestroyRef).onDestroy(() => {
|
|
@@ -3778,7 +4067,6 @@ function tabSync(sig, opt) {
|
|
|
3778
4067
|
}
|
|
3779
4068
|
|
|
3780
4069
|
function until(sourceSignal, predicate, options = {}) {
|
|
3781
|
-
const injector = options.injector ?? inject(Injector);
|
|
3782
4070
|
return new Promise((resolve, reject) => {
|
|
3783
4071
|
let effectRef;
|
|
3784
4072
|
let timeoutId;
|
|
@@ -3815,6 +4103,14 @@ function until(sourceSignal, predicate, options = {}) {
|
|
|
3815
4103
|
cleanupAndResolve(initialValue);
|
|
3816
4104
|
return;
|
|
3817
4105
|
}
|
|
4106
|
+
let injector;
|
|
4107
|
+
try {
|
|
4108
|
+
injector = options.injector ?? inject(Injector);
|
|
4109
|
+
}
|
|
4110
|
+
catch {
|
|
4111
|
+
cleanupAndReject('until: No injector available — provide options.injector when calling outside an injection context.');
|
|
4112
|
+
return;
|
|
4113
|
+
}
|
|
3818
4114
|
if (options?.timeout !== undefined) {
|
|
3819
4115
|
timeoutId = setTimeout(() => cleanupAndReject(`until: Timeout after ${options.timeout}ms.`), options.timeout);
|
|
3820
4116
|
}
|
|
@@ -3832,17 +4128,6 @@ function until(sourceSignal, predicate, options = {}) {
|
|
|
3832
4128
|
});
|
|
3833
4129
|
}
|
|
3834
4130
|
|
|
3835
|
-
/**
|
|
3836
|
-
* @interal
|
|
3837
|
-
*/
|
|
3838
|
-
function getSignalEquality(sig) {
|
|
3839
|
-
const internal = sig[SIGNAL];
|
|
3840
|
-
if (internal && typeof internal.equal === 'function') {
|
|
3841
|
-
return internal.equal;
|
|
3842
|
-
}
|
|
3843
|
-
return Object.is; // Default equality check
|
|
3844
|
-
}
|
|
3845
|
-
|
|
3846
4131
|
/**
|
|
3847
4132
|
* Enhances an existing `WritableSignal` by adding a complete undo/redo history
|
|
3848
4133
|
* stack and an API to control it.
|
|
@@ -3891,9 +4176,10 @@ function getSignalEquality(sig) {
|
|
|
3891
4176
|
* ```
|
|
3892
4177
|
*/
|
|
3893
4178
|
function withHistory(sourceOrValue, opt) {
|
|
3894
|
-
const equal =
|
|
3895
|
-
|
|
3896
|
-
|
|
4179
|
+
const equal = opt?.equal ??
|
|
4180
|
+
(isSignal(sourceOrValue)
|
|
4181
|
+
? getSignalEquality(sourceOrValue)
|
|
4182
|
+
: Object.is);
|
|
3897
4183
|
const source = isSignal(sourceOrValue)
|
|
3898
4184
|
? sourceOrValue
|
|
3899
4185
|
: signal(sourceOrValue);
|
|
@@ -3938,9 +4224,8 @@ function withHistory(sourceOrValue, opt) {
|
|
|
3938
4224
|
if (historyStack.length === 0)
|
|
3939
4225
|
return;
|
|
3940
4226
|
const valueForRedo = untracked(source);
|
|
3941
|
-
|
|
3942
|
-
|
|
3943
|
-
return;
|
|
4227
|
+
// length checked above — a legitimately `undefined` entry must still restore
|
|
4228
|
+
const valueToRestore = historyStack[historyStack.length - 1];
|
|
3944
4229
|
originalSet.call(source, valueToRestore);
|
|
3945
4230
|
history.inline((h) => h.pop());
|
|
3946
4231
|
redoArray.mutate((r) => {
|
|
@@ -3954,9 +4239,8 @@ function withHistory(sourceOrValue, opt) {
|
|
|
3954
4239
|
if (redoStack.length === 0)
|
|
3955
4240
|
return;
|
|
3956
4241
|
const valueForUndo = untracked(source);
|
|
3957
|
-
|
|
3958
|
-
|
|
3959
|
-
return;
|
|
4242
|
+
// length checked above — a legitimately `undefined` entry must still restore
|
|
4243
|
+
const valueToRestore = redoStack[redoStack.length - 1];
|
|
3960
4244
|
originalSet.call(source, valueToRestore);
|
|
3961
4245
|
redoArray.inline((r) => r.pop());
|
|
3962
4246
|
history.mutate((h) => {
|