@mmstack/primitives 19.5.1 → 19.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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, ResourceStatus, 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, ResourceStatus, afterNextRender, Component, isSignal, ElementRef, Injectable } from '@angular/core';
3
3
  import { isPlatformServer } from '@angular/common';
4
4
  import { SIGNAL } from '@angular/core/primitives/signals';
5
5
 
@@ -155,72 +155,6 @@ function nestedEffect(effectFn, options) {
155
155
  return ref;
156
156
  }
157
157
 
158
- /**
159
- * Creates a new `Signal` that processes an array of items in time-sliced chunks. This is useful for handling large lists without blocking the main thread.
160
- *
161
- * The returned signal will initially contain the first `chunkSize` items from the source array. It will then schedule updates to include additional chunks of items based on the specified `delay`.
162
- *
163
- * @template T The type of items in the array.
164
- * @param source A `Signal` or a function that returns an array of items to be processed in chunks.
165
- * @param options Configuration options for chunk size, delay duration, equality function, and injector.
166
- * @returns A `Signal` that emits the current chunk of items being processed.
167
- *
168
- * @example
169
- * const largeList = signal(Array.from({ length: 1000 }, (_, i) => i));
170
- * const chunkedList = chunked(largeList, { chunkSize: 100, delay: 100 });
171
- */
172
- function chunked(source, options) {
173
- const { chunkSize = 50, delay = 'frame', equal, injector } = options || {};
174
- let delayFn;
175
- if (delay === 'frame') {
176
- delayFn =
177
- typeof requestAnimationFrame === 'function'
178
- ? (callback) => {
179
- const num = requestAnimationFrame(callback);
180
- return () => cancelAnimationFrame(num);
181
- }
182
- : // SSR: no requestAnimationFrame — approximate a frame with a timeout
183
- (cb) => {
184
- const num = setTimeout(cb, 16);
185
- return () => clearTimeout(num);
186
- };
187
- }
188
- else if (delay === 'microtask') {
189
- delayFn = (cb) => {
190
- let isCancelled = false;
191
- queueMicrotask(() => {
192
- if (isCancelled)
193
- return;
194
- cb();
195
- });
196
- return () => {
197
- isCancelled = true;
198
- };
199
- };
200
- }
201
- else {
202
- delayFn = (cb) => {
203
- const num = setTimeout(cb, delay);
204
- return () => clearTimeout(num);
205
- };
206
- }
207
- const internal = linkedSignal({
208
- source,
209
- computation: (items) => items.slice(0, chunkSize),
210
- equal,
211
- });
212
- nestedEffect((cleanup) => {
213
- const fullList = source();
214
- const current = internal();
215
- if (current.length >= fullList.length)
216
- return;
217
- return cleanup(delayFn(() => untracked(() => internal.set(fullList.slice(0, current.length + chunkSize)))));
218
- }, {
219
- injector: injector,
220
- });
221
- return internal.asReadonly();
222
- }
223
-
224
158
  /**
225
159
  * Whether the subtree a resource/component lives in is currently PAUSED, for Activity / keep-alive.
226
160
  * Provided by an Activity boundary (`MmActivity`, or the app-builder's per-branch injector) and read
@@ -317,24 +251,23 @@ function providePaused(source) {
317
251
  }
318
252
 
319
253
  /**
320
- * Structural hold-and-swap as a signal. Given a `target` (the desired value — e.g. the
321
- * subtree/def/key you want to show) and a `ready` predicate, returns a signal that keeps
322
- * yielding its PREVIOUS value until `ready()` is true, then swaps to the current target.
323
- *
324
- * This is the structural counterpart to `keepPrevious`/`commit`: where those hold a *value*
325
- * through a reload, this holds a *structure* through a swap. The caller mounts the incoming
326
- * structure off to the side (so its resources can settle and flip `ready`), keeps showing the
327
- * 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.
328
263
  *
329
- * 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 })
330
267
  */
331
- function holdUntilReady(target, ready) {
332
- return linkedSignal({
333
- source: () => ({ t: target(), ready: ready() }),
334
- computation: (curr, prev) => (prev === undefined || curr.ready ? curr.t : prev.value),
335
- });
268
+ function providePausableOptions(opt) {
269
+ return { provide: PAUSABLE_OPTIONS, useValue: opt };
336
270
  }
337
-
338
271
  /**
339
272
  * Resolve a {@link PauseOption} into a pause predicate, or `null` meaning "do not pause".
340
273
  * `null` tells the caller to return the bare primitive — no wrapper is created.
@@ -349,11 +282,7 @@ function holdUntilReady(target, ready) {
349
282
  *
350
283
  * Encapsulating this here keeps every pausable primitive's branching identical and in one place.
351
284
  */
352
- function resolvePause(opt) {
353
- const explicit = opt?.pause; // distinguish explicit `true` from the omitted default
354
- const pause = explicit ?? true; // explicit pausable* calls default to pausing
355
- if (pause === false)
356
- return null;
285
+ function resolvePause(opt, defaultPause = true) {
357
286
  const run = (fn) => opt?.injector ? runInInjectionContext(opt.injector, fn) : fn();
358
287
  // `inject` requires an injection context even with `optional: true`. A bare
359
288
  // `pausableSignal(0)` (documented as "like `signal`") must degrade to the unwrapped
@@ -366,6 +295,12 @@ function resolvePause(opt) {
366
295
  return fallback;
367
296
  }
368
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;
369
304
  const onServer = () => typeof pause === 'function' && !opt?.injector
370
305
  ? typeof globalThis.window === 'undefined'
371
306
  : tryRun(() => isPlatformServer(inject(PLATFORM_ID, { optional: true }) ?? 'browser'), typeof globalThis.window === 'undefined');
@@ -375,7 +310,7 @@ function resolvePause(opt) {
375
310
  return null;
376
311
  const paused = tryRun(() => inject(PAUSED_CONTEXT, { optional: true }), null);
377
312
  if (!paused) {
378
- if (explicit === true && isDevMode())
313
+ if (opt?.pause === true && isDevMode())
379
314
  console.warn('[pausable] `pause: true` but no PAUSED_CONTEXT in scope — not pausing. Provide one via an ' +
380
315
  'Activity boundary (`MmActivity` / `providePaused`), or pass a predicate / `pause: false`.');
381
316
  return null;
@@ -447,6 +382,94 @@ function pausableComputed(computation, options) {
447
382
  return ls.asReadonly();
448
383
  }
449
384
 
385
+ /**
386
+ * Creates a new `Signal` that processes an array of items in time-sliced chunks. This is useful for handling large lists without blocking the main thread.
387
+ *
388
+ * The returned signal will initially contain the first `chunkSize` items from the source array. It will then schedule updates to include additional chunks of items based on the specified `delay`.
389
+ *
390
+ * @template T The type of items in the array.
391
+ * @param source A `Signal` or a function that returns an array of items to be processed in chunks.
392
+ * @param options Configuration options for chunk size, delay duration, equality function, and injector.
393
+ * @returns A `Signal` that emits the current chunk of items being processed.
394
+ *
395
+ * @example
396
+ * const largeList = signal(Array.from({ length: 1000 }, (_, i) => i));
397
+ * const chunkedList = chunked(largeList, { chunkSize: 100, delay: 100 });
398
+ */
399
+ function chunked(source, options) {
400
+ const { chunkSize = 50, delay = 'frame', equal, injector, pause, } = options || {};
401
+ let delayFn;
402
+ if (delay === 'frame') {
403
+ delayFn =
404
+ typeof requestAnimationFrame === 'function'
405
+ ? (callback) => {
406
+ const num = requestAnimationFrame(callback);
407
+ return () => cancelAnimationFrame(num);
408
+ }
409
+ : // SSR: no requestAnimationFrame — approximate a frame with a timeout
410
+ (cb) => {
411
+ const num = setTimeout(cb, 16);
412
+ return () => clearTimeout(num);
413
+ };
414
+ }
415
+ else if (delay === 'microtask') {
416
+ delayFn = (cb) => {
417
+ let isCancelled = false;
418
+ queueMicrotask(() => {
419
+ if (isCancelled)
420
+ return;
421
+ cb();
422
+ });
423
+ return () => {
424
+ isCancelled = true;
425
+ };
426
+ };
427
+ }
428
+ else {
429
+ delayFn = (cb) => {
430
+ const num = setTimeout(cb, delay);
431
+ return () => clearTimeout(num);
432
+ };
433
+ }
434
+ const internal = linkedSignal({
435
+ source,
436
+ computation: (items) => items.slice(0, chunkSize),
437
+ equal,
438
+ });
439
+ const paused = resolvePause({ injector, pause }, false);
440
+ nestedEffect((cleanup) => {
441
+ if (paused?.())
442
+ return;
443
+ const fullList = source();
444
+ const current = internal();
445
+ if (current.length >= fullList.length)
446
+ return;
447
+ return cleanup(delayFn(() => untracked(() => internal.set(fullList.slice(0, current.length + chunkSize)))));
448
+ }, {
449
+ injector,
450
+ });
451
+ return internal.asReadonly();
452
+ }
453
+
454
+ /**
455
+ * Structural hold-and-swap as a signal. Given a `target` (the desired value — e.g. the
456
+ * subtree/def/key you want to show) and a `ready` predicate, returns a signal that keeps
457
+ * yielding its PREVIOUS value until `ready()` is true, then swaps to the current target.
458
+ *
459
+ * This is the structural counterpart to `keepPrevious`/`commit`: where those hold a *value*
460
+ * through a reload, this holds a *structure* through a swap. The caller mounts the incoming
461
+ * structure off to the side (so its resources can settle and flip `ready`), keeps showing the
462
+ * held previous structure meanwhile, and lets the old one go once `ready` releases the swap.
463
+ *
464
+ * The very first value passes straight through (nothing to hold yet).
465
+ */
466
+ function holdUntilReady(target, ready) {
467
+ return linkedSignal({
468
+ source: () => ({ t: target(), ready: ready() }),
469
+ computation: (curr, prev) => (prev === undefined || curr.ready ? curr.t : prev.value),
470
+ });
471
+ }
472
+
450
473
  const { is } = Object;
451
474
  function mutable(initial, opt) {
452
475
  const baseEqual = opt?.equal ?? is;
@@ -697,6 +720,12 @@ function injectStartTransition() {
697
720
  *
698
721
  * `type` selects what "not ready" means: `'value'` (default) suspends only until a first value lands
699
722
  * then holds through reloads; `'loading'` suspends on every in-flight load (strict suspense).
723
+ *
724
+ * SSR: the server serializes whatever the scope reports at stabilization, so a registered resource
725
+ * must keep the app unstable until it settles or the placeholder is what gets serialized (then
726
+ * flashes/mismatches on hydration). HttpClient-backed resources, httpResource & all of `@mmstack/resource`
727
+ * do this automatically via the HTTP layer's `PendingTasks` + transfer cache. A custom loader (raw
728
+ * `fetch`/promise/timer) must opt in itself: wrap it with `inject(PendingTasks).run(() => promise)`.
700
729
  */
701
730
  class SuspenseBoundaryBase {
702
731
  scope = injectTransitionScope();
@@ -2633,11 +2662,19 @@ function throttle(source, opt) {
2633
2662
  tick();
2634
2663
  };
2635
2664
  const update = (fn) => set(fn(untracked(source)));
2665
+ const flush = () => {
2666
+ if (timeout)
2667
+ clearTimeout(timeout);
2668
+ timeout = undefined;
2669
+ pendingTrailing = false;
2670
+ fire();
2671
+ };
2636
2672
  const writable = toWritable(computed(() => {
2637
2673
  trigger();
2638
2674
  return untracked(source);
2639
2675
  }, opt), set, update);
2640
2676
  writable.original = source.asReadonly();
2677
+ writable.flush = flush;
2641
2678
  return writable;
2642
2679
  }
2643
2680
 
@@ -2860,6 +2897,194 @@ function createOrientation(debugName) {
2860
2897
  return state.asReadonly();
2861
2898
  }
2862
2899
 
2900
+ const IDLE = {
2901
+ active: false,
2902
+ start: { x: 0, y: 0 },
2903
+ current: { x: 0, y: 0 },
2904
+ delta: { x: 0, y: 0 },
2905
+ pointerId: null,
2906
+ modifiers: { shift: false, alt: false, ctrl: false, meta: false },
2907
+ button: -1,
2908
+ };
2909
+ function stateEqual(a, b) {
2910
+ return (a.active === b.active &&
2911
+ a.pointerId === b.pointerId &&
2912
+ a.current.x === b.current.x &&
2913
+ a.current.y === b.current.y &&
2914
+ a.button === b.button &&
2915
+ a.modifiers.shift === b.modifiers.shift &&
2916
+ a.modifiers.alt === b.modifiers.alt &&
2917
+ a.modifiers.ctrl === b.modifiers.ctrl &&
2918
+ a.modifiers.meta === b.modifiers.meta);
2919
+ }
2920
+ /**
2921
+ * Tracks a pointer *gesture* (pointerdown → capture → move → up) as a signal —
2922
+ * the foundation for pointer-based drag/move/resize/marquee on a canvas. Unlike
2923
+ * native HTML5 drag, pointer events fire continuously and coordinates are
2924
+ * reliable. SSR-safe; cleans up its listeners automatically.
2925
+ *
2926
+ * @example
2927
+ * ```ts
2928
+ * const drag = pointerDrag({ activationThreshold: 4 });
2929
+ * const position = computed(() => {
2930
+ * const d = drag();
2931
+ * return d.active ? { x: base.x + d.delta.x, y: base.y + d.delta.y } : base;
2932
+ * });
2933
+ * ```
2934
+ */
2935
+ function pointerDrag(opt) {
2936
+ return runInSensorContext(opt?.injector, () => createPointerDrag(opt));
2937
+ }
2938
+ function createPointerDrag(opt) {
2939
+ if (isPlatformServer(inject(PLATFORM_ID))) {
2940
+ const base = computed(() => IDLE, {
2941
+ debugName: opt?.debugName ?? 'pointerDrag',
2942
+ });
2943
+ base.unthrottled = base;
2944
+ base.cancel = () => undefined;
2945
+ return base;
2946
+ }
2947
+ const hostRef = inject((ElementRef), { optional: true });
2948
+ const { target = hostRef?.nativeElement, coordinateSpace = 'client', activationThreshold = 3, throttle = 16, handleSelector, buttons = [0], debugName = 'pointerDrag', } = opt ?? {};
2949
+ const resolve = (t) => {
2950
+ if (!t)
2951
+ return null;
2952
+ return t instanceof ElementRef ? t.nativeElement : t;
2953
+ };
2954
+ if (!isSignal(target) && !resolve(target)) {
2955
+ if (isDevMode())
2956
+ console.warn('pointerDrag: no target element (host ElementRef missing).');
2957
+ const base = computed(() => IDLE, { debugName });
2958
+ base.unthrottled = base;
2959
+ base.cancel = () => undefined;
2960
+ return base;
2961
+ }
2962
+ const state = throttled(IDLE, {
2963
+ ms: throttle,
2964
+ leading: true,
2965
+ trailing: true,
2966
+ equal: stateEqual,
2967
+ debugName,
2968
+ });
2969
+ let startPoint = { x: 0, y: 0 };
2970
+ let activePointerId = null;
2971
+ let activeButton = -1;
2972
+ let activated = false;
2973
+ let gesture = null;
2974
+ const coord = (e) => coordinateSpace === 'page'
2975
+ ? { x: e.pageX, y: e.pageY }
2976
+ : { x: e.clientX, y: e.clientY };
2977
+ const mods = (e) => ({
2978
+ shift: e.shiftKey,
2979
+ alt: e.altKey,
2980
+ ctrl: e.ctrlKey,
2981
+ meta: e.metaKey,
2982
+ });
2983
+ const end = () => {
2984
+ gesture?.abort();
2985
+ gesture = null;
2986
+ activePointerId = null;
2987
+ activeButton = -1;
2988
+ activated = false;
2989
+ state.set(IDLE);
2990
+ state.flush(); // terminal transition: reflect IDLE now, not on the trailing edge
2991
+ };
2992
+ const onMove = (e) => {
2993
+ if (e.pointerId !== activePointerId)
2994
+ return;
2995
+ const current = coord(e);
2996
+ const delta = { x: current.x - startPoint.x, y: current.y - startPoint.y };
2997
+ if (!activated && Math.hypot(delta.x, delta.y) >= activationThreshold) {
2998
+ activated = true;
2999
+ }
3000
+ state.set({
3001
+ active: activated,
3002
+ start: startPoint,
3003
+ current,
3004
+ delta,
3005
+ pointerId: activePointerId,
3006
+ modifiers: mods(e),
3007
+ button: activeButton, // pointermove button is -1; keep the down-button
3008
+ });
3009
+ };
3010
+ const onUp = (e) => {
3011
+ if (e.pointerId === activePointerId)
3012
+ end();
3013
+ };
3014
+ const onCancel = (e) => {
3015
+ if (e.pointerId === activePointerId)
3016
+ end();
3017
+ };
3018
+ const onKey = (e) => {
3019
+ if (e.key === 'Escape' && activePointerId !== null)
3020
+ end();
3021
+ };
3022
+ const onDown = (el) => (e) => {
3023
+ if (activePointerId !== null)
3024
+ return;
3025
+ if (!buttons.includes(e.button))
3026
+ return;
3027
+ if (handleSelector && !e.target?.closest?.(handleSelector)) {
3028
+ return;
3029
+ }
3030
+ activePointerId = e.pointerId;
3031
+ activeButton = e.button;
3032
+ activated = false;
3033
+ startPoint = coord(e);
3034
+ try {
3035
+ el.setPointerCapture(e.pointerId);
3036
+ }
3037
+ catch {
3038
+ // capture unsupported (older browsers / test env) — listeners still work
3039
+ }
3040
+ gesture = new AbortController();
3041
+ const signal = gesture.signal;
3042
+ el.addEventListener('pointermove', onMove, { signal });
3043
+ el.addEventListener('pointerup', onUp, { signal });
3044
+ el.addEventListener('pointercancel', onCancel, { signal });
3045
+ el.addEventListener('lostpointercapture', onCancel, {
3046
+ signal,
3047
+ });
3048
+ window.addEventListener('keydown', onKey, { signal });
3049
+ state.set({
3050
+ active: false,
3051
+ start: startPoint,
3052
+ current: startPoint,
3053
+ delta: { x: 0, y: 0 },
3054
+ pointerId: e.pointerId,
3055
+ modifiers: mods(e),
3056
+ button: e.button,
3057
+ });
3058
+ };
3059
+ const attach = (el) => {
3060
+ const controller = new AbortController();
3061
+ el.addEventListener('pointerdown', onDown(el), {
3062
+ signal: controller.signal,
3063
+ });
3064
+ return () => {
3065
+ controller.abort();
3066
+ end();
3067
+ };
3068
+ };
3069
+ if (isSignal(target)) {
3070
+ effect((cleanup) => {
3071
+ const el = resolve(target());
3072
+ if (!el)
3073
+ return;
3074
+ cleanup(attach(el));
3075
+ });
3076
+ }
3077
+ else {
3078
+ const el = resolve(target);
3079
+ if (el)
3080
+ inject(DestroyRef).onDestroy(attach(el));
3081
+ }
3082
+ const base = state.asReadonly();
3083
+ base.unthrottled = state.original;
3084
+ base.cancel = end;
3085
+ return base;
3086
+ }
3087
+
2863
3088
  /**
2864
3089
  * Creates a read-only signal that tracks the page's visibility state.
2865
3090
  *
@@ -3086,6 +3311,8 @@ function sensor(type, options) {
3086
3311
  switch (type) {
3087
3312
  case 'mousePosition':
3088
3313
  return mousePosition(opts);
3314
+ case 'pointerDrag':
3315
+ return pointerDrag(opts);
3089
3316
  case 'networkStatus':
3090
3317
  return networkStatus(opts);
3091
3318
  case 'pageVisibility':
@@ -3203,18 +3430,61 @@ function signalFromEvent(target, eventName, initial, projectOrOpt, maybeOpt) {
3203
3430
  return untracked(() => state.asReadonly());
3204
3431
  }
3205
3432
 
3206
- function isWritableSignal(value) {
3207
- return isSignal(value) && typeof value.set === 'function';
3208
- }
3433
+ const IS_STORE = Symbol('@mmstack/primitives::store/IS_STORE');
3434
+ const SCOPE_PARENT = Symbol('@mmstack/primitives::store/SCOPE_PARENT');
3209
3435
  /**
3210
- * Runtime marker + compile-time brand for an opaque value. A `const`-declared `Symbol`
3211
- * has a `unique symbol` type, so the same symbol serves as both the property key written
3212
- * by {@link opaque} and the type-level brand carried by {@link Opaque}.
3436
+ * @internal Brand carrying a store's writability ('mutable' | 'writable' | 'readonly'), stamped
3437
+ * on every store proxy. Read by `extendStore` instead of re-deriving via `isWritableSignal`,
3438
+ * which would mis-classify a readonly scoped store (its backing `toWritable` still has a `set`).
3213
3439
  */
3214
- const OPAQUE = Symbol('@mmstack/primitives::store/OPAQUE');
3440
+ const STORE_KIND = Symbol('@mmstack/primitives::store/STORE_KIND');
3215
3441
  /**
3216
- * Marks a plain object as opaque so {@link store} treats it as an indivisible leaf
3217
- * (returned whole, never deep-proxied) — the same way it treats a `Date` or `RegExp`.
3442
+ * @internal Brand exposing the injector a store was built with, so `extendStore` inherits it the
3443
+ * same way `store.extend(...)` does (via closure) — no injection context needed at the call site.
3444
+ */
3445
+ const STORE_INJECTOR = Symbol('@mmstack/primitives::store/STORE_INJECTOR');
3446
+ const SIGNAL_FN_PROP = new Set([
3447
+ 'set',
3448
+ 'update',
3449
+ 'mutate',
3450
+ 'inline',
3451
+ 'asReadonly',
3452
+ ]);
3453
+ /**
3454
+ * @internal
3455
+ * Test-only handle on the proxy cache (deliberately NOT re-exported from the public barrel).
3456
+ * Maps a store's backing signal to its lazily-built child proxies, each held via a `WeakRef`.
3457
+ */
3458
+ const PROXY_CACHE = new WeakMap();
3459
+ /**
3460
+ * @internal
3461
+ * Test-only handle on the finalization registry (deliberately NOT re-exported from the public
3462
+ * barrel). Prunes a cache entry once its proxy is reclaimed by the GC.
3463
+ */
3464
+ const PROXY_CLEANUP = new FinalizationRegistry(({ target, prop }) => {
3465
+ const storeCache = PROXY_CACHE.get(target);
3466
+ if (storeCache)
3467
+ storeCache.delete(prop);
3468
+ });
3469
+ /**
3470
+ * @internal
3471
+ * Validates whether a value is a Signal Store.
3472
+ */
3473
+ function isStore(value) {
3474
+ return (typeof value === 'function' &&
3475
+ value !== null &&
3476
+ value[IS_STORE] === true);
3477
+ }
3478
+
3479
+ /**
3480
+ * Runtime marker + compile-time brand for an opaque value. A `const`-declared `Symbol`
3481
+ * has a `unique symbol` type, so the same symbol serves as both the property key written
3482
+ * by {@link opaque} and the type-level brand carried by {@link Opaque}.
3483
+ */
3484
+ const OPAQUE = Symbol('@mmstack/primitives::store/OPAQUE');
3485
+ /**
3486
+ * Marks a plain object as opaque so {@link store} treats it as an indivisible leaf
3487
+ * (returned whole, never deep-proxied) — the same way it treats a `Date` or `RegExp`.
3218
3488
  * The marker is a non-enumerable symbol, so it never appears in spreads or iteration.
3219
3489
  * Idempotent. Call before freezing (`defineProperty` fails on a frozen object).
3220
3490
  *
@@ -3247,12 +3517,13 @@ function isOpaque(value) {
3247
3517
  value !== null &&
3248
3518
  value[OPAQUE] === true);
3249
3519
  }
3250
- /**
3251
- * @internal Runtime brand carrying a store node's lazily-built leaf probe. Exported (like
3252
- * {@link OPAQUE}) only so the `{ readonly [LEAF]: () => boolean }` brand on the store types is
3253
- * nameable in the emitted declarations — not part of the supported surface; use {@link isLeaf}.
3254
- */
3255
- const LEAF = Symbol('@mmstack/primitives::store/LEAF');
3520
+
3521
+ function isRecord(value) {
3522
+ if (value === null || typeof value !== 'object' || isOpaque(value))
3523
+ return false;
3524
+ const proto = Object.getPrototypeOf(value);
3525
+ return proto === Object.prototype || proto === null;
3526
+ }
3256
3527
  /**
3257
3528
  * @internal Whether a value is a terminal leaf: a concrete non-record/non-array value always is;
3258
3529
  * `null`/`undefined` is a leaf only when vivification is disabled (with vivify on it can still
@@ -3265,6 +3536,65 @@ function isLeafValue(value, vivifyEnabled) {
3265
3536
  return true; // opaque always wins — even arrays
3266
3537
  return !Array.isArray(value) && !isRecord(value);
3267
3538
  }
3539
+ /**
3540
+ * @internal
3541
+ * Resolves the vivify shape for a node from its current value: a present record/array is a
3542
+ * certainty we keep (cached in the derivation, so it survives the value being nulled); an
3543
+ * unknown value (`null`/`undefined`) defers to the caller's option. Off stays off.
3544
+ */
3545
+ function resolveVivify(sample, option) {
3546
+ if (!option)
3547
+ return false;
3548
+ if (Array.isArray(sample))
3549
+ return 'array';
3550
+ if (isRecord(sample))
3551
+ return 'object';
3552
+ return 'auto';
3553
+ }
3554
+ function hasOwnKey(value, key) {
3555
+ return value != null && Object.hasOwn(value, key);
3556
+ }
3557
+ /**
3558
+ * @internal
3559
+ * Builds the `onChange` for the fallback (non-record container) derivation branch. For an
3560
+ * immutable source the container is copied before the write — returning the same mutated
3561
+ * reference would let the source's equality cut propagation (leaving child signals permanently
3562
+ * stale) and alias the caller's original object, breaking the structural-sharing contract
3563
+ * `forkStore` relies on. For a mutable source the write goes through `mutate`, so the chain's
3564
+ * force-notify engages (plain `update` with the same reference would never notify).
3565
+ */
3566
+ function createFallbackOnChange(target, prop, vivifyFn, isMutableSource) {
3567
+ const write = (newValue) => (v) => {
3568
+ const container = vivifyFn(v, prop);
3569
+ if (container === null || container === undefined)
3570
+ return container;
3571
+ const next = isMutableSource
3572
+ ? container
3573
+ : Array.isArray(container)
3574
+ ? container.slice()
3575
+ : isRecord(container)
3576
+ ? { ...container }
3577
+ : container; // non-plain leaf (Date/class instance): legacy in-place attempt
3578
+ try {
3579
+ next[prop] = newValue;
3580
+ }
3581
+ catch (e) {
3582
+ if (isDevMode())
3583
+ console.error(`[store] Failed to set property "${String(prop)}"`, e);
3584
+ }
3585
+ return next;
3586
+ };
3587
+ return isMutableSource
3588
+ ? (newValue) => target.mutate(write(newValue))
3589
+ : (newValue) => target.update(write(newValue));
3590
+ }
3591
+
3592
+ /**
3593
+ * @internal Runtime brand carrying a store node's lazily-built leaf probe. Exported (like
3594
+ * {@link OPAQUE}) only so the `{ readonly [LEAF]: () => boolean }` brand on the store types is
3595
+ * nameable in the emitted declarations — not part of the supported surface; use {@link isLeaf}.
3596
+ */
3597
+ const LEAF = Symbol('@mmstack/primitives::store/LEAF');
3268
3598
  /**
3269
3599
  * @internal Constant leaf probes for nodes whose leaf-ness is statically known, so the reactive
3270
3600
  * `computed` can be skipped entirely.
@@ -3322,227 +3652,72 @@ function markAsLeaf(sig, value, vivifyEnabled, noUnionLeaves) {
3322
3652
  function isLeaf(value) {
3323
3653
  return isStore(value) && value[LEAF]?.() === true;
3324
3654
  }
3325
- const IS_STORE = Symbol('@mmstack/primitives::store/IS_STORE');
3326
- const SCOPE_PARENT = Symbol('@mmstack/primitives::store/SCOPE_PARENT');
3327
- /**
3328
- * @internal
3329
- * Test-only handle on the proxy cache (deliberately NOT re-exported from the public barrel).
3330
- * Maps a store's backing signal to its lazily-built child proxies, each held via a `WeakRef`.
3331
- */
3332
- const PROXY_CACHE = new WeakMap();
3333
- const SIGNAL_FN_PROP = new Set([
3334
- 'set',
3335
- 'update',
3336
- 'mutate',
3337
- 'inline',
3338
- 'asReadonly',
3339
- ]);
3655
+
3340
3656
  /**
3341
- * @internal
3342
- * Test-only handle on the finalization registry (deliberately NOT re-exported from the public
3343
- * barrel). Prunes a cache entry once its proxy is reclaimed by the GC.
3657
+ * @internal Reads (or lazily builds + caches) the child node proxy for `prop` on `target`,
3658
+ * holding it via a `WeakRef` and registering it for finalizer-driven cache pruning. The cache
3659
+ * is keyed per backing signal, so child identity is stable across repeat reads.
3344
3660
  */
3345
- const PROXY_CLEANUP = new FinalizationRegistry(({ target, prop }) => {
3346
- const storeCache = PROXY_CACHE.get(target);
3347
- if (storeCache)
3661
+ function getCachedChild(target, prop, build) {
3662
+ let storeCache = PROXY_CACHE.get(target);
3663
+ if (!storeCache) {
3664
+ storeCache = new Map();
3665
+ PROXY_CACHE.set(target, storeCache);
3666
+ }
3667
+ const cachedRef = storeCache.get(prop);
3668
+ if (cachedRef) {
3669
+ const cached = cachedRef.deref();
3670
+ if (cached)
3671
+ return cached;
3348
3672
  storeCache.delete(prop);
3349
- });
3350
- /**
3351
- * @internal
3352
- * Validates whether a value is a Signal Store.
3353
- */
3354
- function isStore(value) {
3355
- return (typeof value === 'function' &&
3356
- value !== null &&
3357
- value[IS_STORE] === true);
3358
- }
3359
- function isRecord(value) {
3360
- if (value === null || typeof value !== 'object' || isOpaque(value))
3361
- return false;
3362
- const proto = Object.getPrototypeOf(value);
3363
- return proto === Object.prototype || proto === null;
3364
- }
3365
- /**
3366
- * @internal
3367
- * Resolves the vivify shape for a node from its current value: a present record/array is a
3368
- * certainty we keep (cached in the derivation, so it survives the value being nulled); an
3369
- * unknown value (`null`/`undefined`) defers to the caller's option. Off stays off.
3370
- */
3371
- function resolveVivify(sample, option) {
3372
- if (!option)
3373
- return false;
3374
- if (Array.isArray(sample))
3375
- return 'array';
3376
- if (isRecord(sample))
3377
- return 'object';
3378
- return 'auto';
3379
- }
3380
- function hasOwnKey(value, key) {
3381
- return value != null && Object.hasOwn(value, key);
3382
- }
3383
- /**
3384
- * @internal
3385
- * Builds the `onChange` for the fallback (non-record container) derivation branch. For an
3386
- * immutable source the container is copied before the write — returning the same mutated
3387
- * reference would let the source's equality cut propagation (leaving child signals permanently
3388
- * stale) and alias the caller's original object, breaking the structural-sharing contract
3389
- * `forkStore` relies on. For a mutable source the write goes through `mutate`, so the chain's
3390
- * force-notify engages (plain `update` with the same reference would never notify).
3391
- */
3392
- function createFallbackOnChange(target, prop, vivifyFn, isMutableSource) {
3393
- const write = (newValue) => (v) => {
3394
- const container = vivifyFn(v, prop);
3395
- if (container === null || container === undefined)
3396
- return container;
3397
- const next = isMutableSource
3398
- ? container
3399
- : Array.isArray(container)
3400
- ? container.slice()
3401
- : isRecord(container)
3402
- ? { ...container }
3403
- : container; // non-plain leaf (Date/class instance): legacy in-place attempt
3404
- try {
3405
- next[prop] = newValue;
3406
- }
3407
- catch (e) {
3408
- if (isDevMode())
3409
- console.error(`[store] Failed to set property "${String(prop)}"`, e);
3410
- }
3411
- return next;
3412
- };
3413
- return isMutableSource
3414
- ? (newValue) => target.mutate(write(newValue))
3415
- : (newValue) => target.update(write(newValue));
3673
+ PROXY_CLEANUP.unregister(cachedRef);
3674
+ }
3675
+ const proxy = build();
3676
+ const ref = new WeakRef(proxy);
3677
+ storeCache.set(prop, ref);
3678
+ PROXY_CLEANUP.register(proxy, { target, prop }, ref);
3679
+ return proxy;
3416
3680
  }
3417
3681
  /**
3418
- * @internal
3419
- * Makes an array store
3682
+ * @internal Builds the derived child signal for `prop` and wraps it as an array/object substore.
3683
+ * A record parent reads the key directly; any other container goes through the fallback `from`/
3684
+ * `onChange` path. Shared verbatim by the array and object proxies — the only place a child node
3685
+ * is constructed.
3420
3686
  */
3421
- function toArrayStore(source, injector, vivify, noUnionLeaves = false) {
3422
- if (isStore(source))
3423
- return source;
3424
- const isMutableSource = isMutable(source);
3425
- const lengthSignal = computed(() => {
3426
- const v = source();
3427
- if (!Array.isArray(v))
3428
- return 0;
3429
- return v.length;
3430
- });
3431
- return new Proxy(source, {
3432
- has(_, prop) {
3433
- if (prop === 'length')
3434
- return true;
3435
- if (isIndexProp(prop)) {
3436
- const idx = +prop;
3437
- return idx >= 0 && idx < untracked(lengthSignal);
3438
- }
3439
- const v = untracked(source);
3440
- // nullish node values are routinely descended with vivify on — `in` must not throw
3441
- return v == null ? false : Reflect.has(v, prop);
3442
- },
3443
- ownKeys() {
3444
- const v = untracked(source);
3445
- if (!Array.isArray(v))
3446
- return [];
3447
- const len = v.length;
3448
- const arr = new Array(len + 1);
3449
- for (let i = 0; i < len; i++) {
3450
- arr[i] = String(i);
3451
- }
3452
- arr[len] = 'length';
3453
- return arr;
3454
- },
3455
- getPrototypeOf() {
3456
- return Array.prototype;
3457
- },
3458
- getOwnPropertyDescriptor(_, prop) {
3459
- const v = untracked(source);
3460
- if (!Array.isArray(v))
3461
- return;
3462
- if (prop === 'length' ||
3463
- (typeof prop === 'string' && !isNaN(+prop) && +prop < v.length)) {
3464
- return {
3465
- enumerable: true,
3466
- configurable: true, // Required for proxies to dynamic targets
3467
- };
3468
- }
3469
- return;
3470
- },
3471
- get(target, prop, receiver) {
3472
- if (prop === IS_STORE)
3473
- return true;
3474
- if (prop === 'length')
3475
- return lengthSignal;
3476
- if (prop === Symbol.iterator) {
3477
- return function* () {
3478
- // read length reactively: a spread/for-of inside a computed/effect must re-run
3479
- // when items are added or removed, not only when already-read elements change
3480
- for (let i = 0; i < lengthSignal(); i++) {
3481
- yield receiver[i];
3482
- }
3483
- };
3484
- }
3485
- if (typeof prop === 'symbol' || SIGNAL_FN_PROP.has(prop))
3486
- return target[prop];
3487
- if (isIndexProp(prop)) {
3488
- const idx = +prop;
3489
- let storeCache = PROXY_CACHE.get(target);
3490
- if (!storeCache) {
3491
- storeCache = new Map();
3492
- PROXY_CACHE.set(target, storeCache);
3493
- }
3494
- const cachedRef = storeCache.get(idx);
3495
- if (cachedRef) {
3496
- const cached = cachedRef.deref();
3497
- if (cached)
3498
- return cached;
3499
- storeCache.delete(idx);
3500
- PROXY_CLEANUP.unregister(cachedRef);
3501
- }
3502
- const value = untracked(target);
3503
- const valueIsArray = Array.isArray(value);
3504
- const valueIsRecord = isRecord(value);
3505
- const nodeVivify = resolveVivify(value, vivify);
3506
- const vivifyFn = createVivify(nodeVivify);
3507
- const equalFn = (valueIsRecord || valueIsArray) &&
3508
- isMutableSource &&
3509
- typeof value[idx] === 'object'
3510
- ? () => false
3511
- : undefined;
3512
- const computation = valueIsRecord
3513
- ? derived(target, idx, {
3514
- equal: equalFn,
3515
- vivify: nodeVivify,
3516
- })
3517
- : derived(target, {
3518
- from: (v) => v?.[idx],
3519
- onChange: createFallbackOnChange(target, idx, vivifyFn, isMutableSource),
3520
- equal: equalFn,
3521
- });
3522
- const childSample = untracked(computation);
3523
- const childVivify = resolveVivify(childSample, vivify);
3524
- const proxy = Array.isArray(childSample) && !isOpaque(childSample)
3525
- ? toArrayStore(computation, injector, childVivify, noUnionLeaves)
3526
- : toStore(computation, injector, childVivify, noUnionLeaves);
3527
- markAsLeaf(proxy, computation, childVivify !== false, noUnionLeaves);
3528
- const ref = new WeakRef(proxy);
3529
- storeCache.set(idx, ref);
3530
- PROXY_CLEANUP.register(proxy, { target, prop: idx }, ref);
3531
- return proxy;
3532
- }
3533
- return Reflect.get(target, prop, receiver);
3534
- },
3535
- });
3687
+ function buildChildNode(target, prop, isMutableSource, injector, vivify, noUnionLeaves) {
3688
+ const value = untracked(target);
3689
+ const valueIsRecord = isRecord(value);
3690
+ const valueIsArray = Array.isArray(value);
3691
+ const nodeVivify = resolveVivify(value, vivify);
3692
+ const vivifyFn = createVivify(nodeVivify);
3693
+ const equalFn = (valueIsRecord || valueIsArray) &&
3694
+ isMutableSource &&
3695
+ typeof value[prop] === 'object'
3696
+ ? () => false
3697
+ : undefined;
3698
+ const computation = valueIsRecord
3699
+ ? derived(target, prop, { equal: equalFn, vivify: nodeVivify })
3700
+ : derived(target, {
3701
+ from: (v) => v?.[prop],
3702
+ onChange: createFallbackOnChange(target, prop, vivifyFn, isMutableSource),
3703
+ equal: equalFn,
3704
+ });
3705
+ const childSample = untracked(computation);
3706
+ const childVivify = resolveVivify(childSample, vivify);
3707
+ const proxy = toStore(computation, injector, childVivify, noUnionLeaves);
3708
+ markAsLeaf(proxy, computation, childVivify !== false, noUnionLeaves);
3709
+ return proxy;
3536
3710
  }
3537
3711
  /**
3538
3712
  * Converts a Signal into a deep-observable Store.
3539
3713
  * Accessing nested properties returns a derived Signal of that path.
3540
3714
  *
3541
3715
  * @remarks
3542
- * A child's *container kind* (array store vs object store) is resolved when the child is
3543
- * first accessed and cached with the proxy. Leaf↔substore flips are tracked reactively, but a
3544
- * union-typed node that later flips between an array and a record keeps its original trap set —
3545
- * if you need that, re-model the union as `{ kind: ..., value: ... }` instead.
3716
+ * A node's *container kind* (array / record / primitive) is tracked reactively via a per-node
3717
+ * `kind` computed, so the same proxy serves all three and a union node that flips between an
3718
+ * array and a record keeps working. Flips are route-forward: after a flip the node behaves as
3719
+ * its new kind on the next access, while child proxies cached under the old shape go stale and
3720
+ * are pruned by the GC.
3546
3721
  *
3547
3722
  * @example
3548
3723
  * const state = store({ user: { name: 'John' } });
@@ -3553,99 +3728,121 @@ function toStore(source, injector, vivify = false, noUnionLeaves = false) {
3553
3728
  return source;
3554
3729
  if (!injector)
3555
3730
  injector = inject(Injector);
3556
- const writableSource = isWritableSignal(source)
3731
+ const writableSource = isWritableSignal$1(source)
3557
3732
  ? source
3558
3733
  : toWritable(source, () => {
3559
3734
  // noop
3560
3735
  });
3561
- const isWritableSource = isWritableSignal(source);
3736
+ const isWritableSource = isWritableSignal$1(source);
3562
3737
  const isMutableSource = isWritableSource && isMutable(writableSource);
3738
+ const kind = computed(() => {
3739
+ const v = source();
3740
+ if (Array.isArray(v) && !isOpaque(v))
3741
+ return 'array';
3742
+ if (isRecord(v))
3743
+ return 'record';
3744
+ return 'primitive';
3745
+ });
3746
+ // built lazily so non-array nodes never allocate it
3747
+ let length;
3748
+ const arrayLength = () => (length ??= computed(() => {
3749
+ const v = source();
3750
+ return Array.isArray(v) ? v.length : 0;
3751
+ }));
3563
3752
  const s = new Proxy(writableSource, {
3564
3753
  has(_, prop) {
3565
- return Reflect.has(untracked(source), prop);
3754
+ const v = untracked(source);
3755
+ if (untracked(kind) === 'array') {
3756
+ if (prop === 'length')
3757
+ return true;
3758
+ if (isIndexProp(prop)) {
3759
+ const idx = +prop;
3760
+ return idx >= 0 && idx < v.length;
3761
+ }
3762
+ }
3763
+ // nullish node values are routinely descended with vivify on — `in` must not throw
3764
+ return v == null ? false : Reflect.has(v, prop);
3566
3765
  },
3567
3766
  ownKeys() {
3568
3767
  const v = untracked(source);
3768
+ if (untracked(kind) === 'array') {
3769
+ const len = v.length;
3770
+ const arr = new Array(len + 1);
3771
+ for (let i = 0; i < len; i++)
3772
+ arr[i] = String(i);
3773
+ arr[len] = 'length';
3774
+ return arr;
3775
+ }
3569
3776
  if (!isRecord(v))
3570
3777
  return [];
3571
3778
  return Reflect.ownKeys(v);
3572
3779
  },
3573
3780
  getPrototypeOf() {
3574
- return Object.getPrototypeOf(untracked(source));
3781
+ if (untracked(kind) === 'array')
3782
+ return Array.prototype;
3783
+ const v = untracked(source);
3784
+ return v == null ? Object.prototype : Object.getPrototypeOf(v);
3575
3785
  },
3576
3786
  getOwnPropertyDescriptor(_, prop) {
3577
- const value = untracked(source);
3578
- if (!isRecord(value) || !(prop in value))
3787
+ const v = untracked(source);
3788
+ if (untracked(kind) === 'array') {
3789
+ if (prop === 'length' ||
3790
+ (typeof prop === 'string' && !isNaN(+prop) && +prop < v.length))
3791
+ return { enumerable: true, configurable: true };
3579
3792
  return;
3580
- return {
3581
- enumerable: true,
3582
- configurable: true,
3583
- };
3793
+ }
3794
+ if (!isRecord(v) || !(prop in v))
3795
+ return;
3796
+ return { enumerable: true, configurable: true };
3584
3797
  },
3585
- get(target, prop) {
3798
+ get(target, prop, receiver) {
3586
3799
  if (prop === IS_STORE)
3587
3800
  return true;
3801
+ if (prop === STORE_KIND)
3802
+ return isMutableSource
3803
+ ? 'mutable'
3804
+ : isWritableSource
3805
+ ? 'writable'
3806
+ : 'readonly';
3807
+ if (prop === STORE_INJECTOR)
3808
+ return injector;
3588
3809
  if (prop === 'asReadonlyStore')
3589
3810
  return () => {
3590
3811
  if (!isWritableSource)
3591
3812
  return s;
3592
3813
  return untracked(() => toStore(source.asReadonly(), injector, vivify, noUnionLeaves));
3593
3814
  };
3594
- if (prop === 'extend')
3815
+ const k = untracked(kind);
3816
+ if (prop === 'extend' && k !== 'array')
3595
3817
  return (seed) => scopedStore(s, seed, isMutableSource
3596
3818
  ? 'mutable'
3597
3819
  : isWritableSource
3598
3820
  ? 'writable'
3599
3821
  : 'readonly', injector);
3822
+ if (k === 'array') {
3823
+ if (prop === 'length')
3824
+ return arrayLength();
3825
+ if (prop === Symbol.iterator)
3826
+ return function* () {
3827
+ // read length reactively: a spread/for-of inside a computed/effect must re-run
3828
+ // when items are added or removed, not only when already-read elements change
3829
+ const len = arrayLength();
3830
+ for (let i = 0; i < len(); i++)
3831
+ yield receiver[i];
3832
+ };
3833
+ }
3600
3834
  if (typeof prop === 'symbol' || SIGNAL_FN_PROP.has(prop))
3601
3835
  return target[prop];
3602
- let storeCache = PROXY_CACHE.get(target);
3603
- if (!storeCache) {
3604
- storeCache = new Map();
3605
- PROXY_CACHE.set(target, storeCache);
3606
- }
3607
- const cachedRef = storeCache.get(prop);
3608
- if (cachedRef) {
3609
- const cached = cachedRef.deref();
3610
- if (cached)
3611
- return cached;
3612
- storeCache.delete(prop);
3613
- PROXY_CLEANUP.unregister(cachedRef);
3614
- }
3615
- const value = untracked(target);
3616
- const valueIsRecord = isRecord(value);
3617
- const valueIsArray = Array.isArray(value);
3618
- const nodeVivify = resolveVivify(value, vivify);
3619
- const vivifyFn = createVivify(nodeVivify);
3620
- const equalFn = (valueIsRecord || valueIsArray) &&
3621
- isMutableSource &&
3622
- typeof value[prop] === 'object'
3623
- ? () => false
3624
- : undefined;
3625
- const computation = valueIsRecord
3626
- ? derived(target, prop, { equal: equalFn, vivify: nodeVivify })
3627
- : derived(target, {
3628
- from: (v) => v?.[prop],
3629
- onChange: createFallbackOnChange(target, prop, vivifyFn, isMutableSource),
3630
- equal: equalFn,
3631
- });
3632
- const childSample = untracked(computation);
3633
- const childVivify = resolveVivify(childSample, vivify);
3634
- const proxy = Array.isArray(childSample) && !isOpaque(childSample)
3635
- ? toArrayStore(computation, injector, childVivify, noUnionLeaves)
3636
- : toStore(computation, injector, childVivify, noUnionLeaves);
3637
- markAsLeaf(proxy, computation, childVivify !== false, noUnionLeaves);
3638
- const ref = new WeakRef(proxy);
3639
- storeCache.set(prop, ref);
3640
- PROXY_CLEANUP.register(proxy, { target, prop }, ref);
3641
- return proxy;
3836
+ if (k === 'array' && !isIndexProp(prop))
3837
+ return Reflect.get(target, prop, receiver);
3838
+ return getCachedChild(target, prop, () => buildChildNode(target, k === 'array' ? +prop : prop, isMutableSource, injector, vivify, noUnionLeaves));
3642
3839
  },
3643
3840
  });
3644
3841
  return s;
3645
3842
  }
3646
3843
  /**
3647
3844
  * @internal
3648
- * Backs `store.extend(...)`. Builds a scoped overlay over `parent`: the local layer (the seed
3845
+ * Backs `extendStore(...)`. Builds a scoped overlay over `parent`: the local layer (the seed
3649
3846
  * plus any keys created later) is its own signal and `parent` is its own signal, so the getter
3650
3847
  * routes each key by consulting BOTH — local first, then parent, else local (so a write to an
3651
3848
  * as-yet-unknown key lands locally). Inherited keys return the parent's own sub-store (shared
@@ -3692,6 +3889,10 @@ function scopedStore(parent, seed, kind, injector) {
3692
3889
  get(target, prop) {
3693
3890
  if (prop === IS_STORE)
3694
3891
  return true;
3892
+ if (prop === STORE_KIND)
3893
+ return kind;
3894
+ if (prop === STORE_INJECTOR)
3895
+ return injector;
3695
3896
  if (prop === SCOPE_PARENT)
3696
3897
  return parent;
3697
3898
  if (prop === 'extend')
@@ -3724,6 +3925,28 @@ function scopedStore(parent, seed, kind, injector) {
3724
3925
  });
3725
3926
  return scope;
3726
3927
  }
3928
+ /** @internal Reads a store's writability brand, falling back to signal inspection if unbranded. */
3929
+ function storeKind(s) {
3930
+ return (s[STORE_KIND] ??
3931
+ (isWritableSignal$1(s) ? (isMutable(s) ? 'mutable' : 'writable') : 'readonly'));
3932
+ }
3933
+ /**
3934
+ * Extends a store with extra keys via a scoped overlay, returning a new store that reads through
3935
+ * to the parent for inherited keys (shared identity + two-way) while holding the new keys locally.
3936
+ *
3937
+ * The typesafe successor to the deprecated `store.extend(...)` method — moving it off the proxy
3938
+ * frees the `extend` key for use as a normal record key. Writability (readonly/writable/mutable)
3939
+ * is inherited from `store`.
3940
+ *
3941
+ * @example
3942
+ * const base = store({ count: 0 });
3943
+ * const scoped = extendStore(base, { label: 'live' });
3944
+ * scoped.count.set(1); // writes through to base
3945
+ * scoped.label.set('x'); // stays local
3946
+ */
3947
+ function extendStore(store, source, injector) {
3948
+ return scopedStore(store, source, storeKind(store), injector ?? store[STORE_INJECTOR] ?? inject(Injector));
3949
+ }
3727
3950
  /**
3728
3951
  * Creates a WritableSignalStore from a value.
3729
3952
  * @see {@link toStore}
@@ -3808,6 +4031,26 @@ function forkStore(base, opt) {
3808
4031
  };
3809
4032
  }
3810
4033
 
4034
+ /**
4035
+ * @internal The plain-`effect` sibling of the public {@link pausableEffect} (which is built on
4036
+ * `nestedEffect`). For infra utilities that own a single top-level effect/subscription and don't
4037
+ * need frame/nesting semantics. Opt-in (default off): with no `pause` (call site or
4038
+ * `providePausableOptions` default) it returns a bare `effect` (zero overhead, byte-identical to
4039
+ * today); otherwise it gates the body on the resolved predicate — read FIRST so the dependency set
4040
+ * collapses to just the predicate while paused, re-tracking on resume. Deliberately NOT re-exported
4041
+ * from the public barrel.
4042
+ */
4043
+ function pausablePureEffect(effectFn, options) {
4044
+ const paused = resolvePause(options, false);
4045
+ if (!paused)
4046
+ return effect(effectFn, options);
4047
+ return effect((registerCleanup) => {
4048
+ if (paused())
4049
+ return;
4050
+ effectFn(registerCleanup);
4051
+ }, options);
4052
+ }
4053
+
3811
4054
  // Internal dummy store for server-side rendering
3812
4055
  const noopStore = {
3813
4056
  getItem: () => null,
@@ -3869,8 +4112,9 @@ const noopStore = {
3869
4112
  * }
3870
4113
  * ```
3871
4114
  */
3872
- function stored(fallback, { key, store: providedStore, serialize = JSON.stringify, deserialize = JSON.parse, syncTabs = false, equal = Object.is, onKeyChange = 'load', cleanupOldKey = false, validate = () => true, ...rest }) {
3873
- const isServer = isPlatformServer(inject(PLATFORM_ID));
4115
+ function stored(fallback, { key, store: providedStore, serialize = JSON.stringify, deserialize = JSON.parse, syncTabs = false, equal = Object.is, onKeyChange = 'load', cleanupOldKey = false, validate = () => true, pause, injector: providedInjector, ...rest }) {
4116
+ const injector = providedInjector ?? inject(Injector);
4117
+ const isServer = isPlatformServer(injector.get(PLATFORM_ID));
3874
4118
  const fallbackStore = isServer ? noopStore : localStorage;
3875
4119
  const store = providedStore ?? fallbackStore;
3876
4120
  const keySig = typeof key === 'string'
@@ -3878,8 +4122,6 @@ function stored(fallback, { key, store: providedStore, serialize = JSON.stringif
3878
4122
  : isSignal(key)
3879
4123
  ? key
3880
4124
  : computed(key);
3881
- // "no stored value" marker — distinct from `null`/`undefined`, so a nullable `T` can
3882
- // round-trip a legitimate `null` through `set` instead of it acting like `clear()`
3883
4125
  const EMPTY = Symbol();
3884
4126
  const getValue = (key) => {
3885
4127
  const found = store.getItem(key);
@@ -3926,7 +4168,7 @@ function stored(fallback, { key, store: providedStore, serialize = JSON.stringif
3926
4168
  });
3927
4169
  let prevKey = initialKey;
3928
4170
  if (onKeyChange === 'store') {
3929
- effect(() => {
4171
+ pausablePureEffect(() => {
3930
4172
  const k = keySig();
3931
4173
  storeValue(k, internal());
3932
4174
  if (prevKey !== k) {
@@ -3934,10 +4176,10 @@ function stored(fallback, { key, store: providedStore, serialize = JSON.stringif
3934
4176
  store.removeItem(prevKey);
3935
4177
  prevKey = k;
3936
4178
  }
3937
- });
4179
+ }, { injector, pause });
3938
4180
  }
3939
4181
  else {
3940
- effect(() => {
4182
+ pausablePureEffect(() => {
3941
4183
  const k = keySig();
3942
4184
  const internalValue = internal();
3943
4185
  if (k === prevKey) {
@@ -3950,14 +4192,11 @@ function stored(fallback, { key, store: providedStore, serialize = JSON.stringif
3950
4192
  prevKey = k;
3951
4193
  internal.set(value); // load new value
3952
4194
  }
3953
- });
4195
+ }, { injector, pause });
3954
4196
  }
3955
4197
  if (syncTabs && !isServer) {
3956
- const destroyRef = inject(DestroyRef);
4198
+ const destroyRef = injector.get(DestroyRef);
3957
4199
  const sync = (e) => {
3958
- // `storage` events only describe Web Storage — ignore events for a different
3959
- // storage area (or any event when a custom adapter is configured), otherwise an
3960
- // unrelated localStorage write with the same key string corrupts our state
3961
4200
  if (e.storageArea !== store)
3962
4201
  return;
3963
4202
  if (e.key !== untracked(keySig))
@@ -4086,29 +4325,26 @@ function generateDeterministicID() {
4086
4325
  *
4087
4326
  */
4088
4327
  function tabSync(sig, opt) {
4089
- if (isPlatformServer(inject(PLATFORM_ID)))
4328
+ const optObj = typeof opt === 'object' ? opt : undefined;
4329
+ const injector = optObj?.injector ?? inject(Injector);
4330
+ if (isPlatformServer(injector.get(PLATFORM_ID)))
4090
4331
  return sig;
4091
4332
  const id = typeof opt === 'string' ? opt : (opt?.id ?? generateDeterministicID());
4092
- const bus = inject(MessageBus);
4093
- // The last value applied from a remote tab. The outbound effect skips (exactly) the run
4094
- // caused by that write — without this, an inbound object (a fresh structured clone, so
4095
- // never reference-equal) would be re-posted, and two tabs would ping-pong forever.
4333
+ const bus = injector.get(MessageBus);
4096
4334
  const NONE = Symbol();
4097
4335
  let received = NONE;
4098
4336
  const { unsub, post } = bus.subscribe(id, (next) => {
4099
4337
  const before = untracked(sig);
4100
4338
  received = next;
4101
4339
  sig.set(next);
4102
- // Equality-suppressed write (e.g. an identical primitive): no effect run will follow,
4103
- // so clear the marker — it must not swallow a later, genuinely local change.
4104
4340
  if (untracked(sig) === before)
4105
4341
  received = NONE;
4106
4342
  });
4107
- let first = false;
4343
+ let firstDone = false;
4108
4344
  const effectRef = effect(() => {
4109
4345
  const val = sig();
4110
- if (!first) {
4111
- first = true;
4346
+ if (!firstDone) {
4347
+ firstDone = true;
4112
4348
  return;
4113
4349
  }
4114
4350
  if (val === received) {
@@ -4117,8 +4353,8 @@ function tabSync(sig, opt) {
4117
4353
  }
4118
4354
  received = NONE;
4119
4355
  post(val);
4120
- });
4121
- inject(DestroyRef).onDestroy(() => {
4356
+ }, { injector });
4357
+ injector.get(DestroyRef).onDestroy(() => {
4122
4358
  effectRef.destroy();
4123
4359
  unsub();
4124
4360
  });
@@ -4322,5 +4558,5 @@ function withHistory(sourceOrValue, opt) {
4322
4558
  * Generated bundle index. Do not edit.
4323
4559
  */
4324
4560
 
4325
- export { MmActivity, SuspenseBoundary, SuspenseBoundaryBase, UnscopedSuspenseBoundary, activeTransaction, batteryStatus, chunked, clipboard, combineWith, createForwardingScope, createTransaction, createTransitionScope, debounce, debounced, derived, distinct, elementSize, elementVisibility, filter, filterWith, focusWithin, forkStore, geolocation, getTransitionScope, holdUntilReady, idle, indexArray, injectPaused, injectRegisterResource, injectStartTransaction, injectStartTransition, injectTransitionScope, isDerivation, isLeaf, isMutable, isOpaque, isStore, keepPrevious, keyArray, map, mapArray, mapObject, mediaQuery, merge3, mousePosition, mutable, mutableStore, nestedEffect, networkStatus, opaque, orientation, pageVisibility, pairwise, pausableComputed, pausableEffect, pausableSignal, pipeable, piped, pooled, pooledArray, pooledMap, pooledSet, prefersDarkMode, prefersReducedMotion, provideForwardingTransitionScope, providePaused, provideTransitionScope, registerResource, resolvePause, scan, scrollPosition, select, sensor, sensors, signalFromEvent, startWith, store, stored, tabSync, tap, throttle, throttled, toFakeDerivation, toFakeSignalDerivation, toStore, toWritable, until, windowSize, withHistory };
4561
+ export { MmActivity, PAUSABLE_OPTIONS, SuspenseBoundary, SuspenseBoundaryBase, UnscopedSuspenseBoundary, activeTransaction, batteryStatus, chunked, clipboard, combineWith, createForwardingScope, createTransaction, createTransitionScope, debounce, debounced, derived, distinct, elementSize, elementVisibility, extendStore, filter, filterWith, focusWithin, forkStore, geolocation, getTransitionScope, holdUntilReady, idle, indexArray, injectPaused, injectRegisterResource, injectStartTransaction, injectStartTransition, injectTransitionScope, isDerivation, isLeaf, isMutable, isOpaque, isStore, keepPrevious, keyArray, map, mapArray, mapObject, mediaQuery, merge3, mousePosition, mutable, mutableStore, nestedEffect, networkStatus, opaque, orientation, pageVisibility, pairwise, pausableComputed, pausableEffect, pausableSignal, pipeable, piped, pointerDrag, pooled, pooledArray, pooledMap, pooledSet, prefersDarkMode, prefersReducedMotion, provideForwardingTransitionScope, providePausableOptions, providePaused, provideTransitionScope, registerResource, resolvePause, scan, scrollPosition, select, sensor, sensors, signalFromEvent, startWith, store, stored, tabSync, tap, throttle, throttled, toFakeDerivation, toFakeSignalDerivation, toStore, toWritable, until, windowSize, withHistory };
4326
4562
  //# sourceMappingURL=mmstack-primitives.mjs.map