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