@mmstack/primitives 19.4.1 → 19.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/fesm2022/mmstack-primitives.mjs +570 -245
- 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/concurrent/transition-scope.d.ts +15 -1
- package/lib/effect/index.d.ts +1 -0
- package/lib/effect/nested-effect.d.ts +2 -2
- 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 +2 -2
|
@@ -80,7 +80,7 @@ function popFrame() {
|
|
|
80
80
|
* ]);
|
|
81
81
|
*
|
|
82
82
|
* // The fine-grained mapped list
|
|
83
|
-
* const mappedUsers =
|
|
83
|
+
* const mappedUsers = indexArray(
|
|
84
84
|
* users,
|
|
85
85
|
* (userSignal, index) => {
|
|
86
86
|
* // 1. Create a fine-grained SIDE EFFECT for *this item*
|
|
@@ -101,7 +101,7 @@ function popFrame() {
|
|
|
101
101
|
* };
|
|
102
102
|
* },
|
|
103
103
|
* {
|
|
104
|
-
* // 3. Tell
|
|
104
|
+
* // 3. Tell indexArray HOW to clean up when an item is removed, this needs to be manual as it's not a nestedEffect itself
|
|
105
105
|
* onDestroy: (mappedItem) => {
|
|
106
106
|
* mappedItem.destroyEffect();
|
|
107
107
|
* }
|
|
@@ -158,7 +158,7 @@ function nestedEffect(effectFn, options) {
|
|
|
158
158
|
/**
|
|
159
159
|
* Creates a new `Signal` that processes an array of items in time-sliced chunks. This is useful for handling large lists without blocking the main thread.
|
|
160
160
|
*
|
|
161
|
-
* The returned signal will initially contain the first `chunkSize` items from the source array. It will then schedule updates to include additional chunks of items based on the specified `
|
|
161
|
+
* The returned signal will initially contain the first `chunkSize` items from the source array. It will then schedule updates to include additional chunks of items based on the specified `delay`.
|
|
162
162
|
*
|
|
163
163
|
* @template T The type of items in the array.
|
|
164
164
|
* @param source A `Signal` or a function that returns an array of items to be processed in chunks.
|
|
@@ -167,16 +167,23 @@ function nestedEffect(effectFn, options) {
|
|
|
167
167
|
*
|
|
168
168
|
* @example
|
|
169
169
|
* const largeList = signal(Array.from({ length: 1000 }, (_, i) => i));
|
|
170
|
-
* const chunkedList = chunked(largeList, { chunkSize: 100,
|
|
170
|
+
* const chunkedList = chunked(largeList, { chunkSize: 100, delay: 100 });
|
|
171
171
|
*/
|
|
172
172
|
function chunked(source, options) {
|
|
173
173
|
const { chunkSize = 50, delay = 'frame', equal, injector } = options || {};
|
|
174
174
|
let delayFn;
|
|
175
175
|
if (delay === 'frame') {
|
|
176
|
-
delayFn =
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
176
|
+
delayFn =
|
|
177
|
+
typeof requestAnimationFrame === 'function'
|
|
178
|
+
? (callback) => {
|
|
179
|
+
const num = requestAnimationFrame(callback);
|
|
180
|
+
return () => cancelAnimationFrame(num);
|
|
181
|
+
}
|
|
182
|
+
: // SSR: no requestAnimationFrame — approximate a frame with a timeout
|
|
183
|
+
(cb) => {
|
|
184
|
+
const num = setTimeout(cb, 16);
|
|
185
|
+
return () => clearTimeout(num);
|
|
186
|
+
};
|
|
180
187
|
}
|
|
181
188
|
else if (delay === 'microtask') {
|
|
182
189
|
delayFn = (cb) => {
|
|
@@ -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,12 +561,53 @@ 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
|
}
|
|
542
568
|
return scope;
|
|
543
569
|
}
|
|
570
|
+
function createForwardingScope() {
|
|
571
|
+
const own = createTransitionScope();
|
|
572
|
+
const target = signal(null);
|
|
573
|
+
const eff = () => target() ?? own;
|
|
574
|
+
const owners = new Map();
|
|
575
|
+
return {
|
|
576
|
+
setTarget: (t) => target.set(t),
|
|
577
|
+
resources: computed(() => eff().resources()),
|
|
578
|
+
pending: computed(() => eff().pending()),
|
|
579
|
+
suspended: (type) => eff().suspended(type),
|
|
580
|
+
add: (ref, opt) => {
|
|
581
|
+
const t = untracked(target) ?? own;
|
|
582
|
+
owners.set(ref, t);
|
|
583
|
+
t.add(ref, opt);
|
|
584
|
+
},
|
|
585
|
+
remove: (ref) => {
|
|
586
|
+
const t = owners.get(ref) ?? untracked(target) ?? own;
|
|
587
|
+
t.remove(ref);
|
|
588
|
+
owners.delete(ref);
|
|
589
|
+
},
|
|
590
|
+
commit: (value) => linkedSignal({
|
|
591
|
+
source: () => ({ v: value(), settled: !eff().pending() }),
|
|
592
|
+
computation: (curr, prev) => curr.settled || prev === undefined ? curr.v : prev.value,
|
|
593
|
+
}),
|
|
594
|
+
holding: computed(() => eff().holding()),
|
|
595
|
+
beginHold: () => (untracked(target) ?? own).beginHold(),
|
|
596
|
+
endHold: () => (untracked(target) ?? own).endHold(),
|
|
597
|
+
hold: (value) => linkedSignal({
|
|
598
|
+
source: () => ({ v: value(), held: eff().holding() }),
|
|
599
|
+
computation: (curr, prev) => prev !== undefined && curr.held ? prev.value : curr.v,
|
|
600
|
+
}),
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
/** Provide a forwarding transition scope at a boundary (used by the transition outlet). */
|
|
604
|
+
function provideForwardingTransitionScope() {
|
|
605
|
+
return { provide: TRANSITION_SCOPE, useFactory: createForwardingScope };
|
|
606
|
+
}
|
|
607
|
+
/** Read the transition scope reachable from `injector`, or null if none is provided there. */
|
|
608
|
+
function getTransitionScope(injector) {
|
|
609
|
+
return injector.get(TRANSITION_SCOPE, null);
|
|
610
|
+
}
|
|
544
611
|
/**
|
|
545
612
|
* Returns a register function bound to the nearest transition scope: it adds a resource
|
|
546
613
|
* to the scope and removes it when the caller's injection context is destroyed. Pass any
|
|
@@ -569,6 +636,11 @@ function registerResource(res, opt) {
|
|
|
569
636
|
*
|
|
570
637
|
* Must be called in an injection context. This is the *async* generalization (Tier 2): it adds
|
|
571
638
|
* no rendering cost and needs no fork — holding direct/sync readers is a separate, deferred tier.
|
|
639
|
+
*
|
|
640
|
+
* Caveat: work must go in flight by the first post-write render to be awaited. A loader that
|
|
641
|
+
* starts later (a debounced request signal, a chained/deferred resource) is not attributable to
|
|
642
|
+
* this transition — the no-async fallback will have already resolved `done`. Trigger such work
|
|
643
|
+
* eagerly inside `fn`, or coordinate it separately.
|
|
572
644
|
*/
|
|
573
645
|
function injectStartTransition() {
|
|
574
646
|
const scope = injectTransitionScope();
|
|
@@ -718,6 +790,11 @@ function runInTransaction(txn, fn) {
|
|
|
718
790
|
* The writes land on LIVE state immediately (so derived variables and connector requests see the
|
|
719
791
|
* new values and refetch); only the *display* is held, via `scope.hold`. Must run in an injection
|
|
720
792
|
* context.
|
|
793
|
+
*
|
|
794
|
+
* Caveat: work must go in flight by the first post-write render to be part of the transaction. A
|
|
795
|
+
* loader that starts later (a debounced request signal, a chained/deferred resource) is not
|
|
796
|
+
* attributable to it — the no-async fallback will have already committed and released the hold,
|
|
797
|
+
* after which `abort()` is a no-op. Trigger such work eagerly inside `fn`.
|
|
721
798
|
*/
|
|
722
799
|
function injectStartTransaction() {
|
|
723
800
|
const scope = injectTransitionScope();
|
|
@@ -727,7 +804,15 @@ function injectStartTransaction() {
|
|
|
727
804
|
// Hold BEFORE the writes, so the display freezes at pre-transaction values.
|
|
728
805
|
scope.beginHold();
|
|
729
806
|
let finished = false;
|
|
807
|
+
// eslint-disable-next-line prefer-const -- assigned in try/catch, but needs to be declared here for the `finally` block to see it
|
|
730
808
|
let watcher;
|
|
809
|
+
let resolveDone;
|
|
810
|
+
const done = new Promise((resolve) => {
|
|
811
|
+
resolveDone = resolve;
|
|
812
|
+
});
|
|
813
|
+
// Every exit path funnels through here, so `done` always settles — including `abort()`
|
|
814
|
+
// and a throwing transaction body (which would otherwise leak the hold forever and
|
|
815
|
+
// freeze the boundary with no recovery).
|
|
731
816
|
const finish = (restore) => {
|
|
732
817
|
if (finished)
|
|
733
818
|
return;
|
|
@@ -738,27 +823,28 @@ function injectStartTransaction() {
|
|
|
738
823
|
else
|
|
739
824
|
txn.clear();
|
|
740
825
|
scope.endHold();
|
|
826
|
+
resolveDone();
|
|
741
827
|
};
|
|
742
|
-
|
|
828
|
+
try {
|
|
829
|
+
runInTransaction(txn, fn);
|
|
830
|
+
}
|
|
831
|
+
catch (e) {
|
|
832
|
+
finish(true);
|
|
833
|
+
throw e;
|
|
834
|
+
}
|
|
743
835
|
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
|
-
});
|
|
836
|
+
watcher = effect(() => {
|
|
837
|
+
const p = scope.pending();
|
|
838
|
+
if (p)
|
|
839
|
+
sawPending = true;
|
|
840
|
+
if (sawPending && !p)
|
|
841
|
+
finish(false);
|
|
842
|
+
}, { injector });
|
|
843
|
+
// no-async fallback: if nothing ever went in flight, settle once the writes are processed.
|
|
844
|
+
afterNextRender(() => {
|
|
845
|
+
if (!sawPending && !untracked(scope.pending))
|
|
846
|
+
finish(false);
|
|
847
|
+
}, { injector });
|
|
762
848
|
return {
|
|
763
849
|
pending: scope.pending,
|
|
764
850
|
done,
|
|
@@ -767,6 +853,17 @@ function injectStartTransaction() {
|
|
|
767
853
|
};
|
|
768
854
|
}
|
|
769
855
|
|
|
856
|
+
/**
|
|
857
|
+
* @internal
|
|
858
|
+
*/
|
|
859
|
+
function getSignalEquality(sig) {
|
|
860
|
+
const internal = sig[SIGNAL];
|
|
861
|
+
if (internal && typeof internal.equal === 'function') {
|
|
862
|
+
return internal.equal;
|
|
863
|
+
}
|
|
864
|
+
return Object.is; // Default equality check
|
|
865
|
+
}
|
|
866
|
+
|
|
770
867
|
/**
|
|
771
868
|
* Converts a read-only `Signal` into a `WritableSignal` by providing custom `set` and, optionally, `update` functions.
|
|
772
869
|
* This can be useful for creating controlled write access to a signal that is otherwise read-only.
|
|
@@ -865,6 +962,7 @@ function debounced(initial, opt) {
|
|
|
865
962
|
* ```
|
|
866
963
|
*/
|
|
867
964
|
function debounce(source, opt) {
|
|
965
|
+
const eq = opt?.equal ?? getSignalEquality(source);
|
|
868
966
|
const ms = opt?.ms ?? 0;
|
|
869
967
|
const trigger = signal(false);
|
|
870
968
|
let timeout;
|
|
@@ -879,25 +977,25 @@ function debounce(source, opt) {
|
|
|
879
977
|
catch {
|
|
880
978
|
// not in injection context & no destroyRef provided opting out of cleanup
|
|
881
979
|
}
|
|
882
|
-
const
|
|
980
|
+
const set = (next) => {
|
|
981
|
+
const isEqual = eq(untracked(source), next);
|
|
982
|
+
if (!timeout && isEqual)
|
|
983
|
+
return; // nothing to do
|
|
883
984
|
if (timeout)
|
|
884
|
-
clearTimeout(timeout);
|
|
885
|
-
|
|
985
|
+
clearTimeout(timeout); // clear pending
|
|
986
|
+
if (!isEqual)
|
|
987
|
+
source.set(next);
|
|
886
988
|
timeout = setTimeout(() => {
|
|
989
|
+
timeout = undefined;
|
|
887
990
|
trigger.update((c) => !c);
|
|
888
991
|
}, ms);
|
|
889
992
|
};
|
|
890
|
-
const
|
|
891
|
-
triggerFn(value);
|
|
892
|
-
};
|
|
893
|
-
const update = (fn) => {
|
|
894
|
-
triggerFn(fn(untracked(source)));
|
|
895
|
-
};
|
|
993
|
+
const update = (fn) => set(fn(untracked(source)));
|
|
896
994
|
const writable = toWritable(computed(() => {
|
|
897
995
|
trigger();
|
|
898
996
|
return untracked(source);
|
|
899
997
|
}, opt), set, update);
|
|
900
|
-
writable.original = source;
|
|
998
|
+
writable.original = source.asReadonly();
|
|
901
999
|
return writable;
|
|
902
1000
|
}
|
|
903
1001
|
|
|
@@ -1068,8 +1166,18 @@ function derived(source, optOrKey, opt) {
|
|
|
1068
1166
|
if (isMutable(source)) {
|
|
1069
1167
|
sig.mutate = (updater) => {
|
|
1070
1168
|
cnt++;
|
|
1071
|
-
|
|
1072
|
-
|
|
1169
|
+
try {
|
|
1170
|
+
sig.update(updater);
|
|
1171
|
+
// The wrapped computed evaluates its `equal` lazily — at the next read, which would
|
|
1172
|
+
// normally happen after `cnt` has already dropped back to 0. For a reference-stable
|
|
1173
|
+
// mutation that read compares the same object to itself and the version never bumps,
|
|
1174
|
+
// so dependents are never notified. Reading here, while equality is still suppressed,
|
|
1175
|
+
// forces the recompute (and version bump) inside the mutate window.
|
|
1176
|
+
untracked(sig);
|
|
1177
|
+
}
|
|
1178
|
+
finally {
|
|
1179
|
+
cnt--;
|
|
1180
|
+
}
|
|
1073
1181
|
};
|
|
1074
1182
|
sig.inline = (updater) => {
|
|
1075
1183
|
sig.mutate((prev) => {
|
|
@@ -1151,18 +1259,45 @@ function createSetter(source) {
|
|
|
1151
1259
|
}
|
|
1152
1260
|
|
|
1153
1261
|
function keepPrevious(src, opt) {
|
|
1262
|
+
const mutableSrc = isWritableSignal$1(src) && isMutable(src);
|
|
1263
|
+
// For a mutable source the linkedSignal's equality must be suppressible: a forwarded
|
|
1264
|
+
// `mutate` keeps the same reference, which default equality would otherwise swallow.
|
|
1265
|
+
let cnt = 0;
|
|
1266
|
+
const baseEqual = opt?.equal;
|
|
1267
|
+
const equal = mutableSrc
|
|
1268
|
+
? (a, b) => cnt > 0 ? false : baseEqual ? baseEqual(a, b) : Object.is(a, b)
|
|
1269
|
+
: baseEqual;
|
|
1154
1270
|
const persisted = linkedSignal({
|
|
1155
1271
|
...opt,
|
|
1156
1272
|
source: () => src(),
|
|
1157
1273
|
computation: (next, prev) => next === undefined && prev !== undefined ? prev.value : next,
|
|
1274
|
+
equal,
|
|
1158
1275
|
});
|
|
1159
1276
|
if (isWritableSignal$1(src)) {
|
|
1160
1277
|
persisted.set = src.set;
|
|
1161
1278
|
persisted.update = src.update;
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1279
|
+
// NOTE: `asReadonly` deliberately stays the linkedSignal's own — returning the
|
|
1280
|
+
// source's readonly view would reintroduce the `undefined` flashes this wrapper exists
|
|
1281
|
+
// to prevent.
|
|
1282
|
+
if (mutableSrc) {
|
|
1283
|
+
persisted.mutate = (updater) => {
|
|
1284
|
+
cnt++;
|
|
1285
|
+
try {
|
|
1286
|
+
src.mutate(updater);
|
|
1287
|
+
// force the recompute while equality is suppressed, so the reference-stable
|
|
1288
|
+
// mutation bumps the wrapper's version (see derived.ts for the same pattern)
|
|
1289
|
+
untracked(persisted);
|
|
1290
|
+
}
|
|
1291
|
+
finally {
|
|
1292
|
+
cnt--;
|
|
1293
|
+
}
|
|
1294
|
+
};
|
|
1295
|
+
persisted.inline = (updater) => {
|
|
1296
|
+
persisted.mutate((prev) => {
|
|
1297
|
+
updater(prev);
|
|
1298
|
+
return prev;
|
|
1299
|
+
});
|
|
1300
|
+
};
|
|
1166
1301
|
}
|
|
1167
1302
|
if (isDerivation(src)) {
|
|
1168
1303
|
persisted.from = src.from;
|
|
@@ -1193,13 +1328,18 @@ function indexArray(source, map, opt = {}) {
|
|
|
1193
1328
|
: toWritable(data, () => {
|
|
1194
1329
|
// noop
|
|
1195
1330
|
});
|
|
1331
|
+
// copy before defaulting `equal` — assigning onto `opt` would mutate a caller-owned
|
|
1332
|
+
// (possibly shared/reused) options object
|
|
1196
1333
|
if (isWritableSignal$1(data) && isMutable(data) && !opt.equal) {
|
|
1197
|
-
opt
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1334
|
+
opt = {
|
|
1335
|
+
...opt,
|
|
1336
|
+
equal: (a, b) => {
|
|
1337
|
+
if (typeof a !== typeof b)
|
|
1338
|
+
return false;
|
|
1339
|
+
if (typeof a === 'object' || typeof a === 'function')
|
|
1340
|
+
return false;
|
|
1341
|
+
return a === b;
|
|
1342
|
+
},
|
|
1203
1343
|
};
|
|
1204
1344
|
}
|
|
1205
1345
|
return linkedSignal({
|
|
@@ -1393,8 +1533,17 @@ function pooledKeys(src) {
|
|
|
1393
1533
|
for (const k in val)
|
|
1394
1534
|
if (Object.prototype.hasOwnProperty.call(val, k))
|
|
1395
1535
|
spare.add(k);
|
|
1396
|
-
if (active.size === spare.size
|
|
1397
|
-
|
|
1536
|
+
if (active.size === spare.size) {
|
|
1537
|
+
let subset = true;
|
|
1538
|
+
for (const k of active) {
|
|
1539
|
+
if (!spare.has(k)) {
|
|
1540
|
+
subset = false;
|
|
1541
|
+
break;
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
if (subset)
|
|
1545
|
+
return active;
|
|
1546
|
+
}
|
|
1398
1547
|
const temp = active;
|
|
1399
1548
|
active = spare;
|
|
1400
1549
|
spare = temp;
|
|
@@ -1494,7 +1643,7 @@ const filter = (predicate) => (src) => linkedSignal({
|
|
|
1494
1643
|
computation: (next, prev) => {
|
|
1495
1644
|
if (predicate(next))
|
|
1496
1645
|
return next;
|
|
1497
|
-
return prev?.
|
|
1646
|
+
return prev?.value;
|
|
1498
1647
|
},
|
|
1499
1648
|
});
|
|
1500
1649
|
/**
|
|
@@ -1530,7 +1679,7 @@ const tap = (fn, injector) => (src) => {
|
|
|
1530
1679
|
*/
|
|
1531
1680
|
const filterWith = (predicate, initial) => (src) => linkedSignal({
|
|
1532
1681
|
source: src,
|
|
1533
|
-
computation: (next, prev) => predicate(next) ? next :
|
|
1682
|
+
computation: (next, prev) => predicate(next) ? next : prev ? prev.value : initial,
|
|
1534
1683
|
});
|
|
1535
1684
|
/**
|
|
1536
1685
|
* Emit `initial` on the first read, then mirror the source on every subsequent
|
|
@@ -1579,7 +1728,7 @@ const pairwise = () => (src) => linkedSignal({
|
|
|
1579
1728
|
*/
|
|
1580
1729
|
const scan = (reducer, seed) => (src) => linkedSignal({
|
|
1581
1730
|
source: src,
|
|
1582
|
-
computation: (next, prev) => reducer(prev
|
|
1731
|
+
computation: (next, prev) => reducer(prev ? prev.value : seed, next),
|
|
1583
1732
|
});
|
|
1584
1733
|
|
|
1585
1734
|
/**
|
|
@@ -1630,7 +1779,7 @@ function pipeable(signal) {
|
|
|
1630
1779
|
return internal;
|
|
1631
1780
|
}
|
|
1632
1781
|
/**
|
|
1633
|
-
* Create a new **writable** signal and return it as a `
|
|
1782
|
+
* Create a new **writable** signal and return it as a `PipeableSignal`.
|
|
1634
1783
|
*
|
|
1635
1784
|
* The returned value is a `WritableSignal<T>` with `.set`, `.update`, `.asReadonly`
|
|
1636
1785
|
* still available (via intersection type), plus a chainable `.pipe(...)`.
|
|
@@ -1734,6 +1883,20 @@ function pooledMap(optOrComputation, signalOpt) {
|
|
|
1734
1883
|
return pooled(toPooledOptions(optOrComputation, createEmptyMap, resetClearable, signalOpt));
|
|
1735
1884
|
}
|
|
1736
1885
|
|
|
1886
|
+
/**
|
|
1887
|
+
* @internal Run a sensor factory inside `injector` when provided, else in the ambient
|
|
1888
|
+
* injection context. Keeps every sensor's escape hatch identical and in one place.
|
|
1889
|
+
*/
|
|
1890
|
+
function runInSensorContext(injector, fn) {
|
|
1891
|
+
return injector ? runInInjectionContext(injector, fn) : fn();
|
|
1892
|
+
}
|
|
1893
|
+
/**
|
|
1894
|
+
* @internal Normalize the legacy positional `debugName: string` form into {@link SensorRunOptions}.
|
|
1895
|
+
*/
|
|
1896
|
+
function coerceSensorOptions(opt) {
|
|
1897
|
+
return typeof opt === 'string' ? { debugName: opt } : (opt ?? {});
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1737
1900
|
const EVENTS = [
|
|
1738
1901
|
'chargingchange',
|
|
1739
1902
|
'levelchange',
|
|
@@ -1755,7 +1918,11 @@ const EVENTS = [
|
|
|
1755
1918
|
* });
|
|
1756
1919
|
* ```
|
|
1757
1920
|
*/
|
|
1758
|
-
function batteryStatus(
|
|
1921
|
+
function batteryStatus(opt) {
|
|
1922
|
+
const { debugName = 'batteryStatus', injector } = coerceSensorOptions(opt);
|
|
1923
|
+
return runInSensorContext(injector, () => createBatteryStatus(debugName));
|
|
1924
|
+
}
|
|
1925
|
+
function createBatteryStatus(debugName) {
|
|
1759
1926
|
if (isPlatformServer(inject(PLATFORM_ID)) ||
|
|
1760
1927
|
typeof navigator === 'undefined' ||
|
|
1761
1928
|
typeof navigator.getBattery !== 'function') {
|
|
@@ -1764,7 +1931,9 @@ function batteryStatus(debugName = 'batteryStatus') {
|
|
|
1764
1931
|
const state = signal(null, { debugName });
|
|
1765
1932
|
const abortController = new AbortController();
|
|
1766
1933
|
inject(DestroyRef).onDestroy(() => abortController.abort());
|
|
1767
|
-
navigator
|
|
1934
|
+
navigator
|
|
1935
|
+
.getBattery()
|
|
1936
|
+
.then((battery) => {
|
|
1768
1937
|
if (abortController.signal.aborted)
|
|
1769
1938
|
return;
|
|
1770
1939
|
const read = () => ({
|
|
@@ -1780,6 +1949,10 @@ function batteryStatus(debugName = 'batteryStatus') {
|
|
|
1780
1949
|
signal: abortController.signal,
|
|
1781
1950
|
});
|
|
1782
1951
|
}
|
|
1952
|
+
})
|
|
1953
|
+
.catch(() => {
|
|
1954
|
+
// getBattery() rejects (NotAllowedError) when the `battery` permissions-policy is
|
|
1955
|
+
// disallowed, e.g. in cross-origin iframes — stay `null`, same as unsupported.
|
|
1783
1956
|
});
|
|
1784
1957
|
return state.asReadonly();
|
|
1785
1958
|
}
|
|
@@ -1795,7 +1968,11 @@ function batteryStatus(debugName = 'batteryStatus') {
|
|
|
1795
1968
|
* in browsers that gate it. Errors from `navigator.clipboard.readText` are
|
|
1796
1969
|
* swallowed silently to keep the signal value stable.
|
|
1797
1970
|
*/
|
|
1798
|
-
function clipboard(
|
|
1971
|
+
function clipboard(opt) {
|
|
1972
|
+
const { debugName = 'clipboard', injector } = coerceSensorOptions(opt);
|
|
1973
|
+
return runInSensorContext(injector, () => createClipboard(debugName));
|
|
1974
|
+
}
|
|
1975
|
+
function createClipboard(debugName) {
|
|
1799
1976
|
if (isPlatformServer(inject(PLATFORM_ID)) ||
|
|
1800
1977
|
typeof navigator === 'undefined' ||
|
|
1801
1978
|
!navigator.clipboard) {
|
|
@@ -1845,7 +2022,13 @@ function observerSupported$1() {
|
|
|
1845
2022
|
* });
|
|
1846
2023
|
* ```
|
|
1847
2024
|
*/
|
|
1848
|
-
function elementSize(target
|
|
2025
|
+
function elementSize(target, opt) {
|
|
2026
|
+
return runInSensorContext(opt?.injector, () =>
|
|
2027
|
+
// the host-element default must resolve INSIDE the sensor context, not as a
|
|
2028
|
+
// parameter default (which would run before the injector wrapper)
|
|
2029
|
+
createElementSize(target ?? inject(ElementRef), opt));
|
|
2030
|
+
}
|
|
2031
|
+
function createElementSize(target, opt) {
|
|
1849
2032
|
const getElement = () => {
|
|
1850
2033
|
if (isSignal(target)) {
|
|
1851
2034
|
try {
|
|
@@ -1859,8 +2042,8 @@ function elementSize(target = inject(ElementRef), opt) {
|
|
|
1859
2042
|
return target instanceof ElementRef ? target.nativeElement : target;
|
|
1860
2043
|
};
|
|
1861
2044
|
const resolveInitialValue = () => {
|
|
1862
|
-
|
|
1863
|
-
|
|
2045
|
+
// measuring needs only getBoundingClientRect — ResizeObserver support gates
|
|
2046
|
+
// live updates, not the initial read
|
|
1864
2047
|
const el = getElement();
|
|
1865
2048
|
if (el && el.getBoundingClientRect) {
|
|
1866
2049
|
const rect = el.getBoundingClientRect();
|
|
@@ -1978,7 +2161,13 @@ function observerSupported() {
|
|
|
1978
2161
|
* }
|
|
1979
2162
|
* ```
|
|
1980
2163
|
*/
|
|
1981
|
-
function elementVisibility(target
|
|
2164
|
+
function elementVisibility(target, opt) {
|
|
2165
|
+
return runInSensorContext(opt?.injector, () =>
|
|
2166
|
+
// the host-element default must resolve INSIDE the sensor context, not as a
|
|
2167
|
+
// parameter default (which would run before the injector wrapper)
|
|
2168
|
+
createElementVisibility(target ?? inject(ElementRef), opt));
|
|
2169
|
+
}
|
|
2170
|
+
function createElementVisibility(target, opt) {
|
|
1982
2171
|
if (isPlatformServer(inject(PLATFORM_ID)) || !observerSupported()) {
|
|
1983
2172
|
const base = computed(() => undefined, {
|
|
1984
2173
|
debugName: opt?.debugName,
|
|
@@ -2046,11 +2235,18 @@ function unwrap$1(target) {
|
|
|
2046
2235
|
* }
|
|
2047
2236
|
* ```
|
|
2048
2237
|
*/
|
|
2049
|
-
function focusWithin(target
|
|
2238
|
+
function focusWithin(target, opt) {
|
|
2239
|
+
return runInSensorContext(opt?.injector, () =>
|
|
2240
|
+
// the host-element default must resolve INSIDE the sensor context, not as a
|
|
2241
|
+
// parameter default (which would run before the injector wrapper)
|
|
2242
|
+
createFocusWithin(target ?? inject(ElementRef), opt));
|
|
2243
|
+
}
|
|
2244
|
+
function createFocusWithin(target, opt) {
|
|
2245
|
+
const debugName = opt?.debugName ?? 'focusWithin';
|
|
2050
2246
|
if (isPlatformServer(inject(PLATFORM_ID))) {
|
|
2051
|
-
return computed(() => false, { debugName
|
|
2247
|
+
return computed(() => false, { debugName });
|
|
2052
2248
|
}
|
|
2053
|
-
const state = signal(false, { debugName
|
|
2249
|
+
const state = signal(false, { debugName });
|
|
2054
2250
|
const attach = (el) => {
|
|
2055
2251
|
state.set(el.contains(document.activeElement));
|
|
2056
2252
|
const abortController = new AbortController();
|
|
@@ -2098,6 +2294,9 @@ function focusWithin(target = inject(ElementRef)) {
|
|
|
2098
2294
|
* ```
|
|
2099
2295
|
*/
|
|
2100
2296
|
function geolocation(opt) {
|
|
2297
|
+
return runInSensorContext(opt?.injector, () => createGeolocation(opt));
|
|
2298
|
+
}
|
|
2299
|
+
function createGeolocation(opt) {
|
|
2101
2300
|
if (isPlatformServer(inject(PLATFORM_ID)) || typeof navigator === 'undefined' || !navigator.geolocation) {
|
|
2102
2301
|
const sig = computed(() => null, {
|
|
2103
2302
|
debugName: opt?.debugName ?? 'geolocation',
|
|
@@ -2157,6 +2356,9 @@ const serverDate$1 = new Date();
|
|
|
2157
2356
|
* ```
|
|
2158
2357
|
*/
|
|
2159
2358
|
function idle(opt) {
|
|
2359
|
+
return runInSensorContext(opt?.injector, () => createIdle(opt));
|
|
2360
|
+
}
|
|
2361
|
+
function createIdle(opt) {
|
|
2160
2362
|
if (isPlatformServer(inject(PLATFORM_ID))) {
|
|
2161
2363
|
const sig = computed(() => false, {
|
|
2162
2364
|
debugName: opt?.debugName ?? 'idle',
|
|
@@ -2246,7 +2448,11 @@ function idle(opt) {
|
|
|
2246
2448
|
* }
|
|
2247
2449
|
* ```
|
|
2248
2450
|
*/
|
|
2249
|
-
function mediaQuery(query,
|
|
2451
|
+
function mediaQuery(query, opt) {
|
|
2452
|
+
const { debugName = 'mediaQuery', injector } = coerceSensorOptions(opt);
|
|
2453
|
+
return runInSensorContext(injector, () => createMediaQuery(query, debugName));
|
|
2454
|
+
}
|
|
2455
|
+
function createMediaQuery(query, debugName) {
|
|
2250
2456
|
if (isPlatformServer(inject(PLATFORM_ID)) ||
|
|
2251
2457
|
typeof window === 'undefined' ||
|
|
2252
2458
|
typeof window.matchMedia !== 'function' // jsdom doesn't implement matchMedia
|
|
@@ -2284,8 +2490,8 @@ function mediaQuery(query, debugName = 'mediaQuery') {
|
|
|
2284
2490
|
* });
|
|
2285
2491
|
* ```
|
|
2286
2492
|
*/
|
|
2287
|
-
function prefersDarkMode(
|
|
2288
|
-
return mediaQuery('(prefers-color-scheme: dark)',
|
|
2493
|
+
function prefersDarkMode(opt) {
|
|
2494
|
+
return mediaQuery('(prefers-color-scheme: dark)', opt);
|
|
2289
2495
|
}
|
|
2290
2496
|
/**
|
|
2291
2497
|
* Creates a read-only signal that tracks the user's OS/browser preference
|
|
@@ -2312,8 +2518,8 @@ function prefersDarkMode(debugName) {
|
|
|
2312
2518
|
* });
|
|
2313
2519
|
* ```
|
|
2314
2520
|
*/
|
|
2315
|
-
function prefersReducedMotion(
|
|
2316
|
-
return mediaQuery('(prefers-reduced-motion: reduce)',
|
|
2521
|
+
function prefersReducedMotion(opt) {
|
|
2522
|
+
return mediaQuery('(prefers-reduced-motion: reduce)', opt);
|
|
2317
2523
|
}
|
|
2318
2524
|
|
|
2319
2525
|
/**
|
|
@@ -2362,6 +2568,7 @@ function throttled(initial, opt) {
|
|
|
2362
2568
|
* // after the 500ms cooldown.
|
|
2363
2569
|
*/
|
|
2364
2570
|
function throttle(source, opt) {
|
|
2571
|
+
const eq = opt?.equal ?? getSignalEquality(source);
|
|
2365
2572
|
const ms = opt?.ms ?? 0;
|
|
2366
2573
|
const leading = opt?.leading ?? false;
|
|
2367
2574
|
const trailing = opt?.trailing ?? true;
|
|
@@ -2387,31 +2594,32 @@ function throttle(source, opt) {
|
|
|
2387
2594
|
fire();
|
|
2388
2595
|
else
|
|
2389
2596
|
pendingTrailing = trailing;
|
|
2390
|
-
|
|
2597
|
+
const onWindowEnd = () => {
|
|
2391
2598
|
timeout = undefined;
|
|
2392
2599
|
if (trailing && pendingTrailing) {
|
|
2393
2600
|
pendingTrailing = false;
|
|
2394
2601
|
fire();
|
|
2602
|
+
timeout = setTimeout(onWindowEnd, ms);
|
|
2395
2603
|
}
|
|
2396
|
-
}
|
|
2604
|
+
};
|
|
2605
|
+
timeout = setTimeout(onWindowEnd, ms);
|
|
2397
2606
|
return;
|
|
2398
2607
|
}
|
|
2399
2608
|
if (trailing)
|
|
2400
2609
|
pendingTrailing = true;
|
|
2401
2610
|
};
|
|
2402
|
-
const set = (
|
|
2403
|
-
source
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
const update = (fn) => {
|
|
2407
|
-
source.update(fn);
|
|
2611
|
+
const set = (next) => {
|
|
2612
|
+
if (eq(untracked(source), next))
|
|
2613
|
+
return;
|
|
2614
|
+
source.set(next);
|
|
2408
2615
|
tick();
|
|
2409
2616
|
};
|
|
2617
|
+
const update = (fn) => set(fn(untracked(source)));
|
|
2410
2618
|
const writable = toWritable(computed(() => {
|
|
2411
2619
|
trigger();
|
|
2412
2620
|
return untracked(source);
|
|
2413
2621
|
}, opt), set, update);
|
|
2414
|
-
writable.original = source;
|
|
2622
|
+
writable.original = source.asReadonly();
|
|
2415
2623
|
return writable;
|
|
2416
2624
|
}
|
|
2417
2625
|
|
|
@@ -2448,6 +2656,9 @@ function throttle(source, opt) {
|
|
|
2448
2656
|
* ```
|
|
2449
2657
|
*/
|
|
2450
2658
|
function mousePosition(opt) {
|
|
2659
|
+
return runInSensorContext(opt?.injector, () => createMousePosition(opt));
|
|
2660
|
+
}
|
|
2661
|
+
function createMousePosition(opt) {
|
|
2451
2662
|
if (isPlatformServer(inject(PLATFORM_ID))) {
|
|
2452
2663
|
const base = computed(() => ({
|
|
2453
2664
|
x: 0,
|
|
@@ -2459,8 +2670,12 @@ function mousePosition(opt) {
|
|
|
2459
2670
|
return base;
|
|
2460
2671
|
}
|
|
2461
2672
|
const { target = window, coordinateSpace = 'client', touch = false, debugName = 'mousePosition', throttle = 100, } = opt ?? {};
|
|
2462
|
-
const
|
|
2463
|
-
|
|
2673
|
+
const resolve = (t) => {
|
|
2674
|
+
if (!t)
|
|
2675
|
+
return null;
|
|
2676
|
+
return t instanceof ElementRef ? t.nativeElement : t;
|
|
2677
|
+
};
|
|
2678
|
+
if (!isSignal(target) && !resolve(target)) {
|
|
2464
2679
|
if (isDevMode())
|
|
2465
2680
|
console.warn('mousePosition: Target element not found.');
|
|
2466
2681
|
const base = computed(() => ({
|
|
@@ -2483,7 +2698,7 @@ function mousePosition(opt) {
|
|
|
2483
2698
|
x = coordinateSpace === 'page' ? event.pageX : event.clientX;
|
|
2484
2699
|
y = coordinateSpace === 'page' ? event.pageY : event.clientY;
|
|
2485
2700
|
}
|
|
2486
|
-
else if (event.touches
|
|
2701
|
+
else if (event.touches?.length > 0) {
|
|
2487
2702
|
const firstTouch = event.touches[0];
|
|
2488
2703
|
x = coordinateSpace === 'page' ? firstTouch.pageX : firstTouch.clientX;
|
|
2489
2704
|
y = coordinateSpace === 'page' ? firstTouch.pageY : firstTouch.clientY;
|
|
@@ -2493,16 +2708,36 @@ function mousePosition(opt) {
|
|
|
2493
2708
|
}
|
|
2494
2709
|
pos.set({ x, y });
|
|
2495
2710
|
};
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
2711
|
+
// passive: the handler never calls preventDefault, and a non-passive touchmove on
|
|
2712
|
+
// window forces the browser to wait on JS before scrolling (scroll jank on touch)
|
|
2713
|
+
const attach = (el) => {
|
|
2714
|
+
const controller = new AbortController();
|
|
2715
|
+
el.addEventListener('mousemove', updatePosition, {
|
|
2716
|
+
passive: true,
|
|
2717
|
+
signal: controller.signal,
|
|
2718
|
+
});
|
|
2502
2719
|
if (touch) {
|
|
2503
|
-
|
|
2720
|
+
el.addEventListener('touchmove', updatePosition, {
|
|
2721
|
+
passive: true,
|
|
2722
|
+
signal: controller.signal,
|
|
2723
|
+
});
|
|
2504
2724
|
}
|
|
2505
|
-
|
|
2725
|
+
return () => controller.abort();
|
|
2726
|
+
};
|
|
2727
|
+
if (isSignal(target)) {
|
|
2728
|
+
// re-attach whenever the signal resolves to a (new) element — covers viewChild
|
|
2729
|
+
effect((cleanup) => {
|
|
2730
|
+
const el = resolve(target());
|
|
2731
|
+
if (!el)
|
|
2732
|
+
return;
|
|
2733
|
+
cleanup(attach(el));
|
|
2734
|
+
});
|
|
2735
|
+
}
|
|
2736
|
+
else {
|
|
2737
|
+
const el = resolve(target);
|
|
2738
|
+
if (el)
|
|
2739
|
+
inject(DestroyRef).onDestroy(attach(el));
|
|
2740
|
+
}
|
|
2506
2741
|
const base = pos.asReadonly();
|
|
2507
2742
|
base.unthrottled = pos.original;
|
|
2508
2743
|
return base;
|
|
@@ -2516,7 +2751,8 @@ const serverDate = new Date();
|
|
|
2516
2751
|
* An additional `since` signal is attached, tracking when the status last changed.
|
|
2517
2752
|
* It's SSR-safe and automatically cleans up its event listeners.
|
|
2518
2753
|
*
|
|
2519
|
-
* @param
|
|
2754
|
+
* @param opt Optional debug name for the signal, or a {@link SensorRunOptions} object
|
|
2755
|
+
* (with an optional `injector` for creation outside an injection context).
|
|
2520
2756
|
* @returns A `NetworkStatusSignal` instance.
|
|
2521
2757
|
*
|
|
2522
2758
|
* @example
|
|
@@ -2527,7 +2763,11 @@ const serverDate = new Date();
|
|
|
2527
2763
|
* });
|
|
2528
2764
|
* ```
|
|
2529
2765
|
*/
|
|
2530
|
-
function networkStatus(
|
|
2766
|
+
function networkStatus(opt) {
|
|
2767
|
+
const { debugName = 'networkStatus', injector } = coerceSensorOptions(opt);
|
|
2768
|
+
return runInSensorContext(injector, () => createNetworkStatus(debugName));
|
|
2769
|
+
}
|
|
2770
|
+
function createNetworkStatus(debugName) {
|
|
2531
2771
|
if (isPlatformServer(inject(PLATFORM_ID))) {
|
|
2532
2772
|
const sig = computed(() => true, {
|
|
2533
2773
|
debugName,
|
|
@@ -2577,7 +2817,11 @@ const SSR_FALLBACK = {
|
|
|
2577
2817
|
* });
|
|
2578
2818
|
* ```
|
|
2579
2819
|
*/
|
|
2580
|
-
function orientation(
|
|
2820
|
+
function orientation(opt) {
|
|
2821
|
+
const { debugName = 'orientation', injector } = coerceSensorOptions(opt);
|
|
2822
|
+
return runInSensorContext(injector, () => createOrientation(debugName));
|
|
2823
|
+
}
|
|
2824
|
+
function createOrientation(debugName) {
|
|
2581
2825
|
if (isPlatformServer(inject(PLATFORM_ID)) ||
|
|
2582
2826
|
typeof screen === 'undefined' ||
|
|
2583
2827
|
!screen.orientation) {
|
|
@@ -2606,7 +2850,8 @@ function orientation(debugName = 'orientation') {
|
|
|
2606
2850
|
* The primitive is SSR-safe and automatically cleans up its event listeners
|
|
2607
2851
|
* when the creating context is destroyed.
|
|
2608
2852
|
*
|
|
2609
|
-
* @param
|
|
2853
|
+
* @param opt Optional debug name for the signal, or a {@link SensorRunOptions} object
|
|
2854
|
+
* (with an optional `injector` for creation outside an injection context).
|
|
2610
2855
|
* @returns A read-only `Signal<DocumentVisibilityState>`. On the server,
|
|
2611
2856
|
* it returns a static signal with a value of `'visible'`.
|
|
2612
2857
|
*
|
|
@@ -2634,7 +2879,11 @@ function orientation(debugName = 'orientation') {
|
|
|
2634
2879
|
* }
|
|
2635
2880
|
* ```
|
|
2636
2881
|
*/
|
|
2637
|
-
function pageVisibility(
|
|
2882
|
+
function pageVisibility(opt) {
|
|
2883
|
+
const { debugName = 'pageVisibility', injector } = coerceSensorOptions(opt);
|
|
2884
|
+
return runInSensorContext(injector, () => createPageVisibility(debugName));
|
|
2885
|
+
}
|
|
2886
|
+
function createPageVisibility(debugName) {
|
|
2638
2887
|
if (isPlatformServer(inject(PLATFORM_ID))) {
|
|
2639
2888
|
return computed(() => 'visible', { debugName });
|
|
2640
2889
|
}
|
|
@@ -2666,31 +2915,25 @@ function pageVisibility(debugName = 'pageVisibility') {
|
|
|
2666
2915
|
* selector: 'app-scroll-tracker',
|
|
2667
2916
|
* template: `
|
|
2668
2917
|
* <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
|
-
* }
|
|
2918
|
+
* <p>Host Scroll: X: {{ hostScroll().x }}, Y: {{ hostScroll().y }}</p>
|
|
2675
2919
|
* `
|
|
2676
2920
|
* })
|
|
2677
2921
|
* export class ScrollTrackerComponent {
|
|
2678
2922
|
* readonly windowScroll = scrollPosition(); // Defaults to window
|
|
2923
|
+
* // Signal targets (e.g. viewChild) attach once the element exists:
|
|
2679
2924
|
* readonly scrollableDiv = viewChild<ElementRef<HTMLDivElement>>('scrollableDiv');
|
|
2680
|
-
* readonly divScroll = scrollPosition({ target: this.scrollableDiv
|
|
2925
|
+
* readonly divScroll = scrollPosition({ target: this.scrollableDiv });
|
|
2681
2926
|
*
|
|
2682
2927
|
* 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
|
-
* });
|
|
2928
|
+
* effect(() => console.log('Window scrolled to:', this.windowScroll()));
|
|
2689
2929
|
* }
|
|
2690
2930
|
* }
|
|
2691
2931
|
* ```
|
|
2692
2932
|
*/
|
|
2693
2933
|
function scrollPosition(opt) {
|
|
2934
|
+
return runInSensorContext(opt?.injector, () => createScrollPosition(opt));
|
|
2935
|
+
}
|
|
2936
|
+
function createScrollPosition(opt) {
|
|
2694
2937
|
if (isPlatformServer(inject(PLATFORM_ID))) {
|
|
2695
2938
|
const base = computed(() => ({
|
|
2696
2939
|
x: 0,
|
|
@@ -2702,40 +2945,44 @@ function scrollPosition(opt) {
|
|
|
2702
2945
|
return base;
|
|
2703
2946
|
}
|
|
2704
2947
|
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(), {
|
|
2948
|
+
const resolve = (t) => {
|
|
2949
|
+
if (!t)
|
|
2950
|
+
return null;
|
|
2951
|
+
return t instanceof ElementRef ? t.nativeElement : t;
|
|
2952
|
+
};
|
|
2953
|
+
const isWindow = (el) => el.window === el;
|
|
2954
|
+
const readPosition = (el) => isWindow(el)
|
|
2955
|
+
? {
|
|
2956
|
+
x: el.scrollX ?? el.pageXOffset ?? 0,
|
|
2957
|
+
y: el.scrollY ?? el.pageYOffset ?? 0,
|
|
2958
|
+
}
|
|
2959
|
+
: { x: el.scrollLeft, y: el.scrollTop };
|
|
2960
|
+
const initial = resolve(isSignal(target) ? untracked(target) : target);
|
|
2961
|
+
const state = throttled(initial ? readPosition(initial) : { x: 0, y: 0 }, {
|
|
2732
2962
|
debugName,
|
|
2733
2963
|
equal: (a, b) => a.x === b.x && a.y === b.y,
|
|
2734
2964
|
ms: throttle,
|
|
2735
2965
|
});
|
|
2736
|
-
|
|
2737
|
-
|
|
2738
|
-
|
|
2966
|
+
if (isSignal(target)) {
|
|
2967
|
+
// re-attach whenever the signal resolves to a (new) element — covers viewChild
|
|
2968
|
+
effect((cleanup) => {
|
|
2969
|
+
const el = resolve(target());
|
|
2970
|
+
if (!el)
|
|
2971
|
+
return;
|
|
2972
|
+
state.set(readPosition(el)); // sync to the new element immediately
|
|
2973
|
+
const onScroll = () => state.set(readPosition(el));
|
|
2974
|
+
el.addEventListener('scroll', onScroll, { passive: true });
|
|
2975
|
+
cleanup(() => el.removeEventListener('scroll', onScroll));
|
|
2976
|
+
});
|
|
2977
|
+
}
|
|
2978
|
+
else {
|
|
2979
|
+
const el = resolve(target);
|
|
2980
|
+
if (el) {
|
|
2981
|
+
const onScroll = () => state.set(readPosition(el));
|
|
2982
|
+
el.addEventListener('scroll', onScroll, { passive: true });
|
|
2983
|
+
inject(DestroyRef).onDestroy(() => el.removeEventListener('scroll', onScroll));
|
|
2984
|
+
}
|
|
2985
|
+
}
|
|
2739
2986
|
const base = state.asReadonly();
|
|
2740
2987
|
base.unthrottled = state.original;
|
|
2741
2988
|
return base;
|
|
@@ -2783,6 +3030,9 @@ function scrollPosition(opt) {
|
|
|
2783
3030
|
* ```
|
|
2784
3031
|
*/
|
|
2785
3032
|
function windowSize(opt) {
|
|
3033
|
+
return runInSensorContext(opt?.injector, () => createWindowSize(opt));
|
|
3034
|
+
}
|
|
3035
|
+
function createWindowSize(opt) {
|
|
2786
3036
|
if (isPlatformServer(inject(PLATFORM_ID))) {
|
|
2787
3037
|
const base = computed(() => ({
|
|
2788
3038
|
width: 1024,
|
|
@@ -2819,17 +3069,19 @@ function sensor(type, options) {
|
|
|
2819
3069
|
case 'mousePosition':
|
|
2820
3070
|
return mousePosition(opts);
|
|
2821
3071
|
case 'networkStatus':
|
|
2822
|
-
return networkStatus(opts
|
|
3072
|
+
return networkStatus(opts);
|
|
2823
3073
|
case 'pageVisibility':
|
|
2824
|
-
return pageVisibility(opts
|
|
3074
|
+
return pageVisibility(opts);
|
|
2825
3075
|
case 'darkMode':
|
|
2826
3076
|
case 'dark-mode':
|
|
2827
|
-
return prefersDarkMode(opts
|
|
3077
|
+
return prefersDarkMode(opts);
|
|
2828
3078
|
case 'reducedMotion':
|
|
2829
3079
|
case 'reduced-motion':
|
|
2830
|
-
return prefersReducedMotion(opts
|
|
3080
|
+
return prefersReducedMotion(opts);
|
|
2831
3081
|
case 'mediaQuery':
|
|
2832
|
-
|
|
3082
|
+
if (typeof opts?.query !== 'string')
|
|
3083
|
+
throw new Error(`sensor('mediaQuery') requires a 'query' option, e.g. sensor('mediaQuery', { query: '(min-width: 1024px)' })`);
|
|
3084
|
+
return mediaQuery(opts.query, opts);
|
|
2833
3085
|
case 'windowSize':
|
|
2834
3086
|
return windowSize(opts);
|
|
2835
3087
|
case 'scrollPosition':
|
|
@@ -2841,15 +3093,15 @@ function sensor(type, options) {
|
|
|
2841
3093
|
case 'geolocation':
|
|
2842
3094
|
return geolocation(opts);
|
|
2843
3095
|
case 'clipboard':
|
|
2844
|
-
return clipboard(opts
|
|
3096
|
+
return clipboard(opts);
|
|
2845
3097
|
case 'orientation':
|
|
2846
|
-
return orientation(opts
|
|
3098
|
+
return orientation(opts);
|
|
2847
3099
|
case 'batteryStatus':
|
|
2848
|
-
return batteryStatus(opts
|
|
3100
|
+
return batteryStatus(opts);
|
|
2849
3101
|
case 'idle':
|
|
2850
3102
|
return idle(opts);
|
|
2851
3103
|
case 'focusWithin':
|
|
2852
|
-
return focusWithin(opts?.target);
|
|
3104
|
+
return focusWithin(opts?.target, opts);
|
|
2853
3105
|
default:
|
|
2854
3106
|
throw new Error(`Unknown sensor type: ${type}`);
|
|
2855
3107
|
}
|
|
@@ -2903,16 +3155,24 @@ function signalFromEvent(target, eventName, initial, projectOrOpt, maybeOpt) {
|
|
|
2903
3155
|
else
|
|
2904
3156
|
state.set(event);
|
|
2905
3157
|
};
|
|
2906
|
-
const { destroyRef: providedDestroyRef,
|
|
3158
|
+
const { destroyRef: providedDestroyRef,
|
|
3159
|
+
// strip non-listener keys so they don't leak into addEventListener options
|
|
3160
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
3161
|
+
injector: _injector,
|
|
3162
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
3163
|
+
debugName: _debugName, ...listenerOpts } = opt ?? {};
|
|
2907
3164
|
if (isSignal(target)) {
|
|
2908
3165
|
const targetSig = target;
|
|
2909
|
-
effect((cleanup) => {
|
|
3166
|
+
const effectRef = effect((cleanup) => {
|
|
2910
3167
|
const resolved = unwrap(targetSig());
|
|
2911
3168
|
if (!resolved)
|
|
2912
3169
|
return;
|
|
2913
3170
|
resolved.addEventListener(eventName, handler, listenerOpts);
|
|
2914
3171
|
cleanup(() => resolved.removeEventListener(eventName, handler, listenerOpts));
|
|
2915
3172
|
}, { injector });
|
|
3173
|
+
// honor an explicit destroyRef for signal targets too — the effect would otherwise
|
|
3174
|
+
// only follow the injector's lifetime, contradicting the documented option
|
|
3175
|
+
providedDestroyRef?.onDestroy(() => effectRef.destroy());
|
|
2916
3176
|
}
|
|
2917
3177
|
else {
|
|
2918
3178
|
const resolved = unwrap(target);
|
|
@@ -3001,7 +3261,8 @@ function alwaysFalse() {
|
|
|
3001
3261
|
* @internal Attaches a lazy, memoized leaf probe to a store node. The probe (`() => boolean`)
|
|
3002
3262
|
* closes over the node's value signal and its (stable) vivify setting, building the backing
|
|
3003
3263
|
* `computed` on first call so leaf-ness tracks the live value reactively without taxing every
|
|
3004
|
-
* node access.
|
|
3264
|
+
* node access. Under `noUnionLeaves` the caller promises shapes never flip, so the probe is
|
|
3265
|
+
* resolved once from the first sample and frozen as a constant. Idempotent.
|
|
3005
3266
|
*/
|
|
3006
3267
|
function markAsLeaf(sig, value, vivifyEnabled, noUnionLeaves) {
|
|
3007
3268
|
if (typeof sig[LEAF] !== 'function') {
|
|
@@ -3009,13 +3270,11 @@ function markAsLeaf(sig, value, vivifyEnabled, noUnionLeaves) {
|
|
|
3009
3270
|
const probe = () => {
|
|
3010
3271
|
if (memo)
|
|
3011
3272
|
return memo();
|
|
3012
|
-
|
|
3013
|
-
|
|
3014
|
-
|
|
3015
|
-
|
|
3016
|
-
|
|
3017
|
-
: alwaysFalse
|
|
3018
|
-
: computed(() => isLeafValue(value(), vivifyEnabled));
|
|
3273
|
+
memo = noUnionLeaves
|
|
3274
|
+
? isLeafValue(untracked(value), vivifyEnabled)
|
|
3275
|
+
? alwaysTrue
|
|
3276
|
+
: alwaysFalse
|
|
3277
|
+
: computed(() => isLeafValue(value(), vivifyEnabled));
|
|
3019
3278
|
return memo();
|
|
3020
3279
|
};
|
|
3021
3280
|
Object.defineProperty(sig, LEAF, {
|
|
@@ -3103,6 +3362,40 @@ function resolveVivify(sample, option) {
|
|
|
3103
3362
|
function hasOwnKey(value, key) {
|
|
3104
3363
|
return value != null && Object.hasOwn(value, key);
|
|
3105
3364
|
}
|
|
3365
|
+
/**
|
|
3366
|
+
* @internal
|
|
3367
|
+
* Builds the `onChange` for the fallback (non-record container) derivation branch. For an
|
|
3368
|
+
* immutable source the container is copied before the write — returning the same mutated
|
|
3369
|
+
* reference would let the source's equality cut propagation (leaving child signals permanently
|
|
3370
|
+
* stale) and alias the caller's original object, breaking the structural-sharing contract
|
|
3371
|
+
* `forkStore` relies on. For a mutable source the write goes through `mutate`, so the chain's
|
|
3372
|
+
* force-notify engages (plain `update` with the same reference would never notify).
|
|
3373
|
+
*/
|
|
3374
|
+
function createFallbackOnChange(target, prop, vivifyFn, isMutableSource) {
|
|
3375
|
+
const write = (newValue) => (v) => {
|
|
3376
|
+
const container = vivifyFn(v, prop);
|
|
3377
|
+
if (container === null || container === undefined)
|
|
3378
|
+
return container;
|
|
3379
|
+
const next = isMutableSource
|
|
3380
|
+
? container
|
|
3381
|
+
: Array.isArray(container)
|
|
3382
|
+
? container.slice()
|
|
3383
|
+
: isRecord(container)
|
|
3384
|
+
? { ...container }
|
|
3385
|
+
: container; // non-plain leaf (Date/class instance): legacy in-place attempt
|
|
3386
|
+
try {
|
|
3387
|
+
next[prop] = newValue;
|
|
3388
|
+
}
|
|
3389
|
+
catch (e) {
|
|
3390
|
+
if (isDevMode())
|
|
3391
|
+
console.error(`[store] Failed to set property "${String(prop)}"`, e);
|
|
3392
|
+
}
|
|
3393
|
+
return next;
|
|
3394
|
+
};
|
|
3395
|
+
return isMutableSource
|
|
3396
|
+
? (newValue) => target.mutate(write(newValue))
|
|
3397
|
+
: (newValue) => target.update(write(newValue));
|
|
3398
|
+
}
|
|
3106
3399
|
/**
|
|
3107
3400
|
* @internal
|
|
3108
3401
|
* Makes an array store
|
|
@@ -3125,7 +3418,9 @@ function toArrayStore(source, injector, vivify, noUnionLeaves = false) {
|
|
|
3125
3418
|
const idx = +prop;
|
|
3126
3419
|
return idx >= 0 && idx < untracked(lengthSignal);
|
|
3127
3420
|
}
|
|
3128
|
-
|
|
3421
|
+
const v = untracked(source);
|
|
3422
|
+
// nullish node values are routinely descended with vivify on — `in` must not throw
|
|
3423
|
+
return v == null ? false : Reflect.has(v, prop);
|
|
3129
3424
|
},
|
|
3130
3425
|
ownKeys() {
|
|
3131
3426
|
const v = untracked(source);
|
|
@@ -3162,7 +3457,9 @@ function toArrayStore(source, injector, vivify, noUnionLeaves = false) {
|
|
|
3162
3457
|
return lengthSignal;
|
|
3163
3458
|
if (prop === Symbol.iterator) {
|
|
3164
3459
|
return function* () {
|
|
3165
|
-
|
|
3460
|
+
// read length reactively: a spread/for-of inside a computed/effect must re-run
|
|
3461
|
+
// when items are added or removed, not only when already-read elements change
|
|
3462
|
+
for (let i = 0; i < lengthSignal(); i++) {
|
|
3166
3463
|
yield receiver[i];
|
|
3167
3464
|
}
|
|
3168
3465
|
};
|
|
@@ -3201,19 +3498,8 @@ function toArrayStore(source, injector, vivify, noUnionLeaves = false) {
|
|
|
3201
3498
|
})
|
|
3202
3499
|
: derived(target, {
|
|
3203
3500
|
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
|
-
}),
|
|
3501
|
+
onChange: createFallbackOnChange(target, idx, vivifyFn, isMutableSource),
|
|
3502
|
+
equal: equalFn,
|
|
3217
3503
|
});
|
|
3218
3504
|
const childSample = untracked(computation);
|
|
3219
3505
|
const childVivify = resolveVivify(childSample, vivify);
|
|
@@ -3233,6 +3519,13 @@ function toArrayStore(source, injector, vivify, noUnionLeaves = false) {
|
|
|
3233
3519
|
/**
|
|
3234
3520
|
* Converts a Signal into a deep-observable Store.
|
|
3235
3521
|
* Accessing nested properties returns a derived Signal of that path.
|
|
3522
|
+
*
|
|
3523
|
+
* @remarks
|
|
3524
|
+
* A child's *container kind* (array store vs object store) is resolved when the child is
|
|
3525
|
+
* first accessed and cached with the proxy. Leaf↔substore flips are tracked reactively, but a
|
|
3526
|
+
* union-typed node that later flips between an array and a record keeps its original trap set —
|
|
3527
|
+
* if you need that, re-model the union as `{ kind: ..., value: ... }` instead.
|
|
3528
|
+
*
|
|
3236
3529
|
* @example
|
|
3237
3530
|
* const state = store({ user: { name: 'John' } });
|
|
3238
3531
|
* const nameSignal = state.user.name; // WritableSignal<string>
|
|
@@ -3315,19 +3608,8 @@ function toStore(source, injector, vivify = false, noUnionLeaves = false) {
|
|
|
3315
3608
|
? derived(target, prop, { equal: equalFn, vivify: nodeVivify })
|
|
3316
3609
|
: derived(target, {
|
|
3317
3610
|
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
|
-
}),
|
|
3611
|
+
onChange: createFallbackOnChange(target, prop, vivifyFn, isMutableSource),
|
|
3612
|
+
equal: equalFn,
|
|
3331
3613
|
});
|
|
3332
3614
|
const childSample = untracked(computation);
|
|
3333
3615
|
const childVivify = resolveVivify(childSample, vivify);
|
|
@@ -3469,7 +3751,12 @@ function merge3(ancestor, mine, theirs) {
|
|
|
3469
3751
|
if (isPlainRecord(mine) && isPlainRecord(theirs) && isPlainRecord(ancestor)) {
|
|
3470
3752
|
const out = { ...theirs };
|
|
3471
3753
|
for (const key of new Set([...Object.keys(mine), ...Object.keys(theirs)])) {
|
|
3472
|
-
|
|
3754
|
+
const merged = merge3(ancestor[key], mine[key], theirs[key]);
|
|
3755
|
+
// a key deleted on the fork must commit as ABSENT, not as an explicit `undefined`
|
|
3756
|
+
if (merged === undefined && !(key in mine))
|
|
3757
|
+
delete out[key];
|
|
3758
|
+
else
|
|
3759
|
+
out[key] = merged;
|
|
3473
3760
|
}
|
|
3474
3761
|
return out;
|
|
3475
3762
|
}
|
|
@@ -3523,8 +3810,8 @@ const noopStore = {
|
|
|
3523
3810
|
*
|
|
3524
3811
|
* @template T The type of value held by the signal and stored (after serialization).
|
|
3525
3812
|
* @param fallback The default value of type `T` to use when no value is found in storage
|
|
3526
|
-
* or when deserialization fails.
|
|
3527
|
-
*
|
|
3813
|
+
* or when deserialization fails. A stored value (including a legitimate `null` for a
|
|
3814
|
+
* nullable `T`) always round-trips; the fallback only surfaces when the entry is absent.
|
|
3528
3815
|
* @param options Configuration options (`CreateStoredOptions<T>`). Requires at least the `key`.
|
|
3529
3816
|
* @returns A `StoredSignal<T>` instance. This signal behaves like a standard `WritableSignal<T>`,
|
|
3530
3817
|
* but its value is persisted. It includes a `.clear()` method to remove the item from storage
|
|
@@ -3537,7 +3824,8 @@ const noopStore = {
|
|
|
3537
3824
|
* - **Error Handling:** Catches and logs errors during serialization/deserialization in dev mode.
|
|
3538
3825
|
* - **Tab Sync:** If `syncTabs` is true, listens to `storage` events to keep the signal value
|
|
3539
3826
|
* consistent across browser tabs using the same key. Cleanup is handled automatically
|
|
3540
|
-
* using `DestroyRef`.
|
|
3827
|
+
* using `DestroyRef`. Web Storage only: the `storage` event never fires for custom `store`
|
|
3828
|
+
* adapters, so `syncTabs` has no effect with one.
|
|
3541
3829
|
* - **Removal:** Use the `.clear()` method on the returned signal to remove the item from storage.
|
|
3542
3830
|
* Setting the signal to the fallback value will store the fallback value, not remove the item.
|
|
3543
3831
|
*
|
|
@@ -3572,25 +3860,28 @@ function stored(fallback, { key, store: providedStore, serialize = JSON.stringif
|
|
|
3572
3860
|
: isSignal(key)
|
|
3573
3861
|
? key
|
|
3574
3862
|
: computed(key);
|
|
3863
|
+
// "no stored value" marker — distinct from `null`/`undefined`, so a nullable `T` can
|
|
3864
|
+
// round-trip a legitimate `null` through `set` instead of it acting like `clear()`
|
|
3865
|
+
const EMPTY = Symbol();
|
|
3575
3866
|
const getValue = (key) => {
|
|
3576
3867
|
const found = store.getItem(key);
|
|
3577
3868
|
if (found === null)
|
|
3578
|
-
return
|
|
3869
|
+
return EMPTY;
|
|
3579
3870
|
try {
|
|
3580
3871
|
const deserialized = deserialize(found);
|
|
3581
3872
|
if (!validate(deserialized))
|
|
3582
|
-
return
|
|
3873
|
+
return EMPTY;
|
|
3583
3874
|
return deserialized;
|
|
3584
3875
|
}
|
|
3585
3876
|
catch (err) {
|
|
3586
3877
|
if (isDevMode())
|
|
3587
3878
|
console.error(`Failed to parse stored value for key "${key}":`, err);
|
|
3588
|
-
return
|
|
3879
|
+
return EMPTY;
|
|
3589
3880
|
}
|
|
3590
3881
|
};
|
|
3591
3882
|
const storeValue = (key, value) => {
|
|
3592
3883
|
try {
|
|
3593
|
-
if (value ===
|
|
3884
|
+
if (value === EMPTY)
|
|
3594
3885
|
return store.removeItem(key);
|
|
3595
3886
|
const serialized = serialize(value);
|
|
3596
3887
|
store.setItem(key, serialized);
|
|
@@ -3608,9 +3899,9 @@ function stored(fallback, { key, store: providedStore, serialize = JSON.stringif
|
|
|
3608
3899
|
const internal = signal(getValue(initialKey), {
|
|
3609
3900
|
...opt,
|
|
3610
3901
|
equal: (a, b) => {
|
|
3611
|
-
if (a ===
|
|
3902
|
+
if (a === EMPTY && b === EMPTY)
|
|
3612
3903
|
return true;
|
|
3613
|
-
if (a ===
|
|
3904
|
+
if (a === EMPTY || b === EMPTY)
|
|
3614
3905
|
return false;
|
|
3615
3906
|
return equal(a, b);
|
|
3616
3907
|
},
|
|
@@ -3646,19 +3937,27 @@ function stored(fallback, { key, store: providedStore, serialize = JSON.stringif
|
|
|
3646
3937
|
if (syncTabs && !isServer) {
|
|
3647
3938
|
const destroyRef = inject(DestroyRef);
|
|
3648
3939
|
const sync = (e) => {
|
|
3940
|
+
// `storage` events only describe Web Storage — ignore events for a different
|
|
3941
|
+
// storage area (or any event when a custom adapter is configured), otherwise an
|
|
3942
|
+
// unrelated localStorage write with the same key string corrupts our state
|
|
3943
|
+
if (e.storageArea !== store)
|
|
3944
|
+
return;
|
|
3649
3945
|
if (e.key !== untracked(keySig))
|
|
3650
3946
|
return;
|
|
3651
3947
|
if (e.newValue === null)
|
|
3652
|
-
internal.set(
|
|
3948
|
+
internal.set(EMPTY);
|
|
3653
3949
|
else
|
|
3654
3950
|
internal.set(getValue(e.key));
|
|
3655
3951
|
};
|
|
3656
3952
|
window.addEventListener('storage', sync);
|
|
3657
3953
|
destroyRef.onDestroy(() => window.removeEventListener('storage', sync));
|
|
3658
3954
|
}
|
|
3659
|
-
const writable = toWritable(computed(() =>
|
|
3955
|
+
const writable = toWritable(computed(() => {
|
|
3956
|
+
const v = internal();
|
|
3957
|
+
return v === EMPTY ? fallback : v;
|
|
3958
|
+
}, opt), internal.set);
|
|
3660
3959
|
writable.clear = () => {
|
|
3661
|
-
internal.set(
|
|
3960
|
+
internal.set(EMPTY);
|
|
3662
3961
|
};
|
|
3663
3962
|
writable.key = keySig;
|
|
3664
3963
|
return writable;
|
|
@@ -3668,7 +3967,6 @@ class MessageBus {
|
|
|
3668
3967
|
channel = new BroadcastChannel('mmstack-tab-sync-bus');
|
|
3669
3968
|
listeners = new Map();
|
|
3670
3969
|
subscribe(id, listener) {
|
|
3671
|
-
this.unsubscribe(id); // Ensure no duplicate listeners
|
|
3672
3970
|
const wrapped = (ev) => {
|
|
3673
3971
|
try {
|
|
3674
3972
|
if (ev.data?.id === id)
|
|
@@ -3679,18 +3977,28 @@ class MessageBus {
|
|
|
3679
3977
|
}
|
|
3680
3978
|
};
|
|
3681
3979
|
this.channel.addEventListener('message', wrapped);
|
|
3682
|
-
this.listeners.
|
|
3980
|
+
let set = this.listeners.get(id);
|
|
3981
|
+
if (!set) {
|
|
3982
|
+
set = new Set();
|
|
3983
|
+
this.listeners.set(id, set);
|
|
3984
|
+
}
|
|
3985
|
+
set.add(wrapped);
|
|
3683
3986
|
return {
|
|
3684
|
-
unsub: (
|
|
3685
|
-
|
|
3987
|
+
unsub: () => {
|
|
3988
|
+
this.channel.removeEventListener('message', wrapped);
|
|
3989
|
+
const cur = this.listeners.get(id);
|
|
3990
|
+
if (!cur)
|
|
3991
|
+
return;
|
|
3992
|
+
cur.delete(wrapped);
|
|
3993
|
+
if (cur.size === 0)
|
|
3994
|
+
this.listeners.delete(id);
|
|
3995
|
+
},
|
|
3996
|
+
post: (value) => this.channel.postMessage({ id, value }),
|
|
3686
3997
|
};
|
|
3687
3998
|
}
|
|
3688
|
-
|
|
3689
|
-
|
|
3690
|
-
|
|
3691
|
-
return;
|
|
3692
|
-
this.channel.removeEventListener('message', listener);
|
|
3693
|
-
this.listeners.delete(id);
|
|
3999
|
+
ngOnDestroy() {
|
|
4000
|
+
this.channel.close();
|
|
4001
|
+
this.listeners.clear();
|
|
3694
4002
|
}
|
|
3695
4003
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.19", ngImport: i0, type: MessageBus, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
3696
4004
|
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.2.19", ngImport: i0, type: MessageBus, providedIn: 'root' });
|
|
@@ -3701,6 +4009,11 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.19", ngImpo
|
|
|
3701
4009
|
providedIn: 'root',
|
|
3702
4010
|
}]
|
|
3703
4011
|
}] });
|
|
4012
|
+
/**
|
|
4013
|
+
* @deprecated The generated id hashes the call-site stack line, which collides when a shared
|
|
4014
|
+
* helper calls {@link tabSync} for multiple signals and diverges across minified builds during
|
|
4015
|
+
* a rolling deploy. Pass an explicit `{ id }` instead.
|
|
4016
|
+
*/
|
|
3704
4017
|
function generateDeterministicID() {
|
|
3705
4018
|
const stack = new Error().stack;
|
|
3706
4019
|
if (stack) {
|
|
@@ -3738,10 +4051,8 @@ function generateDeterministicID() {
|
|
|
3738
4051
|
*
|
|
3739
4052
|
* @example
|
|
3740
4053
|
* ```typescript
|
|
3741
|
-
* //
|
|
3742
|
-
* const theme = tabSync(signal('dark'));
|
|
3743
|
-
*
|
|
3744
|
-
* // With explicit ID (recommended for production)
|
|
4054
|
+
* // With explicit ID (recommended)
|
|
4055
|
+
* const theme = tabSync(signal('dark'), { id: 'theme' });
|
|
3745
4056
|
* const userPrefs = tabSync(signal({ lang: 'en' }), { id: 'user-preferences' });
|
|
3746
4057
|
*
|
|
3747
4058
|
* // Changes in one tab will sync to all other tabs
|
|
@@ -3753,6 +4064,7 @@ function generateDeterministicID() {
|
|
|
3753
4064
|
* - Uses a single BroadcastChannel for all synchronized signals
|
|
3754
4065
|
* - Automatically cleans up listeners when the injection context is destroyed
|
|
3755
4066
|
* - Initial signal value after sync setup is not broadcasted to prevent loops
|
|
4067
|
+
* - Received values are not re-broadcast, so tabs never echo each other's updates
|
|
3756
4068
|
*
|
|
3757
4069
|
*/
|
|
3758
4070
|
function tabSync(sig, opt) {
|
|
@@ -3760,7 +4072,20 @@ function tabSync(sig, opt) {
|
|
|
3760
4072
|
return sig;
|
|
3761
4073
|
const id = typeof opt === 'string' ? opt : (opt?.id ?? generateDeterministicID());
|
|
3762
4074
|
const bus = inject(MessageBus);
|
|
3763
|
-
|
|
4075
|
+
// The last value applied from a remote tab. The outbound effect skips (exactly) the run
|
|
4076
|
+
// caused by that write — without this, an inbound object (a fresh structured clone, so
|
|
4077
|
+
// never reference-equal) would be re-posted, and two tabs would ping-pong forever.
|
|
4078
|
+
const NONE = Symbol();
|
|
4079
|
+
let received = NONE;
|
|
4080
|
+
const { unsub, post } = bus.subscribe(id, (next) => {
|
|
4081
|
+
const before = untracked(sig);
|
|
4082
|
+
received = next;
|
|
4083
|
+
sig.set(next);
|
|
4084
|
+
// Equality-suppressed write (e.g. an identical primitive): no effect run will follow,
|
|
4085
|
+
// so clear the marker — it must not swallow a later, genuinely local change.
|
|
4086
|
+
if (untracked(sig) === before)
|
|
4087
|
+
received = NONE;
|
|
4088
|
+
});
|
|
3764
4089
|
let first = false;
|
|
3765
4090
|
const effectRef = effect(() => {
|
|
3766
4091
|
const val = sig();
|
|
@@ -3768,6 +4093,11 @@ function tabSync(sig, opt) {
|
|
|
3768
4093
|
first = true;
|
|
3769
4094
|
return;
|
|
3770
4095
|
}
|
|
4096
|
+
if (val === received) {
|
|
4097
|
+
received = NONE;
|
|
4098
|
+
return;
|
|
4099
|
+
}
|
|
4100
|
+
received = NONE;
|
|
3771
4101
|
post(val);
|
|
3772
4102
|
});
|
|
3773
4103
|
inject(DestroyRef).onDestroy(() => {
|
|
@@ -3778,7 +4108,6 @@ function tabSync(sig, opt) {
|
|
|
3778
4108
|
}
|
|
3779
4109
|
|
|
3780
4110
|
function until(sourceSignal, predicate, options = {}) {
|
|
3781
|
-
const injector = options.injector ?? inject(Injector);
|
|
3782
4111
|
return new Promise((resolve, reject) => {
|
|
3783
4112
|
let effectRef;
|
|
3784
4113
|
let timeoutId;
|
|
@@ -3815,6 +4144,14 @@ function until(sourceSignal, predicate, options = {}) {
|
|
|
3815
4144
|
cleanupAndResolve(initialValue);
|
|
3816
4145
|
return;
|
|
3817
4146
|
}
|
|
4147
|
+
let injector;
|
|
4148
|
+
try {
|
|
4149
|
+
injector = options.injector ?? inject(Injector);
|
|
4150
|
+
}
|
|
4151
|
+
catch {
|
|
4152
|
+
cleanupAndReject('until: No injector available — provide options.injector when calling outside an injection context.');
|
|
4153
|
+
return;
|
|
4154
|
+
}
|
|
3818
4155
|
if (options?.timeout !== undefined) {
|
|
3819
4156
|
timeoutId = setTimeout(() => cleanupAndReject(`until: Timeout after ${options.timeout}ms.`), options.timeout);
|
|
3820
4157
|
}
|
|
@@ -3832,17 +4169,6 @@ function until(sourceSignal, predicate, options = {}) {
|
|
|
3832
4169
|
});
|
|
3833
4170
|
}
|
|
3834
4171
|
|
|
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
4172
|
/**
|
|
3847
4173
|
* Enhances an existing `WritableSignal` by adding a complete undo/redo history
|
|
3848
4174
|
* stack and an API to control it.
|
|
@@ -3891,9 +4217,10 @@ function getSignalEquality(sig) {
|
|
|
3891
4217
|
* ```
|
|
3892
4218
|
*/
|
|
3893
4219
|
function withHistory(sourceOrValue, opt) {
|
|
3894
|
-
const equal =
|
|
3895
|
-
|
|
3896
|
-
|
|
4220
|
+
const equal = opt?.equal ??
|
|
4221
|
+
(isSignal(sourceOrValue)
|
|
4222
|
+
? getSignalEquality(sourceOrValue)
|
|
4223
|
+
: Object.is);
|
|
3897
4224
|
const source = isSignal(sourceOrValue)
|
|
3898
4225
|
? sourceOrValue
|
|
3899
4226
|
: signal(sourceOrValue);
|
|
@@ -3938,9 +4265,8 @@ function withHistory(sourceOrValue, opt) {
|
|
|
3938
4265
|
if (historyStack.length === 0)
|
|
3939
4266
|
return;
|
|
3940
4267
|
const valueForRedo = untracked(source);
|
|
3941
|
-
|
|
3942
|
-
|
|
3943
|
-
return;
|
|
4268
|
+
// length checked above — a legitimately `undefined` entry must still restore
|
|
4269
|
+
const valueToRestore = historyStack[historyStack.length - 1];
|
|
3944
4270
|
originalSet.call(source, valueToRestore);
|
|
3945
4271
|
history.inline((h) => h.pop());
|
|
3946
4272
|
redoArray.mutate((r) => {
|
|
@@ -3954,9 +4280,8 @@ function withHistory(sourceOrValue, opt) {
|
|
|
3954
4280
|
if (redoStack.length === 0)
|
|
3955
4281
|
return;
|
|
3956
4282
|
const valueForUndo = untracked(source);
|
|
3957
|
-
|
|
3958
|
-
|
|
3959
|
-
return;
|
|
4283
|
+
// length checked above — a legitimately `undefined` entry must still restore
|
|
4284
|
+
const valueToRestore = redoStack[redoStack.length - 1];
|
|
3960
4285
|
originalSet.call(source, valueToRestore);
|
|
3961
4286
|
redoArray.inline((r) => r.pop());
|
|
3962
4287
|
history.mutate((h) => {
|
|
@@ -3979,5 +4304,5 @@ function withHistory(sourceOrValue, opt) {
|
|
|
3979
4304
|
* Generated bundle index. Do not edit.
|
|
3980
4305
|
*/
|
|
3981
4306
|
|
|
3982
|
-
export { MmActivity, SuspenseBoundary, SuspenseBoundaryBase, UnscopedSuspenseBoundary, activeTransaction, batteryStatus, chunked, clipboard, combineWith, createTransaction, createTransitionScope, debounce, debounced, derived, distinct, elementSize, elementVisibility, filter, filterWith, focusWithin, forkStore, geolocation, holdUntilReady, idle, indexArray, injectPaused, injectRegisterResource, injectStartTransaction, injectStartTransition, injectTransitionScope, isDerivation, isLeaf, isMutable, isOpaque, isStore, keepPrevious, keyArray, map, mapArray, mapObject, mediaQuery, merge3, mousePosition, mutable, mutableStore, nestedEffect, networkStatus, opaque, orientation, pageVisibility, pairwise, pausableComputed, pausableEffect, pausableSignal, pipeable, piped, pooled, pooledArray, pooledMap, pooledSet, prefersDarkMode, prefersReducedMotion, providePaused, provideTransitionScope, registerResource, resolvePause, scan, scrollPosition, select, sensor, sensors, signalFromEvent, startWith, store, stored, tabSync, tap, throttle, throttled, toFakeDerivation, toFakeSignalDerivation, toStore, toWritable, until, windowSize, withHistory };
|
|
4307
|
+
export { MmActivity, SuspenseBoundary, SuspenseBoundaryBase, UnscopedSuspenseBoundary, activeTransaction, batteryStatus, chunked, clipboard, combineWith, createForwardingScope, createTransaction, createTransitionScope, debounce, debounced, derived, distinct, elementSize, elementVisibility, filter, filterWith, focusWithin, forkStore, geolocation, getTransitionScope, holdUntilReady, idle, indexArray, injectPaused, injectRegisterResource, injectStartTransaction, injectStartTransition, injectTransitionScope, isDerivation, isLeaf, isMutable, isOpaque, isStore, keepPrevious, keyArray, map, mapArray, mapObject, mediaQuery, merge3, mousePosition, mutable, mutableStore, nestedEffect, networkStatus, opaque, orientation, pageVisibility, pairwise, pausableComputed, pausableEffect, pausableSignal, pipeable, piped, pooled, pooledArray, pooledMap, pooledSet, prefersDarkMode, prefersReducedMotion, provideForwardingTransitionScope, providePaused, provideTransitionScope, registerResource, resolvePause, scan, scrollPosition, select, sensor, sensors, signalFromEvent, startWith, store, stored, tabSync, tap, throttle, throttled, toFakeDerivation, toFakeSignalDerivation, toStore, toWritable, until, windowSize, withHistory };
|
|
3983
4308
|
//# sourceMappingURL=mmstack-primitives.mjs.map
|