@mmstack/primitives 19.5.0 → 19.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -10
- package/fesm2022/mmstack-primitives.mjs +706 -452
- package/fesm2022/mmstack-primitives.mjs.map +1 -1
- package/lib/chunked.d.ts +7 -0
- package/lib/concurrent/activity.d.ts +1 -0
- package/lib/concurrent/pausable-pure.d.ts +12 -0
- package/lib/concurrent/pausable.d.ts +23 -2
- package/lib/concurrent/suspense-boundary.d.ts +6 -0
- package/lib/sensors/index.d.ts +1 -0
- package/lib/sensors/pointer-drag.d.ts +68 -0
- package/lib/sensors/sensor.d.ts +14 -0
- package/lib/store/internals.d.ts +37 -0
- package/lib/store/leaf.d.ts +36 -0
- package/lib/store/opaque.d.ts +44 -0
- package/lib/store/predicates.d.ts +28 -0
- package/lib/store/public_api.d.ts +1 -1
- package/lib/store/store.d.ts +8 -168
- package/lib/store/types.d.ts +85 -0
- package/lib/stored.d.ts +15 -2
- package/lib/tab-sync.d.ts +9 -1
- package/lib/throttled.d.ts +8 -4
- package/package.json +1 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as i0 from '@angular/core';
|
|
2
|
-
import { isDevMode, inject, Injector, untracked, effect, DestroyRef,
|
|
2
|
+
import { isDevMode, inject, Injector, untracked, effect, DestroyRef, InjectionToken, TemplateRef, ViewContainerRef, PLATFORM_ID, input, computed, Directive, signal, runInInjectionContext, linkedSignal, ResourceStatus, afterNextRender, Component, isSignal, ElementRef, Injectable } from '@angular/core';
|
|
3
3
|
import { isPlatformServer } from '@angular/common';
|
|
4
4
|
import { SIGNAL } from '@angular/core/primitives/signals';
|
|
5
5
|
|
|
@@ -155,72 +155,6 @@ function nestedEffect(effectFn, options) {
|
|
|
155
155
|
return ref;
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
-
/**
|
|
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
|
-
*
|
|
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
|
-
*
|
|
163
|
-
* @template T The type of items in the array.
|
|
164
|
-
* @param source A `Signal` or a function that returns an array of items to be processed in chunks.
|
|
165
|
-
* @param options Configuration options for chunk size, delay duration, equality function, and injector.
|
|
166
|
-
* @returns A `Signal` that emits the current chunk of items being processed.
|
|
167
|
-
*
|
|
168
|
-
* @example
|
|
169
|
-
* const largeList = signal(Array.from({ length: 1000 }, (_, i) => i));
|
|
170
|
-
* const chunkedList = chunked(largeList, { chunkSize: 100, delay: 100 });
|
|
171
|
-
*/
|
|
172
|
-
function chunked(source, options) {
|
|
173
|
-
const { chunkSize = 50, delay = 'frame', equal, injector } = options || {};
|
|
174
|
-
let delayFn;
|
|
175
|
-
if (delay === 'frame') {
|
|
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
|
-
};
|
|
187
|
-
}
|
|
188
|
-
else if (delay === 'microtask') {
|
|
189
|
-
delayFn = (cb) => {
|
|
190
|
-
let isCancelled = false;
|
|
191
|
-
queueMicrotask(() => {
|
|
192
|
-
if (isCancelled)
|
|
193
|
-
return;
|
|
194
|
-
cb();
|
|
195
|
-
});
|
|
196
|
-
return () => {
|
|
197
|
-
isCancelled = true;
|
|
198
|
-
};
|
|
199
|
-
};
|
|
200
|
-
}
|
|
201
|
-
else {
|
|
202
|
-
delayFn = (cb) => {
|
|
203
|
-
const num = setTimeout(cb, delay);
|
|
204
|
-
return () => clearTimeout(num);
|
|
205
|
-
};
|
|
206
|
-
}
|
|
207
|
-
const internal = linkedSignal({
|
|
208
|
-
source,
|
|
209
|
-
computation: (items) => items.slice(0, chunkSize),
|
|
210
|
-
equal,
|
|
211
|
-
});
|
|
212
|
-
nestedEffect((cleanup) => {
|
|
213
|
-
const fullList = source();
|
|
214
|
-
const current = internal();
|
|
215
|
-
if (current.length >= fullList.length)
|
|
216
|
-
return;
|
|
217
|
-
return cleanup(delayFn(() => untracked(() => internal.set(fullList.slice(0, current.length + chunkSize)))));
|
|
218
|
-
}, {
|
|
219
|
-
injector: injector,
|
|
220
|
-
});
|
|
221
|
-
return internal.asReadonly();
|
|
222
|
-
}
|
|
223
|
-
|
|
224
158
|
/**
|
|
225
159
|
* Whether the subtree a resource/component lives in is currently PAUSED, for Activity / keep-alive.
|
|
226
160
|
* Provided by an Activity boundary (`MmActivity`, or the app-builder's per-branch injector) and read
|
|
@@ -248,6 +182,7 @@ class MmActivity {
|
|
|
248
182
|
tpl = inject(TemplateRef);
|
|
249
183
|
vcr = inject(ViewContainerRef);
|
|
250
184
|
parent = inject(Injector);
|
|
185
|
+
onServer = isPlatformServer(inject(PLATFORM_ID, { optional: true }) ?? 'browser');
|
|
251
186
|
/** When false, keep the content mounted but hidden + CD-detached. */
|
|
252
187
|
visible = input.required({ alias: 'mmActivity' });
|
|
253
188
|
/** Paused == not visible — handed to the kept subtree as PAUSED_CONTEXT. */
|
|
@@ -270,6 +205,8 @@ class MmActivity {
|
|
|
270
205
|
}),
|
|
271
206
|
});
|
|
272
207
|
}
|
|
208
|
+
if (this.onServer)
|
|
209
|
+
return;
|
|
273
210
|
for (const node of this.view.rootNodes) {
|
|
274
211
|
// covers HTML and SVG roots; text/comment roots can't be styled — their CD is still
|
|
275
212
|
// detached, but prefer an element root for true visual hiding
|
|
@@ -314,24 +251,23 @@ function providePaused(source) {
|
|
|
314
251
|
}
|
|
315
252
|
|
|
316
253
|
/**
|
|
317
|
-
*
|
|
318
|
-
*
|
|
319
|
-
*
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
*
|
|
324
|
-
*
|
|
254
|
+
* @internal Token carrying an app-wide default {@link PauseOption}, set via
|
|
255
|
+
* {@link providePausableOptions}. {@link resolvePause} consults it when the call site didn't
|
|
256
|
+
* specify `pause`, so users can opt every pausable-aware primitive in (or out) from one place.
|
|
257
|
+
*/
|
|
258
|
+
const PAUSABLE_OPTIONS = new InjectionToken('@mmstack/primitives:pausable-options');
|
|
259
|
+
/**
|
|
260
|
+
* Provides an app-wide default {@link PauseOption} for every pausable-aware primitive (the public
|
|
261
|
+
* `pausable*` family plus the opt-in integrations like `stored` / `chunked`). A call-site `pause`
|
|
262
|
+
* always wins; this only fills in when the call didn't specify one.
|
|
325
263
|
*
|
|
326
|
-
*
|
|
264
|
+
* @example
|
|
265
|
+
* // Make everything that can pause honour the ambient Activity boundary by default:
|
|
266
|
+
* providePausableOptions({ pause: true })
|
|
327
267
|
*/
|
|
328
|
-
function
|
|
329
|
-
return
|
|
330
|
-
source: () => ({ t: target(), ready: ready() }),
|
|
331
|
-
computation: (curr, prev) => (prev === undefined || curr.ready ? curr.t : prev.value),
|
|
332
|
-
});
|
|
268
|
+
function providePausableOptions(opt) {
|
|
269
|
+
return { provide: PAUSABLE_OPTIONS, useValue: opt };
|
|
333
270
|
}
|
|
334
|
-
|
|
335
271
|
/**
|
|
336
272
|
* Resolve a {@link PauseOption} into a pause predicate, or `null` meaning "do not pause".
|
|
337
273
|
* `null` tells the caller to return the bare primitive — no wrapper is created.
|
|
@@ -346,11 +282,7 @@ function holdUntilReady(target, ready) {
|
|
|
346
282
|
*
|
|
347
283
|
* Encapsulating this here keeps every pausable primitive's branching identical and in one place.
|
|
348
284
|
*/
|
|
349
|
-
function resolvePause(opt) {
|
|
350
|
-
const explicit = opt?.pause; // distinguish explicit `true` from the omitted default
|
|
351
|
-
const pause = explicit ?? true; // explicit pausable* calls default to pausing
|
|
352
|
-
if (pause === false)
|
|
353
|
-
return null;
|
|
285
|
+
function resolvePause(opt, defaultPause = true) {
|
|
354
286
|
const run = (fn) => opt?.injector ? runInInjectionContext(opt.injector, fn) : fn();
|
|
355
287
|
// `inject` requires an injection context even with `optional: true`. A bare
|
|
356
288
|
// `pausableSignal(0)` (documented as "like `signal`") must degrade to the unwrapped
|
|
@@ -363,6 +295,12 @@ function resolvePause(opt) {
|
|
|
363
295
|
return fallback;
|
|
364
296
|
}
|
|
365
297
|
};
|
|
298
|
+
// A `providePausableOptions(...)` default fills in when the call site didn't specify `pause`.
|
|
299
|
+
const providedPause = tryRun(() => inject(PAUSABLE_OPTIONS, { optional: true })?.pause, undefined);
|
|
300
|
+
const explicit = opt?.pause ?? providedPause;
|
|
301
|
+
const pause = explicit ?? defaultPause; // public pausable* default `true`; opt-in integrations `false`
|
|
302
|
+
if (pause === false)
|
|
303
|
+
return null;
|
|
366
304
|
const onServer = () => typeof pause === 'function' && !opt?.injector
|
|
367
305
|
? typeof globalThis.window === 'undefined'
|
|
368
306
|
: tryRun(() => isPlatformServer(inject(PLATFORM_ID, { optional: true }) ?? 'browser'), typeof globalThis.window === 'undefined');
|
|
@@ -372,7 +310,7 @@ function resolvePause(opt) {
|
|
|
372
310
|
return null;
|
|
373
311
|
const paused = tryRun(() => inject(PAUSED_CONTEXT, { optional: true }), null);
|
|
374
312
|
if (!paused) {
|
|
375
|
-
if (
|
|
313
|
+
if (opt?.pause === true && isDevMode())
|
|
376
314
|
console.warn('[pausable] `pause: true` but no PAUSED_CONTEXT in scope — not pausing. Provide one via an ' +
|
|
377
315
|
'Activity boundary (`MmActivity` / `providePaused`), or pass a predicate / `pause: false`.');
|
|
378
316
|
return null;
|
|
@@ -444,6 +382,94 @@ function pausableComputed(computation, options) {
|
|
|
444
382
|
return ls.asReadonly();
|
|
445
383
|
}
|
|
446
384
|
|
|
385
|
+
/**
|
|
386
|
+
* 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.
|
|
387
|
+
*
|
|
388
|
+
* 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`.
|
|
389
|
+
*
|
|
390
|
+
* @template T The type of items in the array.
|
|
391
|
+
* @param source A `Signal` or a function that returns an array of items to be processed in chunks.
|
|
392
|
+
* @param options Configuration options for chunk size, delay duration, equality function, and injector.
|
|
393
|
+
* @returns A `Signal` that emits the current chunk of items being processed.
|
|
394
|
+
*
|
|
395
|
+
* @example
|
|
396
|
+
* const largeList = signal(Array.from({ length: 1000 }, (_, i) => i));
|
|
397
|
+
* const chunkedList = chunked(largeList, { chunkSize: 100, delay: 100 });
|
|
398
|
+
*/
|
|
399
|
+
function chunked(source, options) {
|
|
400
|
+
const { chunkSize = 50, delay = 'frame', equal, injector, pause, } = options || {};
|
|
401
|
+
let delayFn;
|
|
402
|
+
if (delay === 'frame') {
|
|
403
|
+
delayFn =
|
|
404
|
+
typeof requestAnimationFrame === 'function'
|
|
405
|
+
? (callback) => {
|
|
406
|
+
const num = requestAnimationFrame(callback);
|
|
407
|
+
return () => cancelAnimationFrame(num);
|
|
408
|
+
}
|
|
409
|
+
: // SSR: no requestAnimationFrame — approximate a frame with a timeout
|
|
410
|
+
(cb) => {
|
|
411
|
+
const num = setTimeout(cb, 16);
|
|
412
|
+
return () => clearTimeout(num);
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
else if (delay === 'microtask') {
|
|
416
|
+
delayFn = (cb) => {
|
|
417
|
+
let isCancelled = false;
|
|
418
|
+
queueMicrotask(() => {
|
|
419
|
+
if (isCancelled)
|
|
420
|
+
return;
|
|
421
|
+
cb();
|
|
422
|
+
});
|
|
423
|
+
return () => {
|
|
424
|
+
isCancelled = true;
|
|
425
|
+
};
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
else {
|
|
429
|
+
delayFn = (cb) => {
|
|
430
|
+
const num = setTimeout(cb, delay);
|
|
431
|
+
return () => clearTimeout(num);
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
const internal = linkedSignal({
|
|
435
|
+
source,
|
|
436
|
+
computation: (items) => items.slice(0, chunkSize),
|
|
437
|
+
equal,
|
|
438
|
+
});
|
|
439
|
+
const paused = resolvePause({ injector, pause }, false);
|
|
440
|
+
nestedEffect((cleanup) => {
|
|
441
|
+
if (paused?.())
|
|
442
|
+
return;
|
|
443
|
+
const fullList = source();
|
|
444
|
+
const current = internal();
|
|
445
|
+
if (current.length >= fullList.length)
|
|
446
|
+
return;
|
|
447
|
+
return cleanup(delayFn(() => untracked(() => internal.set(fullList.slice(0, current.length + chunkSize)))));
|
|
448
|
+
}, {
|
|
449
|
+
injector,
|
|
450
|
+
});
|
|
451
|
+
return internal.asReadonly();
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Structural hold-and-swap as a signal. Given a `target` (the desired value — e.g. the
|
|
456
|
+
* subtree/def/key you want to show) and a `ready` predicate, returns a signal that keeps
|
|
457
|
+
* yielding its PREVIOUS value until `ready()` is true, then swaps to the current target.
|
|
458
|
+
*
|
|
459
|
+
* This is the structural counterpart to `keepPrevious`/`commit`: where those hold a *value*
|
|
460
|
+
* through a reload, this holds a *structure* through a swap. The caller mounts the incoming
|
|
461
|
+
* structure off to the side (so its resources can settle and flip `ready`), keeps showing the
|
|
462
|
+
* held previous structure meanwhile, and lets the old one go once `ready` releases the swap.
|
|
463
|
+
*
|
|
464
|
+
* The very first value passes straight through (nothing to hold yet).
|
|
465
|
+
*/
|
|
466
|
+
function holdUntilReady(target, ready) {
|
|
467
|
+
return linkedSignal({
|
|
468
|
+
source: () => ({ t: target(), ready: ready() }),
|
|
469
|
+
computation: (curr, prev) => (prev === undefined || curr.ready ? curr.t : prev.value),
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
|
|
447
473
|
const { is } = Object;
|
|
448
474
|
function mutable(initial, opt) {
|
|
449
475
|
const baseEqual = opt?.equal ?? is;
|
|
@@ -645,6 +671,7 @@ function registerResource(res, opt) {
|
|
|
645
671
|
function injectStartTransition() {
|
|
646
672
|
const scope = injectTransitionScope();
|
|
647
673
|
const injector = inject(Injector);
|
|
674
|
+
const onServer = isPlatformServer(inject(PLATFORM_ID, { optional: true }) ?? 'browser');
|
|
648
675
|
return (fn) => {
|
|
649
676
|
untracked(fn);
|
|
650
677
|
let sawPending = false;
|
|
@@ -659,6 +686,13 @@ function injectStartTransition() {
|
|
|
659
686
|
resolve();
|
|
660
687
|
}
|
|
661
688
|
}, { injector });
|
|
689
|
+
if (onServer) {
|
|
690
|
+
if (!untracked(scope.pending)) {
|
|
691
|
+
watcher.destroy();
|
|
692
|
+
resolve();
|
|
693
|
+
}
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
662
696
|
// no-async fallback: once the reactive system has processed the writes (afterNextRender),
|
|
663
697
|
// if nothing ever went in flight, the transition is already complete.
|
|
664
698
|
afterNextRender(() => {
|
|
@@ -686,6 +720,12 @@ function injectStartTransition() {
|
|
|
686
720
|
*
|
|
687
721
|
* `type` selects what "not ready" means: `'value'` (default) suspends only until a first value lands
|
|
688
722
|
* then holds through reloads; `'loading'` suspends on every in-flight load (strict suspense).
|
|
723
|
+
*
|
|
724
|
+
* SSR: the server serializes whatever the scope reports at stabilization, so a registered resource
|
|
725
|
+
* must keep the app unstable until it settles or the placeholder is what gets serialized (then
|
|
726
|
+
* flashes/mismatches on hydration). HttpClient-backed resources, httpResource & all of `@mmstack/resource`
|
|
727
|
+
* do this automatically via the HTTP layer's `PendingTasks` + transfer cache. A custom loader (raw
|
|
728
|
+
* `fetch`/promise/timer) must opt in itself: wrap it with `inject(PendingTasks).run(() => promise)`.
|
|
689
729
|
*/
|
|
690
730
|
class SuspenseBoundaryBase {
|
|
691
731
|
scope = injectTransitionScope();
|
|
@@ -799,6 +839,7 @@ function runInTransaction(txn, fn) {
|
|
|
799
839
|
function injectStartTransaction() {
|
|
800
840
|
const scope = injectTransitionScope();
|
|
801
841
|
const injector = inject(Injector);
|
|
842
|
+
const onServer = isPlatformServer(inject(PLATFORM_ID, { optional: true }) ?? 'browser');
|
|
802
843
|
return (fn) => {
|
|
803
844
|
const txn = createTransaction();
|
|
804
845
|
// Hold BEFORE the writes, so the display freezes at pre-transaction values.
|
|
@@ -840,11 +881,17 @@ function injectStartTransaction() {
|
|
|
840
881
|
if (sawPending && !p)
|
|
841
882
|
finish(false);
|
|
842
883
|
}, { injector });
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
if (!sawPending && !untracked(scope.pending))
|
|
884
|
+
if (onServer) {
|
|
885
|
+
if (!untracked(scope.pending))
|
|
846
886
|
finish(false);
|
|
847
|
-
}
|
|
887
|
+
}
|
|
888
|
+
else {
|
|
889
|
+
// no-async fallback: if nothing ever went in flight, settle once the writes are processed.
|
|
890
|
+
afterNextRender(() => {
|
|
891
|
+
if (!sawPending && !untracked(scope.pending))
|
|
892
|
+
finish(false);
|
|
893
|
+
}, { injector });
|
|
894
|
+
}
|
|
848
895
|
return {
|
|
849
896
|
pending: scope.pending,
|
|
850
897
|
done,
|
|
@@ -2615,11 +2662,19 @@ function throttle(source, opt) {
|
|
|
2615
2662
|
tick();
|
|
2616
2663
|
};
|
|
2617
2664
|
const update = (fn) => set(fn(untracked(source)));
|
|
2665
|
+
const flush = () => {
|
|
2666
|
+
if (timeout)
|
|
2667
|
+
clearTimeout(timeout);
|
|
2668
|
+
timeout = undefined;
|
|
2669
|
+
pendingTrailing = false;
|
|
2670
|
+
fire();
|
|
2671
|
+
};
|
|
2618
2672
|
const writable = toWritable(computed(() => {
|
|
2619
2673
|
trigger();
|
|
2620
2674
|
return untracked(source);
|
|
2621
2675
|
}, opt), set, update);
|
|
2622
2676
|
writable.original = source.asReadonly();
|
|
2677
|
+
writable.flush = flush;
|
|
2623
2678
|
return writable;
|
|
2624
2679
|
}
|
|
2625
2680
|
|
|
@@ -2842,81 +2897,269 @@ function createOrientation(debugName) {
|
|
|
2842
2897
|
return state.asReadonly();
|
|
2843
2898
|
}
|
|
2844
2899
|
|
|
2900
|
+
const IDLE = {
|
|
2901
|
+
active: false,
|
|
2902
|
+
start: { x: 0, y: 0 },
|
|
2903
|
+
current: { x: 0, y: 0 },
|
|
2904
|
+
delta: { x: 0, y: 0 },
|
|
2905
|
+
pointerId: null,
|
|
2906
|
+
modifiers: { shift: false, alt: false, ctrl: false, meta: false },
|
|
2907
|
+
button: -1,
|
|
2908
|
+
};
|
|
2909
|
+
function stateEqual(a, b) {
|
|
2910
|
+
return (a.active === b.active &&
|
|
2911
|
+
a.pointerId === b.pointerId &&
|
|
2912
|
+
a.current.x === b.current.x &&
|
|
2913
|
+
a.current.y === b.current.y &&
|
|
2914
|
+
a.button === b.button &&
|
|
2915
|
+
a.modifiers.shift === b.modifiers.shift &&
|
|
2916
|
+
a.modifiers.alt === b.modifiers.alt &&
|
|
2917
|
+
a.modifiers.ctrl === b.modifiers.ctrl &&
|
|
2918
|
+
a.modifiers.meta === b.modifiers.meta);
|
|
2919
|
+
}
|
|
2845
2920
|
/**
|
|
2846
|
-
*
|
|
2847
|
-
*
|
|
2848
|
-
*
|
|
2849
|
-
*
|
|
2850
|
-
* The primitive is SSR-safe and automatically cleans up its event listeners
|
|
2851
|
-
* when the creating context is destroyed.
|
|
2852
|
-
*
|
|
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).
|
|
2855
|
-
* @returns A read-only `Signal<DocumentVisibilityState>`. On the server,
|
|
2856
|
-
* it returns a static signal with a value of `'visible'`.
|
|
2921
|
+
* Tracks a pointer *gesture* (pointerdown → capture → move → up) as a signal —
|
|
2922
|
+
* the foundation for pointer-based drag/move/resize/marquee on a canvas. Unlike
|
|
2923
|
+
* native HTML5 drag, pointer events fire continuously and coordinates are
|
|
2924
|
+
* reliable. SSR-safe; cleans up its listeners automatically.
|
|
2857
2925
|
*
|
|
2858
2926
|
* @example
|
|
2859
2927
|
* ```ts
|
|
2860
|
-
*
|
|
2861
|
-
*
|
|
2862
|
-
*
|
|
2863
|
-
*
|
|
2864
|
-
* selector: 'app-visibility-tracker',
|
|
2865
|
-
* template: `<p>Page is currently: {{ visibilityState() }}</p>`
|
|
2866
|
-
* })
|
|
2867
|
-
* export class VisibilityTrackerComponent {
|
|
2868
|
-
* readonly visibilityState = pageVisibility();
|
|
2869
|
-
*
|
|
2870
|
-
* constructor() {
|
|
2871
|
-
* effect(() => {
|
|
2872
|
-
* if (this.visibilityState() === 'hidden') {
|
|
2873
|
-
* console.log('Page is hidden, pausing expensive animations...');
|
|
2874
|
-
* } else {
|
|
2875
|
-
* console.log('Page is visible, resuming activity.');
|
|
2876
|
-
* }
|
|
2928
|
+
* const drag = pointerDrag({ activationThreshold: 4 });
|
|
2929
|
+
* const position = computed(() => {
|
|
2930
|
+
* const d = drag();
|
|
2931
|
+
* return d.active ? { x: base.x + d.delta.x, y: base.y + d.delta.y } : base;
|
|
2877
2932
|
* });
|
|
2878
|
-
* }
|
|
2879
|
-
* }
|
|
2880
2933
|
* ```
|
|
2881
2934
|
*/
|
|
2882
|
-
function
|
|
2883
|
-
|
|
2884
|
-
return runInSensorContext(injector, () => createPageVisibility(debugName));
|
|
2935
|
+
function pointerDrag(opt) {
|
|
2936
|
+
return runInSensorContext(opt?.injector, () => createPointerDrag(opt));
|
|
2885
2937
|
}
|
|
2886
|
-
function
|
|
2938
|
+
function createPointerDrag(opt) {
|
|
2887
2939
|
if (isPlatformServer(inject(PLATFORM_ID))) {
|
|
2888
|
-
|
|
2940
|
+
const base = computed(() => IDLE, {
|
|
2941
|
+
debugName: opt?.debugName ?? 'pointerDrag',
|
|
2942
|
+
});
|
|
2943
|
+
base.unthrottled = base;
|
|
2944
|
+
base.cancel = () => undefined;
|
|
2945
|
+
return base;
|
|
2889
2946
|
}
|
|
2890
|
-
const
|
|
2891
|
-
const
|
|
2892
|
-
|
|
2893
|
-
|
|
2894
|
-
|
|
2895
|
-
|
|
2896
|
-
|
|
2897
|
-
|
|
2898
|
-
|
|
2899
|
-
|
|
2900
|
-
|
|
2901
|
-
|
|
2902
|
-
|
|
2903
|
-
|
|
2904
|
-
|
|
2905
|
-
|
|
2906
|
-
|
|
2907
|
-
|
|
2908
|
-
|
|
2909
|
-
|
|
2910
|
-
|
|
2911
|
-
|
|
2912
|
-
|
|
2913
|
-
|
|
2914
|
-
|
|
2915
|
-
|
|
2916
|
-
|
|
2917
|
-
|
|
2918
|
-
|
|
2919
|
-
|
|
2947
|
+
const hostRef = inject((ElementRef), { optional: true });
|
|
2948
|
+
const { target = hostRef?.nativeElement, coordinateSpace = 'client', activationThreshold = 3, throttle = 16, handleSelector, buttons = [0], debugName = 'pointerDrag', } = opt ?? {};
|
|
2949
|
+
const resolve = (t) => {
|
|
2950
|
+
if (!t)
|
|
2951
|
+
return null;
|
|
2952
|
+
return t instanceof ElementRef ? t.nativeElement : t;
|
|
2953
|
+
};
|
|
2954
|
+
if (!isSignal(target) && !resolve(target)) {
|
|
2955
|
+
if (isDevMode())
|
|
2956
|
+
console.warn('pointerDrag: no target element (host ElementRef missing).');
|
|
2957
|
+
const base = computed(() => IDLE, { debugName });
|
|
2958
|
+
base.unthrottled = base;
|
|
2959
|
+
base.cancel = () => undefined;
|
|
2960
|
+
return base;
|
|
2961
|
+
}
|
|
2962
|
+
const state = throttled(IDLE, {
|
|
2963
|
+
ms: throttle,
|
|
2964
|
+
leading: true,
|
|
2965
|
+
trailing: true,
|
|
2966
|
+
equal: stateEqual,
|
|
2967
|
+
debugName,
|
|
2968
|
+
});
|
|
2969
|
+
let startPoint = { x: 0, y: 0 };
|
|
2970
|
+
let activePointerId = null;
|
|
2971
|
+
let activeButton = -1;
|
|
2972
|
+
let activated = false;
|
|
2973
|
+
let gesture = null;
|
|
2974
|
+
const coord = (e) => coordinateSpace === 'page'
|
|
2975
|
+
? { x: e.pageX, y: e.pageY }
|
|
2976
|
+
: { x: e.clientX, y: e.clientY };
|
|
2977
|
+
const mods = (e) => ({
|
|
2978
|
+
shift: e.shiftKey,
|
|
2979
|
+
alt: e.altKey,
|
|
2980
|
+
ctrl: e.ctrlKey,
|
|
2981
|
+
meta: e.metaKey,
|
|
2982
|
+
});
|
|
2983
|
+
const end = () => {
|
|
2984
|
+
gesture?.abort();
|
|
2985
|
+
gesture = null;
|
|
2986
|
+
activePointerId = null;
|
|
2987
|
+
activeButton = -1;
|
|
2988
|
+
activated = false;
|
|
2989
|
+
state.set(IDLE);
|
|
2990
|
+
state.flush(); // terminal transition: reflect IDLE now, not on the trailing edge
|
|
2991
|
+
};
|
|
2992
|
+
const onMove = (e) => {
|
|
2993
|
+
if (e.pointerId !== activePointerId)
|
|
2994
|
+
return;
|
|
2995
|
+
const current = coord(e);
|
|
2996
|
+
const delta = { x: current.x - startPoint.x, y: current.y - startPoint.y };
|
|
2997
|
+
if (!activated && Math.hypot(delta.x, delta.y) >= activationThreshold) {
|
|
2998
|
+
activated = true;
|
|
2999
|
+
}
|
|
3000
|
+
state.set({
|
|
3001
|
+
active: activated,
|
|
3002
|
+
start: startPoint,
|
|
3003
|
+
current,
|
|
3004
|
+
delta,
|
|
3005
|
+
pointerId: activePointerId,
|
|
3006
|
+
modifiers: mods(e),
|
|
3007
|
+
button: activeButton, // pointermove button is -1; keep the down-button
|
|
3008
|
+
});
|
|
3009
|
+
};
|
|
3010
|
+
const onUp = (e) => {
|
|
3011
|
+
if (e.pointerId === activePointerId)
|
|
3012
|
+
end();
|
|
3013
|
+
};
|
|
3014
|
+
const onCancel = (e) => {
|
|
3015
|
+
if (e.pointerId === activePointerId)
|
|
3016
|
+
end();
|
|
3017
|
+
};
|
|
3018
|
+
const onKey = (e) => {
|
|
3019
|
+
if (e.key === 'Escape' && activePointerId !== null)
|
|
3020
|
+
end();
|
|
3021
|
+
};
|
|
3022
|
+
const onDown = (el) => (e) => {
|
|
3023
|
+
if (activePointerId !== null)
|
|
3024
|
+
return;
|
|
3025
|
+
if (!buttons.includes(e.button))
|
|
3026
|
+
return;
|
|
3027
|
+
if (handleSelector && !e.target?.closest?.(handleSelector)) {
|
|
3028
|
+
return;
|
|
3029
|
+
}
|
|
3030
|
+
activePointerId = e.pointerId;
|
|
3031
|
+
activeButton = e.button;
|
|
3032
|
+
activated = false;
|
|
3033
|
+
startPoint = coord(e);
|
|
3034
|
+
try {
|
|
3035
|
+
el.setPointerCapture(e.pointerId);
|
|
3036
|
+
}
|
|
3037
|
+
catch {
|
|
3038
|
+
// capture unsupported (older browsers / test env) — listeners still work
|
|
3039
|
+
}
|
|
3040
|
+
gesture = new AbortController();
|
|
3041
|
+
const signal = gesture.signal;
|
|
3042
|
+
el.addEventListener('pointermove', onMove, { signal });
|
|
3043
|
+
el.addEventListener('pointerup', onUp, { signal });
|
|
3044
|
+
el.addEventListener('pointercancel', onCancel, { signal });
|
|
3045
|
+
el.addEventListener('lostpointercapture', onCancel, {
|
|
3046
|
+
signal,
|
|
3047
|
+
});
|
|
3048
|
+
window.addEventListener('keydown', onKey, { signal });
|
|
3049
|
+
state.set({
|
|
3050
|
+
active: false,
|
|
3051
|
+
start: startPoint,
|
|
3052
|
+
current: startPoint,
|
|
3053
|
+
delta: { x: 0, y: 0 },
|
|
3054
|
+
pointerId: e.pointerId,
|
|
3055
|
+
modifiers: mods(e),
|
|
3056
|
+
button: e.button,
|
|
3057
|
+
});
|
|
3058
|
+
};
|
|
3059
|
+
const attach = (el) => {
|
|
3060
|
+
const controller = new AbortController();
|
|
3061
|
+
el.addEventListener('pointerdown', onDown(el), {
|
|
3062
|
+
signal: controller.signal,
|
|
3063
|
+
});
|
|
3064
|
+
return () => {
|
|
3065
|
+
controller.abort();
|
|
3066
|
+
end();
|
|
3067
|
+
};
|
|
3068
|
+
};
|
|
3069
|
+
if (isSignal(target)) {
|
|
3070
|
+
effect((cleanup) => {
|
|
3071
|
+
const el = resolve(target());
|
|
3072
|
+
if (!el)
|
|
3073
|
+
return;
|
|
3074
|
+
cleanup(attach(el));
|
|
3075
|
+
});
|
|
3076
|
+
}
|
|
3077
|
+
else {
|
|
3078
|
+
const el = resolve(target);
|
|
3079
|
+
if (el)
|
|
3080
|
+
inject(DestroyRef).onDestroy(attach(el));
|
|
3081
|
+
}
|
|
3082
|
+
const base = state.asReadonly();
|
|
3083
|
+
base.unthrottled = state.original;
|
|
3084
|
+
base.cancel = end;
|
|
3085
|
+
return base;
|
|
3086
|
+
}
|
|
3087
|
+
|
|
3088
|
+
/**
|
|
3089
|
+
* Creates a read-only signal that tracks the page's visibility state.
|
|
3090
|
+
*
|
|
3091
|
+
* It uses the browser's Page Visibility API to reactively report if the
|
|
3092
|
+
* current document is `'visible'`, `'hidden'`, or in another state.
|
|
3093
|
+
* The primitive is SSR-safe and automatically cleans up its event listeners
|
|
3094
|
+
* when the creating context is destroyed.
|
|
3095
|
+
*
|
|
3096
|
+
* @param opt Optional debug name for the signal, or a {@link SensorRunOptions} object
|
|
3097
|
+
* (with an optional `injector` for creation outside an injection context).
|
|
3098
|
+
* @returns A read-only `Signal<DocumentVisibilityState>`. On the server,
|
|
3099
|
+
* it returns a static signal with a value of `'visible'`.
|
|
3100
|
+
*
|
|
3101
|
+
* @example
|
|
3102
|
+
* ```ts
|
|
3103
|
+
* import { Component, effect } from '@angular/core';
|
|
3104
|
+
* import { pageVisibility } from '@mmstack/primitives';
|
|
3105
|
+
*
|
|
3106
|
+
* @Component({
|
|
3107
|
+
* selector: 'app-visibility-tracker',
|
|
3108
|
+
* template: `<p>Page is currently: {{ visibilityState() }}</p>`
|
|
3109
|
+
* })
|
|
3110
|
+
* export class VisibilityTrackerComponent {
|
|
3111
|
+
* readonly visibilityState = pageVisibility();
|
|
3112
|
+
*
|
|
3113
|
+
* constructor() {
|
|
3114
|
+
* effect(() => {
|
|
3115
|
+
* if (this.visibilityState() === 'hidden') {
|
|
3116
|
+
* console.log('Page is hidden, pausing expensive animations...');
|
|
3117
|
+
* } else {
|
|
3118
|
+
* console.log('Page is visible, resuming activity.');
|
|
3119
|
+
* }
|
|
3120
|
+
* });
|
|
3121
|
+
* }
|
|
3122
|
+
* }
|
|
3123
|
+
* ```
|
|
3124
|
+
*/
|
|
3125
|
+
function pageVisibility(opt) {
|
|
3126
|
+
const { debugName = 'pageVisibility', injector } = coerceSensorOptions(opt);
|
|
3127
|
+
return runInSensorContext(injector, () => createPageVisibility(debugName));
|
|
3128
|
+
}
|
|
3129
|
+
function createPageVisibility(debugName) {
|
|
3130
|
+
if (isPlatformServer(inject(PLATFORM_ID))) {
|
|
3131
|
+
return computed(() => 'visible', { debugName });
|
|
3132
|
+
}
|
|
3133
|
+
const visibility = signal(document.visibilityState, { debugName });
|
|
3134
|
+
const onVisibilityChange = () => visibility.set(document.visibilityState);
|
|
3135
|
+
document.addEventListener('visibilitychange', onVisibilityChange);
|
|
3136
|
+
inject(DestroyRef).onDestroy(() => document.removeEventListener('visibilitychange', onVisibilityChange));
|
|
3137
|
+
return visibility.asReadonly();
|
|
3138
|
+
}
|
|
3139
|
+
|
|
3140
|
+
/**
|
|
3141
|
+
* Creates a read-only signal that tracks the scroll position (x, y) of the window
|
|
3142
|
+
* or a specified HTML element.
|
|
3143
|
+
*
|
|
3144
|
+
* Updates are throttled by default to optimize performance. An `unthrottled`
|
|
3145
|
+
* property is available on the returned signal for direct access to raw updates.
|
|
3146
|
+
* The primitive is SSR-safe and automatically cleans up its event listeners.
|
|
3147
|
+
*
|
|
3148
|
+
* @param options Optional configuration for the scroll sensor.
|
|
3149
|
+
* @returns A `ScrollPositionSignal`. On the server, it returns a static
|
|
3150
|
+
* signal with `{ x: 0, y: 0 }`.
|
|
3151
|
+
*
|
|
3152
|
+
* @example
|
|
3153
|
+
* ```ts
|
|
3154
|
+
* import { Component, effect, ElementRef, viewChild } from '@angular/core';
|
|
3155
|
+
* import { scrollPosition } from '@mmstack/primitives';
|
|
3156
|
+
*
|
|
3157
|
+
* @Component({
|
|
3158
|
+
* selector: 'app-scroll-tracker',
|
|
3159
|
+
* template: `
|
|
3160
|
+
* <p>Window Scroll: X: {{ windowScroll().x }}, Y: {{ windowScroll().y }}</p>
|
|
3161
|
+
* <p>Host Scroll: X: {{ hostScroll().x }}, Y: {{ hostScroll().y }}</p>
|
|
3162
|
+
* `
|
|
2920
3163
|
* })
|
|
2921
3164
|
* export class ScrollTrackerComponent {
|
|
2922
3165
|
* readonly windowScroll = scrollPosition(); // Defaults to window
|
|
@@ -3068,6 +3311,8 @@ function sensor(type, options) {
|
|
|
3068
3311
|
switch (type) {
|
|
3069
3312
|
case 'mousePosition':
|
|
3070
3313
|
return mousePosition(opts);
|
|
3314
|
+
case 'pointerDrag':
|
|
3315
|
+
return pointerDrag(opts);
|
|
3071
3316
|
case 'networkStatus':
|
|
3072
3317
|
return networkStatus(opts);
|
|
3073
3318
|
case 'pageVisibility':
|
|
@@ -3185,9 +3430,52 @@ function signalFromEvent(target, eventName, initial, projectOrOpt, maybeOpt) {
|
|
|
3185
3430
|
return untracked(() => state.asReadonly());
|
|
3186
3431
|
}
|
|
3187
3432
|
|
|
3188
|
-
|
|
3189
|
-
|
|
3433
|
+
const IS_STORE = Symbol('@mmstack/primitives::store/IS_STORE');
|
|
3434
|
+
const SCOPE_PARENT = Symbol('@mmstack/primitives::store/SCOPE_PARENT');
|
|
3435
|
+
/**
|
|
3436
|
+
* @internal Brand carrying a store's writability ('mutable' | 'writable' | 'readonly'), stamped
|
|
3437
|
+
* on every store proxy. Read by `extendStore` instead of re-deriving via `isWritableSignal`,
|
|
3438
|
+
* which would mis-classify a readonly scoped store (its backing `toWritable` still has a `set`).
|
|
3439
|
+
*/
|
|
3440
|
+
const STORE_KIND = Symbol('@mmstack/primitives::store/STORE_KIND');
|
|
3441
|
+
/**
|
|
3442
|
+
* @internal Brand exposing the injector a store was built with, so `extendStore` inherits it the
|
|
3443
|
+
* same way `store.extend(...)` does (via closure) — no injection context needed at the call site.
|
|
3444
|
+
*/
|
|
3445
|
+
const STORE_INJECTOR = Symbol('@mmstack/primitives::store/STORE_INJECTOR');
|
|
3446
|
+
const SIGNAL_FN_PROP = new Set([
|
|
3447
|
+
'set',
|
|
3448
|
+
'update',
|
|
3449
|
+
'mutate',
|
|
3450
|
+
'inline',
|
|
3451
|
+
'asReadonly',
|
|
3452
|
+
]);
|
|
3453
|
+
/**
|
|
3454
|
+
* @internal
|
|
3455
|
+
* Test-only handle on the proxy cache (deliberately NOT re-exported from the public barrel).
|
|
3456
|
+
* Maps a store's backing signal to its lazily-built child proxies, each held via a `WeakRef`.
|
|
3457
|
+
*/
|
|
3458
|
+
const PROXY_CACHE = new WeakMap();
|
|
3459
|
+
/**
|
|
3460
|
+
* @internal
|
|
3461
|
+
* Test-only handle on the finalization registry (deliberately NOT re-exported from the public
|
|
3462
|
+
* barrel). Prunes a cache entry once its proxy is reclaimed by the GC.
|
|
3463
|
+
*/
|
|
3464
|
+
const PROXY_CLEANUP = new FinalizationRegistry(({ target, prop }) => {
|
|
3465
|
+
const storeCache = PROXY_CACHE.get(target);
|
|
3466
|
+
if (storeCache)
|
|
3467
|
+
storeCache.delete(prop);
|
|
3468
|
+
});
|
|
3469
|
+
/**
|
|
3470
|
+
* @internal
|
|
3471
|
+
* Validates whether a value is a Signal Store.
|
|
3472
|
+
*/
|
|
3473
|
+
function isStore(value) {
|
|
3474
|
+
return (typeof value === 'function' &&
|
|
3475
|
+
value !== null &&
|
|
3476
|
+
value[IS_STORE] === true);
|
|
3190
3477
|
}
|
|
3478
|
+
|
|
3191
3479
|
/**
|
|
3192
3480
|
* Runtime marker + compile-time brand for an opaque value. A `const`-declared `Symbol`
|
|
3193
3481
|
* has a `unique symbol` type, so the same symbol serves as both the property key written
|
|
@@ -3229,12 +3517,13 @@ function isOpaque(value) {
|
|
|
3229
3517
|
value !== null &&
|
|
3230
3518
|
value[OPAQUE] === true);
|
|
3231
3519
|
}
|
|
3232
|
-
|
|
3233
|
-
|
|
3234
|
-
|
|
3235
|
-
|
|
3236
|
-
|
|
3237
|
-
|
|
3520
|
+
|
|
3521
|
+
function isRecord(value) {
|
|
3522
|
+
if (value === null || typeof value !== 'object' || isOpaque(value))
|
|
3523
|
+
return false;
|
|
3524
|
+
const proto = Object.getPrototypeOf(value);
|
|
3525
|
+
return proto === Object.prototype || proto === null;
|
|
3526
|
+
}
|
|
3238
3527
|
/**
|
|
3239
3528
|
* @internal Whether a value is a terminal leaf: a concrete non-record/non-array value always is;
|
|
3240
3529
|
* `null`/`undefined` is a leaf only when vivification is disabled (with vivify on it can still
|
|
@@ -3247,6 +3536,65 @@ function isLeafValue(value, vivifyEnabled) {
|
|
|
3247
3536
|
return true; // opaque always wins — even arrays
|
|
3248
3537
|
return !Array.isArray(value) && !isRecord(value);
|
|
3249
3538
|
}
|
|
3539
|
+
/**
|
|
3540
|
+
* @internal
|
|
3541
|
+
* Resolves the vivify shape for a node from its current value: a present record/array is a
|
|
3542
|
+
* certainty we keep (cached in the derivation, so it survives the value being nulled); an
|
|
3543
|
+
* unknown value (`null`/`undefined`) defers to the caller's option. Off stays off.
|
|
3544
|
+
*/
|
|
3545
|
+
function resolveVivify(sample, option) {
|
|
3546
|
+
if (!option)
|
|
3547
|
+
return false;
|
|
3548
|
+
if (Array.isArray(sample))
|
|
3549
|
+
return 'array';
|
|
3550
|
+
if (isRecord(sample))
|
|
3551
|
+
return 'object';
|
|
3552
|
+
return 'auto';
|
|
3553
|
+
}
|
|
3554
|
+
function hasOwnKey(value, key) {
|
|
3555
|
+
return value != null && Object.hasOwn(value, key);
|
|
3556
|
+
}
|
|
3557
|
+
/**
|
|
3558
|
+
* @internal
|
|
3559
|
+
* Builds the `onChange` for the fallback (non-record container) derivation branch. For an
|
|
3560
|
+
* immutable source the container is copied before the write — returning the same mutated
|
|
3561
|
+
* reference would let the source's equality cut propagation (leaving child signals permanently
|
|
3562
|
+
* stale) and alias the caller's original object, breaking the structural-sharing contract
|
|
3563
|
+
* `forkStore` relies on. For a mutable source the write goes through `mutate`, so the chain's
|
|
3564
|
+
* force-notify engages (plain `update` with the same reference would never notify).
|
|
3565
|
+
*/
|
|
3566
|
+
function createFallbackOnChange(target, prop, vivifyFn, isMutableSource) {
|
|
3567
|
+
const write = (newValue) => (v) => {
|
|
3568
|
+
const container = vivifyFn(v, prop);
|
|
3569
|
+
if (container === null || container === undefined)
|
|
3570
|
+
return container;
|
|
3571
|
+
const next = isMutableSource
|
|
3572
|
+
? container
|
|
3573
|
+
: Array.isArray(container)
|
|
3574
|
+
? container.slice()
|
|
3575
|
+
: isRecord(container)
|
|
3576
|
+
? { ...container }
|
|
3577
|
+
: container; // non-plain leaf (Date/class instance): legacy in-place attempt
|
|
3578
|
+
try {
|
|
3579
|
+
next[prop] = newValue;
|
|
3580
|
+
}
|
|
3581
|
+
catch (e) {
|
|
3582
|
+
if (isDevMode())
|
|
3583
|
+
console.error(`[store] Failed to set property "${String(prop)}"`, e);
|
|
3584
|
+
}
|
|
3585
|
+
return next;
|
|
3586
|
+
};
|
|
3587
|
+
return isMutableSource
|
|
3588
|
+
? (newValue) => target.mutate(write(newValue))
|
|
3589
|
+
: (newValue) => target.update(write(newValue));
|
|
3590
|
+
}
|
|
3591
|
+
|
|
3592
|
+
/**
|
|
3593
|
+
* @internal Runtime brand carrying a store node's lazily-built leaf probe. Exported (like
|
|
3594
|
+
* {@link OPAQUE}) only so the `{ readonly [LEAF]: () => boolean }` brand on the store types is
|
|
3595
|
+
* nameable in the emitted declarations — not part of the supported surface; use {@link isLeaf}.
|
|
3596
|
+
*/
|
|
3597
|
+
const LEAF = Symbol('@mmstack/primitives::store/LEAF');
|
|
3250
3598
|
/**
|
|
3251
3599
|
* @internal Constant leaf probes for nodes whose leaf-ness is statically known, so the reactive
|
|
3252
3600
|
* `computed` can be skipped entirely.
|
|
@@ -3304,227 +3652,72 @@ function markAsLeaf(sig, value, vivifyEnabled, noUnionLeaves) {
|
|
|
3304
3652
|
function isLeaf(value) {
|
|
3305
3653
|
return isStore(value) && value[LEAF]?.() === true;
|
|
3306
3654
|
}
|
|
3307
|
-
|
|
3308
|
-
const SCOPE_PARENT = Symbol('@mmstack/primitives::store/SCOPE_PARENT');
|
|
3309
|
-
/**
|
|
3310
|
-
* @internal
|
|
3311
|
-
* Test-only handle on the proxy cache (deliberately NOT re-exported from the public barrel).
|
|
3312
|
-
* Maps a store's backing signal to its lazily-built child proxies, each held via a `WeakRef`.
|
|
3313
|
-
*/
|
|
3314
|
-
const PROXY_CACHE = new WeakMap();
|
|
3315
|
-
const SIGNAL_FN_PROP = new Set([
|
|
3316
|
-
'set',
|
|
3317
|
-
'update',
|
|
3318
|
-
'mutate',
|
|
3319
|
-
'inline',
|
|
3320
|
-
'asReadonly',
|
|
3321
|
-
]);
|
|
3655
|
+
|
|
3322
3656
|
/**
|
|
3323
|
-
* @internal
|
|
3324
|
-
*
|
|
3325
|
-
*
|
|
3657
|
+
* @internal Reads (or lazily builds + caches) the child node proxy for `prop` on `target`,
|
|
3658
|
+
* holding it via a `WeakRef` and registering it for finalizer-driven cache pruning. The cache
|
|
3659
|
+
* is keyed per backing signal, so child identity is stable across repeat reads.
|
|
3326
3660
|
*/
|
|
3327
|
-
|
|
3328
|
-
|
|
3329
|
-
if (storeCache)
|
|
3661
|
+
function getCachedChild(target, prop, build) {
|
|
3662
|
+
let storeCache = PROXY_CACHE.get(target);
|
|
3663
|
+
if (!storeCache) {
|
|
3664
|
+
storeCache = new Map();
|
|
3665
|
+
PROXY_CACHE.set(target, storeCache);
|
|
3666
|
+
}
|
|
3667
|
+
const cachedRef = storeCache.get(prop);
|
|
3668
|
+
if (cachedRef) {
|
|
3669
|
+
const cached = cachedRef.deref();
|
|
3670
|
+
if (cached)
|
|
3671
|
+
return cached;
|
|
3330
3672
|
storeCache.delete(prop);
|
|
3331
|
-
|
|
3332
|
-
|
|
3333
|
-
|
|
3334
|
-
|
|
3335
|
-
|
|
3336
|
-
|
|
3337
|
-
return
|
|
3338
|
-
value !== null &&
|
|
3339
|
-
value[IS_STORE] === true);
|
|
3340
|
-
}
|
|
3341
|
-
function isRecord(value) {
|
|
3342
|
-
if (value === null || typeof value !== 'object' || isOpaque(value))
|
|
3343
|
-
return false;
|
|
3344
|
-
const proto = Object.getPrototypeOf(value);
|
|
3345
|
-
return proto === Object.prototype || proto === null;
|
|
3346
|
-
}
|
|
3347
|
-
/**
|
|
3348
|
-
* @internal
|
|
3349
|
-
* Resolves the vivify shape for a node from its current value: a present record/array is a
|
|
3350
|
-
* certainty we keep (cached in the derivation, so it survives the value being nulled); an
|
|
3351
|
-
* unknown value (`null`/`undefined`) defers to the caller's option. Off stays off.
|
|
3352
|
-
*/
|
|
3353
|
-
function resolveVivify(sample, option) {
|
|
3354
|
-
if (!option)
|
|
3355
|
-
return false;
|
|
3356
|
-
if (Array.isArray(sample))
|
|
3357
|
-
return 'array';
|
|
3358
|
-
if (isRecord(sample))
|
|
3359
|
-
return 'object';
|
|
3360
|
-
return 'auto';
|
|
3361
|
-
}
|
|
3362
|
-
function hasOwnKey(value, key) {
|
|
3363
|
-
return value != null && Object.hasOwn(value, key);
|
|
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));
|
|
3673
|
+
PROXY_CLEANUP.unregister(cachedRef);
|
|
3674
|
+
}
|
|
3675
|
+
const proxy = build();
|
|
3676
|
+
const ref = new WeakRef(proxy);
|
|
3677
|
+
storeCache.set(prop, ref);
|
|
3678
|
+
PROXY_CLEANUP.register(proxy, { target, prop }, ref);
|
|
3679
|
+
return proxy;
|
|
3398
3680
|
}
|
|
3399
3681
|
/**
|
|
3400
|
-
* @internal
|
|
3401
|
-
*
|
|
3682
|
+
* @internal Builds the derived child signal for `prop` and wraps it as an array/object substore.
|
|
3683
|
+
* A record parent reads the key directly; any other container goes through the fallback `from`/
|
|
3684
|
+
* `onChange` path. Shared verbatim by the array and object proxies — the only place a child node
|
|
3685
|
+
* is constructed.
|
|
3402
3686
|
*/
|
|
3403
|
-
function
|
|
3404
|
-
|
|
3405
|
-
|
|
3406
|
-
const
|
|
3407
|
-
const
|
|
3408
|
-
|
|
3409
|
-
|
|
3410
|
-
|
|
3411
|
-
|
|
3412
|
-
|
|
3413
|
-
|
|
3414
|
-
|
|
3415
|
-
|
|
3416
|
-
|
|
3417
|
-
|
|
3418
|
-
|
|
3419
|
-
|
|
3420
|
-
|
|
3421
|
-
|
|
3422
|
-
|
|
3423
|
-
|
|
3424
|
-
|
|
3425
|
-
|
|
3426
|
-
const v = untracked(source);
|
|
3427
|
-
if (!Array.isArray(v))
|
|
3428
|
-
return [];
|
|
3429
|
-
const len = v.length;
|
|
3430
|
-
const arr = new Array(len + 1);
|
|
3431
|
-
for (let i = 0; i < len; i++) {
|
|
3432
|
-
arr[i] = String(i);
|
|
3433
|
-
}
|
|
3434
|
-
arr[len] = 'length';
|
|
3435
|
-
return arr;
|
|
3436
|
-
},
|
|
3437
|
-
getPrototypeOf() {
|
|
3438
|
-
return Array.prototype;
|
|
3439
|
-
},
|
|
3440
|
-
getOwnPropertyDescriptor(_, prop) {
|
|
3441
|
-
const v = untracked(source);
|
|
3442
|
-
if (!Array.isArray(v))
|
|
3443
|
-
return;
|
|
3444
|
-
if (prop === 'length' ||
|
|
3445
|
-
(typeof prop === 'string' && !isNaN(+prop) && +prop < v.length)) {
|
|
3446
|
-
return {
|
|
3447
|
-
enumerable: true,
|
|
3448
|
-
configurable: true, // Required for proxies to dynamic targets
|
|
3449
|
-
};
|
|
3450
|
-
}
|
|
3451
|
-
return;
|
|
3452
|
-
},
|
|
3453
|
-
get(target, prop, receiver) {
|
|
3454
|
-
if (prop === IS_STORE)
|
|
3455
|
-
return true;
|
|
3456
|
-
if (prop === 'length')
|
|
3457
|
-
return lengthSignal;
|
|
3458
|
-
if (prop === Symbol.iterator) {
|
|
3459
|
-
return function* () {
|
|
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++) {
|
|
3463
|
-
yield receiver[i];
|
|
3464
|
-
}
|
|
3465
|
-
};
|
|
3466
|
-
}
|
|
3467
|
-
if (typeof prop === 'symbol' || SIGNAL_FN_PROP.has(prop))
|
|
3468
|
-
return target[prop];
|
|
3469
|
-
if (isIndexProp(prop)) {
|
|
3470
|
-
const idx = +prop;
|
|
3471
|
-
let storeCache = PROXY_CACHE.get(target);
|
|
3472
|
-
if (!storeCache) {
|
|
3473
|
-
storeCache = new Map();
|
|
3474
|
-
PROXY_CACHE.set(target, storeCache);
|
|
3475
|
-
}
|
|
3476
|
-
const cachedRef = storeCache.get(idx);
|
|
3477
|
-
if (cachedRef) {
|
|
3478
|
-
const cached = cachedRef.deref();
|
|
3479
|
-
if (cached)
|
|
3480
|
-
return cached;
|
|
3481
|
-
storeCache.delete(idx);
|
|
3482
|
-
PROXY_CLEANUP.unregister(cachedRef);
|
|
3483
|
-
}
|
|
3484
|
-
const value = untracked(target);
|
|
3485
|
-
const valueIsArray = Array.isArray(value);
|
|
3486
|
-
const valueIsRecord = isRecord(value);
|
|
3487
|
-
const nodeVivify = resolveVivify(value, vivify);
|
|
3488
|
-
const vivifyFn = createVivify(nodeVivify);
|
|
3489
|
-
const equalFn = (valueIsRecord || valueIsArray) &&
|
|
3490
|
-
isMutableSource &&
|
|
3491
|
-
typeof value[idx] === 'object'
|
|
3492
|
-
? () => false
|
|
3493
|
-
: undefined;
|
|
3494
|
-
const computation = valueIsRecord
|
|
3495
|
-
? derived(target, idx, {
|
|
3496
|
-
equal: equalFn,
|
|
3497
|
-
vivify: nodeVivify,
|
|
3498
|
-
})
|
|
3499
|
-
: derived(target, {
|
|
3500
|
-
from: (v) => v?.[idx],
|
|
3501
|
-
onChange: createFallbackOnChange(target, idx, vivifyFn, isMutableSource),
|
|
3502
|
-
equal: equalFn,
|
|
3503
|
-
});
|
|
3504
|
-
const childSample = untracked(computation);
|
|
3505
|
-
const childVivify = resolveVivify(childSample, vivify);
|
|
3506
|
-
const proxy = Array.isArray(childSample) && !isOpaque(childSample)
|
|
3507
|
-
? toArrayStore(computation, injector, childVivify, noUnionLeaves)
|
|
3508
|
-
: toStore(computation, injector, childVivify, noUnionLeaves);
|
|
3509
|
-
markAsLeaf(proxy, computation, childVivify !== false, noUnionLeaves);
|
|
3510
|
-
const ref = new WeakRef(proxy);
|
|
3511
|
-
storeCache.set(idx, ref);
|
|
3512
|
-
PROXY_CLEANUP.register(proxy, { target, prop: idx }, ref);
|
|
3513
|
-
return proxy;
|
|
3514
|
-
}
|
|
3515
|
-
return Reflect.get(target, prop, receiver);
|
|
3516
|
-
},
|
|
3517
|
-
});
|
|
3687
|
+
function buildChildNode(target, prop, isMutableSource, injector, vivify, noUnionLeaves) {
|
|
3688
|
+
const value = untracked(target);
|
|
3689
|
+
const valueIsRecord = isRecord(value);
|
|
3690
|
+
const valueIsArray = Array.isArray(value);
|
|
3691
|
+
const nodeVivify = resolveVivify(value, vivify);
|
|
3692
|
+
const vivifyFn = createVivify(nodeVivify);
|
|
3693
|
+
const equalFn = (valueIsRecord || valueIsArray) &&
|
|
3694
|
+
isMutableSource &&
|
|
3695
|
+
typeof value[prop] === 'object'
|
|
3696
|
+
? () => false
|
|
3697
|
+
: undefined;
|
|
3698
|
+
const computation = valueIsRecord
|
|
3699
|
+
? derived(target, prop, { equal: equalFn, vivify: nodeVivify })
|
|
3700
|
+
: derived(target, {
|
|
3701
|
+
from: (v) => v?.[prop],
|
|
3702
|
+
onChange: createFallbackOnChange(target, prop, vivifyFn, isMutableSource),
|
|
3703
|
+
equal: equalFn,
|
|
3704
|
+
});
|
|
3705
|
+
const childSample = untracked(computation);
|
|
3706
|
+
const childVivify = resolveVivify(childSample, vivify);
|
|
3707
|
+
const proxy = toStore(computation, injector, childVivify, noUnionLeaves);
|
|
3708
|
+
markAsLeaf(proxy, computation, childVivify !== false, noUnionLeaves);
|
|
3709
|
+
return proxy;
|
|
3518
3710
|
}
|
|
3519
3711
|
/**
|
|
3520
3712
|
* Converts a Signal into a deep-observable Store.
|
|
3521
3713
|
* Accessing nested properties returns a derived Signal of that path.
|
|
3522
3714
|
*
|
|
3523
3715
|
* @remarks
|
|
3524
|
-
* A
|
|
3525
|
-
*
|
|
3526
|
-
*
|
|
3527
|
-
*
|
|
3716
|
+
* A node's *container kind* (array / record / primitive) is tracked reactively via a per-node
|
|
3717
|
+
* `kind` computed, so the same proxy serves all three and a union node that flips between an
|
|
3718
|
+
* array and a record keeps working. Flips are route-forward: after a flip the node behaves as
|
|
3719
|
+
* its new kind on the next access, while child proxies cached under the old shape go stale and
|
|
3720
|
+
* are pruned by the GC.
|
|
3528
3721
|
*
|
|
3529
3722
|
* @example
|
|
3530
3723
|
* const state = store({ user: { name: 'John' } });
|
|
@@ -3535,99 +3728,121 @@ function toStore(source, injector, vivify = false, noUnionLeaves = false) {
|
|
|
3535
3728
|
return source;
|
|
3536
3729
|
if (!injector)
|
|
3537
3730
|
injector = inject(Injector);
|
|
3538
|
-
const writableSource = isWritableSignal(source)
|
|
3731
|
+
const writableSource = isWritableSignal$1(source)
|
|
3539
3732
|
? source
|
|
3540
3733
|
: toWritable(source, () => {
|
|
3541
3734
|
// noop
|
|
3542
3735
|
});
|
|
3543
|
-
const isWritableSource = isWritableSignal(source);
|
|
3736
|
+
const isWritableSource = isWritableSignal$1(source);
|
|
3544
3737
|
const isMutableSource = isWritableSource && isMutable(writableSource);
|
|
3738
|
+
const kind = computed(() => {
|
|
3739
|
+
const v = source();
|
|
3740
|
+
if (Array.isArray(v) && !isOpaque(v))
|
|
3741
|
+
return 'array';
|
|
3742
|
+
if (isRecord(v))
|
|
3743
|
+
return 'record';
|
|
3744
|
+
return 'primitive';
|
|
3745
|
+
});
|
|
3746
|
+
// built lazily so non-array nodes never allocate it
|
|
3747
|
+
let length;
|
|
3748
|
+
const arrayLength = () => (length ??= computed(() => {
|
|
3749
|
+
const v = source();
|
|
3750
|
+
return Array.isArray(v) ? v.length : 0;
|
|
3751
|
+
}));
|
|
3545
3752
|
const s = new Proxy(writableSource, {
|
|
3546
3753
|
has(_, prop) {
|
|
3547
|
-
|
|
3754
|
+
const v = untracked(source);
|
|
3755
|
+
if (untracked(kind) === 'array') {
|
|
3756
|
+
if (prop === 'length')
|
|
3757
|
+
return true;
|
|
3758
|
+
if (isIndexProp(prop)) {
|
|
3759
|
+
const idx = +prop;
|
|
3760
|
+
return idx >= 0 && idx < v.length;
|
|
3761
|
+
}
|
|
3762
|
+
}
|
|
3763
|
+
// nullish node values are routinely descended with vivify on — `in` must not throw
|
|
3764
|
+
return v == null ? false : Reflect.has(v, prop);
|
|
3548
3765
|
},
|
|
3549
3766
|
ownKeys() {
|
|
3550
3767
|
const v = untracked(source);
|
|
3768
|
+
if (untracked(kind) === 'array') {
|
|
3769
|
+
const len = v.length;
|
|
3770
|
+
const arr = new Array(len + 1);
|
|
3771
|
+
for (let i = 0; i < len; i++)
|
|
3772
|
+
arr[i] = String(i);
|
|
3773
|
+
arr[len] = 'length';
|
|
3774
|
+
return arr;
|
|
3775
|
+
}
|
|
3551
3776
|
if (!isRecord(v))
|
|
3552
3777
|
return [];
|
|
3553
3778
|
return Reflect.ownKeys(v);
|
|
3554
3779
|
},
|
|
3555
3780
|
getPrototypeOf() {
|
|
3556
|
-
|
|
3781
|
+
if (untracked(kind) === 'array')
|
|
3782
|
+
return Array.prototype;
|
|
3783
|
+
const v = untracked(source);
|
|
3784
|
+
return v == null ? Object.prototype : Object.getPrototypeOf(v);
|
|
3557
3785
|
},
|
|
3558
3786
|
getOwnPropertyDescriptor(_, prop) {
|
|
3559
|
-
const
|
|
3560
|
-
if (
|
|
3787
|
+
const v = untracked(source);
|
|
3788
|
+
if (untracked(kind) === 'array') {
|
|
3789
|
+
if (prop === 'length' ||
|
|
3790
|
+
(typeof prop === 'string' && !isNaN(+prop) && +prop < v.length))
|
|
3791
|
+
return { enumerable: true, configurable: true };
|
|
3561
3792
|
return;
|
|
3562
|
-
|
|
3563
|
-
|
|
3564
|
-
|
|
3565
|
-
};
|
|
3793
|
+
}
|
|
3794
|
+
if (!isRecord(v) || !(prop in v))
|
|
3795
|
+
return;
|
|
3796
|
+
return { enumerable: true, configurable: true };
|
|
3566
3797
|
},
|
|
3567
|
-
get(target, prop) {
|
|
3798
|
+
get(target, prop, receiver) {
|
|
3568
3799
|
if (prop === IS_STORE)
|
|
3569
3800
|
return true;
|
|
3801
|
+
if (prop === STORE_KIND)
|
|
3802
|
+
return isMutableSource
|
|
3803
|
+
? 'mutable'
|
|
3804
|
+
: isWritableSource
|
|
3805
|
+
? 'writable'
|
|
3806
|
+
: 'readonly';
|
|
3807
|
+
if (prop === STORE_INJECTOR)
|
|
3808
|
+
return injector;
|
|
3570
3809
|
if (prop === 'asReadonlyStore')
|
|
3571
3810
|
return () => {
|
|
3572
3811
|
if (!isWritableSource)
|
|
3573
3812
|
return s;
|
|
3574
3813
|
return untracked(() => toStore(source.asReadonly(), injector, vivify, noUnionLeaves));
|
|
3575
3814
|
};
|
|
3576
|
-
|
|
3815
|
+
const k = untracked(kind);
|
|
3816
|
+
if (prop === 'extend' && k !== 'array')
|
|
3577
3817
|
return (seed) => scopedStore(s, seed, isMutableSource
|
|
3578
3818
|
? 'mutable'
|
|
3579
3819
|
: isWritableSource
|
|
3580
3820
|
? 'writable'
|
|
3581
3821
|
: 'readonly', injector);
|
|
3822
|
+
if (k === 'array') {
|
|
3823
|
+
if (prop === 'length')
|
|
3824
|
+
return arrayLength();
|
|
3825
|
+
if (prop === Symbol.iterator)
|
|
3826
|
+
return function* () {
|
|
3827
|
+
// read length reactively: a spread/for-of inside a computed/effect must re-run
|
|
3828
|
+
// when items are added or removed, not only when already-read elements change
|
|
3829
|
+
const len = arrayLength();
|
|
3830
|
+
for (let i = 0; i < len(); i++)
|
|
3831
|
+
yield receiver[i];
|
|
3832
|
+
};
|
|
3833
|
+
}
|
|
3582
3834
|
if (typeof prop === 'symbol' || SIGNAL_FN_PROP.has(prop))
|
|
3583
3835
|
return target[prop];
|
|
3584
|
-
|
|
3585
|
-
|
|
3586
|
-
|
|
3587
|
-
PROXY_CACHE.set(target, storeCache);
|
|
3588
|
-
}
|
|
3589
|
-
const cachedRef = storeCache.get(prop);
|
|
3590
|
-
if (cachedRef) {
|
|
3591
|
-
const cached = cachedRef.deref();
|
|
3592
|
-
if (cached)
|
|
3593
|
-
return cached;
|
|
3594
|
-
storeCache.delete(prop);
|
|
3595
|
-
PROXY_CLEANUP.unregister(cachedRef);
|
|
3596
|
-
}
|
|
3597
|
-
const value = untracked(target);
|
|
3598
|
-
const valueIsRecord = isRecord(value);
|
|
3599
|
-
const valueIsArray = Array.isArray(value);
|
|
3600
|
-
const nodeVivify = resolveVivify(value, vivify);
|
|
3601
|
-
const vivifyFn = createVivify(nodeVivify);
|
|
3602
|
-
const equalFn = (valueIsRecord || valueIsArray) &&
|
|
3603
|
-
isMutableSource &&
|
|
3604
|
-
typeof value[prop] === 'object'
|
|
3605
|
-
? () => false
|
|
3606
|
-
: undefined;
|
|
3607
|
-
const computation = valueIsRecord
|
|
3608
|
-
? derived(target, prop, { equal: equalFn, vivify: nodeVivify })
|
|
3609
|
-
: derived(target, {
|
|
3610
|
-
from: (v) => v?.[prop],
|
|
3611
|
-
onChange: createFallbackOnChange(target, prop, vivifyFn, isMutableSource),
|
|
3612
|
-
equal: equalFn,
|
|
3613
|
-
});
|
|
3614
|
-
const childSample = untracked(computation);
|
|
3615
|
-
const childVivify = resolveVivify(childSample, vivify);
|
|
3616
|
-
const proxy = Array.isArray(childSample) && !isOpaque(childSample)
|
|
3617
|
-
? toArrayStore(computation, injector, childVivify, noUnionLeaves)
|
|
3618
|
-
: toStore(computation, injector, childVivify, noUnionLeaves);
|
|
3619
|
-
markAsLeaf(proxy, computation, childVivify !== false, noUnionLeaves);
|
|
3620
|
-
const ref = new WeakRef(proxy);
|
|
3621
|
-
storeCache.set(prop, ref);
|
|
3622
|
-
PROXY_CLEANUP.register(proxy, { target, prop }, ref);
|
|
3623
|
-
return proxy;
|
|
3836
|
+
if (k === 'array' && !isIndexProp(prop))
|
|
3837
|
+
return Reflect.get(target, prop, receiver);
|
|
3838
|
+
return getCachedChild(target, prop, () => buildChildNode(target, k === 'array' ? +prop : prop, isMutableSource, injector, vivify, noUnionLeaves));
|
|
3624
3839
|
},
|
|
3625
3840
|
});
|
|
3626
3841
|
return s;
|
|
3627
3842
|
}
|
|
3628
3843
|
/**
|
|
3629
3844
|
* @internal
|
|
3630
|
-
* Backs `
|
|
3845
|
+
* Backs `extendStore(...)`. Builds a scoped overlay over `parent`: the local layer (the seed
|
|
3631
3846
|
* plus any keys created later) is its own signal and `parent` is its own signal, so the getter
|
|
3632
3847
|
* routes each key by consulting BOTH — local first, then parent, else local (so a write to an
|
|
3633
3848
|
* as-yet-unknown key lands locally). Inherited keys return the parent's own sub-store (shared
|
|
@@ -3674,6 +3889,10 @@ function scopedStore(parent, seed, kind, injector) {
|
|
|
3674
3889
|
get(target, prop) {
|
|
3675
3890
|
if (prop === IS_STORE)
|
|
3676
3891
|
return true;
|
|
3892
|
+
if (prop === STORE_KIND)
|
|
3893
|
+
return kind;
|
|
3894
|
+
if (prop === STORE_INJECTOR)
|
|
3895
|
+
return injector;
|
|
3677
3896
|
if (prop === SCOPE_PARENT)
|
|
3678
3897
|
return parent;
|
|
3679
3898
|
if (prop === 'extend')
|
|
@@ -3706,6 +3925,28 @@ function scopedStore(parent, seed, kind, injector) {
|
|
|
3706
3925
|
});
|
|
3707
3926
|
return scope;
|
|
3708
3927
|
}
|
|
3928
|
+
/** @internal Reads a store's writability brand, falling back to signal inspection if unbranded. */
|
|
3929
|
+
function storeKind(s) {
|
|
3930
|
+
return (s[STORE_KIND] ??
|
|
3931
|
+
(isWritableSignal$1(s) ? (isMutable(s) ? 'mutable' : 'writable') : 'readonly'));
|
|
3932
|
+
}
|
|
3933
|
+
/**
|
|
3934
|
+
* Extends a store with extra keys via a scoped overlay, returning a new store that reads through
|
|
3935
|
+
* to the parent for inherited keys (shared identity + two-way) while holding the new keys locally.
|
|
3936
|
+
*
|
|
3937
|
+
* The typesafe successor to the deprecated `store.extend(...)` method — moving it off the proxy
|
|
3938
|
+
* frees the `extend` key for use as a normal record key. Writability (readonly/writable/mutable)
|
|
3939
|
+
* is inherited from `store`.
|
|
3940
|
+
*
|
|
3941
|
+
* @example
|
|
3942
|
+
* const base = store({ count: 0 });
|
|
3943
|
+
* const scoped = extendStore(base, { label: 'live' });
|
|
3944
|
+
* scoped.count.set(1); // writes through to base
|
|
3945
|
+
* scoped.label.set('x'); // stays local
|
|
3946
|
+
*/
|
|
3947
|
+
function extendStore(store, source, injector) {
|
|
3948
|
+
return scopedStore(store, source, storeKind(store), injector ?? store[STORE_INJECTOR] ?? inject(Injector));
|
|
3949
|
+
}
|
|
3709
3950
|
/**
|
|
3710
3951
|
* Creates a WritableSignalStore from a value.
|
|
3711
3952
|
* @see {@link toStore}
|
|
@@ -3790,6 +4031,26 @@ function forkStore(base, opt) {
|
|
|
3790
4031
|
};
|
|
3791
4032
|
}
|
|
3792
4033
|
|
|
4034
|
+
/**
|
|
4035
|
+
* @internal The plain-`effect` sibling of the public {@link pausableEffect} (which is built on
|
|
4036
|
+
* `nestedEffect`). For infra utilities that own a single top-level effect/subscription and don't
|
|
4037
|
+
* need frame/nesting semantics. Opt-in (default off): with no `pause` (call site or
|
|
4038
|
+
* `providePausableOptions` default) it returns a bare `effect` (zero overhead, byte-identical to
|
|
4039
|
+
* today); otherwise it gates the body on the resolved predicate — read FIRST so the dependency set
|
|
4040
|
+
* collapses to just the predicate while paused, re-tracking on resume. Deliberately NOT re-exported
|
|
4041
|
+
* from the public barrel.
|
|
4042
|
+
*/
|
|
4043
|
+
function pausablePureEffect(effectFn, options) {
|
|
4044
|
+
const paused = resolvePause(options, false);
|
|
4045
|
+
if (!paused)
|
|
4046
|
+
return effect(effectFn, options);
|
|
4047
|
+
return effect((registerCleanup) => {
|
|
4048
|
+
if (paused())
|
|
4049
|
+
return;
|
|
4050
|
+
effectFn(registerCleanup);
|
|
4051
|
+
}, options);
|
|
4052
|
+
}
|
|
4053
|
+
|
|
3793
4054
|
// Internal dummy store for server-side rendering
|
|
3794
4055
|
const noopStore = {
|
|
3795
4056
|
getItem: () => null,
|
|
@@ -3851,8 +4112,9 @@ const noopStore = {
|
|
|
3851
4112
|
* }
|
|
3852
4113
|
* ```
|
|
3853
4114
|
*/
|
|
3854
|
-
function stored(fallback, { key, store: providedStore, serialize = JSON.stringify, deserialize = JSON.parse, syncTabs = false, equal = Object.is, onKeyChange = 'load', cleanupOldKey = false, validate = () => true, ...rest }) {
|
|
3855
|
-
const
|
|
4115
|
+
function stored(fallback, { key, store: providedStore, serialize = JSON.stringify, deserialize = JSON.parse, syncTabs = false, equal = Object.is, onKeyChange = 'load', cleanupOldKey = false, validate = () => true, pause, injector: providedInjector, ...rest }) {
|
|
4116
|
+
const injector = providedInjector ?? inject(Injector);
|
|
4117
|
+
const isServer = isPlatformServer(injector.get(PLATFORM_ID));
|
|
3856
4118
|
const fallbackStore = isServer ? noopStore : localStorage;
|
|
3857
4119
|
const store = providedStore ?? fallbackStore;
|
|
3858
4120
|
const keySig = typeof key === 'string'
|
|
@@ -3860,8 +4122,6 @@ function stored(fallback, { key, store: providedStore, serialize = JSON.stringif
|
|
|
3860
4122
|
: isSignal(key)
|
|
3861
4123
|
? key
|
|
3862
4124
|
: 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
4125
|
const EMPTY = Symbol();
|
|
3866
4126
|
const getValue = (key) => {
|
|
3867
4127
|
const found = store.getItem(key);
|
|
@@ -3908,7 +4168,7 @@ function stored(fallback, { key, store: providedStore, serialize = JSON.stringif
|
|
|
3908
4168
|
});
|
|
3909
4169
|
let prevKey = initialKey;
|
|
3910
4170
|
if (onKeyChange === 'store') {
|
|
3911
|
-
|
|
4171
|
+
pausablePureEffect(() => {
|
|
3912
4172
|
const k = keySig();
|
|
3913
4173
|
storeValue(k, internal());
|
|
3914
4174
|
if (prevKey !== k) {
|
|
@@ -3916,10 +4176,10 @@ function stored(fallback, { key, store: providedStore, serialize = JSON.stringif
|
|
|
3916
4176
|
store.removeItem(prevKey);
|
|
3917
4177
|
prevKey = k;
|
|
3918
4178
|
}
|
|
3919
|
-
});
|
|
4179
|
+
}, { injector, pause });
|
|
3920
4180
|
}
|
|
3921
4181
|
else {
|
|
3922
|
-
|
|
4182
|
+
pausablePureEffect(() => {
|
|
3923
4183
|
const k = keySig();
|
|
3924
4184
|
const internalValue = internal();
|
|
3925
4185
|
if (k === prevKey) {
|
|
@@ -3932,14 +4192,11 @@ function stored(fallback, { key, store: providedStore, serialize = JSON.stringif
|
|
|
3932
4192
|
prevKey = k;
|
|
3933
4193
|
internal.set(value); // load new value
|
|
3934
4194
|
}
|
|
3935
|
-
});
|
|
4195
|
+
}, { injector, pause });
|
|
3936
4196
|
}
|
|
3937
4197
|
if (syncTabs && !isServer) {
|
|
3938
|
-
const destroyRef =
|
|
4198
|
+
const destroyRef = injector.get(DestroyRef);
|
|
3939
4199
|
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
4200
|
if (e.storageArea !== store)
|
|
3944
4201
|
return;
|
|
3945
4202
|
if (e.key !== untracked(keySig))
|
|
@@ -4068,29 +4325,26 @@ function generateDeterministicID() {
|
|
|
4068
4325
|
*
|
|
4069
4326
|
*/
|
|
4070
4327
|
function tabSync(sig, opt) {
|
|
4071
|
-
|
|
4328
|
+
const optObj = typeof opt === 'object' ? opt : undefined;
|
|
4329
|
+
const injector = optObj?.injector ?? inject(Injector);
|
|
4330
|
+
if (isPlatformServer(injector.get(PLATFORM_ID)))
|
|
4072
4331
|
return sig;
|
|
4073
4332
|
const id = typeof opt === 'string' ? opt : (opt?.id ?? generateDeterministicID());
|
|
4074
|
-
const bus =
|
|
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.
|
|
4333
|
+
const bus = injector.get(MessageBus);
|
|
4078
4334
|
const NONE = Symbol();
|
|
4079
4335
|
let received = NONE;
|
|
4080
4336
|
const { unsub, post } = bus.subscribe(id, (next) => {
|
|
4081
4337
|
const before = untracked(sig);
|
|
4082
4338
|
received = next;
|
|
4083
4339
|
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
4340
|
if (untracked(sig) === before)
|
|
4087
4341
|
received = NONE;
|
|
4088
4342
|
});
|
|
4089
|
-
let
|
|
4343
|
+
let firstDone = false;
|
|
4090
4344
|
const effectRef = effect(() => {
|
|
4091
4345
|
const val = sig();
|
|
4092
|
-
if (!
|
|
4093
|
-
|
|
4346
|
+
if (!firstDone) {
|
|
4347
|
+
firstDone = true;
|
|
4094
4348
|
return;
|
|
4095
4349
|
}
|
|
4096
4350
|
if (val === received) {
|
|
@@ -4099,8 +4353,8 @@ function tabSync(sig, opt) {
|
|
|
4099
4353
|
}
|
|
4100
4354
|
received = NONE;
|
|
4101
4355
|
post(val);
|
|
4102
|
-
});
|
|
4103
|
-
|
|
4356
|
+
}, { injector });
|
|
4357
|
+
injector.get(DestroyRef).onDestroy(() => {
|
|
4104
4358
|
effectRef.destroy();
|
|
4105
4359
|
unsub();
|
|
4106
4360
|
});
|
|
@@ -4304,5 +4558,5 @@ function withHistory(sourceOrValue, opt) {
|
|
|
4304
4558
|
* Generated bundle index. Do not edit.
|
|
4305
4559
|
*/
|
|
4306
4560
|
|
|
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 };
|
|
4561
|
+
export { MmActivity, PAUSABLE_OPTIONS, SuspenseBoundary, SuspenseBoundaryBase, UnscopedSuspenseBoundary, activeTransaction, batteryStatus, chunked, clipboard, combineWith, createForwardingScope, createTransaction, createTransitionScope, debounce, debounced, derived, distinct, elementSize, elementVisibility, extendStore, 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, pointerDrag, pooled, pooledArray, pooledMap, pooledSet, prefersDarkMode, prefersReducedMotion, provideForwardingTransitionScope, providePausableOptions, providePaused, provideTransitionScope, registerResource, resolvePause, scan, scrollPosition, select, sensor, sensors, signalFromEvent, startWith, store, stored, tabSync, tap, throttle, throttled, toFakeDerivation, toFakeSignalDerivation, toStore, toWritable, until, windowSize, withHistory };
|
|
4308
4562
|
//# sourceMappingURL=mmstack-primitives.mjs.map
|