@mmstack/primitives 21.2.1 → 21.2.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.
@@ -1,5 +1,5 @@
1
1
  import * as i0 from '@angular/core';
2
- import { isDevMode, inject, Injector, untracked, effect, DestroyRef, linkedSignal, InjectionToken, TemplateRef, ViewContainerRef, PLATFORM_ID, input, computed, Directive, signal, runInInjectionContext, afterNextRender, Component, isWritableSignal as isWritableSignal$2, isSignal, ElementRef, Injectable } from '@angular/core';
2
+ import { isDevMode, inject, Injector, untracked, effect, DestroyRef, InjectionToken, TemplateRef, ViewContainerRef, PLATFORM_ID, input, computed, Directive, signal, runInInjectionContext, linkedSignal, afterNextRender, Component, isWritableSignal as isWritableSignal$2, 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,70 +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({ ...(ngDevMode ? { debugName: "internal" } : /* istanbul ignore next */ {}), source,
208
- computation: (items) => items.slice(0, chunkSize),
209
- equal });
210
- nestedEffect((cleanup) => {
211
- const fullList = source();
212
- const current = internal();
213
- if (current.length >= fullList.length)
214
- return;
215
- return cleanup(delayFn(() => untracked(() => internal.set(fullList.slice(0, current.length + chunkSize)))));
216
- }, {
217
- injector: injector,
218
- });
219
- return internal.asReadonly();
220
- }
221
-
222
158
  /**
223
159
  * Whether the subtree a resource/component lives in is currently PAUSED, for Activity / keep-alive.
224
160
  * Provided by an Activity boundary (`MmActivity`, or the app-builder's per-branch injector) and read
@@ -315,24 +251,23 @@ function providePaused(source) {
315
251
  }
316
252
 
317
253
  /**
318
- * Structural hold-and-swap as a signal. Given a `target` (the desired value — e.g. the
319
- * subtree/def/key you want to show) and a `ready` predicate, returns a signal that keeps
320
- * yielding its PREVIOUS value until `ready()` is true, then swaps to the current target.
321
- *
322
- * This is the structural counterpart to `keepPrevious`/`commit`: where those hold a *value*
323
- * through a reload, this holds a *structure* through a swap. The caller mounts the incoming
324
- * structure off to the side (so its resources can settle and flip `ready`), keeps showing the
325
- * held previous structure meanwhile, and lets the old one go once `ready` releases the swap.
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.
326
263
  *
327
- * The very first value passes straight through (nothing to hold yet).
264
+ * @example
265
+ * // Make everything that can pause honour the ambient Activity boundary by default:
266
+ * providePausableOptions({ pause: true })
328
267
  */
329
- function holdUntilReady(target, ready) {
330
- return linkedSignal({
331
- source: () => ({ t: target(), ready: ready() }),
332
- computation: (curr, prev) => (prev === undefined || curr.ready ? curr.t : prev.value),
333
- });
268
+ function providePausableOptions(opt) {
269
+ return { provide: PAUSABLE_OPTIONS, useValue: opt };
334
270
  }
335
-
336
271
  /**
337
272
  * Resolve a {@link PauseOption} into a pause predicate, or `null` meaning "do not pause".
338
273
  * `null` tells the caller to return the bare primitive — no wrapper is created.
@@ -347,11 +282,7 @@ function holdUntilReady(target, ready) {
347
282
  *
348
283
  * Encapsulating this here keeps every pausable primitive's branching identical and in one place.
349
284
  */
350
- function resolvePause(opt) {
351
- const explicit = opt?.pause; // distinguish explicit `true` from the omitted default
352
- const pause = explicit ?? true; // explicit pausable* calls default to pausing
353
- if (pause === false)
354
- return null;
285
+ function resolvePause(opt, defaultPause = true) {
355
286
  const run = (fn) => opt?.injector ? runInInjectionContext(opt.injector, fn) : fn();
356
287
  // `inject` requires an injection context even with `optional: true`. A bare
357
288
  // `pausableSignal(0)` (documented as "like `signal`") must degrade to the unwrapped
@@ -364,6 +295,12 @@ function resolvePause(opt) {
364
295
  return fallback;
365
296
  }
366
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;
367
304
  const onServer = () => typeof pause === 'function' && !opt?.injector
368
305
  ? typeof globalThis.window === 'undefined'
369
306
  : tryRun(() => isPlatformServer(inject(PLATFORM_ID, { optional: true }) ?? 'browser'), typeof globalThis.window === 'undefined');
@@ -373,7 +310,7 @@ function resolvePause(opt) {
373
310
  return null;
374
311
  const paused = tryRun(() => inject(PAUSED_CONTEXT, { optional: true }), null);
375
312
  if (!paused) {
376
- if (explicit === true && isDevMode())
313
+ if (opt?.pause === true && isDevMode())
377
314
  console.warn('[pausable] `pause: true` but no PAUSED_CONTEXT in scope — not pausing. Provide one via an ' +
378
315
  'Activity boundary (`MmActivity` / `providePaused`), or pass a predicate / `pause: false`.');
379
316
  return null;
@@ -441,6 +378,92 @@ function pausableComputed(computation, options) {
441
378
  return ls.asReadonly();
442
379
  }
443
380
 
381
+ /**
382
+ * 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.
383
+ *
384
+ * 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`.
385
+ *
386
+ * @template T The type of items in the array.
387
+ * @param source A `Signal` or a function that returns an array of items to be processed in chunks.
388
+ * @param options Configuration options for chunk size, delay duration, equality function, and injector.
389
+ * @returns A `Signal` that emits the current chunk of items being processed.
390
+ *
391
+ * @example
392
+ * const largeList = signal(Array.from({ length: 1000 }, (_, i) => i));
393
+ * const chunkedList = chunked(largeList, { chunkSize: 100, delay: 100 });
394
+ */
395
+ function chunked(source, options) {
396
+ const { chunkSize = 50, delay = 'frame', equal, injector, pause, } = options || {};
397
+ let delayFn;
398
+ if (delay === 'frame') {
399
+ delayFn =
400
+ typeof requestAnimationFrame === 'function'
401
+ ? (callback) => {
402
+ const num = requestAnimationFrame(callback);
403
+ return () => cancelAnimationFrame(num);
404
+ }
405
+ : // SSR: no requestAnimationFrame — approximate a frame with a timeout
406
+ (cb) => {
407
+ const num = setTimeout(cb, 16);
408
+ return () => clearTimeout(num);
409
+ };
410
+ }
411
+ else if (delay === 'microtask') {
412
+ delayFn = (cb) => {
413
+ let isCancelled = false;
414
+ queueMicrotask(() => {
415
+ if (isCancelled)
416
+ return;
417
+ cb();
418
+ });
419
+ return () => {
420
+ isCancelled = true;
421
+ };
422
+ };
423
+ }
424
+ else {
425
+ delayFn = (cb) => {
426
+ const num = setTimeout(cb, delay);
427
+ return () => clearTimeout(num);
428
+ };
429
+ }
430
+ const internal = linkedSignal({ ...(ngDevMode ? { debugName: "internal" } : /* istanbul ignore next */ {}), source,
431
+ computation: (items) => items.slice(0, chunkSize),
432
+ equal });
433
+ const paused = resolvePause({ injector, pause }, false);
434
+ nestedEffect((cleanup) => {
435
+ if (paused?.())
436
+ return;
437
+ const fullList = source();
438
+ const current = internal();
439
+ if (current.length >= fullList.length)
440
+ return;
441
+ return cleanup(delayFn(() => untracked(() => internal.set(fullList.slice(0, current.length + chunkSize)))));
442
+ }, {
443
+ injector,
444
+ });
445
+ return internal.asReadonly();
446
+ }
447
+
448
+ /**
449
+ * Structural hold-and-swap as a signal. Given a `target` (the desired value — e.g. the
450
+ * subtree/def/key you want to show) and a `ready` predicate, returns a signal that keeps
451
+ * yielding its PREVIOUS value until `ready()` is true, then swaps to the current target.
452
+ *
453
+ * This is the structural counterpart to `keepPrevious`/`commit`: where those hold a *value*
454
+ * through a reload, this holds a *structure* through a swap. The caller mounts the incoming
455
+ * structure off to the side (so its resources can settle and flip `ready`), keeps showing the
456
+ * held previous structure meanwhile, and lets the old one go once `ready` releases the swap.
457
+ *
458
+ * The very first value passes straight through (nothing to hold yet).
459
+ */
460
+ function holdUntilReady(target, ready) {
461
+ return linkedSignal({
462
+ source: () => ({ t: target(), ready: ready() }),
463
+ computation: (curr, prev) => (prev === undefined || curr.ready ? curr.t : prev.value),
464
+ });
465
+ }
466
+
444
467
  const { is } = Object;
445
468
  function mutable(initial, opt) {
446
469
  const baseEqual = opt?.equal ?? is;
@@ -691,6 +714,12 @@ function injectStartTransition() {
691
714
  *
692
715
  * `type` selects what "not ready" means: `'value'` (default) suspends only until a first value lands
693
716
  * then holds through reloads; `'loading'` suspends on every in-flight load (strict suspense).
717
+ *
718
+ * SSR: the server serializes whatever the scope reports at stabilization, so a registered resource
719
+ * must keep the app unstable until it settles or the placeholder is what gets serialized (then
720
+ * flashes/mismatches on hydration). HttpClient-backed resources, httpResource & all of `@mmstack/resource`
721
+ * do this automatically via the HTTP layer's `PendingTasks` + transfer cache. A custom loader (raw
722
+ * `fetch`/promise/timer) must opt in itself: wrap it with `inject(PendingTasks).run(() => promise)`.
694
723
  */
695
724
  class SuspenseBoundaryBase {
696
725
  scope = injectTransitionScope();
@@ -2625,11 +2654,19 @@ function throttle(source, opt) {
2625
2654
  tick();
2626
2655
  };
2627
2656
  const update = (fn) => set(fn(untracked(source)));
2657
+ const flush = () => {
2658
+ if (timeout)
2659
+ clearTimeout(timeout);
2660
+ timeout = undefined;
2661
+ pendingTrailing = false;
2662
+ fire();
2663
+ };
2628
2664
  const writable = toWritable(computed(() => {
2629
2665
  trigger();
2630
2666
  return untracked(source);
2631
2667
  }, opt), set, update);
2632
2668
  writable.original = source.asReadonly();
2669
+ writable.flush = flush;
2633
2670
  return writable;
2634
2671
  }
2635
2672
 
@@ -2848,6 +2885,194 @@ function createOrientation(debugName) {
2848
2885
  return state.asReadonly();
2849
2886
  }
2850
2887
 
2888
+ const IDLE = {
2889
+ active: false,
2890
+ start: { x: 0, y: 0 },
2891
+ current: { x: 0, y: 0 },
2892
+ delta: { x: 0, y: 0 },
2893
+ pointerId: null,
2894
+ modifiers: { shift: false, alt: false, ctrl: false, meta: false },
2895
+ button: -1,
2896
+ };
2897
+ function stateEqual(a, b) {
2898
+ return (a.active === b.active &&
2899
+ a.pointerId === b.pointerId &&
2900
+ a.current.x === b.current.x &&
2901
+ a.current.y === b.current.y &&
2902
+ a.button === b.button &&
2903
+ a.modifiers.shift === b.modifiers.shift &&
2904
+ a.modifiers.alt === b.modifiers.alt &&
2905
+ a.modifiers.ctrl === b.modifiers.ctrl &&
2906
+ a.modifiers.meta === b.modifiers.meta);
2907
+ }
2908
+ /**
2909
+ * Tracks a pointer *gesture* (pointerdown → capture → move → up) as a signal —
2910
+ * the foundation for pointer-based drag/move/resize/marquee on a canvas. Unlike
2911
+ * native HTML5 drag, pointer events fire continuously and coordinates are
2912
+ * reliable. SSR-safe; cleans up its listeners automatically.
2913
+ *
2914
+ * @example
2915
+ * ```ts
2916
+ * const drag = pointerDrag({ activationThreshold: 4 });
2917
+ * const position = computed(() => {
2918
+ * const d = drag();
2919
+ * return d.active ? { x: base.x + d.delta.x, y: base.y + d.delta.y } : base;
2920
+ * });
2921
+ * ```
2922
+ */
2923
+ function pointerDrag(opt) {
2924
+ return runInSensorContext(opt?.injector, () => createPointerDrag(opt));
2925
+ }
2926
+ function createPointerDrag(opt) {
2927
+ if (isPlatformServer(inject(PLATFORM_ID))) {
2928
+ const base = computed(() => IDLE, {
2929
+ debugName: opt?.debugName ?? 'pointerDrag',
2930
+ });
2931
+ base.unthrottled = base;
2932
+ base.cancel = () => undefined;
2933
+ return base;
2934
+ }
2935
+ const hostRef = inject((ElementRef), { optional: true });
2936
+ const { target = hostRef?.nativeElement, coordinateSpace = 'client', activationThreshold = 3, throttle = 16, handleSelector, buttons = [0], debugName = 'pointerDrag', } = opt ?? {};
2937
+ const resolve = (t) => {
2938
+ if (!t)
2939
+ return null;
2940
+ return t instanceof ElementRef ? t.nativeElement : t;
2941
+ };
2942
+ if (!isSignal(target) && !resolve(target)) {
2943
+ if (isDevMode())
2944
+ console.warn('pointerDrag: no target element (host ElementRef missing).');
2945
+ const base = computed(() => IDLE, { debugName });
2946
+ base.unthrottled = base;
2947
+ base.cancel = () => undefined;
2948
+ return base;
2949
+ }
2950
+ const state = throttled(IDLE, {
2951
+ ms: throttle,
2952
+ leading: true,
2953
+ trailing: true,
2954
+ equal: stateEqual,
2955
+ debugName,
2956
+ });
2957
+ let startPoint = { x: 0, y: 0 };
2958
+ let activePointerId = null;
2959
+ let activeButton = -1;
2960
+ let activated = false;
2961
+ let gesture = null;
2962
+ const coord = (e) => coordinateSpace === 'page'
2963
+ ? { x: e.pageX, y: e.pageY }
2964
+ : { x: e.clientX, y: e.clientY };
2965
+ const mods = (e) => ({
2966
+ shift: e.shiftKey,
2967
+ alt: e.altKey,
2968
+ ctrl: e.ctrlKey,
2969
+ meta: e.metaKey,
2970
+ });
2971
+ const end = () => {
2972
+ gesture?.abort();
2973
+ gesture = null;
2974
+ activePointerId = null;
2975
+ activeButton = -1;
2976
+ activated = false;
2977
+ state.set(IDLE);
2978
+ state.flush(); // terminal transition: reflect IDLE now, not on the trailing edge
2979
+ };
2980
+ const onMove = (e) => {
2981
+ if (e.pointerId !== activePointerId)
2982
+ return;
2983
+ const current = coord(e);
2984
+ const delta = { x: current.x - startPoint.x, y: current.y - startPoint.y };
2985
+ if (!activated && Math.hypot(delta.x, delta.y) >= activationThreshold) {
2986
+ activated = true;
2987
+ }
2988
+ state.set({
2989
+ active: activated,
2990
+ start: startPoint,
2991
+ current,
2992
+ delta,
2993
+ pointerId: activePointerId,
2994
+ modifiers: mods(e),
2995
+ button: activeButton, // pointermove button is -1; keep the down-button
2996
+ });
2997
+ };
2998
+ const onUp = (e) => {
2999
+ if (e.pointerId === activePointerId)
3000
+ end();
3001
+ };
3002
+ const onCancel = (e) => {
3003
+ if (e.pointerId === activePointerId)
3004
+ end();
3005
+ };
3006
+ const onKey = (e) => {
3007
+ if (e.key === 'Escape' && activePointerId !== null)
3008
+ end();
3009
+ };
3010
+ const onDown = (el) => (e) => {
3011
+ if (activePointerId !== null)
3012
+ return;
3013
+ if (!buttons.includes(e.button))
3014
+ return;
3015
+ if (handleSelector && !e.target?.closest?.(handleSelector)) {
3016
+ return;
3017
+ }
3018
+ activePointerId = e.pointerId;
3019
+ activeButton = e.button;
3020
+ activated = false;
3021
+ startPoint = coord(e);
3022
+ try {
3023
+ el.setPointerCapture(e.pointerId);
3024
+ }
3025
+ catch {
3026
+ // capture unsupported (older browsers / test env) — listeners still work
3027
+ }
3028
+ gesture = new AbortController();
3029
+ const signal = gesture.signal;
3030
+ el.addEventListener('pointermove', onMove, { signal });
3031
+ el.addEventListener('pointerup', onUp, { signal });
3032
+ el.addEventListener('pointercancel', onCancel, { signal });
3033
+ el.addEventListener('lostpointercapture', onCancel, {
3034
+ signal,
3035
+ });
3036
+ window.addEventListener('keydown', onKey, { signal });
3037
+ state.set({
3038
+ active: false,
3039
+ start: startPoint,
3040
+ current: startPoint,
3041
+ delta: { x: 0, y: 0 },
3042
+ pointerId: e.pointerId,
3043
+ modifiers: mods(e),
3044
+ button: e.button,
3045
+ });
3046
+ };
3047
+ const attach = (el) => {
3048
+ const controller = new AbortController();
3049
+ el.addEventListener('pointerdown', onDown(el), {
3050
+ signal: controller.signal,
3051
+ });
3052
+ return () => {
3053
+ controller.abort();
3054
+ end();
3055
+ };
3056
+ };
3057
+ if (isSignal(target)) {
3058
+ effect((cleanup) => {
3059
+ const el = resolve(target());
3060
+ if (!el)
3061
+ return;
3062
+ cleanup(attach(el));
3063
+ });
3064
+ }
3065
+ else {
3066
+ const el = resolve(target);
3067
+ if (el)
3068
+ inject(DestroyRef).onDestroy(attach(el));
3069
+ }
3070
+ const base = state.asReadonly();
3071
+ base.unthrottled = state.original;
3072
+ base.cancel = end;
3073
+ return base;
3074
+ }
3075
+
2851
3076
  /**
2852
3077
  * Creates a read-only signal that tracks the page's visibility state.
2853
3078
  *
@@ -3074,6 +3299,8 @@ function sensor(type, options) {
3074
3299
  switch (type) {
3075
3300
  case 'mousePosition':
3076
3301
  return mousePosition(opts);
3302
+ case 'pointerDrag':
3303
+ return pointerDrag(opts);
3077
3304
  case 'networkStatus':
3078
3305
  return networkStatus(opts);
3079
3306
  case 'pageVisibility':
@@ -3191,9 +3418,6 @@ function signalFromEvent(target, eventName, initial, projectOrOpt, maybeOpt) {
3191
3418
  return untracked(() => state.asReadonly());
3192
3419
  }
3193
3420
 
3194
- function isWritableSignal(value) {
3195
- return isWritableSignal$2(value);
3196
- }
3197
3421
  /**
3198
3422
  * Runtime marker + compile-time brand for an opaque value. A `const`-declared `Symbol`
3199
3423
  * has a `unique symbol` type, so the same symbol serves as both the property key written
@@ -3235,12 +3459,16 @@ function isOpaque(value) {
3235
3459
  value !== null &&
3236
3460
  value[OPAQUE] === true);
3237
3461
  }
3238
- /**
3239
- * @internal Runtime brand carrying a store node's lazily-built leaf probe. Exported (like
3240
- * {@link OPAQUE}) only so the `{ readonly [LEAF]: () => boolean }` brand on the store types is
3241
- * nameable in the emitted declarations — not part of the supported surface; use {@link isLeaf}.
3242
- */
3243
- const LEAF = Symbol('@mmstack/primitives::store/LEAF');
3462
+
3463
+ function isWritableSignal(value) {
3464
+ return isWritableSignal$2(value);
3465
+ }
3466
+ function isRecord(value) {
3467
+ if (value === null || typeof value !== 'object' || isOpaque(value))
3468
+ return false;
3469
+ const proto = Object.getPrototypeOf(value);
3470
+ return proto === Object.prototype || proto === null;
3471
+ }
3244
3472
  /**
3245
3473
  * @internal Whether a value is a terminal leaf: a concrete non-record/non-array value always is;
3246
3474
  * `null`/`undefined` is a leaf only when vivification is disabled (with vivify on it can still
@@ -3253,6 +3481,65 @@ function isLeafValue(value, vivifyEnabled) {
3253
3481
  return true; // opaque always wins — even arrays
3254
3482
  return !Array.isArray(value) && !isRecord(value);
3255
3483
  }
3484
+ /**
3485
+ * @internal
3486
+ * Resolves the vivify shape for a node from its current value: a present record/array is a
3487
+ * certainty we keep (cached in the derivation, so it survives the value being nulled); an
3488
+ * unknown value (`null`/`undefined`) defers to the caller's option. Off stays off.
3489
+ */
3490
+ function resolveVivify(sample, option) {
3491
+ if (!option)
3492
+ return false;
3493
+ if (Array.isArray(sample))
3494
+ return 'array';
3495
+ if (isRecord(sample))
3496
+ return 'object';
3497
+ return 'auto';
3498
+ }
3499
+ function hasOwnKey(value, key) {
3500
+ return value != null && Object.hasOwn(value, key);
3501
+ }
3502
+ /**
3503
+ * @internal
3504
+ * Builds the `onChange` for the fallback (non-record container) derivation branch. For an
3505
+ * immutable source the container is copied before the write — returning the same mutated
3506
+ * reference would let the source's equality cut propagation (leaving child signals permanently
3507
+ * stale) and alias the caller's original object, breaking the structural-sharing contract
3508
+ * `forkStore` relies on. For a mutable source the write goes through `mutate`, so the chain's
3509
+ * force-notify engages (plain `update` with the same reference would never notify).
3510
+ */
3511
+ function createFallbackOnChange(target, prop, vivifyFn, isMutableSource) {
3512
+ const write = (newValue) => (v) => {
3513
+ const container = vivifyFn(v, prop);
3514
+ if (container === null || container === undefined)
3515
+ return container;
3516
+ const next = isMutableSource
3517
+ ? container
3518
+ : Array.isArray(container)
3519
+ ? container.slice()
3520
+ : isRecord(container)
3521
+ ? { ...container }
3522
+ : container; // non-plain leaf (Date/class instance): legacy in-place attempt
3523
+ try {
3524
+ next[prop] = newValue;
3525
+ }
3526
+ catch (e) {
3527
+ if (isDevMode())
3528
+ console.error(`[store] Failed to set property "${String(prop)}"`, e);
3529
+ }
3530
+ return next;
3531
+ };
3532
+ return isMutableSource
3533
+ ? (newValue) => target.mutate(write(newValue))
3534
+ : (newValue) => target.update(write(newValue));
3535
+ }
3536
+
3537
+ /**
3538
+ * @internal Runtime brand carrying a store node's lazily-built leaf probe. Exported (like
3539
+ * {@link OPAQUE}) only so the `{ readonly [LEAF]: () => boolean }` brand on the store types is
3540
+ * nameable in the emitted declarations — not part of the supported surface; use {@link isLeaf}.
3541
+ */
3542
+ const LEAF = Symbol('@mmstack/primitives::store/LEAF');
3256
3543
  /**
3257
3544
  * @internal Constant leaf probes for nodes whose leaf-ness is statically known, so the reactive
3258
3545
  * `computed` can be skipped entirely.
@@ -3310,14 +3597,20 @@ function markAsLeaf(sig, value, vivifyEnabled, noUnionLeaves) {
3310
3597
  function isLeaf(value) {
3311
3598
  return isStore(value) && value[LEAF]?.() === true;
3312
3599
  }
3600
+
3313
3601
  const IS_STORE = Symbol('@mmstack/primitives::store/IS_STORE');
3314
3602
  const SCOPE_PARENT = Symbol('@mmstack/primitives::store/SCOPE_PARENT');
3315
3603
  /**
3316
- * @internal
3317
- * Test-only handle on the proxy cache (deliberately NOT re-exported from the public barrel).
3318
- * Maps a store's backing signal to its lazily-built child proxies, each held via a `WeakRef`.
3604
+ * @internal Brand carrying a store's writability ('mutable' | 'writable' | 'readonly'), stamped
3605
+ * on every store proxy. Read by `extendStore` instead of re-deriving via `isWritableSignal`,
3606
+ * which would mis-classify a readonly scoped store (its backing `toWritable` still has a `set`).
3319
3607
  */
3320
- const PROXY_CACHE = new WeakMap();
3608
+ const STORE_KIND = Symbol('@mmstack/primitives::store/STORE_KIND');
3609
+ /**
3610
+ * @internal Brand exposing the injector a store was built with, so `extendStore` inherits it the
3611
+ * same way `store.extend(...)` does (via closure) — no injection context needed at the call site.
3612
+ */
3613
+ const STORE_INJECTOR = Symbol('@mmstack/primitives::store/STORE_INJECTOR');
3321
3614
  const SIGNAL_FN_PROP = new Set([
3322
3615
  'set',
3323
3616
  'update',
@@ -3325,6 +3618,12 @@ const SIGNAL_FN_PROP = new Set([
3325
3618
  'inline',
3326
3619
  'asReadonly',
3327
3620
  ]);
3621
+ /**
3622
+ * @internal
3623
+ * Test-only handle on the proxy cache (deliberately NOT re-exported from the public barrel).
3624
+ * Maps a store's backing signal to its lazily-built child proxies, each held via a `WeakRef`.
3625
+ */
3626
+ const PROXY_CACHE = new WeakMap();
3328
3627
  /**
3329
3628
  * @internal
3330
3629
  * Test-only handle on the finalization registry (deliberately NOT re-exported from the public
@@ -3344,193 +3643,72 @@ function isStore(value) {
3344
3643
  value !== null &&
3345
3644
  value[IS_STORE] === true);
3346
3645
  }
3347
- function isRecord(value) {
3348
- if (value === null || typeof value !== 'object' || isOpaque(value))
3349
- return false;
3350
- const proto = Object.getPrototypeOf(value);
3351
- return proto === Object.prototype || proto === null;
3352
- }
3646
+
3353
3647
  /**
3354
- * @internal
3355
- * Resolves the vivify shape for a node from its current value: a present record/array is a
3356
- * certainty we keep (cached in the derivation, so it survives the value being nulled); an
3357
- * unknown value (`null`/`undefined`) defers to the caller's option. Off stays off.
3648
+ * @internal Reads (or lazily builds + caches) the child node proxy for `prop` on `target`,
3649
+ * holding it via a `WeakRef` and registering it for finalizer-driven cache pruning. The cache
3650
+ * is keyed per backing signal, so child identity is stable across repeat reads.
3358
3651
  */
3359
- function resolveVivify(sample, option) {
3360
- if (!option)
3361
- return false;
3362
- if (Array.isArray(sample))
3363
- return 'array';
3364
- if (isRecord(sample))
3365
- return 'object';
3366
- return 'auto';
3367
- }
3368
- function hasOwnKey(value, key) {
3369
- return value != null && Object.hasOwn(value, key);
3370
- }
3371
- /**
3372
- * @internal
3373
- * Builds the `onChange` for the fallback (non-record container) derivation branch. For an
3374
- * immutable source the container is copied before the write — returning the same mutated
3375
- * reference would let the source's equality cut propagation (leaving child signals permanently
3376
- * stale) and alias the caller's original object, breaking the structural-sharing contract
3377
- * `forkStore` relies on. For a mutable source the write goes through `mutate`, so the chain's
3378
- * force-notify engages (plain `update` with the same reference would never notify).
3379
- */
3380
- function createFallbackOnChange(target, prop, vivifyFn, isMutableSource) {
3381
- const write = (newValue) => (v) => {
3382
- const container = vivifyFn(v, prop);
3383
- if (container === null || container === undefined)
3384
- return container;
3385
- const next = isMutableSource
3386
- ? container
3387
- : Array.isArray(container)
3388
- ? container.slice()
3389
- : isRecord(container)
3390
- ? { ...container }
3391
- : container; // non-plain leaf (Date/class instance): legacy in-place attempt
3392
- try {
3393
- next[prop] = newValue;
3394
- }
3395
- catch (e) {
3396
- if (isDevMode())
3397
- console.error(`[store] Failed to set property "${String(prop)}"`, e);
3398
- }
3399
- return next;
3400
- };
3401
- return isMutableSource
3402
- ? (newValue) => target.mutate(write(newValue))
3403
- : (newValue) => target.update(write(newValue));
3652
+ function getCachedChild(target, prop, build) {
3653
+ let storeCache = PROXY_CACHE.get(target);
3654
+ if (!storeCache) {
3655
+ storeCache = new Map();
3656
+ PROXY_CACHE.set(target, storeCache);
3657
+ }
3658
+ const cachedRef = storeCache.get(prop);
3659
+ if (cachedRef) {
3660
+ const cached = cachedRef.deref();
3661
+ if (cached)
3662
+ return cached;
3663
+ storeCache.delete(prop);
3664
+ PROXY_CLEANUP.unregister(cachedRef);
3665
+ }
3666
+ const proxy = build();
3667
+ const ref = new WeakRef(proxy);
3668
+ storeCache.set(prop, ref);
3669
+ PROXY_CLEANUP.register(proxy, { target, prop }, ref);
3670
+ return proxy;
3404
3671
  }
3405
3672
  /**
3406
- * @internal
3407
- * Makes an array store
3673
+ * @internal Builds the derived child signal for `prop` and wraps it as an array/object substore.
3674
+ * A record parent reads the key directly; any other container goes through the fallback `from`/
3675
+ * `onChange` path. Shared verbatim by the array and object proxies — the only place a child node
3676
+ * is constructed.
3408
3677
  */
3409
- function toArrayStore(source, injector, vivify, noUnionLeaves = false) {
3410
- if (isStore(source))
3411
- return source;
3412
- const isMutableSource = isMutable(source);
3413
- const lengthSignal = computed(() => {
3414
- const v = source();
3415
- if (!Array.isArray(v))
3416
- return 0;
3417
- return v.length;
3418
- }, ...(ngDevMode ? [{ debugName: "lengthSignal" }] : /* istanbul ignore next */ []));
3419
- return new Proxy(source, {
3420
- has(_, prop) {
3421
- if (prop === 'length')
3422
- return true;
3423
- if (isIndexProp(prop)) {
3424
- const idx = +prop;
3425
- return idx >= 0 && idx < untracked(lengthSignal);
3426
- }
3427
- const v = untracked(source);
3428
- // nullish node values are routinely descended with vivify on — `in` must not throw
3429
- return v == null ? false : Reflect.has(v, prop);
3430
- },
3431
- ownKeys() {
3432
- const v = untracked(source);
3433
- if (!Array.isArray(v))
3434
- return [];
3435
- const len = v.length;
3436
- const arr = new Array(len + 1);
3437
- for (let i = 0; i < len; i++) {
3438
- arr[i] = String(i);
3439
- }
3440
- arr[len] = 'length';
3441
- return arr;
3442
- },
3443
- getPrototypeOf() {
3444
- return Array.prototype;
3445
- },
3446
- getOwnPropertyDescriptor(_, prop) {
3447
- const v = untracked(source);
3448
- if (!Array.isArray(v))
3449
- return;
3450
- if (prop === 'length' ||
3451
- (typeof prop === 'string' && !isNaN(+prop) && +prop < v.length)) {
3452
- return {
3453
- enumerable: true,
3454
- configurable: true, // Required for proxies to dynamic targets
3455
- };
3456
- }
3457
- return;
3458
- },
3459
- get(target, prop, receiver) {
3460
- if (prop === IS_STORE)
3461
- return true;
3462
- if (prop === 'length')
3463
- return lengthSignal;
3464
- if (prop === Symbol.iterator) {
3465
- return function* () {
3466
- // read length reactively: a spread/for-of inside a computed/effect must re-run
3467
- // when items are added or removed, not only when already-read elements change
3468
- for (let i = 0; i < lengthSignal(); i++) {
3469
- yield receiver[i];
3470
- }
3471
- };
3472
- }
3473
- if (typeof prop === 'symbol' || SIGNAL_FN_PROP.has(prop))
3474
- return target[prop];
3475
- if (isIndexProp(prop)) {
3476
- const idx = +prop;
3477
- let storeCache = PROXY_CACHE.get(target);
3478
- if (!storeCache) {
3479
- storeCache = new Map();
3480
- PROXY_CACHE.set(target, storeCache);
3481
- }
3482
- const cachedRef = storeCache.get(idx);
3483
- if (cachedRef) {
3484
- const cached = cachedRef.deref();
3485
- if (cached)
3486
- return cached;
3487
- storeCache.delete(idx);
3488
- PROXY_CLEANUP.unregister(cachedRef);
3489
- }
3490
- const value = untracked(target);
3491
- const valueIsArray = Array.isArray(value);
3492
- const valueIsRecord = isRecord(value);
3493
- const nodeVivify = resolveVivify(value, vivify);
3494
- const vivifyFn = createVivify(nodeVivify);
3495
- const equalFn = (valueIsRecord || valueIsArray) &&
3496
- isMutableSource &&
3497
- typeof value[idx] === 'object'
3498
- ? () => false
3499
- : undefined;
3500
- const computation = valueIsRecord
3501
- ? derived(target, idx, {
3502
- equal: equalFn,
3503
- vivify: nodeVivify,
3504
- })
3505
- : derived(target, {
3506
- from: (v) => v?.[idx],
3507
- onChange: createFallbackOnChange(target, idx, vivifyFn, isMutableSource),
3508
- equal: equalFn,
3509
- });
3510
- const childSample = untracked(computation);
3511
- const childVivify = resolveVivify(childSample, vivify);
3512
- const proxy = Array.isArray(childSample) && !isOpaque(childSample)
3513
- ? toArrayStore(computation, injector, childVivify, noUnionLeaves)
3514
- : toStore(computation, injector, childVivify, noUnionLeaves);
3515
- markAsLeaf(proxy, computation, childVivify !== false, noUnionLeaves);
3516
- const ref = new WeakRef(proxy);
3517
- storeCache.set(idx, ref);
3518
- PROXY_CLEANUP.register(proxy, { target, prop: idx }, ref);
3519
- return proxy;
3520
- }
3521
- return Reflect.get(target, prop, receiver);
3522
- },
3523
- });
3678
+ function buildChildNode(target, prop, isMutableSource, injector, vivify, noUnionLeaves) {
3679
+ const value = untracked(target);
3680
+ const valueIsRecord = isRecord(value);
3681
+ const valueIsArray = Array.isArray(value);
3682
+ const nodeVivify = resolveVivify(value, vivify);
3683
+ const vivifyFn = createVivify(nodeVivify);
3684
+ const equalFn = (valueIsRecord || valueIsArray) &&
3685
+ isMutableSource &&
3686
+ typeof value[prop] === 'object'
3687
+ ? () => false
3688
+ : undefined;
3689
+ const computation = valueIsRecord
3690
+ ? derived(target, prop, { equal: equalFn, vivify: nodeVivify })
3691
+ : derived(target, {
3692
+ from: (v) => v?.[prop],
3693
+ onChange: createFallbackOnChange(target, prop, vivifyFn, isMutableSource),
3694
+ equal: equalFn,
3695
+ });
3696
+ const childSample = untracked(computation);
3697
+ const childVivify = resolveVivify(childSample, vivify);
3698
+ const proxy = toStore(computation, injector, childVivify, noUnionLeaves);
3699
+ markAsLeaf(proxy, computation, childVivify !== false, noUnionLeaves);
3700
+ return proxy;
3524
3701
  }
3525
3702
  /**
3526
3703
  * Converts a Signal into a deep-observable Store.
3527
3704
  * Accessing nested properties returns a derived Signal of that path.
3528
3705
  *
3529
3706
  * @remarks
3530
- * A child's *container kind* (array store vs object store) is resolved when the child is
3531
- * first accessed and cached with the proxy. Leaf↔substore flips are tracked reactively, but a
3532
- * union-typed node that later flips between an array and a record keeps its original trap set —
3533
- * if you need that, re-model the union as `{ kind: ..., value: ... }` instead.
3707
+ * A node's *container kind* (array / record / primitive) is tracked reactively via a per-node
3708
+ * `kind` computed, so the same proxy serves all three and a union node that flips between an
3709
+ * array and a record keeps working. Flips are route-forward: after a flip the node behaves as
3710
+ * its new kind on the next access, while child proxies cached under the old shape go stale and
3711
+ * are pruned by the GC.
3534
3712
  *
3535
3713
  * @example
3536
3714
  * const state = store({ user: { name: 'John' } });
@@ -3548,92 +3726,114 @@ function toStore(source, injector, vivify = false, noUnionLeaves = false) {
3548
3726
  });
3549
3727
  const isWritableSource = isWritableSignal(source);
3550
3728
  const isMutableSource = isWritableSource && isMutable(writableSource);
3729
+ const kind = computed(() => {
3730
+ const v = source();
3731
+ if (Array.isArray(v) && !isOpaque(v))
3732
+ return 'array';
3733
+ if (isRecord(v))
3734
+ return 'record';
3735
+ return 'primitive';
3736
+ }, ...(ngDevMode ? [{ debugName: "kind" }] : /* istanbul ignore next */ []));
3737
+ // built lazily so non-array nodes never allocate it
3738
+ let length;
3739
+ const arrayLength = () => (length ??= computed(() => {
3740
+ const v = source();
3741
+ return Array.isArray(v) ? v.length : 0;
3742
+ }));
3551
3743
  const s = new Proxy(writableSource, {
3552
3744
  has(_, prop) {
3553
- return Reflect.has(untracked(source), prop);
3745
+ const v = untracked(source);
3746
+ if (untracked(kind) === 'array') {
3747
+ if (prop === 'length')
3748
+ return true;
3749
+ if (isIndexProp(prop)) {
3750
+ const idx = +prop;
3751
+ return idx >= 0 && idx < v.length;
3752
+ }
3753
+ }
3754
+ // nullish node values are routinely descended with vivify on — `in` must not throw
3755
+ return v == null ? false : Reflect.has(v, prop);
3554
3756
  },
3555
3757
  ownKeys() {
3556
3758
  const v = untracked(source);
3759
+ if (untracked(kind) === 'array') {
3760
+ const len = v.length;
3761
+ const arr = new Array(len + 1);
3762
+ for (let i = 0; i < len; i++)
3763
+ arr[i] = String(i);
3764
+ arr[len] = 'length';
3765
+ return arr;
3766
+ }
3557
3767
  if (!isRecord(v))
3558
3768
  return [];
3559
3769
  return Reflect.ownKeys(v);
3560
3770
  },
3561
3771
  getPrototypeOf() {
3562
- return Object.getPrototypeOf(untracked(source));
3772
+ if (untracked(kind) === 'array')
3773
+ return Array.prototype;
3774
+ const v = untracked(source);
3775
+ return v == null ? Object.prototype : Object.getPrototypeOf(v);
3563
3776
  },
3564
3777
  getOwnPropertyDescriptor(_, prop) {
3565
- const value = untracked(source);
3566
- if (!isRecord(value) || !(prop in value))
3778
+ const v = untracked(source);
3779
+ if (untracked(kind) === 'array') {
3780
+ if (prop === 'length' ||
3781
+ (typeof prop === 'string' && !isNaN(+prop) && +prop < v.length))
3782
+ return { enumerable: true, configurable: true };
3567
3783
  return;
3568
- return {
3569
- enumerable: true,
3570
- configurable: true,
3571
- };
3784
+ }
3785
+ if (!isRecord(v) || !(prop in v))
3786
+ return;
3787
+ return { enumerable: true, configurable: true };
3572
3788
  },
3573
- get(target, prop) {
3789
+ get(target, prop, receiver) {
3574
3790
  if (prop === IS_STORE)
3575
3791
  return true;
3792
+ if (prop === STORE_KIND)
3793
+ return isMutableSource
3794
+ ? 'mutable'
3795
+ : isWritableSource
3796
+ ? 'writable'
3797
+ : 'readonly';
3798
+ if (prop === STORE_INJECTOR)
3799
+ return injector;
3576
3800
  if (prop === 'asReadonlyStore')
3577
3801
  return () => {
3578
3802
  if (!isWritableSource)
3579
3803
  return s;
3580
3804
  return untracked(() => toStore(source.asReadonly(), injector, vivify, noUnionLeaves));
3581
3805
  };
3582
- if (prop === 'extend')
3806
+ const k = untracked(kind);
3807
+ if (prop === 'extend' && k !== 'array')
3583
3808
  return (seed) => scopedStore(s, seed, isMutableSource
3584
3809
  ? 'mutable'
3585
3810
  : isWritableSource
3586
3811
  ? 'writable'
3587
3812
  : 'readonly', injector);
3813
+ if (k === 'array') {
3814
+ if (prop === 'length')
3815
+ return arrayLength();
3816
+ if (prop === Symbol.iterator)
3817
+ return function* () {
3818
+ // read length reactively: a spread/for-of inside a computed/effect must re-run
3819
+ // when items are added or removed, not only when already-read elements change
3820
+ const len = arrayLength();
3821
+ for (let i = 0; i < len(); i++)
3822
+ yield receiver[i];
3823
+ };
3824
+ }
3588
3825
  if (typeof prop === 'symbol' || SIGNAL_FN_PROP.has(prop))
3589
3826
  return target[prop];
3590
- let storeCache = PROXY_CACHE.get(target);
3591
- if (!storeCache) {
3592
- storeCache = new Map();
3593
- PROXY_CACHE.set(target, storeCache);
3594
- }
3595
- const cachedRef = storeCache.get(prop);
3596
- if (cachedRef) {
3597
- const cached = cachedRef.deref();
3598
- if (cached)
3599
- return cached;
3600
- storeCache.delete(prop);
3601
- PROXY_CLEANUP.unregister(cachedRef);
3602
- }
3603
- const value = untracked(target);
3604
- const valueIsRecord = isRecord(value);
3605
- const valueIsArray = Array.isArray(value);
3606
- const nodeVivify = resolveVivify(value, vivify);
3607
- const vivifyFn = createVivify(nodeVivify);
3608
- const equalFn = (valueIsRecord || valueIsArray) &&
3609
- isMutableSource &&
3610
- typeof value[prop] === 'object'
3611
- ? () => false
3612
- : undefined;
3613
- const computation = valueIsRecord
3614
- ? derived(target, prop, { equal: equalFn, vivify: nodeVivify })
3615
- : derived(target, {
3616
- from: (v) => v?.[prop],
3617
- onChange: createFallbackOnChange(target, prop, vivifyFn, isMutableSource),
3618
- equal: equalFn,
3619
- });
3620
- const childSample = untracked(computation);
3621
- const childVivify = resolveVivify(childSample, vivify);
3622
- const proxy = Array.isArray(childSample) && !isOpaque(childSample)
3623
- ? toArrayStore(computation, injector, childVivify, noUnionLeaves)
3624
- : toStore(computation, injector, childVivify, noUnionLeaves);
3625
- markAsLeaf(proxy, computation, childVivify !== false, noUnionLeaves);
3626
- const ref = new WeakRef(proxy);
3627
- storeCache.set(prop, ref);
3628
- PROXY_CLEANUP.register(proxy, { target, prop }, ref);
3629
- return proxy;
3827
+ if (k === 'array' && !isIndexProp(prop))
3828
+ return Reflect.get(target, prop, receiver);
3829
+ return getCachedChild(target, prop, () => buildChildNode(target, k === 'array' ? +prop : prop, isMutableSource, injector, vivify, noUnionLeaves));
3630
3830
  },
3631
3831
  });
3632
3832
  return s;
3633
3833
  }
3634
3834
  /**
3635
3835
  * @internal
3636
- * Backs `store.extend(...)`. Builds a scoped overlay over `parent`: the local layer (the seed
3836
+ * Backs `extendStore(...)`. Builds a scoped overlay over `parent`: the local layer (the seed
3637
3837
  * plus any keys created later) is its own signal and `parent` is its own signal, so the getter
3638
3838
  * routes each key by consulting BOTH — local first, then parent, else local (so a write to an
3639
3839
  * as-yet-unknown key lands locally). Inherited keys return the parent's own sub-store (shared
@@ -3680,6 +3880,10 @@ function scopedStore(parent, seed, kind, injector) {
3680
3880
  get(target, prop) {
3681
3881
  if (prop === IS_STORE)
3682
3882
  return true;
3883
+ if (prop === STORE_KIND)
3884
+ return kind;
3885
+ if (prop === STORE_INJECTOR)
3886
+ return injector;
3683
3887
  if (prop === SCOPE_PARENT)
3684
3888
  return parent;
3685
3889
  if (prop === 'extend')
@@ -3712,6 +3916,28 @@ function scopedStore(parent, seed, kind, injector) {
3712
3916
  });
3713
3917
  return scope;
3714
3918
  }
3919
+ /** @internal Reads a store's writability brand, falling back to signal inspection if unbranded. */
3920
+ function storeKind(s) {
3921
+ return (s[STORE_KIND] ??
3922
+ (isWritableSignal(s) ? (isMutable(s) ? 'mutable' : 'writable') : 'readonly'));
3923
+ }
3924
+ /**
3925
+ * Extends a store with extra keys via a scoped overlay, returning a new store that reads through
3926
+ * to the parent for inherited keys (shared identity + two-way) while holding the new keys locally.
3927
+ *
3928
+ * The typesafe successor to the deprecated `store.extend(...)` method — moving it off the proxy
3929
+ * frees the `extend` key for use as a normal record key. Writability (readonly/writable/mutable)
3930
+ * is inherited from `store`.
3931
+ *
3932
+ * @example
3933
+ * const base = store({ count: 0 });
3934
+ * const scoped = extendStore(base, { label: 'live' });
3935
+ * scoped.count.set(1); // writes through to base
3936
+ * scoped.label.set('x'); // stays local
3937
+ */
3938
+ function extendStore(store, source, injector) {
3939
+ return scopedStore(store, source, storeKind(store), injector ?? store[STORE_INJECTOR] ?? inject(Injector));
3940
+ }
3715
3941
  /**
3716
3942
  * Creates a WritableSignalStore from a value.
3717
3943
  * @see {@link toStore}
@@ -3794,6 +4020,26 @@ function forkStore(base, opt) {
3794
4020
  };
3795
4021
  }
3796
4022
 
4023
+ /**
4024
+ * @internal The plain-`effect` sibling of the public {@link pausableEffect} (which is built on
4025
+ * `nestedEffect`). For infra utilities that own a single top-level effect/subscription and don't
4026
+ * need frame/nesting semantics. Opt-in (default off): with no `pause` (call site or
4027
+ * `providePausableOptions` default) it returns a bare `effect` (zero overhead, byte-identical to
4028
+ * today); otherwise it gates the body on the resolved predicate — read FIRST so the dependency set
4029
+ * collapses to just the predicate while paused, re-tracking on resume. Deliberately NOT re-exported
4030
+ * from the public barrel.
4031
+ */
4032
+ function pausablePureEffect(effectFn, options) {
4033
+ const paused = resolvePause(options, false);
4034
+ if (!paused)
4035
+ return effect(effectFn, options);
4036
+ return effect((registerCleanup) => {
4037
+ if (paused())
4038
+ return;
4039
+ effectFn(registerCleanup);
4040
+ }, options);
4041
+ }
4042
+
3797
4043
  // Internal dummy store for server-side rendering
3798
4044
  const noopStore = {
3799
4045
  getItem: () => null,
@@ -3855,8 +4101,9 @@ const noopStore = {
3855
4101
  * }
3856
4102
  * ```
3857
4103
  */
3858
- function stored(fallback, { key, store: providedStore, serialize = JSON.stringify, deserialize = JSON.parse, syncTabs = false, equal = Object.is, onKeyChange = 'load', cleanupOldKey = false, validate = () => true, ...rest }) {
3859
- const isServer = isPlatformServer(inject(PLATFORM_ID));
4104
+ 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 }) {
4105
+ const injector = providedInjector ?? inject(Injector);
4106
+ const isServer = isPlatformServer(injector.get(PLATFORM_ID));
3860
4107
  const fallbackStore = isServer ? noopStore : localStorage;
3861
4108
  const store = providedStore ?? fallbackStore;
3862
4109
  const keySig = typeof key === 'string'
@@ -3864,8 +4111,6 @@ function stored(fallback, { key, store: providedStore, serialize = JSON.stringif
3864
4111
  : isSignal(key)
3865
4112
  ? key
3866
4113
  : computed(key);
3867
- // "no stored value" marker — distinct from `null`/`undefined`, so a nullable `T` can
3868
- // round-trip a legitimate `null` through `set` instead of it acting like `clear()`
3869
4114
  const EMPTY = Symbol();
3870
4115
  const getValue = (key) => {
3871
4116
  const found = store.getItem(key);
@@ -3910,7 +4155,7 @@ function stored(fallback, { key, store: providedStore, serialize = JSON.stringif
3910
4155
  } });
3911
4156
  let prevKey = initialKey;
3912
4157
  if (onKeyChange === 'store') {
3913
- effect(() => {
4158
+ pausablePureEffect(() => {
3914
4159
  const k = keySig();
3915
4160
  storeValue(k, internal());
3916
4161
  if (prevKey !== k) {
@@ -3918,10 +4163,10 @@ function stored(fallback, { key, store: providedStore, serialize = JSON.stringif
3918
4163
  store.removeItem(prevKey);
3919
4164
  prevKey = k;
3920
4165
  }
3921
- });
4166
+ }, { injector, pause });
3922
4167
  }
3923
4168
  else {
3924
- effect(() => {
4169
+ pausablePureEffect(() => {
3925
4170
  const k = keySig();
3926
4171
  const internalValue = internal();
3927
4172
  if (k === prevKey) {
@@ -3934,14 +4179,11 @@ function stored(fallback, { key, store: providedStore, serialize = JSON.stringif
3934
4179
  prevKey = k;
3935
4180
  internal.set(value); // load new value
3936
4181
  }
3937
- });
4182
+ }, { injector, pause });
3938
4183
  }
3939
4184
  if (syncTabs && !isServer) {
3940
- const destroyRef = inject(DestroyRef);
4185
+ const destroyRef = injector.get(DestroyRef);
3941
4186
  const sync = (e) => {
3942
- // `storage` events only describe Web Storage — ignore events for a different
3943
- // storage area (or any event when a custom adapter is configured), otherwise an
3944
- // unrelated localStorage write with the same key string corrupts our state
3945
4187
  if (e.storageArea !== store)
3946
4188
  return;
3947
4189
  if (e.key !== untracked(keySig))
@@ -4070,29 +4312,26 @@ function generateDeterministicID() {
4070
4312
  *
4071
4313
  */
4072
4314
  function tabSync(sig, opt) {
4073
- if (isPlatformServer(inject(PLATFORM_ID)))
4315
+ const optObj = typeof opt === 'object' ? opt : undefined;
4316
+ const injector = optObj?.injector ?? inject(Injector);
4317
+ if (isPlatformServer(injector.get(PLATFORM_ID)))
4074
4318
  return sig;
4075
4319
  const id = typeof opt === 'string' ? opt : (opt?.id ?? generateDeterministicID());
4076
- const bus = inject(MessageBus);
4077
- // The last value applied from a remote tab. The outbound effect skips (exactly) the run
4078
- // caused by that write — without this, an inbound object (a fresh structured clone, so
4079
- // never reference-equal) would be re-posted, and two tabs would ping-pong forever.
4320
+ const bus = injector.get(MessageBus);
4080
4321
  const NONE = Symbol();
4081
4322
  let received = NONE;
4082
4323
  const { unsub, post } = bus.subscribe(id, (next) => {
4083
4324
  const before = untracked(sig);
4084
4325
  received = next;
4085
4326
  sig.set(next);
4086
- // Equality-suppressed write (e.g. an identical primitive): no effect run will follow,
4087
- // so clear the marker — it must not swallow a later, genuinely local change.
4088
4327
  if (untracked(sig) === before)
4089
4328
  received = NONE;
4090
4329
  });
4091
- let first = false;
4330
+ let firstDone = false;
4092
4331
  const effectRef = effect(() => {
4093
4332
  const val = sig();
4094
- if (!first) {
4095
- first = true;
4333
+ if (!firstDone) {
4334
+ firstDone = true;
4096
4335
  return;
4097
4336
  }
4098
4337
  if (val === received) {
@@ -4101,8 +4340,8 @@ function tabSync(sig, opt) {
4101
4340
  }
4102
4341
  received = NONE;
4103
4342
  post(val);
4104
- }, ...(ngDevMode ? [{ debugName: "effectRef" }] : /* istanbul ignore next */ []));
4105
- inject(DestroyRef).onDestroy(() => {
4343
+ }, { ...(ngDevMode ? { debugName: "effectRef" } : /* istanbul ignore next */ {}), injector });
4344
+ injector.get(DestroyRef).onDestroy(() => {
4106
4345
  effectRef.destroy();
4107
4346
  unsub();
4108
4347
  });
@@ -4306,5 +4545,5 @@ function withHistory(sourceOrValue, opt) {
4306
4545
  * Generated bundle index. Do not edit.
4307
4546
  */
4308
4547
 
4309
- 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 };
4548
+ 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 };
4310
4549
  //# sourceMappingURL=mmstack-primitives.mjs.map