@mmstack/primitives 20.6.1 → 20.6.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 +531 -246
- package/fesm2022/mmstack-primitives.mjs.map +1 -1
- package/index.d.ts +111 -104
- 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) => {
|
|
@@ -266,7 +273,9 @@ class MmActivity {
|
|
|
266
273
|
});
|
|
267
274
|
}
|
|
268
275
|
for (const node of this.view.rootNodes) {
|
|
269
|
-
|
|
276
|
+
// covers HTML and SVG roots; text/comment roots can't be styled — their CD is still
|
|
277
|
+
// detached, but prefer an element root for true visual hiding
|
|
278
|
+
if (node instanceof HTMLElement || node instanceof SVGElement)
|
|
270
279
|
node.style.display = visible ? '' : 'none';
|
|
271
280
|
}
|
|
272
281
|
if (visible)
|
|
@@ -345,14 +354,25 @@ function resolvePause(opt) {
|
|
|
345
354
|
if (pause === false)
|
|
346
355
|
return null;
|
|
347
356
|
const run = (fn) => opt?.injector ? runInInjectionContext(opt.injector, fn) : fn();
|
|
357
|
+
// `inject` requires an injection context even with `optional: true`. A bare
|
|
358
|
+
// `pausableSignal(0)` (documented as "like `signal`") must degrade to the unwrapped
|
|
359
|
+
// primitive outside DI, not throw NG0203 — so injection failures fall back gracefully.
|
|
360
|
+
const tryRun = (fn, fallback) => {
|
|
361
|
+
try {
|
|
362
|
+
return run(fn);
|
|
363
|
+
}
|
|
364
|
+
catch {
|
|
365
|
+
return fallback;
|
|
366
|
+
}
|
|
367
|
+
};
|
|
348
368
|
const onServer = () => typeof pause === 'function' && !opt?.injector
|
|
349
369
|
? typeof globalThis.window === 'undefined'
|
|
350
|
-
:
|
|
370
|
+
: tryRun(() => isPlatformServer(inject(PLATFORM_ID, { optional: true }) ?? 'browser'), typeof globalThis.window === 'undefined');
|
|
351
371
|
if (typeof pause === 'function')
|
|
352
372
|
return onServer() ? null : pause;
|
|
353
373
|
if (onServer())
|
|
354
374
|
return null;
|
|
355
|
-
const paused =
|
|
375
|
+
const paused = tryRun(() => inject(PAUSED_CONTEXT, { optional: true }), null);
|
|
356
376
|
if (!paused) {
|
|
357
377
|
if (explicit === true && isDevMode())
|
|
358
378
|
console.warn('[pausable] `pause: true` but no PAUSED_CONTEXT in scope — not pausing. Provide one via an ' +
|
|
@@ -381,8 +401,9 @@ function pausableEffect(effectFn, options) {
|
|
|
381
401
|
/**
|
|
382
402
|
* Like `signal`, but pausable. While paused, READS hold the last value; writes still land on the
|
|
383
403
|
* underlying signal and surface on resume. Built on the `keepPrevious`/`hold` shape — a
|
|
384
|
-
* `linkedSignal` gated on the pause predicate, with `set`/`update
|
|
385
|
-
*
|
|
404
|
+
* `linkedSignal` gated on the pause predicate, with `set`/`update` forwarded to the source signal.
|
|
405
|
+
* `asReadonly()` returns the held (gated) view, so both views of the signal agree while paused.
|
|
406
|
+
* With no `pause` option it defaults to the ambient `PAUSED_CONTEXT`; `pause: false`
|
|
386
407
|
* makes it a plain `signal` — no `linkedSignal` is created.
|
|
387
408
|
*
|
|
388
409
|
* NOTE: while paused, `set(x)` followed by a read returns the *held* (pre-pause) value, not `x` — the
|
|
@@ -403,7 +424,8 @@ function pausableSignal(initialValue, options) {
|
|
|
403
424
|
}]));
|
|
404
425
|
read.set = src.set;
|
|
405
426
|
read.update = src.update;
|
|
406
|
-
|
|
427
|
+
// NOTE: `asReadonly` deliberately stays the linkedSignal's own (the held view) — the
|
|
428
|
+
// source's readonly view would show live values while the signal itself shows held ones.
|
|
407
429
|
return read;
|
|
408
430
|
}
|
|
409
431
|
/**
|
|
@@ -444,8 +466,12 @@ function mutable(initial, opt) {
|
|
|
444
466
|
const internalUpdate = sig.update;
|
|
445
467
|
sig.mutate = (updater) => {
|
|
446
468
|
cnt++;
|
|
447
|
-
|
|
448
|
-
|
|
469
|
+
try {
|
|
470
|
+
internalUpdate(updater);
|
|
471
|
+
}
|
|
472
|
+
finally {
|
|
473
|
+
cnt--;
|
|
474
|
+
}
|
|
449
475
|
};
|
|
450
476
|
sig.inline = (updater) => {
|
|
451
477
|
sig.mutate((prev) => {
|
|
@@ -532,7 +558,7 @@ function createNoopScope() {
|
|
|
532
558
|
hold: (value) => value,
|
|
533
559
|
};
|
|
534
560
|
}
|
|
535
|
-
const TRANSITION_SCOPE = new InjectionToken('@mmstack/
|
|
561
|
+
const TRANSITION_SCOPE = new InjectionToken('@mmstack/primitives:transition-scope');
|
|
536
562
|
/** Provide a fresh transition scope at a boundary so its subtree's resources are tracked independently. */
|
|
537
563
|
function provideTransitionScope() {
|
|
538
564
|
return { provide: TRANSITION_SCOPE, useFactory: createTransitionScope };
|
|
@@ -541,7 +567,7 @@ function injectTransitionScope() {
|
|
|
541
567
|
const scope = inject(TRANSITION_SCOPE, { optional: true });
|
|
542
568
|
if (!scope) {
|
|
543
569
|
if (isDevMode())
|
|
544
|
-
console.warn('[mmstack/
|
|
570
|
+
console.warn('[mmstack/primitives] No transition scope in context — registration/tracking here is a no-op. ' +
|
|
545
571
|
'Use a <mm-suspense> boundary or provideTransitionScope() in an ancestor.');
|
|
546
572
|
return createNoopScope();
|
|
547
573
|
}
|
|
@@ -575,6 +601,11 @@ function registerResource(res, opt) {
|
|
|
575
601
|
*
|
|
576
602
|
* Must be called in an injection context. This is the *async* generalization (Tier 2): it adds
|
|
577
603
|
* no rendering cost and needs no fork — holding direct/sync readers is a separate, deferred tier.
|
|
604
|
+
*
|
|
605
|
+
* Caveat: work must go in flight by the first post-write render to be awaited. A loader that
|
|
606
|
+
* starts later (a debounced request signal, a chained/deferred resource) is not attributable to
|
|
607
|
+
* this transition — the no-async fallback will have already resolved `done`. Trigger such work
|
|
608
|
+
* eagerly inside `fn`, or coordinate it separately.
|
|
578
609
|
*/
|
|
579
610
|
function injectStartTransition() {
|
|
580
611
|
const scope = injectTransitionScope();
|
|
@@ -724,6 +755,11 @@ function runInTransaction(txn, fn) {
|
|
|
724
755
|
* The writes land on LIVE state immediately (so derived variables and connector requests see the
|
|
725
756
|
* new values and refetch); only the *display* is held, via `scope.hold`. Must run in an injection
|
|
726
757
|
* context.
|
|
758
|
+
*
|
|
759
|
+
* Caveat: work must go in flight by the first post-write render to be part of the transaction. A
|
|
760
|
+
* loader that starts later (a debounced request signal, a chained/deferred resource) is not
|
|
761
|
+
* attributable to it — the no-async fallback will have already committed and released the hold,
|
|
762
|
+
* after which `abort()` is a no-op. Trigger such work eagerly inside `fn`.
|
|
727
763
|
*/
|
|
728
764
|
function injectStartTransaction() {
|
|
729
765
|
const scope = injectTransitionScope();
|
|
@@ -733,7 +769,15 @@ function injectStartTransaction() {
|
|
|
733
769
|
// Hold BEFORE the writes, so the display freezes at pre-transaction values.
|
|
734
770
|
scope.beginHold();
|
|
735
771
|
let finished = false;
|
|
772
|
+
// eslint-disable-next-line prefer-const -- assigned in try/catch, but needs to be declared here for the `finally` block to see it
|
|
736
773
|
let watcher;
|
|
774
|
+
let resolveDone;
|
|
775
|
+
const done = new Promise((resolve) => {
|
|
776
|
+
resolveDone = resolve;
|
|
777
|
+
});
|
|
778
|
+
// Every exit path funnels through here, so `done` always settles — including `abort()`
|
|
779
|
+
// and a throwing transaction body (which would otherwise leak the hold forever and
|
|
780
|
+
// freeze the boundary with no recovery).
|
|
737
781
|
const finish = (restore) => {
|
|
738
782
|
if (finished)
|
|
739
783
|
return;
|
|
@@ -744,27 +788,28 @@ function injectStartTransaction() {
|
|
|
744
788
|
else
|
|
745
789
|
txn.clear();
|
|
746
790
|
scope.endHold();
|
|
791
|
+
resolveDone();
|
|
747
792
|
};
|
|
748
|
-
|
|
793
|
+
try {
|
|
794
|
+
runInTransaction(txn, fn);
|
|
795
|
+
}
|
|
796
|
+
catch (e) {
|
|
797
|
+
finish(true);
|
|
798
|
+
throw e;
|
|
799
|
+
}
|
|
749
800
|
let sawPending = false;
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
if (!sawPending && !untracked(scope.pending)) {
|
|
763
|
-
finish(false);
|
|
764
|
-
resolve();
|
|
765
|
-
}
|
|
766
|
-
}, { injector });
|
|
767
|
-
});
|
|
801
|
+
watcher = effect(() => {
|
|
802
|
+
const p = scope.pending();
|
|
803
|
+
if (p)
|
|
804
|
+
sawPending = true;
|
|
805
|
+
if (sawPending && !p)
|
|
806
|
+
finish(false);
|
|
807
|
+
}, { injector });
|
|
808
|
+
// no-async fallback: if nothing ever went in flight, settle once the writes are processed.
|
|
809
|
+
afterNextRender(() => {
|
|
810
|
+
if (!sawPending && !untracked(scope.pending))
|
|
811
|
+
finish(false);
|
|
812
|
+
}, { injector });
|
|
768
813
|
return {
|
|
769
814
|
pending: scope.pending,
|
|
770
815
|
done,
|
|
@@ -773,6 +818,17 @@ function injectStartTransaction() {
|
|
|
773
818
|
};
|
|
774
819
|
}
|
|
775
820
|
|
|
821
|
+
/**
|
|
822
|
+
* @internal
|
|
823
|
+
*/
|
|
824
|
+
function getSignalEquality(sig) {
|
|
825
|
+
const internal = sig[SIGNAL];
|
|
826
|
+
if (internal && typeof internal.equal === 'function') {
|
|
827
|
+
return internal.equal;
|
|
828
|
+
}
|
|
829
|
+
return Object.is; // Default equality check
|
|
830
|
+
}
|
|
831
|
+
|
|
776
832
|
/**
|
|
777
833
|
* Converts a read-only `Signal` into a `WritableSignal` by providing custom `set` and, optionally, `update` functions.
|
|
778
834
|
* This can be useful for creating controlled write access to a signal that is otherwise read-only.
|
|
@@ -871,6 +927,7 @@ function debounced(initial, opt) {
|
|
|
871
927
|
* ```
|
|
872
928
|
*/
|
|
873
929
|
function debounce(source, opt) {
|
|
930
|
+
const eq = opt?.equal ?? getSignalEquality(source);
|
|
874
931
|
const ms = opt?.ms ?? 0;
|
|
875
932
|
const trigger = signal(false, ...(ngDevMode ? [{ debugName: "trigger" }] : []));
|
|
876
933
|
let timeout;
|
|
@@ -885,25 +942,25 @@ function debounce(source, opt) {
|
|
|
885
942
|
catch {
|
|
886
943
|
// not in injection context & no destroyRef provided opting out of cleanup
|
|
887
944
|
}
|
|
888
|
-
const
|
|
945
|
+
const set = (next) => {
|
|
946
|
+
const isEqual = eq(untracked(source), next);
|
|
947
|
+
if (!timeout && isEqual)
|
|
948
|
+
return; // nothing to do
|
|
889
949
|
if (timeout)
|
|
890
|
-
clearTimeout(timeout);
|
|
891
|
-
|
|
950
|
+
clearTimeout(timeout); // clear pending
|
|
951
|
+
if (!isEqual)
|
|
952
|
+
source.set(next);
|
|
892
953
|
timeout = setTimeout(() => {
|
|
954
|
+
timeout = undefined;
|
|
893
955
|
trigger.update((c) => !c);
|
|
894
956
|
}, ms);
|
|
895
957
|
};
|
|
896
|
-
const
|
|
897
|
-
triggerFn(value);
|
|
898
|
-
};
|
|
899
|
-
const update = (fn) => {
|
|
900
|
-
triggerFn(fn(untracked(source)));
|
|
901
|
-
};
|
|
958
|
+
const update = (fn) => set(fn(untracked(source)));
|
|
902
959
|
const writable = toWritable(computed(() => {
|
|
903
960
|
trigger();
|
|
904
961
|
return untracked(source);
|
|
905
962
|
}, opt), set, update);
|
|
906
|
-
writable.original = source;
|
|
963
|
+
writable.original = source.asReadonly();
|
|
907
964
|
return writable;
|
|
908
965
|
}
|
|
909
966
|
|
|
@@ -1074,8 +1131,18 @@ function derived(source, optOrKey, opt) {
|
|
|
1074
1131
|
if (isMutable(source)) {
|
|
1075
1132
|
sig.mutate = (updater) => {
|
|
1076
1133
|
cnt++;
|
|
1077
|
-
|
|
1078
|
-
|
|
1134
|
+
try {
|
|
1135
|
+
sig.update(updater);
|
|
1136
|
+
// The wrapped computed evaluates its `equal` lazily — at the next read, which would
|
|
1137
|
+
// normally happen after `cnt` has already dropped back to 0. For a reference-stable
|
|
1138
|
+
// mutation that read compares the same object to itself and the version never bumps,
|
|
1139
|
+
// so dependents are never notified. Reading here, while equality is still suppressed,
|
|
1140
|
+
// forces the recompute (and version bump) inside the mutate window.
|
|
1141
|
+
untracked(sig);
|
|
1142
|
+
}
|
|
1143
|
+
finally {
|
|
1144
|
+
cnt--;
|
|
1145
|
+
}
|
|
1079
1146
|
};
|
|
1080
1147
|
sig.inline = (updater) => {
|
|
1081
1148
|
sig.mutate((prev) => {
|
|
@@ -1157,20 +1224,48 @@ function createSetter(source) {
|
|
|
1157
1224
|
}
|
|
1158
1225
|
|
|
1159
1226
|
function keepPrevious(src, opt) {
|
|
1227
|
+
const mutableSrc = isWritableSignal(src) && isMutable(src);
|
|
1228
|
+
// For a mutable source the linkedSignal's equality must be suppressible: a forwarded
|
|
1229
|
+
// `mutate` keeps the same reference, which default equality would otherwise swallow.
|
|
1230
|
+
let cnt = 0;
|
|
1231
|
+
const baseEqual = opt?.equal;
|
|
1232
|
+
const equal = mutableSrc
|
|
1233
|
+
? (a, b) => cnt > 0 ? false : baseEqual ? baseEqual(a, b) : Object.is(a, b)
|
|
1234
|
+
: baseEqual;
|
|
1160
1235
|
const persisted = linkedSignal(...(ngDevMode ? [{ debugName: "persisted", ...opt,
|
|
1161
1236
|
source: () => src(),
|
|
1162
|
-
computation: (next, prev) => next === undefined && prev !== undefined ? prev.value : next
|
|
1237
|
+
computation: (next, prev) => next === undefined && prev !== undefined ? prev.value : next,
|
|
1238
|
+
equal }] : [{
|
|
1163
1239
|
...opt,
|
|
1164
1240
|
source: () => src(),
|
|
1165
1241
|
computation: (next, prev) => next === undefined && prev !== undefined ? prev.value : next,
|
|
1242
|
+
equal,
|
|
1166
1243
|
}]));
|
|
1167
1244
|
if (isWritableSignal(src)) {
|
|
1168
1245
|
persisted.set = src.set;
|
|
1169
1246
|
persisted.update = src.update;
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1247
|
+
// NOTE: `asReadonly` deliberately stays the linkedSignal's own — returning the
|
|
1248
|
+
// source's readonly view would reintroduce the `undefined` flashes this wrapper exists
|
|
1249
|
+
// to prevent.
|
|
1250
|
+
if (mutableSrc) {
|
|
1251
|
+
persisted.mutate = (updater) => {
|
|
1252
|
+
cnt++;
|
|
1253
|
+
try {
|
|
1254
|
+
src.mutate(updater);
|
|
1255
|
+
// force the recompute while equality is suppressed, so the reference-stable
|
|
1256
|
+
// mutation bumps the wrapper's version (see derived.ts for the same pattern)
|
|
1257
|
+
untracked(persisted);
|
|
1258
|
+
}
|
|
1259
|
+
finally {
|
|
1260
|
+
cnt--;
|
|
1261
|
+
}
|
|
1262
|
+
};
|
|
1263
|
+
persisted.inline = (updater) => {
|
|
1264
|
+
persisted.mutate((prev) => {
|
|
1265
|
+
updater(prev);
|
|
1266
|
+
return prev;
|
|
1267
|
+
});
|
|
1268
|
+
};
|
|
1174
1269
|
}
|
|
1175
1270
|
if (isDerivation(src)) {
|
|
1176
1271
|
persisted.from = src.from;
|
|
@@ -1201,13 +1296,18 @@ function indexArray(source, map, opt = {}) {
|
|
|
1201
1296
|
: toWritable(data, () => {
|
|
1202
1297
|
// noop
|
|
1203
1298
|
});
|
|
1299
|
+
// copy before defaulting `equal` — assigning onto `opt` would mutate a caller-owned
|
|
1300
|
+
// (possibly shared/reused) options object
|
|
1204
1301
|
if (isWritableSignal(data) && isMutable(data) && !opt.equal) {
|
|
1205
|
-
opt
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1302
|
+
opt = {
|
|
1303
|
+
...opt,
|
|
1304
|
+
equal: (a, b) => {
|
|
1305
|
+
if (typeof a !== typeof b)
|
|
1306
|
+
return false;
|
|
1307
|
+
if (typeof a === 'object' || typeof a === 'function')
|
|
1308
|
+
return false;
|
|
1309
|
+
return a === b;
|
|
1310
|
+
},
|
|
1211
1311
|
};
|
|
1212
1312
|
}
|
|
1213
1313
|
return linkedSignal({
|
|
@@ -1401,8 +1501,17 @@ function pooledKeys(src) {
|
|
|
1401
1501
|
for (const k in val)
|
|
1402
1502
|
if (Object.prototype.hasOwnProperty.call(val, k))
|
|
1403
1503
|
spare.add(k);
|
|
1404
|
-
if (active.size === spare.size
|
|
1405
|
-
|
|
1504
|
+
if (active.size === spare.size) {
|
|
1505
|
+
let subset = true;
|
|
1506
|
+
for (const k of active) {
|
|
1507
|
+
if (!spare.has(k)) {
|
|
1508
|
+
subset = false;
|
|
1509
|
+
break;
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1512
|
+
if (subset)
|
|
1513
|
+
return active;
|
|
1514
|
+
}
|
|
1406
1515
|
const temp = active;
|
|
1407
1516
|
active = spare;
|
|
1408
1517
|
spare = temp;
|
|
@@ -1502,7 +1611,7 @@ const filter = (predicate) => (src) => linkedSignal({
|
|
|
1502
1611
|
computation: (next, prev) => {
|
|
1503
1612
|
if (predicate(next))
|
|
1504
1613
|
return next;
|
|
1505
|
-
return prev?.
|
|
1614
|
+
return prev?.value;
|
|
1506
1615
|
},
|
|
1507
1616
|
});
|
|
1508
1617
|
/**
|
|
@@ -1538,7 +1647,7 @@ const tap = (fn, injector) => (src) => {
|
|
|
1538
1647
|
*/
|
|
1539
1648
|
const filterWith = (predicate, initial) => (src) => linkedSignal({
|
|
1540
1649
|
source: src,
|
|
1541
|
-
computation: (next, prev) => predicate(next) ? next :
|
|
1650
|
+
computation: (next, prev) => predicate(next) ? next : prev ? prev.value : initial,
|
|
1542
1651
|
});
|
|
1543
1652
|
/**
|
|
1544
1653
|
* Emit `initial` on the first read, then mirror the source on every subsequent
|
|
@@ -1587,7 +1696,7 @@ const pairwise = () => (src) => linkedSignal({
|
|
|
1587
1696
|
*/
|
|
1588
1697
|
const scan = (reducer, seed) => (src) => linkedSignal({
|
|
1589
1698
|
source: src,
|
|
1590
|
-
computation: (next, prev) => reducer(prev
|
|
1699
|
+
computation: (next, prev) => reducer(prev ? prev.value : seed, next),
|
|
1591
1700
|
});
|
|
1592
1701
|
|
|
1593
1702
|
/**
|
|
@@ -1638,7 +1747,7 @@ function pipeable(signal) {
|
|
|
1638
1747
|
return internal;
|
|
1639
1748
|
}
|
|
1640
1749
|
/**
|
|
1641
|
-
* Create a new **writable** signal and return it as a `
|
|
1750
|
+
* Create a new **writable** signal and return it as a `PipeableSignal`.
|
|
1642
1751
|
*
|
|
1643
1752
|
* The returned value is a `WritableSignal<T>` with `.set`, `.update`, `.asReadonly`
|
|
1644
1753
|
* still available (via intersection type), plus a chainable `.pipe(...)`.
|
|
@@ -1742,6 +1851,20 @@ function pooledMap(optOrComputation, signalOpt) {
|
|
|
1742
1851
|
return pooled(toPooledOptions(optOrComputation, createEmptyMap, resetClearable, signalOpt));
|
|
1743
1852
|
}
|
|
1744
1853
|
|
|
1854
|
+
/**
|
|
1855
|
+
* @internal Run a sensor factory inside `injector` when provided, else in the ambient
|
|
1856
|
+
* injection context. Keeps every sensor's escape hatch identical and in one place.
|
|
1857
|
+
*/
|
|
1858
|
+
function runInSensorContext(injector, fn) {
|
|
1859
|
+
return injector ? runInInjectionContext(injector, fn) : fn();
|
|
1860
|
+
}
|
|
1861
|
+
/**
|
|
1862
|
+
* @internal Normalize the legacy positional `debugName: string` form into {@link SensorRunOptions}.
|
|
1863
|
+
*/
|
|
1864
|
+
function coerceSensorOptions(opt) {
|
|
1865
|
+
return typeof opt === 'string' ? { debugName: opt } : (opt ?? {});
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1745
1868
|
const EVENTS = [
|
|
1746
1869
|
'chargingchange',
|
|
1747
1870
|
'levelchange',
|
|
@@ -1763,7 +1886,11 @@ const EVENTS = [
|
|
|
1763
1886
|
* });
|
|
1764
1887
|
* ```
|
|
1765
1888
|
*/
|
|
1766
|
-
function batteryStatus(
|
|
1889
|
+
function batteryStatus(opt) {
|
|
1890
|
+
const { debugName = 'batteryStatus', injector } = coerceSensorOptions(opt);
|
|
1891
|
+
return runInSensorContext(injector, () => createBatteryStatus(debugName));
|
|
1892
|
+
}
|
|
1893
|
+
function createBatteryStatus(debugName) {
|
|
1767
1894
|
if (isPlatformServer(inject(PLATFORM_ID)) ||
|
|
1768
1895
|
typeof navigator === 'undefined' ||
|
|
1769
1896
|
typeof navigator.getBattery !== 'function') {
|
|
@@ -1772,7 +1899,9 @@ function batteryStatus(debugName = 'batteryStatus') {
|
|
|
1772
1899
|
const state = signal(null, ...(ngDevMode ? [{ debugName: "state", debugName }] : [{ debugName }]));
|
|
1773
1900
|
const abortController = new AbortController();
|
|
1774
1901
|
inject(DestroyRef).onDestroy(() => abortController.abort());
|
|
1775
|
-
navigator
|
|
1902
|
+
navigator
|
|
1903
|
+
.getBattery()
|
|
1904
|
+
.then((battery) => {
|
|
1776
1905
|
if (abortController.signal.aborted)
|
|
1777
1906
|
return;
|
|
1778
1907
|
const read = () => ({
|
|
@@ -1788,6 +1917,10 @@ function batteryStatus(debugName = 'batteryStatus') {
|
|
|
1788
1917
|
signal: abortController.signal,
|
|
1789
1918
|
});
|
|
1790
1919
|
}
|
|
1920
|
+
})
|
|
1921
|
+
.catch(() => {
|
|
1922
|
+
// getBattery() rejects (NotAllowedError) when the `battery` permissions-policy is
|
|
1923
|
+
// disallowed, e.g. in cross-origin iframes — stay `null`, same as unsupported.
|
|
1791
1924
|
});
|
|
1792
1925
|
return state.asReadonly();
|
|
1793
1926
|
}
|
|
@@ -1803,7 +1936,11 @@ function batteryStatus(debugName = 'batteryStatus') {
|
|
|
1803
1936
|
* in browsers that gate it. Errors from `navigator.clipboard.readText` are
|
|
1804
1937
|
* swallowed silently to keep the signal value stable.
|
|
1805
1938
|
*/
|
|
1806
|
-
function clipboard(
|
|
1939
|
+
function clipboard(opt) {
|
|
1940
|
+
const { debugName = 'clipboard', injector } = coerceSensorOptions(opt);
|
|
1941
|
+
return runInSensorContext(injector, () => createClipboard(debugName));
|
|
1942
|
+
}
|
|
1943
|
+
function createClipboard(debugName) {
|
|
1807
1944
|
if (isPlatformServer(inject(PLATFORM_ID)) ||
|
|
1808
1945
|
typeof navigator === 'undefined' ||
|
|
1809
1946
|
!navigator.clipboard) {
|
|
@@ -1853,7 +1990,13 @@ function observerSupported$1() {
|
|
|
1853
1990
|
* });
|
|
1854
1991
|
* ```
|
|
1855
1992
|
*/
|
|
1856
|
-
function elementSize(target
|
|
1993
|
+
function elementSize(target, opt) {
|
|
1994
|
+
return runInSensorContext(opt?.injector, () =>
|
|
1995
|
+
// the host-element default must resolve INSIDE the sensor context, not as a
|
|
1996
|
+
// parameter default (which would run before the injector wrapper)
|
|
1997
|
+
createElementSize(target ?? inject(ElementRef), opt));
|
|
1998
|
+
}
|
|
1999
|
+
function createElementSize(target, opt) {
|
|
1857
2000
|
const getElement = () => {
|
|
1858
2001
|
if (isSignal(target)) {
|
|
1859
2002
|
try {
|
|
@@ -1867,8 +2010,8 @@ function elementSize(target = inject(ElementRef), opt) {
|
|
|
1867
2010
|
return target instanceof ElementRef ? target.nativeElement : target;
|
|
1868
2011
|
};
|
|
1869
2012
|
const resolveInitialValue = () => {
|
|
1870
|
-
|
|
1871
|
-
|
|
2013
|
+
// measuring needs only getBoundingClientRect — ResizeObserver support gates
|
|
2014
|
+
// live updates, not the initial read
|
|
1872
2015
|
const el = getElement();
|
|
1873
2016
|
if (el && el.getBoundingClientRect) {
|
|
1874
2017
|
const rect = el.getBoundingClientRect();
|
|
@@ -1986,7 +2129,13 @@ function observerSupported() {
|
|
|
1986
2129
|
* }
|
|
1987
2130
|
* ```
|
|
1988
2131
|
*/
|
|
1989
|
-
function elementVisibility(target
|
|
2132
|
+
function elementVisibility(target, opt) {
|
|
2133
|
+
return runInSensorContext(opt?.injector, () =>
|
|
2134
|
+
// the host-element default must resolve INSIDE the sensor context, not as a
|
|
2135
|
+
// parameter default (which would run before the injector wrapper)
|
|
2136
|
+
createElementVisibility(target ?? inject(ElementRef), opt));
|
|
2137
|
+
}
|
|
2138
|
+
function createElementVisibility(target, opt) {
|
|
1990
2139
|
if (isPlatformServer(inject(PLATFORM_ID)) || !observerSupported()) {
|
|
1991
2140
|
const base = computed(() => undefined, {
|
|
1992
2141
|
debugName: opt?.debugName,
|
|
@@ -2054,11 +2203,18 @@ function unwrap$1(target) {
|
|
|
2054
2203
|
* }
|
|
2055
2204
|
* ```
|
|
2056
2205
|
*/
|
|
2057
|
-
function focusWithin(target
|
|
2206
|
+
function focusWithin(target, opt) {
|
|
2207
|
+
return runInSensorContext(opt?.injector, () =>
|
|
2208
|
+
// the host-element default must resolve INSIDE the sensor context, not as a
|
|
2209
|
+
// parameter default (which would run before the injector wrapper)
|
|
2210
|
+
createFocusWithin(target ?? inject(ElementRef), opt));
|
|
2211
|
+
}
|
|
2212
|
+
function createFocusWithin(target, opt) {
|
|
2213
|
+
const debugName = opt?.debugName ?? 'focusWithin';
|
|
2058
2214
|
if (isPlatformServer(inject(PLATFORM_ID))) {
|
|
2059
|
-
return computed(() => false, { debugName
|
|
2215
|
+
return computed(() => false, { debugName });
|
|
2060
2216
|
}
|
|
2061
|
-
const state = signal(false, { debugName:
|
|
2217
|
+
const state = signal(false, ...(ngDevMode ? [{ debugName: "state", debugName }] : [{ debugName }]));
|
|
2062
2218
|
const attach = (el) => {
|
|
2063
2219
|
state.set(el.contains(document.activeElement));
|
|
2064
2220
|
const abortController = new AbortController();
|
|
@@ -2106,6 +2262,9 @@ function focusWithin(target = inject(ElementRef)) {
|
|
|
2106
2262
|
* ```
|
|
2107
2263
|
*/
|
|
2108
2264
|
function geolocation(opt) {
|
|
2265
|
+
return runInSensorContext(opt?.injector, () => createGeolocation(opt));
|
|
2266
|
+
}
|
|
2267
|
+
function createGeolocation(opt) {
|
|
2109
2268
|
if (isPlatformServer(inject(PLATFORM_ID)) || typeof navigator === 'undefined' || !navigator.geolocation) {
|
|
2110
2269
|
const sig = computed(() => null, {
|
|
2111
2270
|
debugName: opt?.debugName ?? 'geolocation',
|
|
@@ -2165,6 +2324,9 @@ const serverDate$1 = new Date();
|
|
|
2165
2324
|
* ```
|
|
2166
2325
|
*/
|
|
2167
2326
|
function idle(opt) {
|
|
2327
|
+
return runInSensorContext(opt?.injector, () => createIdle(opt));
|
|
2328
|
+
}
|
|
2329
|
+
function createIdle(opt) {
|
|
2168
2330
|
if (isPlatformServer(inject(PLATFORM_ID))) {
|
|
2169
2331
|
const sig = computed(() => false, {
|
|
2170
2332
|
debugName: opt?.debugName ?? 'idle',
|
|
@@ -2254,7 +2416,11 @@ function idle(opt) {
|
|
|
2254
2416
|
* }
|
|
2255
2417
|
* ```
|
|
2256
2418
|
*/
|
|
2257
|
-
function mediaQuery(query,
|
|
2419
|
+
function mediaQuery(query, opt) {
|
|
2420
|
+
const { debugName = 'mediaQuery', injector } = coerceSensorOptions(opt);
|
|
2421
|
+
return runInSensorContext(injector, () => createMediaQuery(query, debugName));
|
|
2422
|
+
}
|
|
2423
|
+
function createMediaQuery(query, debugName) {
|
|
2258
2424
|
if (isPlatformServer(inject(PLATFORM_ID)) ||
|
|
2259
2425
|
typeof window === 'undefined' ||
|
|
2260
2426
|
typeof window.matchMedia !== 'function' // jsdom doesn't implement matchMedia
|
|
@@ -2292,8 +2458,8 @@ function mediaQuery(query, debugName = 'mediaQuery') {
|
|
|
2292
2458
|
* });
|
|
2293
2459
|
* ```
|
|
2294
2460
|
*/
|
|
2295
|
-
function prefersDarkMode(
|
|
2296
|
-
return mediaQuery('(prefers-color-scheme: dark)',
|
|
2461
|
+
function prefersDarkMode(opt) {
|
|
2462
|
+
return mediaQuery('(prefers-color-scheme: dark)', opt);
|
|
2297
2463
|
}
|
|
2298
2464
|
/**
|
|
2299
2465
|
* Creates a read-only signal that tracks the user's OS/browser preference
|
|
@@ -2320,8 +2486,8 @@ function prefersDarkMode(debugName) {
|
|
|
2320
2486
|
* });
|
|
2321
2487
|
* ```
|
|
2322
2488
|
*/
|
|
2323
|
-
function prefersReducedMotion(
|
|
2324
|
-
return mediaQuery('(prefers-reduced-motion: reduce)',
|
|
2489
|
+
function prefersReducedMotion(opt) {
|
|
2490
|
+
return mediaQuery('(prefers-reduced-motion: reduce)', opt);
|
|
2325
2491
|
}
|
|
2326
2492
|
|
|
2327
2493
|
/**
|
|
@@ -2370,6 +2536,7 @@ function throttled(initial, opt) {
|
|
|
2370
2536
|
* // after the 500ms cooldown.
|
|
2371
2537
|
*/
|
|
2372
2538
|
function throttle(source, opt) {
|
|
2539
|
+
const eq = opt?.equal ?? getSignalEquality(source);
|
|
2373
2540
|
const ms = opt?.ms ?? 0;
|
|
2374
2541
|
const leading = opt?.leading ?? false;
|
|
2375
2542
|
const trailing = opt?.trailing ?? true;
|
|
@@ -2395,31 +2562,32 @@ function throttle(source, opt) {
|
|
|
2395
2562
|
fire();
|
|
2396
2563
|
else
|
|
2397
2564
|
pendingTrailing = trailing;
|
|
2398
|
-
|
|
2565
|
+
const onWindowEnd = () => {
|
|
2399
2566
|
timeout = undefined;
|
|
2400
2567
|
if (trailing && pendingTrailing) {
|
|
2401
2568
|
pendingTrailing = false;
|
|
2402
2569
|
fire();
|
|
2570
|
+
timeout = setTimeout(onWindowEnd, ms);
|
|
2403
2571
|
}
|
|
2404
|
-
}
|
|
2572
|
+
};
|
|
2573
|
+
timeout = setTimeout(onWindowEnd, ms);
|
|
2405
2574
|
return;
|
|
2406
2575
|
}
|
|
2407
2576
|
if (trailing)
|
|
2408
2577
|
pendingTrailing = true;
|
|
2409
2578
|
};
|
|
2410
|
-
const set = (
|
|
2411
|
-
source
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
const update = (fn) => {
|
|
2415
|
-
source.update(fn);
|
|
2579
|
+
const set = (next) => {
|
|
2580
|
+
if (eq(untracked(source), next))
|
|
2581
|
+
return;
|
|
2582
|
+
source.set(next);
|
|
2416
2583
|
tick();
|
|
2417
2584
|
};
|
|
2585
|
+
const update = (fn) => set(fn(untracked(source)));
|
|
2418
2586
|
const writable = toWritable(computed(() => {
|
|
2419
2587
|
trigger();
|
|
2420
2588
|
return untracked(source);
|
|
2421
2589
|
}, opt), set, update);
|
|
2422
|
-
writable.original = source;
|
|
2590
|
+
writable.original = source.asReadonly();
|
|
2423
2591
|
return writable;
|
|
2424
2592
|
}
|
|
2425
2593
|
|
|
@@ -2456,6 +2624,9 @@ function throttle(source, opt) {
|
|
|
2456
2624
|
* ```
|
|
2457
2625
|
*/
|
|
2458
2626
|
function mousePosition(opt) {
|
|
2627
|
+
return runInSensorContext(opt?.injector, () => createMousePosition(opt));
|
|
2628
|
+
}
|
|
2629
|
+
function createMousePosition(opt) {
|
|
2459
2630
|
if (isPlatformServer(inject(PLATFORM_ID))) {
|
|
2460
2631
|
const base = computed(() => ({
|
|
2461
2632
|
x: 0,
|
|
@@ -2467,8 +2638,12 @@ function mousePosition(opt) {
|
|
|
2467
2638
|
return base;
|
|
2468
2639
|
}
|
|
2469
2640
|
const { target = window, coordinateSpace = 'client', touch = false, debugName = 'mousePosition', throttle = 100, } = opt ?? {};
|
|
2470
|
-
const
|
|
2471
|
-
|
|
2641
|
+
const resolve = (t) => {
|
|
2642
|
+
if (!t)
|
|
2643
|
+
return null;
|
|
2644
|
+
return t instanceof ElementRef ? t.nativeElement : t;
|
|
2645
|
+
};
|
|
2646
|
+
if (!isSignal(target) && !resolve(target)) {
|
|
2472
2647
|
if (isDevMode())
|
|
2473
2648
|
console.warn('mousePosition: Target element not found.');
|
|
2474
2649
|
const base = computed(() => ({
|
|
@@ -2491,7 +2666,7 @@ function mousePosition(opt) {
|
|
|
2491
2666
|
x = coordinateSpace === 'page' ? event.pageX : event.clientX;
|
|
2492
2667
|
y = coordinateSpace === 'page' ? event.pageY : event.clientY;
|
|
2493
2668
|
}
|
|
2494
|
-
else if (event.touches
|
|
2669
|
+
else if (event.touches?.length > 0) {
|
|
2495
2670
|
const firstTouch = event.touches[0];
|
|
2496
2671
|
x = coordinateSpace === 'page' ? firstTouch.pageX : firstTouch.clientX;
|
|
2497
2672
|
y = coordinateSpace === 'page' ? firstTouch.pageY : firstTouch.clientY;
|
|
@@ -2501,16 +2676,36 @@ function mousePosition(opt) {
|
|
|
2501
2676
|
}
|
|
2502
2677
|
pos.set({ x, y });
|
|
2503
2678
|
};
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2679
|
+
// passive: the handler never calls preventDefault, and a non-passive touchmove on
|
|
2680
|
+
// window forces the browser to wait on JS before scrolling (scroll jank on touch)
|
|
2681
|
+
const attach = (el) => {
|
|
2682
|
+
const controller = new AbortController();
|
|
2683
|
+
el.addEventListener('mousemove', updatePosition, {
|
|
2684
|
+
passive: true,
|
|
2685
|
+
signal: controller.signal,
|
|
2686
|
+
});
|
|
2510
2687
|
if (touch) {
|
|
2511
|
-
|
|
2688
|
+
el.addEventListener('touchmove', updatePosition, {
|
|
2689
|
+
passive: true,
|
|
2690
|
+
signal: controller.signal,
|
|
2691
|
+
});
|
|
2512
2692
|
}
|
|
2513
|
-
|
|
2693
|
+
return () => controller.abort();
|
|
2694
|
+
};
|
|
2695
|
+
if (isSignal(target)) {
|
|
2696
|
+
// re-attach whenever the signal resolves to a (new) element — covers viewChild
|
|
2697
|
+
effect((cleanup) => {
|
|
2698
|
+
const el = resolve(target());
|
|
2699
|
+
if (!el)
|
|
2700
|
+
return;
|
|
2701
|
+
cleanup(attach(el));
|
|
2702
|
+
});
|
|
2703
|
+
}
|
|
2704
|
+
else {
|
|
2705
|
+
const el = resolve(target);
|
|
2706
|
+
if (el)
|
|
2707
|
+
inject(DestroyRef).onDestroy(attach(el));
|
|
2708
|
+
}
|
|
2514
2709
|
const base = pos.asReadonly();
|
|
2515
2710
|
base.unthrottled = pos.original;
|
|
2516
2711
|
return base;
|
|
@@ -2524,7 +2719,8 @@ const serverDate = new Date();
|
|
|
2524
2719
|
* An additional `since` signal is attached, tracking when the status last changed.
|
|
2525
2720
|
* It's SSR-safe and automatically cleans up its event listeners.
|
|
2526
2721
|
*
|
|
2527
|
-
* @param
|
|
2722
|
+
* @param opt Optional debug name for the signal, or a {@link SensorRunOptions} object
|
|
2723
|
+
* (with an optional `injector` for creation outside an injection context).
|
|
2528
2724
|
* @returns A `NetworkStatusSignal` instance.
|
|
2529
2725
|
*
|
|
2530
2726
|
* @example
|
|
@@ -2535,7 +2731,11 @@ const serverDate = new Date();
|
|
|
2535
2731
|
* });
|
|
2536
2732
|
* ```
|
|
2537
2733
|
*/
|
|
2538
|
-
function networkStatus(
|
|
2734
|
+
function networkStatus(opt) {
|
|
2735
|
+
const { debugName = 'networkStatus', injector } = coerceSensorOptions(opt);
|
|
2736
|
+
return runInSensorContext(injector, () => createNetworkStatus(debugName));
|
|
2737
|
+
}
|
|
2738
|
+
function createNetworkStatus(debugName) {
|
|
2539
2739
|
if (isPlatformServer(inject(PLATFORM_ID))) {
|
|
2540
2740
|
const sig = computed(() => true, {
|
|
2541
2741
|
debugName,
|
|
@@ -2585,7 +2785,11 @@ const SSR_FALLBACK = {
|
|
|
2585
2785
|
* });
|
|
2586
2786
|
* ```
|
|
2587
2787
|
*/
|
|
2588
|
-
function orientation(
|
|
2788
|
+
function orientation(opt) {
|
|
2789
|
+
const { debugName = 'orientation', injector } = coerceSensorOptions(opt);
|
|
2790
|
+
return runInSensorContext(injector, () => createOrientation(debugName));
|
|
2791
|
+
}
|
|
2792
|
+
function createOrientation(debugName) {
|
|
2589
2793
|
if (isPlatformServer(inject(PLATFORM_ID)) ||
|
|
2590
2794
|
typeof screen === 'undefined' ||
|
|
2591
2795
|
!screen.orientation) {
|
|
@@ -2615,7 +2819,8 @@ function orientation(debugName = 'orientation') {
|
|
|
2615
2819
|
* The primitive is SSR-safe and automatically cleans up its event listeners
|
|
2616
2820
|
* when the creating context is destroyed.
|
|
2617
2821
|
*
|
|
2618
|
-
* @param
|
|
2822
|
+
* @param opt Optional debug name for the signal, or a {@link SensorRunOptions} object
|
|
2823
|
+
* (with an optional `injector` for creation outside an injection context).
|
|
2619
2824
|
* @returns A read-only `Signal<DocumentVisibilityState>`. On the server,
|
|
2620
2825
|
* it returns a static signal with a value of `'visible'`.
|
|
2621
2826
|
*
|
|
@@ -2643,7 +2848,11 @@ function orientation(debugName = 'orientation') {
|
|
|
2643
2848
|
* }
|
|
2644
2849
|
* ```
|
|
2645
2850
|
*/
|
|
2646
|
-
function pageVisibility(
|
|
2851
|
+
function pageVisibility(opt) {
|
|
2852
|
+
const { debugName = 'pageVisibility', injector } = coerceSensorOptions(opt);
|
|
2853
|
+
return runInSensorContext(injector, () => createPageVisibility(debugName));
|
|
2854
|
+
}
|
|
2855
|
+
function createPageVisibility(debugName) {
|
|
2647
2856
|
if (isPlatformServer(inject(PLATFORM_ID))) {
|
|
2648
2857
|
return computed(() => 'visible', { debugName });
|
|
2649
2858
|
}
|
|
@@ -2675,31 +2884,25 @@ function pageVisibility(debugName = 'pageVisibility') {
|
|
|
2675
2884
|
* selector: 'app-scroll-tracker',
|
|
2676
2885
|
* template: `
|
|
2677
2886
|
* <p>Window Scroll: X: {{ windowScroll().x }}, Y: {{ windowScroll().y }}</p>
|
|
2678
|
-
* <
|
|
2679
|
-
* <div style="height: 400px; width: 400px;">Scroll me!</div>
|
|
2680
|
-
* </div>
|
|
2681
|
-
* @if (divScroll()) {
|
|
2682
|
-
* <p>Div Scroll: X: {{ divScroll().x }}, Y: {{ divScroll().y }}</p>
|
|
2683
|
-
* }
|
|
2887
|
+
* <p>Host Scroll: X: {{ hostScroll().x }}, Y: {{ hostScroll().y }}</p>
|
|
2684
2888
|
* `
|
|
2685
2889
|
* })
|
|
2686
2890
|
* export class ScrollTrackerComponent {
|
|
2687
2891
|
* readonly windowScroll = scrollPosition(); // Defaults to window
|
|
2892
|
+
* // Signal targets (e.g. viewChild) attach once the element exists:
|
|
2688
2893
|
* readonly scrollableDiv = viewChild<ElementRef<HTMLDivElement>>('scrollableDiv');
|
|
2689
|
-
* readonly divScroll = scrollPosition({ target: this.scrollableDiv
|
|
2894
|
+
* readonly divScroll = scrollPosition({ target: this.scrollableDiv });
|
|
2690
2895
|
*
|
|
2691
2896
|
* constructor() {
|
|
2692
|
-
* effect(() =>
|
|
2693
|
-
* console.log('Window scrolled to:', this.windowScroll());
|
|
2694
|
-
* if (this.divScroll()) {
|
|
2695
|
-
* console.log('Div scrolled to:', this.divScroll());
|
|
2696
|
-
* }
|
|
2697
|
-
* });
|
|
2897
|
+
* effect(() => console.log('Window scrolled to:', this.windowScroll()));
|
|
2698
2898
|
* }
|
|
2699
2899
|
* }
|
|
2700
2900
|
* ```
|
|
2701
2901
|
*/
|
|
2702
2902
|
function scrollPosition(opt) {
|
|
2903
|
+
return runInSensorContext(opt?.injector, () => createScrollPosition(opt));
|
|
2904
|
+
}
|
|
2905
|
+
function createScrollPosition(opt) {
|
|
2703
2906
|
if (isPlatformServer(inject(PLATFORM_ID))) {
|
|
2704
2907
|
const base = computed(() => ({
|
|
2705
2908
|
x: 0,
|
|
@@ -2711,40 +2914,44 @@ function scrollPosition(opt) {
|
|
|
2711
2914
|
return base;
|
|
2712
2915
|
}
|
|
2713
2916
|
const { target = window, throttle = 100, debugName = 'scrollPosition', } = opt || {};
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
};
|
|
2729
|
-
};
|
|
2730
|
-
}
|
|
2731
|
-
else {
|
|
2732
|
-
element = target;
|
|
2733
|
-
getScrollPosition = () => {
|
|
2734
|
-
return {
|
|
2735
|
-
x: target.scrollLeft,
|
|
2736
|
-
y: target.scrollTop,
|
|
2737
|
-
};
|
|
2738
|
-
};
|
|
2739
|
-
}
|
|
2740
|
-
const state = throttled(getScrollPosition(), {
|
|
2917
|
+
const resolve = (t) => {
|
|
2918
|
+
if (!t)
|
|
2919
|
+
return null;
|
|
2920
|
+
return t instanceof ElementRef ? t.nativeElement : t;
|
|
2921
|
+
};
|
|
2922
|
+
const isWindow = (el) => el.window === el;
|
|
2923
|
+
const readPosition = (el) => isWindow(el)
|
|
2924
|
+
? {
|
|
2925
|
+
x: el.scrollX ?? el.pageXOffset ?? 0,
|
|
2926
|
+
y: el.scrollY ?? el.pageYOffset ?? 0,
|
|
2927
|
+
}
|
|
2928
|
+
: { x: el.scrollLeft, y: el.scrollTop };
|
|
2929
|
+
const initial = resolve(isSignal(target) ? untracked(target) : target);
|
|
2930
|
+
const state = throttled(initial ? readPosition(initial) : { x: 0, y: 0 }, {
|
|
2741
2931
|
debugName,
|
|
2742
2932
|
equal: (a, b) => a.x === b.x && a.y === b.y,
|
|
2743
2933
|
ms: throttle,
|
|
2744
2934
|
});
|
|
2745
|
-
|
|
2746
|
-
|
|
2747
|
-
|
|
2935
|
+
if (isSignal(target)) {
|
|
2936
|
+
// re-attach whenever the signal resolves to a (new) element — covers viewChild
|
|
2937
|
+
effect((cleanup) => {
|
|
2938
|
+
const el = resolve(target());
|
|
2939
|
+
if (!el)
|
|
2940
|
+
return;
|
|
2941
|
+
state.set(readPosition(el)); // sync to the new element immediately
|
|
2942
|
+
const onScroll = () => state.set(readPosition(el));
|
|
2943
|
+
el.addEventListener('scroll', onScroll, { passive: true });
|
|
2944
|
+
cleanup(() => el.removeEventListener('scroll', onScroll));
|
|
2945
|
+
});
|
|
2946
|
+
}
|
|
2947
|
+
else {
|
|
2948
|
+
const el = resolve(target);
|
|
2949
|
+
if (el) {
|
|
2950
|
+
const onScroll = () => state.set(readPosition(el));
|
|
2951
|
+
el.addEventListener('scroll', onScroll, { passive: true });
|
|
2952
|
+
inject(DestroyRef).onDestroy(() => el.removeEventListener('scroll', onScroll));
|
|
2953
|
+
}
|
|
2954
|
+
}
|
|
2748
2955
|
const base = state.asReadonly();
|
|
2749
2956
|
base.unthrottled = state.original;
|
|
2750
2957
|
return base;
|
|
@@ -2792,6 +2999,9 @@ function scrollPosition(opt) {
|
|
|
2792
2999
|
* ```
|
|
2793
3000
|
*/
|
|
2794
3001
|
function windowSize(opt) {
|
|
3002
|
+
return runInSensorContext(opt?.injector, () => createWindowSize(opt));
|
|
3003
|
+
}
|
|
3004
|
+
function createWindowSize(opt) {
|
|
2795
3005
|
if (isPlatformServer(inject(PLATFORM_ID))) {
|
|
2796
3006
|
const base = computed(() => ({
|
|
2797
3007
|
width: 1024,
|
|
@@ -2828,17 +3038,19 @@ function sensor(type, options) {
|
|
|
2828
3038
|
case 'mousePosition':
|
|
2829
3039
|
return mousePosition(opts);
|
|
2830
3040
|
case 'networkStatus':
|
|
2831
|
-
return networkStatus(opts
|
|
3041
|
+
return networkStatus(opts);
|
|
2832
3042
|
case 'pageVisibility':
|
|
2833
|
-
return pageVisibility(opts
|
|
3043
|
+
return pageVisibility(opts);
|
|
2834
3044
|
case 'darkMode':
|
|
2835
3045
|
case 'dark-mode':
|
|
2836
|
-
return prefersDarkMode(opts
|
|
3046
|
+
return prefersDarkMode(opts);
|
|
2837
3047
|
case 'reducedMotion':
|
|
2838
3048
|
case 'reduced-motion':
|
|
2839
|
-
return prefersReducedMotion(opts
|
|
3049
|
+
return prefersReducedMotion(opts);
|
|
2840
3050
|
case 'mediaQuery':
|
|
2841
|
-
|
|
3051
|
+
if (typeof opts?.query !== 'string')
|
|
3052
|
+
throw new Error(`sensor('mediaQuery') requires a 'query' option, e.g. sensor('mediaQuery', { query: '(min-width: 1024px)' })`);
|
|
3053
|
+
return mediaQuery(opts.query, opts);
|
|
2842
3054
|
case 'windowSize':
|
|
2843
3055
|
return windowSize(opts);
|
|
2844
3056
|
case 'scrollPosition':
|
|
@@ -2850,15 +3062,15 @@ function sensor(type, options) {
|
|
|
2850
3062
|
case 'geolocation':
|
|
2851
3063
|
return geolocation(opts);
|
|
2852
3064
|
case 'clipboard':
|
|
2853
|
-
return clipboard(opts
|
|
3065
|
+
return clipboard(opts);
|
|
2854
3066
|
case 'orientation':
|
|
2855
|
-
return orientation(opts
|
|
3067
|
+
return orientation(opts);
|
|
2856
3068
|
case 'batteryStatus':
|
|
2857
|
-
return batteryStatus(opts
|
|
3069
|
+
return batteryStatus(opts);
|
|
2858
3070
|
case 'idle':
|
|
2859
3071
|
return idle(opts);
|
|
2860
3072
|
case 'focusWithin':
|
|
2861
|
-
return focusWithin(opts?.target);
|
|
3073
|
+
return focusWithin(opts?.target, opts);
|
|
2862
3074
|
default:
|
|
2863
3075
|
throw new Error(`Unknown sensor type: ${type}`);
|
|
2864
3076
|
}
|
|
@@ -2912,16 +3124,24 @@ function signalFromEvent(target, eventName, initial, projectOrOpt, maybeOpt) {
|
|
|
2912
3124
|
else
|
|
2913
3125
|
state.set(event);
|
|
2914
3126
|
};
|
|
2915
|
-
const { destroyRef: providedDestroyRef,
|
|
3127
|
+
const { destroyRef: providedDestroyRef,
|
|
3128
|
+
// strip non-listener keys so they don't leak into addEventListener options
|
|
3129
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
3130
|
+
injector: _injector,
|
|
3131
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
3132
|
+
debugName: _debugName, ...listenerOpts } = opt ?? {};
|
|
2916
3133
|
if (isSignal(target)) {
|
|
2917
3134
|
const targetSig = target;
|
|
2918
|
-
effect((cleanup) => {
|
|
3135
|
+
const effectRef = effect((cleanup) => {
|
|
2919
3136
|
const resolved = unwrap(targetSig());
|
|
2920
3137
|
if (!resolved)
|
|
2921
3138
|
return;
|
|
2922
3139
|
resolved.addEventListener(eventName, handler, listenerOpts);
|
|
2923
3140
|
cleanup(() => resolved.removeEventListener(eventName, handler, listenerOpts));
|
|
2924
|
-
}, { injector });
|
|
3141
|
+
}, ...(ngDevMode ? [{ debugName: "effectRef", injector }] : [{ injector }]));
|
|
3142
|
+
// honor an explicit destroyRef for signal targets too — the effect would otherwise
|
|
3143
|
+
// only follow the injector's lifetime, contradicting the documented option
|
|
3144
|
+
providedDestroyRef?.onDestroy(() => effectRef.destroy());
|
|
2925
3145
|
}
|
|
2926
3146
|
else {
|
|
2927
3147
|
const resolved = unwrap(target);
|
|
@@ -3007,7 +3227,8 @@ function alwaysFalse() {
|
|
|
3007
3227
|
* @internal Attaches a lazy, memoized leaf probe to a store node. The probe (`() => boolean`)
|
|
3008
3228
|
* closes over the node's value signal and its (stable) vivify setting, building the backing
|
|
3009
3229
|
* `computed` on first call so leaf-ness tracks the live value reactively without taxing every
|
|
3010
|
-
* node access.
|
|
3230
|
+
* node access. Under `noUnionLeaves` the caller promises shapes never flip, so the probe is
|
|
3231
|
+
* resolved once from the first sample and frozen as a constant. Idempotent.
|
|
3011
3232
|
*/
|
|
3012
3233
|
function markAsLeaf(sig, value, vivifyEnabled, noUnionLeaves) {
|
|
3013
3234
|
if (typeof sig[LEAF] !== 'function') {
|
|
@@ -3015,13 +3236,11 @@ function markAsLeaf(sig, value, vivifyEnabled, noUnionLeaves) {
|
|
|
3015
3236
|
const probe = () => {
|
|
3016
3237
|
if (memo)
|
|
3017
3238
|
return memo();
|
|
3018
|
-
|
|
3019
|
-
|
|
3020
|
-
|
|
3021
|
-
|
|
3022
|
-
|
|
3023
|
-
: alwaysFalse
|
|
3024
|
-
: computed(() => isLeafValue(value(), vivifyEnabled));
|
|
3239
|
+
memo = noUnionLeaves
|
|
3240
|
+
? isLeafValue(untracked(value), vivifyEnabled)
|
|
3241
|
+
? alwaysTrue
|
|
3242
|
+
: alwaysFalse
|
|
3243
|
+
: computed(() => isLeafValue(value(), vivifyEnabled));
|
|
3025
3244
|
return memo();
|
|
3026
3245
|
};
|
|
3027
3246
|
Object.defineProperty(sig, LEAF, {
|
|
@@ -3109,6 +3328,40 @@ function resolveVivify(sample, option) {
|
|
|
3109
3328
|
function hasOwnKey(value, key) {
|
|
3110
3329
|
return value != null && Object.hasOwn(value, key);
|
|
3111
3330
|
}
|
|
3331
|
+
/**
|
|
3332
|
+
* @internal
|
|
3333
|
+
* Builds the `onChange` for the fallback (non-record container) derivation branch. For an
|
|
3334
|
+
* immutable source the container is copied before the write — returning the same mutated
|
|
3335
|
+
* reference would let the source's equality cut propagation (leaving child signals permanently
|
|
3336
|
+
* stale) and alias the caller's original object, breaking the structural-sharing contract
|
|
3337
|
+
* `forkStore` relies on. For a mutable source the write goes through `mutate`, so the chain's
|
|
3338
|
+
* force-notify engages (plain `update` with the same reference would never notify).
|
|
3339
|
+
*/
|
|
3340
|
+
function createFallbackOnChange(target, prop, vivifyFn, isMutableSource) {
|
|
3341
|
+
const write = (newValue) => (v) => {
|
|
3342
|
+
const container = vivifyFn(v, prop);
|
|
3343
|
+
if (container === null || container === undefined)
|
|
3344
|
+
return container;
|
|
3345
|
+
const next = isMutableSource
|
|
3346
|
+
? container
|
|
3347
|
+
: Array.isArray(container)
|
|
3348
|
+
? container.slice()
|
|
3349
|
+
: isRecord(container)
|
|
3350
|
+
? { ...container }
|
|
3351
|
+
: container; // non-plain leaf (Date/class instance): legacy in-place attempt
|
|
3352
|
+
try {
|
|
3353
|
+
next[prop] = newValue;
|
|
3354
|
+
}
|
|
3355
|
+
catch (e) {
|
|
3356
|
+
if (isDevMode())
|
|
3357
|
+
console.error(`[store] Failed to set property "${String(prop)}"`, e);
|
|
3358
|
+
}
|
|
3359
|
+
return next;
|
|
3360
|
+
};
|
|
3361
|
+
return isMutableSource
|
|
3362
|
+
? (newValue) => target.mutate(write(newValue))
|
|
3363
|
+
: (newValue) => target.update(write(newValue));
|
|
3364
|
+
}
|
|
3112
3365
|
/**
|
|
3113
3366
|
* @internal
|
|
3114
3367
|
* Makes an array store
|
|
@@ -3131,7 +3384,9 @@ function toArrayStore(source, injector, vivify, noUnionLeaves = false) {
|
|
|
3131
3384
|
const idx = +prop;
|
|
3132
3385
|
return idx >= 0 && idx < untracked(lengthSignal);
|
|
3133
3386
|
}
|
|
3134
|
-
|
|
3387
|
+
const v = untracked(source);
|
|
3388
|
+
// nullish node values are routinely descended with vivify on — `in` must not throw
|
|
3389
|
+
return v == null ? false : Reflect.has(v, prop);
|
|
3135
3390
|
},
|
|
3136
3391
|
ownKeys() {
|
|
3137
3392
|
const v = untracked(source);
|
|
@@ -3168,7 +3423,9 @@ function toArrayStore(source, injector, vivify, noUnionLeaves = false) {
|
|
|
3168
3423
|
return lengthSignal;
|
|
3169
3424
|
if (prop === Symbol.iterator) {
|
|
3170
3425
|
return function* () {
|
|
3171
|
-
|
|
3426
|
+
// read length reactively: a spread/for-of inside a computed/effect must re-run
|
|
3427
|
+
// when items are added or removed, not only when already-read elements change
|
|
3428
|
+
for (let i = 0; i < lengthSignal(); i++) {
|
|
3172
3429
|
yield receiver[i];
|
|
3173
3430
|
}
|
|
3174
3431
|
};
|
|
@@ -3207,19 +3464,8 @@ function toArrayStore(source, injector, vivify, noUnionLeaves = false) {
|
|
|
3207
3464
|
})
|
|
3208
3465
|
: derived(target, {
|
|
3209
3466
|
from: (v) => v?.[idx],
|
|
3210
|
-
onChange: (
|
|
3211
|
-
|
|
3212
|
-
if (container === null || container === undefined)
|
|
3213
|
-
return container;
|
|
3214
|
-
try {
|
|
3215
|
-
container[idx] = newValue;
|
|
3216
|
-
}
|
|
3217
|
-
catch (e) {
|
|
3218
|
-
if (isDevMode())
|
|
3219
|
-
console.error(`[store] Failed to set property "${String(idx)}"`, e);
|
|
3220
|
-
}
|
|
3221
|
-
return container;
|
|
3222
|
-
}),
|
|
3467
|
+
onChange: createFallbackOnChange(target, idx, vivifyFn, isMutableSource),
|
|
3468
|
+
equal: equalFn,
|
|
3223
3469
|
});
|
|
3224
3470
|
const childSample = untracked(computation);
|
|
3225
3471
|
const childVivify = resolveVivify(childSample, vivify);
|
|
@@ -3239,6 +3485,13 @@ function toArrayStore(source, injector, vivify, noUnionLeaves = false) {
|
|
|
3239
3485
|
/**
|
|
3240
3486
|
* Converts a Signal into a deep-observable Store.
|
|
3241
3487
|
* Accessing nested properties returns a derived Signal of that path.
|
|
3488
|
+
*
|
|
3489
|
+
* @remarks
|
|
3490
|
+
* A child's *container kind* (array store vs object store) is resolved when the child is
|
|
3491
|
+
* first accessed and cached with the proxy. Leaf↔substore flips are tracked reactively, but a
|
|
3492
|
+
* union-typed node that later flips between an array and a record keeps its original trap set —
|
|
3493
|
+
* if you need that, re-model the union as `{ kind: ..., value: ... }` instead.
|
|
3494
|
+
*
|
|
3242
3495
|
* @example
|
|
3243
3496
|
* const state = store({ user: { name: 'John' } });
|
|
3244
3497
|
* const nameSignal = state.user.name; // WritableSignal<string>
|
|
@@ -3321,19 +3574,8 @@ function toStore(source, injector, vivify = false, noUnionLeaves = false) {
|
|
|
3321
3574
|
? derived(target, prop, { equal: equalFn, vivify: nodeVivify })
|
|
3322
3575
|
: derived(target, {
|
|
3323
3576
|
from: (v) => v?.[prop],
|
|
3324
|
-
onChange: (
|
|
3325
|
-
|
|
3326
|
-
if (container === null || container === undefined)
|
|
3327
|
-
return container;
|
|
3328
|
-
try {
|
|
3329
|
-
container[prop] = newValue;
|
|
3330
|
-
}
|
|
3331
|
-
catch (e) {
|
|
3332
|
-
if (isDevMode())
|
|
3333
|
-
console.error(`[store] Failed to set property "${String(prop)}"`, e);
|
|
3334
|
-
}
|
|
3335
|
-
return container;
|
|
3336
|
-
}),
|
|
3577
|
+
onChange: createFallbackOnChange(target, prop, vivifyFn, isMutableSource),
|
|
3578
|
+
equal: equalFn,
|
|
3337
3579
|
});
|
|
3338
3580
|
const childSample = untracked(computation);
|
|
3339
3581
|
const childVivify = resolveVivify(childSample, vivify);
|
|
@@ -3475,7 +3717,12 @@ function merge3(ancestor, mine, theirs) {
|
|
|
3475
3717
|
if (isPlainRecord(mine) && isPlainRecord(theirs) && isPlainRecord(ancestor)) {
|
|
3476
3718
|
const out = { ...theirs };
|
|
3477
3719
|
for (const key of new Set([...Object.keys(mine), ...Object.keys(theirs)])) {
|
|
3478
|
-
|
|
3720
|
+
const merged = merge3(ancestor[key], mine[key], theirs[key]);
|
|
3721
|
+
// a key deleted on the fork must commit as ABSENT, not as an explicit `undefined`
|
|
3722
|
+
if (merged === undefined && !(key in mine))
|
|
3723
|
+
delete out[key];
|
|
3724
|
+
else
|
|
3725
|
+
out[key] = merged;
|
|
3479
3726
|
}
|
|
3480
3727
|
return out;
|
|
3481
3728
|
}
|
|
@@ -3530,8 +3777,8 @@ const noopStore = {
|
|
|
3530
3777
|
*
|
|
3531
3778
|
* @template T The type of value held by the signal and stored (after serialization).
|
|
3532
3779
|
* @param fallback The default value of type `T` to use when no value is found in storage
|
|
3533
|
-
* or when deserialization fails.
|
|
3534
|
-
*
|
|
3780
|
+
* or when deserialization fails. A stored value (including a legitimate `null` for a
|
|
3781
|
+
* nullable `T`) always round-trips; the fallback only surfaces when the entry is absent.
|
|
3535
3782
|
* @param options Configuration options (`CreateStoredOptions<T>`). Requires at least the `key`.
|
|
3536
3783
|
* @returns A `StoredSignal<T>` instance. This signal behaves like a standard `WritableSignal<T>`,
|
|
3537
3784
|
* but its value is persisted. It includes a `.clear()` method to remove the item from storage
|
|
@@ -3544,7 +3791,8 @@ const noopStore = {
|
|
|
3544
3791
|
* - **Error Handling:** Catches and logs errors during serialization/deserialization in dev mode.
|
|
3545
3792
|
* - **Tab Sync:** If `syncTabs` is true, listens to `storage` events to keep the signal value
|
|
3546
3793
|
* consistent across browser tabs using the same key. Cleanup is handled automatically
|
|
3547
|
-
* using `DestroyRef`.
|
|
3794
|
+
* using `DestroyRef`. Web Storage only: the `storage` event never fires for custom `store`
|
|
3795
|
+
* adapters, so `syncTabs` has no effect with one.
|
|
3548
3796
|
* - **Removal:** Use the `.clear()` method on the returned signal to remove the item from storage.
|
|
3549
3797
|
* Setting the signal to the fallback value will store the fallback value, not remove the item.
|
|
3550
3798
|
*
|
|
@@ -3579,25 +3827,28 @@ function stored(fallback, { key, store: providedStore, serialize = JSON.stringif
|
|
|
3579
3827
|
: isSignal(key)
|
|
3580
3828
|
? key
|
|
3581
3829
|
: computed(key);
|
|
3830
|
+
// "no stored value" marker — distinct from `null`/`undefined`, so a nullable `T` can
|
|
3831
|
+
// round-trip a legitimate `null` through `set` instead of it acting like `clear()`
|
|
3832
|
+
const EMPTY = Symbol();
|
|
3582
3833
|
const getValue = (key) => {
|
|
3583
3834
|
const found = store.getItem(key);
|
|
3584
3835
|
if (found === null)
|
|
3585
|
-
return
|
|
3836
|
+
return EMPTY;
|
|
3586
3837
|
try {
|
|
3587
3838
|
const deserialized = deserialize(found);
|
|
3588
3839
|
if (!validate(deserialized))
|
|
3589
|
-
return
|
|
3840
|
+
return EMPTY;
|
|
3590
3841
|
return deserialized;
|
|
3591
3842
|
}
|
|
3592
3843
|
catch (err) {
|
|
3593
3844
|
if (isDevMode())
|
|
3594
3845
|
console.error(`Failed to parse stored value for key "${key}":`, err);
|
|
3595
|
-
return
|
|
3846
|
+
return EMPTY;
|
|
3596
3847
|
}
|
|
3597
3848
|
};
|
|
3598
3849
|
const storeValue = (key, value) => {
|
|
3599
3850
|
try {
|
|
3600
|
-
if (value ===
|
|
3851
|
+
if (value === EMPTY)
|
|
3601
3852
|
return store.removeItem(key);
|
|
3602
3853
|
const serialized = serialize(value);
|
|
3603
3854
|
store.setItem(key, serialized);
|
|
@@ -3614,17 +3865,17 @@ function stored(fallback, { key, store: providedStore, serialize = JSON.stringif
|
|
|
3614
3865
|
const initialKey = untracked(keySig);
|
|
3615
3866
|
const internal = signal(getValue(initialKey), ...(ngDevMode ? [{ debugName: "internal", ...opt,
|
|
3616
3867
|
equal: (a, b) => {
|
|
3617
|
-
if (a ===
|
|
3868
|
+
if (a === EMPTY && b === EMPTY)
|
|
3618
3869
|
return true;
|
|
3619
|
-
if (a ===
|
|
3870
|
+
if (a === EMPTY || b === EMPTY)
|
|
3620
3871
|
return false;
|
|
3621
3872
|
return equal(a, b);
|
|
3622
3873
|
} }] : [{
|
|
3623
3874
|
...opt,
|
|
3624
3875
|
equal: (a, b) => {
|
|
3625
|
-
if (a ===
|
|
3876
|
+
if (a === EMPTY && b === EMPTY)
|
|
3626
3877
|
return true;
|
|
3627
|
-
if (a ===
|
|
3878
|
+
if (a === EMPTY || b === EMPTY)
|
|
3628
3879
|
return false;
|
|
3629
3880
|
return equal(a, b);
|
|
3630
3881
|
},
|
|
@@ -3660,19 +3911,27 @@ function stored(fallback, { key, store: providedStore, serialize = JSON.stringif
|
|
|
3660
3911
|
if (syncTabs && !isServer) {
|
|
3661
3912
|
const destroyRef = inject(DestroyRef);
|
|
3662
3913
|
const sync = (e) => {
|
|
3914
|
+
// `storage` events only describe Web Storage — ignore events for a different
|
|
3915
|
+
// storage area (or any event when a custom adapter is configured), otherwise an
|
|
3916
|
+
// unrelated localStorage write with the same key string corrupts our state
|
|
3917
|
+
if (e.storageArea !== store)
|
|
3918
|
+
return;
|
|
3663
3919
|
if (e.key !== untracked(keySig))
|
|
3664
3920
|
return;
|
|
3665
3921
|
if (e.newValue === null)
|
|
3666
|
-
internal.set(
|
|
3922
|
+
internal.set(EMPTY);
|
|
3667
3923
|
else
|
|
3668
3924
|
internal.set(getValue(e.key));
|
|
3669
3925
|
};
|
|
3670
3926
|
window.addEventListener('storage', sync);
|
|
3671
3927
|
destroyRef.onDestroy(() => window.removeEventListener('storage', sync));
|
|
3672
3928
|
}
|
|
3673
|
-
const writable = toWritable(computed(() =>
|
|
3929
|
+
const writable = toWritable(computed(() => {
|
|
3930
|
+
const v = internal();
|
|
3931
|
+
return v === EMPTY ? fallback : v;
|
|
3932
|
+
}, opt), internal.set);
|
|
3674
3933
|
writable.clear = () => {
|
|
3675
|
-
internal.set(
|
|
3934
|
+
internal.set(EMPTY);
|
|
3676
3935
|
};
|
|
3677
3936
|
writable.key = keySig;
|
|
3678
3937
|
return writable;
|
|
@@ -3682,7 +3941,6 @@ class MessageBus {
|
|
|
3682
3941
|
channel = new BroadcastChannel('mmstack-tab-sync-bus');
|
|
3683
3942
|
listeners = new Map();
|
|
3684
3943
|
subscribe(id, listener) {
|
|
3685
|
-
this.unsubscribe(id); // Ensure no duplicate listeners
|
|
3686
3944
|
const wrapped = (ev) => {
|
|
3687
3945
|
try {
|
|
3688
3946
|
if (ev.data?.id === id)
|
|
@@ -3693,18 +3951,28 @@ class MessageBus {
|
|
|
3693
3951
|
}
|
|
3694
3952
|
};
|
|
3695
3953
|
this.channel.addEventListener('message', wrapped);
|
|
3696
|
-
this.listeners.
|
|
3954
|
+
let set = this.listeners.get(id);
|
|
3955
|
+
if (!set) {
|
|
3956
|
+
set = new Set();
|
|
3957
|
+
this.listeners.set(id, set);
|
|
3958
|
+
}
|
|
3959
|
+
set.add(wrapped);
|
|
3697
3960
|
return {
|
|
3698
|
-
unsub: (
|
|
3699
|
-
|
|
3961
|
+
unsub: () => {
|
|
3962
|
+
this.channel.removeEventListener('message', wrapped);
|
|
3963
|
+
const cur = this.listeners.get(id);
|
|
3964
|
+
if (!cur)
|
|
3965
|
+
return;
|
|
3966
|
+
cur.delete(wrapped);
|
|
3967
|
+
if (cur.size === 0)
|
|
3968
|
+
this.listeners.delete(id);
|
|
3969
|
+
},
|
|
3970
|
+
post: (value) => this.channel.postMessage({ id, value }),
|
|
3700
3971
|
};
|
|
3701
3972
|
}
|
|
3702
|
-
|
|
3703
|
-
|
|
3704
|
-
|
|
3705
|
-
return;
|
|
3706
|
-
this.channel.removeEventListener('message', listener);
|
|
3707
|
-
this.listeners.delete(id);
|
|
3973
|
+
ngOnDestroy() {
|
|
3974
|
+
this.channel.close();
|
|
3975
|
+
this.listeners.clear();
|
|
3708
3976
|
}
|
|
3709
3977
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.17", ngImport: i0, type: MessageBus, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
3710
3978
|
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.17", ngImport: i0, type: MessageBus, providedIn: 'root' });
|
|
@@ -3715,6 +3983,11 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.17", ngImpo
|
|
|
3715
3983
|
providedIn: 'root',
|
|
3716
3984
|
}]
|
|
3717
3985
|
}] });
|
|
3986
|
+
/**
|
|
3987
|
+
* @deprecated The generated id hashes the call-site stack line, which collides when a shared
|
|
3988
|
+
* helper calls {@link tabSync} for multiple signals and diverges across minified builds during
|
|
3989
|
+
* a rolling deploy. Pass an explicit `{ id }` instead.
|
|
3990
|
+
*/
|
|
3718
3991
|
function generateDeterministicID() {
|
|
3719
3992
|
const stack = new Error().stack;
|
|
3720
3993
|
if (stack) {
|
|
@@ -3752,10 +4025,8 @@ function generateDeterministicID() {
|
|
|
3752
4025
|
*
|
|
3753
4026
|
* @example
|
|
3754
4027
|
* ```typescript
|
|
3755
|
-
* //
|
|
3756
|
-
* const theme = tabSync(signal('dark'));
|
|
3757
|
-
*
|
|
3758
|
-
* // With explicit ID (recommended for production)
|
|
4028
|
+
* // With explicit ID (recommended)
|
|
4029
|
+
* const theme = tabSync(signal('dark'), { id: 'theme' });
|
|
3759
4030
|
* const userPrefs = tabSync(signal({ lang: 'en' }), { id: 'user-preferences' });
|
|
3760
4031
|
*
|
|
3761
4032
|
* // Changes in one tab will sync to all other tabs
|
|
@@ -3767,6 +4038,7 @@ function generateDeterministicID() {
|
|
|
3767
4038
|
* - Uses a single BroadcastChannel for all synchronized signals
|
|
3768
4039
|
* - Automatically cleans up listeners when the injection context is destroyed
|
|
3769
4040
|
* - Initial signal value after sync setup is not broadcasted to prevent loops
|
|
4041
|
+
* - Received values are not re-broadcast, so tabs never echo each other's updates
|
|
3770
4042
|
*
|
|
3771
4043
|
*/
|
|
3772
4044
|
function tabSync(sig, opt) {
|
|
@@ -3774,7 +4046,20 @@ function tabSync(sig, opt) {
|
|
|
3774
4046
|
return sig;
|
|
3775
4047
|
const id = typeof opt === 'string' ? opt : (opt?.id ?? generateDeterministicID());
|
|
3776
4048
|
const bus = inject(MessageBus);
|
|
3777
|
-
|
|
4049
|
+
// The last value applied from a remote tab. The outbound effect skips (exactly) the run
|
|
4050
|
+
// caused by that write — without this, an inbound object (a fresh structured clone, so
|
|
4051
|
+
// never reference-equal) would be re-posted, and two tabs would ping-pong forever.
|
|
4052
|
+
const NONE = Symbol();
|
|
4053
|
+
let received = NONE;
|
|
4054
|
+
const { unsub, post } = bus.subscribe(id, (next) => {
|
|
4055
|
+
const before = untracked(sig);
|
|
4056
|
+
received = next;
|
|
4057
|
+
sig.set(next);
|
|
4058
|
+
// Equality-suppressed write (e.g. an identical primitive): no effect run will follow,
|
|
4059
|
+
// so clear the marker — it must not swallow a later, genuinely local change.
|
|
4060
|
+
if (untracked(sig) === before)
|
|
4061
|
+
received = NONE;
|
|
4062
|
+
});
|
|
3778
4063
|
let first = false;
|
|
3779
4064
|
const effectRef = effect(() => {
|
|
3780
4065
|
const val = sig();
|
|
@@ -3782,6 +4067,11 @@ function tabSync(sig, opt) {
|
|
|
3782
4067
|
first = true;
|
|
3783
4068
|
return;
|
|
3784
4069
|
}
|
|
4070
|
+
if (val === received) {
|
|
4071
|
+
received = NONE;
|
|
4072
|
+
return;
|
|
4073
|
+
}
|
|
4074
|
+
received = NONE;
|
|
3785
4075
|
post(val);
|
|
3786
4076
|
}, ...(ngDevMode ? [{ debugName: "effectRef" }] : []));
|
|
3787
4077
|
inject(DestroyRef).onDestroy(() => {
|
|
@@ -3792,7 +4082,6 @@ function tabSync(sig, opt) {
|
|
|
3792
4082
|
}
|
|
3793
4083
|
|
|
3794
4084
|
function until(sourceSignal, predicate, options = {}) {
|
|
3795
|
-
const injector = options.injector ?? inject(Injector);
|
|
3796
4085
|
return new Promise((resolve, reject) => {
|
|
3797
4086
|
let effectRef;
|
|
3798
4087
|
let timeoutId;
|
|
@@ -3829,6 +4118,14 @@ function until(sourceSignal, predicate, options = {}) {
|
|
|
3829
4118
|
cleanupAndResolve(initialValue);
|
|
3830
4119
|
return;
|
|
3831
4120
|
}
|
|
4121
|
+
let injector;
|
|
4122
|
+
try {
|
|
4123
|
+
injector = options.injector ?? inject(Injector);
|
|
4124
|
+
}
|
|
4125
|
+
catch {
|
|
4126
|
+
cleanupAndReject('until: No injector available — provide options.injector when calling outside an injection context.');
|
|
4127
|
+
return;
|
|
4128
|
+
}
|
|
3832
4129
|
if (options?.timeout !== undefined) {
|
|
3833
4130
|
timeoutId = setTimeout(() => cleanupAndReject(`until: Timeout after ${options.timeout}ms.`), options.timeout);
|
|
3834
4131
|
}
|
|
@@ -3846,17 +4143,6 @@ function until(sourceSignal, predicate, options = {}) {
|
|
|
3846
4143
|
});
|
|
3847
4144
|
}
|
|
3848
4145
|
|
|
3849
|
-
/**
|
|
3850
|
-
* @interal
|
|
3851
|
-
*/
|
|
3852
|
-
function getSignalEquality(sig) {
|
|
3853
|
-
const internal = sig[SIGNAL];
|
|
3854
|
-
if (internal && typeof internal.equal === 'function') {
|
|
3855
|
-
return internal.equal;
|
|
3856
|
-
}
|
|
3857
|
-
return Object.is; // Default equality check
|
|
3858
|
-
}
|
|
3859
|
-
|
|
3860
4146
|
/**
|
|
3861
4147
|
* Enhances an existing `WritableSignal` by adding a complete undo/redo history
|
|
3862
4148
|
* stack and an API to control it.
|
|
@@ -3905,9 +4191,10 @@ function getSignalEquality(sig) {
|
|
|
3905
4191
|
* ```
|
|
3906
4192
|
*/
|
|
3907
4193
|
function withHistory(sourceOrValue, opt) {
|
|
3908
|
-
const equal =
|
|
3909
|
-
|
|
3910
|
-
|
|
4194
|
+
const equal = opt?.equal ??
|
|
4195
|
+
(isSignal(sourceOrValue)
|
|
4196
|
+
? getSignalEquality(sourceOrValue)
|
|
4197
|
+
: Object.is);
|
|
3911
4198
|
const source = isSignal(sourceOrValue)
|
|
3912
4199
|
? sourceOrValue
|
|
3913
4200
|
: signal(sourceOrValue);
|
|
@@ -3952,9 +4239,8 @@ function withHistory(sourceOrValue, opt) {
|
|
|
3952
4239
|
if (historyStack.length === 0)
|
|
3953
4240
|
return;
|
|
3954
4241
|
const valueForRedo = untracked(source);
|
|
3955
|
-
|
|
3956
|
-
|
|
3957
|
-
return;
|
|
4242
|
+
// length checked above — a legitimately `undefined` entry must still restore
|
|
4243
|
+
const valueToRestore = historyStack[historyStack.length - 1];
|
|
3958
4244
|
originalSet.call(source, valueToRestore);
|
|
3959
4245
|
history.inline((h) => h.pop());
|
|
3960
4246
|
redoArray.mutate((r) => {
|
|
@@ -3968,9 +4254,8 @@ function withHistory(sourceOrValue, opt) {
|
|
|
3968
4254
|
if (redoStack.length === 0)
|
|
3969
4255
|
return;
|
|
3970
4256
|
const valueForUndo = untracked(source);
|
|
3971
|
-
|
|
3972
|
-
|
|
3973
|
-
return;
|
|
4257
|
+
// length checked above — a legitimately `undefined` entry must still restore
|
|
4258
|
+
const valueToRestore = redoStack[redoStack.length - 1];
|
|
3974
4259
|
originalSet.call(source, valueToRestore);
|
|
3975
4260
|
redoArray.inline((r) => r.pop());
|
|
3976
4261
|
history.mutate((h) => {
|