@mmstack/primitives 22.2.1 → 22.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
@@ -316,24 +252,23 @@ function providePaused(source) {
316
252
  }
317
253
 
318
254
  /**
319
- * Structural hold-and-swap as a signal. Given a `target` (the desired value — e.g. the
320
- * subtree/def/key you want to show) and a `ready` predicate, returns a signal that keeps
321
- * yielding its PREVIOUS value until `ready()` is true, then swaps to the current target.
322
- *
323
- * This is the structural counterpart to `keepPrevious`/`commit`: where those hold a *value*
324
- * through a reload, this holds a *structure* through a swap. The caller mounts the incoming
325
- * structure off to the side (so its resources can settle and flip `ready`), keeps showing the
326
- * held previous structure meanwhile, and lets the old one go once `ready` releases the swap.
255
+ * @internal Token carrying an app-wide default {@link PauseOption}, set via
256
+ * {@link providePausableOptions}. {@link resolvePause} consults it when the call site didn't
257
+ * specify `pause`, so users can opt every pausable-aware primitive in (or out) from one place.
258
+ */
259
+ const PAUSABLE_OPTIONS = new InjectionToken('@mmstack/primitives:pausable-options');
260
+ /**
261
+ * Provides an app-wide default {@link PauseOption} for every pausable-aware primitive (the public
262
+ * `pausable*` family plus the opt-in integrations like `stored` / `chunked`). A call-site `pause`
263
+ * always wins; this only fills in when the call didn't specify one.
327
264
  *
328
- * The very first value passes straight through (nothing to hold yet).
265
+ * @example
266
+ * // Make everything that can pause honour the ambient Activity boundary by default:
267
+ * providePausableOptions({ pause: true })
329
268
  */
330
- function holdUntilReady(target, ready) {
331
- return linkedSignal({
332
- source: () => ({ t: target(), ready: ready() }),
333
- computation: (curr, prev) => (prev === undefined || curr.ready ? curr.t : prev.value),
334
- });
269
+ function providePausableOptions(opt) {
270
+ return { provide: PAUSABLE_OPTIONS, useValue: opt };
335
271
  }
336
-
337
272
  /**
338
273
  * Resolve a {@link PauseOption} into a pause predicate, or `null` meaning "do not pause".
339
274
  * `null` tells the caller to return the bare primitive — no wrapper is created.
@@ -348,11 +283,7 @@ function holdUntilReady(target, ready) {
348
283
  *
349
284
  * Encapsulating this here keeps every pausable primitive's branching identical and in one place.
350
285
  */
351
- function resolvePause(opt) {
352
- const explicit = opt?.pause; // distinguish explicit `true` from the omitted default
353
- const pause = explicit ?? true; // explicit pausable* calls default to pausing
354
- if (pause === false)
355
- return null;
286
+ function resolvePause(opt, defaultPause = true) {
356
287
  const run = (fn) => opt?.injector ? runInInjectionContext(opt.injector, fn) : fn();
357
288
  // `inject` requires an injection context even with `optional: true`. A bare
358
289
  // `pausableSignal(0)` (documented as "like `signal`") must degrade to the unwrapped
@@ -365,6 +296,12 @@ function resolvePause(opt) {
365
296
  return fallback;
366
297
  }
367
298
  };
299
+ // A `providePausableOptions(...)` default fills in when the call site didn't specify `pause`.
300
+ const providedPause = tryRun(() => inject(PAUSABLE_OPTIONS, { optional: true })?.pause, undefined);
301
+ const explicit = opt?.pause ?? providedPause;
302
+ const pause = explicit ?? defaultPause; // public pausable* default `true`; opt-in integrations `false`
303
+ if (pause === false)
304
+ return null;
368
305
  const onServer = () => typeof pause === 'function' && !opt?.injector
369
306
  ? typeof globalThis.window === 'undefined'
370
307
  : tryRun(() => isPlatformServer(inject(PLATFORM_ID, { optional: true }) ?? 'browser'), typeof globalThis.window === 'undefined');
@@ -374,7 +311,7 @@ function resolvePause(opt) {
374
311
  return null;
375
312
  const paused = tryRun(() => inject(PAUSED_CONTEXT, { optional: true }), null);
376
313
  if (!paused) {
377
- if (explicit === true && isDevMode())
314
+ if (opt?.pause === true && isDevMode())
378
315
  console.warn('[pausable] `pause: true` but no PAUSED_CONTEXT in scope — not pausing. Provide one via an ' +
379
316
  'Activity boundary (`MmActivity` / `providePaused`), or pass a predicate / `pause: false`.');
380
317
  return null;
@@ -442,6 +379,92 @@ function pausableComputed(computation, options) {
442
379
  return ls.asReadonly();
443
380
  }
444
381
 
382
+ /**
383
+ * 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.
384
+ *
385
+ * 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`.
386
+ *
387
+ * @template T The type of items in the array.
388
+ * @param source A `Signal` or a function that returns an array of items to be processed in chunks.
389
+ * @param options Configuration options for chunk size, delay duration, equality function, and injector.
390
+ * @returns A `Signal` that emits the current chunk of items being processed.
391
+ *
392
+ * @example
393
+ * const largeList = signal(Array.from({ length: 1000 }, (_, i) => i));
394
+ * const chunkedList = chunked(largeList, { chunkSize: 100, delay: 100 });
395
+ */
396
+ function chunked(source, options) {
397
+ const { chunkSize = 50, delay = 'frame', equal, injector, pause, } = options || {};
398
+ let delayFn;
399
+ if (delay === 'frame') {
400
+ delayFn =
401
+ typeof requestAnimationFrame === 'function'
402
+ ? (callback) => {
403
+ const num = requestAnimationFrame(callback);
404
+ return () => cancelAnimationFrame(num);
405
+ }
406
+ : // SSR: no requestAnimationFrame — approximate a frame with a timeout
407
+ (cb) => {
408
+ const num = setTimeout(cb, 16);
409
+ return () => clearTimeout(num);
410
+ };
411
+ }
412
+ else if (delay === 'microtask') {
413
+ delayFn = (cb) => {
414
+ let isCancelled = false;
415
+ queueMicrotask(() => {
416
+ if (isCancelled)
417
+ return;
418
+ cb();
419
+ });
420
+ return () => {
421
+ isCancelled = true;
422
+ };
423
+ };
424
+ }
425
+ else {
426
+ delayFn = (cb) => {
427
+ const num = setTimeout(cb, delay);
428
+ return () => clearTimeout(num);
429
+ };
430
+ }
431
+ const internal = linkedSignal({ ...(ngDevMode ? { debugName: "internal" } : /* istanbul ignore next */ {}), source,
432
+ computation: (items) => items.slice(0, chunkSize),
433
+ equal });
434
+ const paused = resolvePause({ injector, pause }, false);
435
+ nestedEffect((cleanup) => {
436
+ if (paused?.())
437
+ return;
438
+ const fullList = source();
439
+ const current = internal();
440
+ if (current.length >= fullList.length)
441
+ return;
442
+ return cleanup(delayFn(() => untracked(() => internal.set(fullList.slice(0, current.length + chunkSize)))));
443
+ }, {
444
+ injector,
445
+ });
446
+ return internal.asReadonly();
447
+ }
448
+
449
+ /**
450
+ * Structural hold-and-swap as a signal. Given a `target` (the desired value — e.g. the
451
+ * subtree/def/key you want to show) and a `ready` predicate, returns a signal that keeps
452
+ * yielding its PREVIOUS value until `ready()` is true, then swaps to the current target.
453
+ *
454
+ * This is the structural counterpart to `keepPrevious`/`commit`: where those hold a *value*
455
+ * through a reload, this holds a *structure* through a swap. The caller mounts the incoming
456
+ * structure off to the side (so its resources can settle and flip `ready`), keeps showing the
457
+ * held previous structure meanwhile, and lets the old one go once `ready` releases the swap.
458
+ *
459
+ * The very first value passes straight through (nothing to hold yet).
460
+ */
461
+ function holdUntilReady(target, ready) {
462
+ return linkedSignal({
463
+ source: () => ({ t: target(), ready: ready() }),
464
+ computation: (curr, prev) => (prev === undefined || curr.ready ? curr.t : prev.value),
465
+ });
466
+ }
467
+
445
468
  const { is } = Object;
446
469
  function mutable(initial, opt) {
447
470
  const baseEqual = opt?.equal ?? is;
@@ -696,6 +719,12 @@ function injectStartTransition() {
696
719
  *
697
720
  * `type` selects what "not ready" means: `'value'` (default) suspends only until a first value lands
698
721
  * then holds through reloads; `'loading'` suspends on every in-flight load (strict suspense).
722
+ *
723
+ * SSR: the server serializes whatever the scope reports at stabilization, so a registered resource
724
+ * must keep the app unstable until it settles or the placeholder is what gets serialized (then
725
+ * flashes/mismatches on hydration). HttpClient-backed resources, httpResource & all of `@mmstack/resource`
726
+ * do this automatically via the HTTP layer's `PendingTasks` + transfer cache. A custom loader (raw
727
+ * `fetch`/promise/timer) must opt in itself: wrap it with `inject(PendingTasks).run(() => promise)`.
699
728
  */
700
729
  class SuspenseBoundaryBase {
701
730
  scope = injectTransitionScope();
@@ -2649,11 +2678,19 @@ function throttle(source, opt) {
2649
2678
  tick();
2650
2679
  };
2651
2680
  const update = (fn) => set(fn(untracked(source)));
2681
+ const flush = () => {
2682
+ if (timeout)
2683
+ clearTimeout(timeout);
2684
+ timeout = undefined;
2685
+ pendingTrailing = false;
2686
+ fire();
2687
+ };
2652
2688
  const writable = toWritable(computed(() => {
2653
2689
  trigger();
2654
2690
  return untracked(source);
2655
2691
  }, opt), set, update);
2656
2692
  writable.original = source.asReadonly();
2693
+ writable.flush = flush;
2657
2694
  return writable;
2658
2695
  }
2659
2696
 
@@ -2874,6 +2911,194 @@ function createOrientation(debugName) {
2874
2911
  return state.asReadonly();
2875
2912
  }
2876
2913
 
2914
+ const IDLE = {
2915
+ active: false,
2916
+ start: { x: 0, y: 0 },
2917
+ current: { x: 0, y: 0 },
2918
+ delta: { x: 0, y: 0 },
2919
+ pointerId: null,
2920
+ modifiers: { shift: false, alt: false, ctrl: false, meta: false },
2921
+ button: -1,
2922
+ };
2923
+ function stateEqual(a, b) {
2924
+ return (a.active === b.active &&
2925
+ a.pointerId === b.pointerId &&
2926
+ a.current.x === b.current.x &&
2927
+ a.current.y === b.current.y &&
2928
+ a.button === b.button &&
2929
+ a.modifiers.shift === b.modifiers.shift &&
2930
+ a.modifiers.alt === b.modifiers.alt &&
2931
+ a.modifiers.ctrl === b.modifiers.ctrl &&
2932
+ a.modifiers.meta === b.modifiers.meta);
2933
+ }
2934
+ /**
2935
+ * Tracks a pointer *gesture* (pointerdown → capture → move → up) as a signal —
2936
+ * the foundation for pointer-based drag/move/resize/marquee on a canvas. Unlike
2937
+ * native HTML5 drag, pointer events fire continuously and coordinates are
2938
+ * reliable. SSR-safe; cleans up its listeners automatically.
2939
+ *
2940
+ * @example
2941
+ * ```ts
2942
+ * const drag = pointerDrag({ activationThreshold: 4 });
2943
+ * const position = computed(() => {
2944
+ * const d = drag();
2945
+ * return d.active ? { x: base.x + d.delta.x, y: base.y + d.delta.y } : base;
2946
+ * });
2947
+ * ```
2948
+ */
2949
+ function pointerDrag(opt) {
2950
+ return runInSensorContext(opt?.injector, () => createPointerDrag(opt));
2951
+ }
2952
+ function createPointerDrag(opt) {
2953
+ if (isPlatformServer(inject(PLATFORM_ID))) {
2954
+ const base = computed(() => IDLE, {
2955
+ debugName: opt?.debugName ?? 'pointerDrag',
2956
+ });
2957
+ base.unthrottled = base;
2958
+ base.cancel = () => undefined;
2959
+ return base;
2960
+ }
2961
+ const hostRef = inject((ElementRef), { optional: true });
2962
+ const { target = hostRef?.nativeElement, coordinateSpace = 'client', activationThreshold = 3, throttle = 16, handleSelector, buttons = [0], debugName = 'pointerDrag', } = opt ?? {};
2963
+ const resolve = (t) => {
2964
+ if (!t)
2965
+ return null;
2966
+ return t instanceof ElementRef ? t.nativeElement : t;
2967
+ };
2968
+ if (!isSignal(target) && !resolve(target)) {
2969
+ if (isDevMode())
2970
+ console.warn('pointerDrag: no target element (host ElementRef missing).');
2971
+ const base = computed(() => IDLE, { debugName });
2972
+ base.unthrottled = base;
2973
+ base.cancel = () => undefined;
2974
+ return base;
2975
+ }
2976
+ const state = throttled(IDLE, {
2977
+ ms: throttle,
2978
+ leading: true,
2979
+ trailing: true,
2980
+ equal: stateEqual,
2981
+ debugName,
2982
+ });
2983
+ let startPoint = { x: 0, y: 0 };
2984
+ let activePointerId = null;
2985
+ let activeButton = -1;
2986
+ let activated = false;
2987
+ let gesture = null;
2988
+ const coord = (e) => coordinateSpace === 'page'
2989
+ ? { x: e.pageX, y: e.pageY }
2990
+ : { x: e.clientX, y: e.clientY };
2991
+ const mods = (e) => ({
2992
+ shift: e.shiftKey,
2993
+ alt: e.altKey,
2994
+ ctrl: e.ctrlKey,
2995
+ meta: e.metaKey,
2996
+ });
2997
+ const end = () => {
2998
+ gesture?.abort();
2999
+ gesture = null;
3000
+ activePointerId = null;
3001
+ activeButton = -1;
3002
+ activated = false;
3003
+ state.set(IDLE);
3004
+ state.flush(); // terminal transition: reflect IDLE now, not on the trailing edge
3005
+ };
3006
+ const onMove = (e) => {
3007
+ if (e.pointerId !== activePointerId)
3008
+ return;
3009
+ const current = coord(e);
3010
+ const delta = { x: current.x - startPoint.x, y: current.y - startPoint.y };
3011
+ if (!activated && Math.hypot(delta.x, delta.y) >= activationThreshold) {
3012
+ activated = true;
3013
+ }
3014
+ state.set({
3015
+ active: activated,
3016
+ start: startPoint,
3017
+ current,
3018
+ delta,
3019
+ pointerId: activePointerId,
3020
+ modifiers: mods(e),
3021
+ button: activeButton, // pointermove button is -1; keep the down-button
3022
+ });
3023
+ };
3024
+ const onUp = (e) => {
3025
+ if (e.pointerId === activePointerId)
3026
+ end();
3027
+ };
3028
+ const onCancel = (e) => {
3029
+ if (e.pointerId === activePointerId)
3030
+ end();
3031
+ };
3032
+ const onKey = (e) => {
3033
+ if (e.key === 'Escape' && activePointerId !== null)
3034
+ end();
3035
+ };
3036
+ const onDown = (el) => (e) => {
3037
+ if (activePointerId !== null)
3038
+ return;
3039
+ if (!buttons.includes(e.button))
3040
+ return;
3041
+ if (handleSelector && !e.target?.closest?.(handleSelector)) {
3042
+ return;
3043
+ }
3044
+ activePointerId = e.pointerId;
3045
+ activeButton = e.button;
3046
+ activated = false;
3047
+ startPoint = coord(e);
3048
+ try {
3049
+ el.setPointerCapture(e.pointerId);
3050
+ }
3051
+ catch {
3052
+ // capture unsupported (older browsers / test env) — listeners still work
3053
+ }
3054
+ gesture = new AbortController();
3055
+ const signal = gesture.signal;
3056
+ el.addEventListener('pointermove', onMove, { signal });
3057
+ el.addEventListener('pointerup', onUp, { signal });
3058
+ el.addEventListener('pointercancel', onCancel, { signal });
3059
+ el.addEventListener('lostpointercapture', onCancel, {
3060
+ signal,
3061
+ });
3062
+ window.addEventListener('keydown', onKey, { signal });
3063
+ state.set({
3064
+ active: false,
3065
+ start: startPoint,
3066
+ current: startPoint,
3067
+ delta: { x: 0, y: 0 },
3068
+ pointerId: e.pointerId,
3069
+ modifiers: mods(e),
3070
+ button: e.button,
3071
+ });
3072
+ };
3073
+ const attach = (el) => {
3074
+ const controller = new AbortController();
3075
+ el.addEventListener('pointerdown', onDown(el), {
3076
+ signal: controller.signal,
3077
+ });
3078
+ return () => {
3079
+ controller.abort();
3080
+ end();
3081
+ };
3082
+ };
3083
+ if (isSignal(target)) {
3084
+ effect((cleanup) => {
3085
+ const el = resolve(target());
3086
+ if (!el)
3087
+ return;
3088
+ cleanup(attach(el));
3089
+ });
3090
+ }
3091
+ else {
3092
+ const el = resolve(target);
3093
+ if (el)
3094
+ inject(DestroyRef).onDestroy(attach(el));
3095
+ }
3096
+ const base = state.asReadonly();
3097
+ base.unthrottled = state.original;
3098
+ base.cancel = end;
3099
+ return base;
3100
+ }
3101
+
2877
3102
  /**
2878
3103
  * Creates a read-only signal that tracks the page's visibility state.
2879
3104
  *
@@ -3100,6 +3325,8 @@ function sensor(type, options) {
3100
3325
  switch (type) {
3101
3326
  case 'mousePosition':
3102
3327
  return mousePosition(opts);
3328
+ case 'pointerDrag':
3329
+ return pointerDrag(opts);
3103
3330
  case 'networkStatus':
3104
3331
  return networkStatus(opts);
3105
3332
  case 'pageVisibility':
@@ -3217,9 +3444,6 @@ function signalFromEvent(target, eventName, initial, projectOrOpt, maybeOpt) {
3217
3444
  return untracked(() => state.asReadonly());
3218
3445
  }
3219
3446
 
3220
- function isWritableSignal(value) {
3221
- return isWritableSignal$2(value);
3222
- }
3223
3447
  /**
3224
3448
  * Runtime marker + compile-time brand for an opaque value. A `const`-declared `Symbol`
3225
3449
  * has a `unique symbol` type, so the same symbol serves as both the property key written
@@ -3261,12 +3485,16 @@ function isOpaque(value) {
3261
3485
  value !== null &&
3262
3486
  value[OPAQUE] === true);
3263
3487
  }
3264
- /**
3265
- * @internal Runtime brand carrying a store node's lazily-built leaf probe. Exported (like
3266
- * {@link OPAQUE}) only so the `{ readonly [LEAF]: () => boolean }` brand on the store types is
3267
- * nameable in the emitted declarations — not part of the supported surface; use {@link isLeaf}.
3268
- */
3269
- const LEAF = Symbol('@mmstack/primitives::store/LEAF');
3488
+
3489
+ function isWritableSignal(value) {
3490
+ return isWritableSignal$2(value);
3491
+ }
3492
+ function isRecord(value) {
3493
+ if (value === null || typeof value !== 'object' || isOpaque(value))
3494
+ return false;
3495
+ const proto = Object.getPrototypeOf(value);
3496
+ return proto === Object.prototype || proto === null;
3497
+ }
3270
3498
  /**
3271
3499
  * @internal Whether a value is a terminal leaf: a concrete non-record/non-array value always is;
3272
3500
  * `null`/`undefined` is a leaf only when vivification is disabled (with vivify on it can still
@@ -3279,6 +3507,65 @@ function isLeafValue(value, vivifyEnabled) {
3279
3507
  return true; // opaque always wins — even arrays
3280
3508
  return !Array.isArray(value) && !isRecord(value);
3281
3509
  }
3510
+ /**
3511
+ * @internal
3512
+ * Resolves the vivify shape for a node from its current value: a present record/array is a
3513
+ * certainty we keep (cached in the derivation, so it survives the value being nulled); an
3514
+ * unknown value (`null`/`undefined`) defers to the caller's option. Off stays off.
3515
+ */
3516
+ function resolveVivify(sample, option) {
3517
+ if (!option)
3518
+ return false;
3519
+ if (Array.isArray(sample))
3520
+ return 'array';
3521
+ if (isRecord(sample))
3522
+ return 'object';
3523
+ return 'auto';
3524
+ }
3525
+ function hasOwnKey(value, key) {
3526
+ return value != null && Object.hasOwn(value, key);
3527
+ }
3528
+ /**
3529
+ * @internal
3530
+ * Builds the `onChange` for the fallback (non-record container) derivation branch. For an
3531
+ * immutable source the container is copied before the write — returning the same mutated
3532
+ * reference would let the source's equality cut propagation (leaving child signals permanently
3533
+ * stale) and alias the caller's original object, breaking the structural-sharing contract
3534
+ * `forkStore` relies on. For a mutable source the write goes through `mutate`, so the chain's
3535
+ * force-notify engages (plain `update` with the same reference would never notify).
3536
+ */
3537
+ function createFallbackOnChange(target, prop, vivifyFn, isMutableSource) {
3538
+ const write = (newValue) => (v) => {
3539
+ const container = vivifyFn(v, prop);
3540
+ if (container === null || container === undefined)
3541
+ return container;
3542
+ const next = isMutableSource
3543
+ ? container
3544
+ : Array.isArray(container)
3545
+ ? container.slice()
3546
+ : isRecord(container)
3547
+ ? { ...container }
3548
+ : container; // non-plain leaf (Date/class instance): legacy in-place attempt
3549
+ try {
3550
+ next[prop] = newValue;
3551
+ }
3552
+ catch (e) {
3553
+ if (isDevMode())
3554
+ console.error(`[store] Failed to set property "${String(prop)}"`, e);
3555
+ }
3556
+ return next;
3557
+ };
3558
+ return isMutableSource
3559
+ ? (newValue) => target.mutate(write(newValue))
3560
+ : (newValue) => target.update(write(newValue));
3561
+ }
3562
+
3563
+ /**
3564
+ * @internal Runtime brand carrying a store node's lazily-built leaf probe. Exported (like
3565
+ * {@link OPAQUE}) only so the `{ readonly [LEAF]: () => boolean }` brand on the store types is
3566
+ * nameable in the emitted declarations — not part of the supported surface; use {@link isLeaf}.
3567
+ */
3568
+ const LEAF = Symbol('@mmstack/primitives::store/LEAF');
3282
3569
  /**
3283
3570
  * @internal Constant leaf probes for nodes whose leaf-ness is statically known, so the reactive
3284
3571
  * `computed` can be skipped entirely.
@@ -3336,14 +3623,20 @@ function markAsLeaf(sig, value, vivifyEnabled, noUnionLeaves) {
3336
3623
  function isLeaf(value) {
3337
3624
  return isStore(value) && value[LEAF]?.() === true;
3338
3625
  }
3626
+
3339
3627
  const IS_STORE = Symbol('@mmstack/primitives::store/IS_STORE');
3340
3628
  const SCOPE_PARENT = Symbol('@mmstack/primitives::store/SCOPE_PARENT');
3341
3629
  /**
3342
- * @internal
3343
- * Test-only handle on the proxy cache (deliberately NOT re-exported from the public barrel).
3344
- * Maps a store's backing signal to its lazily-built child proxies, each held via a `WeakRef`.
3630
+ * @internal Brand carrying a store's writability ('mutable' | 'writable' | 'readonly'), stamped
3631
+ * on every store proxy. Read by `extendStore` instead of re-deriving via `isWritableSignal`,
3632
+ * which would mis-classify a readonly scoped store (its backing `toWritable` still has a `set`).
3345
3633
  */
3346
- const PROXY_CACHE = new WeakMap();
3634
+ const STORE_KIND = Symbol('@mmstack/primitives::store/STORE_KIND');
3635
+ /**
3636
+ * @internal Brand exposing the injector a store was built with, so `extendStore` inherits it the
3637
+ * same way `store.extend(...)` does (via closure) — no injection context needed at the call site.
3638
+ */
3639
+ const STORE_INJECTOR = Symbol('@mmstack/primitives::store/STORE_INJECTOR');
3347
3640
  const SIGNAL_FN_PROP = new Set([
3348
3641
  'set',
3349
3642
  'update',
@@ -3351,6 +3644,12 @@ const SIGNAL_FN_PROP = new Set([
3351
3644
  'inline',
3352
3645
  'asReadonly',
3353
3646
  ]);
3647
+ /**
3648
+ * @internal
3649
+ * Test-only handle on the proxy cache (deliberately NOT re-exported from the public barrel).
3650
+ * Maps a store's backing signal to its lazily-built child proxies, each held via a `WeakRef`.
3651
+ */
3652
+ const PROXY_CACHE = new WeakMap();
3354
3653
  /**
3355
3654
  * @internal
3356
3655
  * Test-only handle on the finalization registry (deliberately NOT re-exported from the public
@@ -3370,194 +3669,72 @@ function isStore(value) {
3370
3669
  value !== null &&
3371
3670
  value[IS_STORE] === true);
3372
3671
  }
3373
- function isRecord(value) {
3374
- if (value === null || typeof value !== 'object' || isOpaque(value))
3375
- return false;
3376
- const proto = Object.getPrototypeOf(value);
3377
- return proto === Object.prototype || proto === null;
3378
- }
3672
+
3379
3673
  /**
3380
- * @internal
3381
- * Resolves the vivify shape for a node from its current value: a present record/array is a
3382
- * certainty we keep (cached in the derivation, so it survives the value being nulled); an
3383
- * unknown value (`null`/`undefined`) defers to the caller's option. Off stays off.
3674
+ * @internal Reads (or lazily builds + caches) the child node proxy for `prop` on `target`,
3675
+ * holding it via a `WeakRef` and registering it for finalizer-driven cache pruning. The cache
3676
+ * is keyed per backing signal, so child identity is stable across repeat reads.
3384
3677
  */
3385
- function resolveVivify(sample, option) {
3386
- if (!option)
3387
- return false;
3388
- if (Array.isArray(sample))
3389
- return 'array';
3390
- if (isRecord(sample))
3391
- return 'object';
3392
- return 'auto';
3393
- }
3394
- function hasOwnKey(value, key) {
3395
- return value != null && Object.hasOwn(value, key);
3396
- }
3397
- /**
3398
- * @internal
3399
- * Builds the `onChange` for the fallback (non-record container) derivation branch. For an
3400
- * immutable source the container is copied before the write — returning the same mutated
3401
- * reference would let the source's equality cut propagation (leaving child signals permanently
3402
- * stale) and alias the caller's original object, breaking the structural-sharing contract
3403
- * `forkStore` relies on. For a mutable source the write goes through `mutate`, so the chain's
3404
- * force-notify engages (plain `update` with the same reference would never notify).
3405
- */
3406
- function createFallbackOnChange(target, prop, vivifyFn, isMutableSource) {
3407
- const write = (newValue) => (v) => {
3408
- const container = vivifyFn(v, prop);
3409
- if (container === null || container === undefined)
3410
- return container;
3411
- const next = isMutableSource
3412
- ? container
3413
- : Array.isArray(container)
3414
- ? container.slice()
3415
- : isRecord(container)
3416
- ? { ...container }
3417
- : container; // non-plain leaf (Date/class instance): legacy in-place attempt
3418
- try {
3419
- next[prop] = newValue;
3420
- }
3421
- catch (e) {
3422
- if (isDevMode())
3423
- console.error(`[store] Failed to set property "${String(prop)}"`, e);
3424
- }
3425
- return next;
3426
- };
3427
- return isMutableSource
3428
- ? (newValue) => target.mutate(write(newValue))
3429
- : (newValue) => target.update(write(newValue));
3678
+ function getCachedChild(target, prop, build) {
3679
+ let storeCache = PROXY_CACHE.get(target);
3680
+ if (!storeCache) {
3681
+ storeCache = new Map();
3682
+ PROXY_CACHE.set(target, storeCache);
3683
+ }
3684
+ const cachedRef = storeCache.get(prop);
3685
+ if (cachedRef) {
3686
+ const cached = cachedRef.deref();
3687
+ if (cached)
3688
+ return cached;
3689
+ storeCache.delete(prop);
3690
+ PROXY_CLEANUP.unregister(cachedRef);
3691
+ }
3692
+ const proxy = build();
3693
+ const ref = new WeakRef(proxy);
3694
+ storeCache.set(prop, ref);
3695
+ PROXY_CLEANUP.register(proxy, { target, prop }, ref);
3696
+ return proxy;
3430
3697
  }
3431
3698
  /**
3432
- * @internal
3433
- * Makes an array store
3699
+ * @internal Builds the derived child signal for `prop` and wraps it as an array/object substore.
3700
+ * A record parent reads the key directly; any other container goes through the fallback `from`/
3701
+ * `onChange` path. Shared verbatim by the array and object proxies — the only place a child node
3702
+ * is constructed.
3434
3703
  */
3435
- function toArrayStore(source, injector, vivify, noUnionLeaves = false) {
3436
- if (isStore(source))
3437
- return source;
3438
- const isMutableSource = isMutable(source);
3439
- const lengthSignal = computed(() => {
3440
- const v = source();
3441
- if (!Array.isArray(v))
3442
- return 0;
3443
- return v.length;
3444
- }, /* @ts-ignore */
3445
- ...(ngDevMode ? [{ debugName: "lengthSignal" }] : /* istanbul ignore next */ []));
3446
- return new Proxy(source, {
3447
- has(_, prop) {
3448
- if (prop === 'length')
3449
- return true;
3450
- if (isIndexProp(prop)) {
3451
- const idx = +prop;
3452
- return idx >= 0 && idx < untracked(lengthSignal);
3453
- }
3454
- const v = untracked(source);
3455
- // nullish node values are routinely descended with vivify on — `in` must not throw
3456
- return v == null ? false : Reflect.has(v, prop);
3457
- },
3458
- ownKeys() {
3459
- const v = untracked(source);
3460
- if (!Array.isArray(v))
3461
- return [];
3462
- const len = v.length;
3463
- const arr = new Array(len + 1);
3464
- for (let i = 0; i < len; i++) {
3465
- arr[i] = String(i);
3466
- }
3467
- arr[len] = 'length';
3468
- return arr;
3469
- },
3470
- getPrototypeOf() {
3471
- return Array.prototype;
3472
- },
3473
- getOwnPropertyDescriptor(_, prop) {
3474
- const v = untracked(source);
3475
- if (!Array.isArray(v))
3476
- return;
3477
- if (prop === 'length' ||
3478
- (typeof prop === 'string' && !isNaN(+prop) && +prop < v.length)) {
3479
- return {
3480
- enumerable: true,
3481
- configurable: true, // Required for proxies to dynamic targets
3482
- };
3483
- }
3484
- return;
3485
- },
3486
- get(target, prop, receiver) {
3487
- if (prop === IS_STORE)
3488
- return true;
3489
- if (prop === 'length')
3490
- return lengthSignal;
3491
- if (prop === Symbol.iterator) {
3492
- return function* () {
3493
- // read length reactively: a spread/for-of inside a computed/effect must re-run
3494
- // when items are added or removed, not only when already-read elements change
3495
- for (let i = 0; i < lengthSignal(); i++) {
3496
- yield receiver[i];
3497
- }
3498
- };
3499
- }
3500
- if (typeof prop === 'symbol' || SIGNAL_FN_PROP.has(prop))
3501
- return target[prop];
3502
- if (isIndexProp(prop)) {
3503
- const idx = +prop;
3504
- let storeCache = PROXY_CACHE.get(target);
3505
- if (!storeCache) {
3506
- storeCache = new Map();
3507
- PROXY_CACHE.set(target, storeCache);
3508
- }
3509
- const cachedRef = storeCache.get(idx);
3510
- if (cachedRef) {
3511
- const cached = cachedRef.deref();
3512
- if (cached)
3513
- return cached;
3514
- storeCache.delete(idx);
3515
- PROXY_CLEANUP.unregister(cachedRef);
3516
- }
3517
- const value = untracked(target);
3518
- const valueIsArray = Array.isArray(value);
3519
- const valueIsRecord = isRecord(value);
3520
- const nodeVivify = resolveVivify(value, vivify);
3521
- const vivifyFn = createVivify(nodeVivify);
3522
- const equalFn = (valueIsRecord || valueIsArray) &&
3523
- isMutableSource &&
3524
- typeof value[idx] === 'object'
3525
- ? () => false
3526
- : undefined;
3527
- const computation = valueIsRecord
3528
- ? derived(target, idx, {
3529
- equal: equalFn,
3530
- vivify: nodeVivify,
3531
- })
3532
- : derived(target, {
3533
- from: (v) => v?.[idx],
3534
- onChange: createFallbackOnChange(target, idx, vivifyFn, isMutableSource),
3535
- equal: equalFn,
3536
- });
3537
- const childSample = untracked(computation);
3538
- const childVivify = resolveVivify(childSample, vivify);
3539
- const proxy = Array.isArray(childSample) && !isOpaque(childSample)
3540
- ? toArrayStore(computation, injector, childVivify, noUnionLeaves)
3541
- : toStore(computation, injector, childVivify, noUnionLeaves);
3542
- markAsLeaf(proxy, computation, childVivify !== false, noUnionLeaves);
3543
- const ref = new WeakRef(proxy);
3544
- storeCache.set(idx, ref);
3545
- PROXY_CLEANUP.register(proxy, { target, prop: idx }, ref);
3546
- return proxy;
3547
- }
3548
- return Reflect.get(target, prop, receiver);
3549
- },
3550
- });
3704
+ function buildChildNode(target, prop, isMutableSource, injector, vivify, noUnionLeaves) {
3705
+ const value = untracked(target);
3706
+ const valueIsRecord = isRecord(value);
3707
+ const valueIsArray = Array.isArray(value);
3708
+ const nodeVivify = resolveVivify(value, vivify);
3709
+ const vivifyFn = createVivify(nodeVivify);
3710
+ const equalFn = (valueIsRecord || valueIsArray) &&
3711
+ isMutableSource &&
3712
+ typeof value[prop] === 'object'
3713
+ ? () => false
3714
+ : undefined;
3715
+ const computation = valueIsRecord
3716
+ ? derived(target, prop, { equal: equalFn, vivify: nodeVivify })
3717
+ : derived(target, {
3718
+ from: (v) => v?.[prop],
3719
+ onChange: createFallbackOnChange(target, prop, vivifyFn, isMutableSource),
3720
+ equal: equalFn,
3721
+ });
3722
+ const childSample = untracked(computation);
3723
+ const childVivify = resolveVivify(childSample, vivify);
3724
+ const proxy = toStore(computation, injector, childVivify, noUnionLeaves);
3725
+ markAsLeaf(proxy, computation, childVivify !== false, noUnionLeaves);
3726
+ return proxy;
3551
3727
  }
3552
3728
  /**
3553
3729
  * Converts a Signal into a deep-observable Store.
3554
3730
  * Accessing nested properties returns a derived Signal of that path.
3555
3731
  *
3556
3732
  * @remarks
3557
- * A child's *container kind* (array store vs object store) is resolved when the child is
3558
- * first accessed and cached with the proxy. Leaf↔substore flips are tracked reactively, but a
3559
- * union-typed node that later flips between an array and a record keeps its original trap set —
3560
- * if you need that, re-model the union as `{ kind: ..., value: ... }` instead.
3733
+ * A node's *container kind* (array / record / primitive) is tracked reactively via a per-node
3734
+ * `kind` computed, so the same proxy serves all three and a union node that flips between an
3735
+ * array and a record keeps working. Flips are route-forward: after a flip the node behaves as
3736
+ * its new kind on the next access, while child proxies cached under the old shape go stale and
3737
+ * are pruned by the GC.
3561
3738
  *
3562
3739
  * @example
3563
3740
  * const state = store({ user: { name: 'John' } });
@@ -3575,92 +3752,115 @@ function toStore(source, injector, vivify = false, noUnionLeaves = false) {
3575
3752
  });
3576
3753
  const isWritableSource = isWritableSignal(source);
3577
3754
  const isMutableSource = isWritableSource && isMutable(writableSource);
3755
+ const kind = computed(() => {
3756
+ const v = source();
3757
+ if (Array.isArray(v) && !isOpaque(v))
3758
+ return 'array';
3759
+ if (isRecord(v))
3760
+ return 'record';
3761
+ return 'primitive';
3762
+ }, /* @ts-ignore */
3763
+ ...(ngDevMode ? [{ debugName: "kind" }] : /* istanbul ignore next */ []));
3764
+ // built lazily so non-array nodes never allocate it
3765
+ let length;
3766
+ const arrayLength = () => (length ??= computed(() => {
3767
+ const v = source();
3768
+ return Array.isArray(v) ? v.length : 0;
3769
+ }));
3578
3770
  const s = new Proxy(writableSource, {
3579
3771
  has(_, prop) {
3580
- return Reflect.has(untracked(source), prop);
3772
+ const v = untracked(source);
3773
+ if (untracked(kind) === 'array') {
3774
+ if (prop === 'length')
3775
+ return true;
3776
+ if (isIndexProp(prop)) {
3777
+ const idx = +prop;
3778
+ return idx >= 0 && idx < v.length;
3779
+ }
3780
+ }
3781
+ // nullish node values are routinely descended with vivify on — `in` must not throw
3782
+ return v == null ? false : Reflect.has(v, prop);
3581
3783
  },
3582
3784
  ownKeys() {
3583
3785
  const v = untracked(source);
3786
+ if (untracked(kind) === 'array') {
3787
+ const len = v.length;
3788
+ const arr = new Array(len + 1);
3789
+ for (let i = 0; i < len; i++)
3790
+ arr[i] = String(i);
3791
+ arr[len] = 'length';
3792
+ return arr;
3793
+ }
3584
3794
  if (!isRecord(v))
3585
3795
  return [];
3586
3796
  return Reflect.ownKeys(v);
3587
3797
  },
3588
3798
  getPrototypeOf() {
3589
- return Object.getPrototypeOf(untracked(source));
3799
+ if (untracked(kind) === 'array')
3800
+ return Array.prototype;
3801
+ const v = untracked(source);
3802
+ return v == null ? Object.prototype : Object.getPrototypeOf(v);
3590
3803
  },
3591
3804
  getOwnPropertyDescriptor(_, prop) {
3592
- const value = untracked(source);
3593
- if (!isRecord(value) || !(prop in value))
3805
+ const v = untracked(source);
3806
+ if (untracked(kind) === 'array') {
3807
+ if (prop === 'length' ||
3808
+ (typeof prop === 'string' && !isNaN(+prop) && +prop < v.length))
3809
+ return { enumerable: true, configurable: true };
3594
3810
  return;
3595
- return {
3596
- enumerable: true,
3597
- configurable: true,
3598
- };
3811
+ }
3812
+ if (!isRecord(v) || !(prop in v))
3813
+ return;
3814
+ return { enumerable: true, configurable: true };
3599
3815
  },
3600
- get(target, prop) {
3816
+ get(target, prop, receiver) {
3601
3817
  if (prop === IS_STORE)
3602
3818
  return true;
3819
+ if (prop === STORE_KIND)
3820
+ return isMutableSource
3821
+ ? 'mutable'
3822
+ : isWritableSource
3823
+ ? 'writable'
3824
+ : 'readonly';
3825
+ if (prop === STORE_INJECTOR)
3826
+ return injector;
3603
3827
  if (prop === 'asReadonlyStore')
3604
3828
  return () => {
3605
3829
  if (!isWritableSource)
3606
3830
  return s;
3607
3831
  return untracked(() => toStore(source.asReadonly(), injector, vivify, noUnionLeaves));
3608
3832
  };
3609
- if (prop === 'extend')
3833
+ const k = untracked(kind);
3834
+ if (prop === 'extend' && k !== 'array')
3610
3835
  return (seed) => scopedStore(s, seed, isMutableSource
3611
3836
  ? 'mutable'
3612
3837
  : isWritableSource
3613
3838
  ? 'writable'
3614
3839
  : 'readonly', injector);
3840
+ if (k === 'array') {
3841
+ if (prop === 'length')
3842
+ return arrayLength();
3843
+ if (prop === Symbol.iterator)
3844
+ return function* () {
3845
+ // read length reactively: a spread/for-of inside a computed/effect must re-run
3846
+ // when items are added or removed, not only when already-read elements change
3847
+ const len = arrayLength();
3848
+ for (let i = 0; i < len(); i++)
3849
+ yield receiver[i];
3850
+ };
3851
+ }
3615
3852
  if (typeof prop === 'symbol' || SIGNAL_FN_PROP.has(prop))
3616
3853
  return target[prop];
3617
- let storeCache = PROXY_CACHE.get(target);
3618
- if (!storeCache) {
3619
- storeCache = new Map();
3620
- PROXY_CACHE.set(target, storeCache);
3621
- }
3622
- const cachedRef = storeCache.get(prop);
3623
- if (cachedRef) {
3624
- const cached = cachedRef.deref();
3625
- if (cached)
3626
- return cached;
3627
- storeCache.delete(prop);
3628
- PROXY_CLEANUP.unregister(cachedRef);
3629
- }
3630
- const value = untracked(target);
3631
- const valueIsRecord = isRecord(value);
3632
- const valueIsArray = Array.isArray(value);
3633
- const nodeVivify = resolveVivify(value, vivify);
3634
- const vivifyFn = createVivify(nodeVivify);
3635
- const equalFn = (valueIsRecord || valueIsArray) &&
3636
- isMutableSource &&
3637
- typeof value[prop] === 'object'
3638
- ? () => false
3639
- : undefined;
3640
- const computation = valueIsRecord
3641
- ? derived(target, prop, { equal: equalFn, vivify: nodeVivify })
3642
- : derived(target, {
3643
- from: (v) => v?.[prop],
3644
- onChange: createFallbackOnChange(target, prop, vivifyFn, isMutableSource),
3645
- equal: equalFn,
3646
- });
3647
- const childSample = untracked(computation);
3648
- const childVivify = resolveVivify(childSample, vivify);
3649
- const proxy = Array.isArray(childSample) && !isOpaque(childSample)
3650
- ? toArrayStore(computation, injector, childVivify, noUnionLeaves)
3651
- : toStore(computation, injector, childVivify, noUnionLeaves);
3652
- markAsLeaf(proxy, computation, childVivify !== false, noUnionLeaves);
3653
- const ref = new WeakRef(proxy);
3654
- storeCache.set(prop, ref);
3655
- PROXY_CLEANUP.register(proxy, { target, prop }, ref);
3656
- return proxy;
3854
+ if (k === 'array' && !isIndexProp(prop))
3855
+ return Reflect.get(target, prop, receiver);
3856
+ return getCachedChild(target, prop, () => buildChildNode(target, k === 'array' ? +prop : prop, isMutableSource, injector, vivify, noUnionLeaves));
3657
3857
  },
3658
3858
  });
3659
3859
  return s;
3660
3860
  }
3661
3861
  /**
3662
3862
  * @internal
3663
- * Backs `store.extend(...)`. Builds a scoped overlay over `parent`: the local layer (the seed
3863
+ * Backs `extendStore(...)`. Builds a scoped overlay over `parent`: the local layer (the seed
3664
3864
  * plus any keys created later) is its own signal and `parent` is its own signal, so the getter
3665
3865
  * routes each key by consulting BOTH — local first, then parent, else local (so a write to an
3666
3866
  * as-yet-unknown key lands locally). Inherited keys return the parent's own sub-store (shared
@@ -3708,6 +3908,10 @@ function scopedStore(parent, seed, kind, injector) {
3708
3908
  get(target, prop) {
3709
3909
  if (prop === IS_STORE)
3710
3910
  return true;
3911
+ if (prop === STORE_KIND)
3912
+ return kind;
3913
+ if (prop === STORE_INJECTOR)
3914
+ return injector;
3711
3915
  if (prop === SCOPE_PARENT)
3712
3916
  return parent;
3713
3917
  if (prop === 'extend')
@@ -3740,6 +3944,28 @@ function scopedStore(parent, seed, kind, injector) {
3740
3944
  });
3741
3945
  return scope;
3742
3946
  }
3947
+ /** @internal Reads a store's writability brand, falling back to signal inspection if unbranded. */
3948
+ function storeKind(s) {
3949
+ return (s[STORE_KIND] ??
3950
+ (isWritableSignal(s) ? (isMutable(s) ? 'mutable' : 'writable') : 'readonly'));
3951
+ }
3952
+ /**
3953
+ * Extends a store with extra keys via a scoped overlay, returning a new store that reads through
3954
+ * to the parent for inherited keys (shared identity + two-way) while holding the new keys locally.
3955
+ *
3956
+ * The typesafe successor to the deprecated `store.extend(...)` method — moving it off the proxy
3957
+ * frees the `extend` key for use as a normal record key. Writability (readonly/writable/mutable)
3958
+ * is inherited from `store`.
3959
+ *
3960
+ * @example
3961
+ * const base = store({ count: 0 });
3962
+ * const scoped = extendStore(base, { label: 'live' });
3963
+ * scoped.count.set(1); // writes through to base
3964
+ * scoped.label.set('x'); // stays local
3965
+ */
3966
+ function extendStore(store, source, injector) {
3967
+ return scopedStore(store, source, storeKind(store), injector ?? store[STORE_INJECTOR] ?? inject(Injector));
3968
+ }
3743
3969
  /**
3744
3970
  * Creates a WritableSignalStore from a value.
3745
3971
  * @see {@link toStore}
@@ -3822,6 +4048,26 @@ function forkStore(base, opt) {
3822
4048
  };
3823
4049
  }
3824
4050
 
4051
+ /**
4052
+ * @internal The plain-`effect` sibling of the public {@link pausableEffect} (which is built on
4053
+ * `nestedEffect`). For infra utilities that own a single top-level effect/subscription and don't
4054
+ * need frame/nesting semantics. Opt-in (default off): with no `pause` (call site or
4055
+ * `providePausableOptions` default) it returns a bare `effect` (zero overhead, byte-identical to
4056
+ * today); otherwise it gates the body on the resolved predicate — read FIRST so the dependency set
4057
+ * collapses to just the predicate while paused, re-tracking on resume. Deliberately NOT re-exported
4058
+ * from the public barrel.
4059
+ */
4060
+ function pausablePureEffect(effectFn, options) {
4061
+ const paused = resolvePause(options, false);
4062
+ if (!paused)
4063
+ return effect(effectFn, options);
4064
+ return effect((registerCleanup) => {
4065
+ if (paused())
4066
+ return;
4067
+ effectFn(registerCleanup);
4068
+ }, options);
4069
+ }
4070
+
3825
4071
  // Internal dummy store for server-side rendering
3826
4072
  const noopStore = {
3827
4073
  getItem: () => null,
@@ -3883,8 +4129,9 @@ const noopStore = {
3883
4129
  * }
3884
4130
  * ```
3885
4131
  */
3886
- function stored(fallback, { key, store: providedStore, serialize = JSON.stringify, deserialize = JSON.parse, syncTabs = false, equal = Object.is, onKeyChange = 'load', cleanupOldKey = false, validate = () => true, ...rest }) {
3887
- const isServer = isPlatformServer(inject(PLATFORM_ID));
4132
+ 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 }) {
4133
+ const injector = providedInjector ?? inject(Injector);
4134
+ const isServer = isPlatformServer(injector.get(PLATFORM_ID));
3888
4135
  const fallbackStore = isServer ? noopStore : localStorage;
3889
4136
  const store = providedStore ?? fallbackStore;
3890
4137
  const keySig = typeof key === 'string'
@@ -3892,8 +4139,6 @@ function stored(fallback, { key, store: providedStore, serialize = JSON.stringif
3892
4139
  : isSignal(key)
3893
4140
  ? key
3894
4141
  : computed(key);
3895
- // "no stored value" marker — distinct from `null`/`undefined`, so a nullable `T` can
3896
- // round-trip a legitimate `null` through `set` instead of it acting like `clear()`
3897
4142
  const EMPTY = Symbol();
3898
4143
  const getValue = (key) => {
3899
4144
  const found = store.getItem(key);
@@ -3938,7 +4183,7 @@ function stored(fallback, { key, store: providedStore, serialize = JSON.stringif
3938
4183
  } });
3939
4184
  let prevKey = initialKey;
3940
4185
  if (onKeyChange === 'store') {
3941
- effect(() => {
4186
+ pausablePureEffect(() => {
3942
4187
  const k = keySig();
3943
4188
  storeValue(k, internal());
3944
4189
  if (prevKey !== k) {
@@ -3946,10 +4191,10 @@ function stored(fallback, { key, store: providedStore, serialize = JSON.stringif
3946
4191
  store.removeItem(prevKey);
3947
4192
  prevKey = k;
3948
4193
  }
3949
- });
4194
+ }, { injector, pause });
3950
4195
  }
3951
4196
  else {
3952
- effect(() => {
4197
+ pausablePureEffect(() => {
3953
4198
  const k = keySig();
3954
4199
  const internalValue = internal();
3955
4200
  if (k === prevKey) {
@@ -3962,14 +4207,11 @@ function stored(fallback, { key, store: providedStore, serialize = JSON.stringif
3962
4207
  prevKey = k;
3963
4208
  internal.set(value); // load new value
3964
4209
  }
3965
- });
4210
+ }, { injector, pause });
3966
4211
  }
3967
4212
  if (syncTabs && !isServer) {
3968
- const destroyRef = inject(DestroyRef);
4213
+ const destroyRef = injector.get(DestroyRef);
3969
4214
  const sync = (e) => {
3970
- // `storage` events only describe Web Storage — ignore events for a different
3971
- // storage area (or any event when a custom adapter is configured), otherwise an
3972
- // unrelated localStorage write with the same key string corrupts our state
3973
4215
  if (e.storageArea !== store)
3974
4216
  return;
3975
4217
  if (e.key !== untracked(keySig))
@@ -4098,29 +4340,26 @@ function generateDeterministicID() {
4098
4340
  *
4099
4341
  */
4100
4342
  function tabSync(sig, opt) {
4101
- if (isPlatformServer(inject(PLATFORM_ID)))
4343
+ const optObj = typeof opt === 'object' ? opt : undefined;
4344
+ const injector = optObj?.injector ?? inject(Injector);
4345
+ if (isPlatformServer(injector.get(PLATFORM_ID)))
4102
4346
  return sig;
4103
4347
  const id = typeof opt === 'string' ? opt : (opt?.id ?? generateDeterministicID());
4104
- const bus = inject(MessageBus);
4105
- // The last value applied from a remote tab. The outbound effect skips (exactly) the run
4106
- // caused by that write — without this, an inbound object (a fresh structured clone, so
4107
- // never reference-equal) would be re-posted, and two tabs would ping-pong forever.
4348
+ const bus = injector.get(MessageBus);
4108
4349
  const NONE = Symbol();
4109
4350
  let received = NONE;
4110
4351
  const { unsub, post } = bus.subscribe(id, (next) => {
4111
4352
  const before = untracked(sig);
4112
4353
  received = next;
4113
4354
  sig.set(next);
4114
- // Equality-suppressed write (e.g. an identical primitive): no effect run will follow,
4115
- // so clear the marker — it must not swallow a later, genuinely local change.
4116
4355
  if (untracked(sig) === before)
4117
4356
  received = NONE;
4118
4357
  });
4119
- let first = false;
4358
+ let firstDone = false;
4120
4359
  const effectRef = effect(() => {
4121
4360
  const val = sig();
4122
- if (!first) {
4123
- first = true;
4361
+ if (!firstDone) {
4362
+ firstDone = true;
4124
4363
  return;
4125
4364
  }
4126
4365
  if (val === received) {
@@ -4129,9 +4368,8 @@ function tabSync(sig, opt) {
4129
4368
  }
4130
4369
  received = NONE;
4131
4370
  post(val);
4132
- }, /* @ts-ignore */
4133
- ...(ngDevMode ? [{ debugName: "effectRef" }] : /* istanbul ignore next */ []));
4134
- inject(DestroyRef).onDestroy(() => {
4371
+ }, { ...(ngDevMode ? { debugName: "effectRef" } : /* istanbul ignore next */ {}), injector });
4372
+ injector.get(DestroyRef).onDestroy(() => {
4135
4373
  effectRef.destroy();
4136
4374
  unsub();
4137
4375
  });
@@ -4338,5 +4576,5 @@ function withHistory(sourceOrValue, opt) {
4338
4576
  * Generated bundle index. Do not edit.
4339
4577
  */
4340
4578
 
4341
- 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 };
4579
+ 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 };
4342
4580
  //# sourceMappingURL=mmstack-primitives.mjs.map