@mmstack/primitives 19.3.11 → 19.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.d.ts CHANGED
@@ -1,13 +1,15 @@
1
1
  export * from './lib/chunked';
2
+ export * from './lib/concurrent';
2
3
  export * from './lib/debounced';
3
4
  export * from './lib/derived';
4
5
  export { nestedEffect } from './lib/effect';
6
+ export * from './lib/keep-previous';
5
7
  export * from './lib/mappers';
6
8
  export * from './lib/mutable';
7
9
  export * from './lib/pipeable/public_api';
8
10
  export * from './lib/pooled';
9
11
  export * from './lib/sensors';
10
- export { isLeaf, isOpaque, isStore, mutableStore, opaque, store, toStore, type MutableSignalStore, type Opaque, type SignalStore, type WritableSignalStore, } from './lib/store';
12
+ export * from './lib/store/public_api';
11
13
  export * from './lib/stored';
12
14
  export { tabSync } from './lib/tabSync';
13
15
  export * from './lib/throttled';
@@ -0,0 +1,52 @@
1
+ import { InjectionToken, type Provider, type Signal } from '@angular/core';
2
+ import * as i0 from "@angular/core";
3
+ /**
4
+ * Whether the subtree a resource/component lives in is currently PAUSED, for Activity / keep-alive.
5
+ * Provided by an Activity boundary (`MmActivity`, or the app-builder's per-branch injector) and read
6
+ * — only at instantiation — by anything that should pause its background work while paused (a resource
7
+ * returning its `paused` token, a `<video>` pausing playback, the pausable primitives, …). Absent
8
+ * unless an Activity boundary provides one — read it via `injectPaused()`, which falls back to a
9
+ * never-paused signal, so code that isn't inside an Activity boundary is unaffected.
10
+ */
11
+ export declare const PAUSED_CONTEXT: InjectionToken<Signal<boolean>>;
12
+ /**
13
+ * Keep-alive (the Angular analog of React's `<Activity>` / Vue's `<keep-alive>`): the wrapped
14
+ * subtree is mounted ONCE and kept — when `[mmActivity]` is false it's hidden (`display:none`) and
15
+ * its change detection is paused, preserving state (scroll, inputs, a video's position, loaded
16
+ * data); when true it's shown and CD resumes. It is never destroyed until the directive is.
17
+ *
18
+ * It also provides {@link PAUSED_CONTEXT} to the content (= the negation of `visible`), so descendants
19
+ * can pause *effect-driven* or *Observable* work while hidden (CD-detach alone pauses pull-based/template work, not
20
+ * effects/polling). If you're using the pausable primitives this is done automatically
21
+ *
22
+ * ```html
23
+ * <section *mmActivity="tab() === 'editor'"> ...heavy stateful editor... </section>
24
+ * ```
25
+ */
26
+ export declare class MmActivity {
27
+ private readonly tpl;
28
+ private readonly vcr;
29
+ private readonly parent;
30
+ /** When false, keep the content mounted but hidden + CD-detached. */
31
+ readonly visible: import("@angular/core").InputSignal<boolean>;
32
+ /** Paused == not visible — handed to the kept subtree as PAUSED_CONTEXT. */
33
+ private readonly paused;
34
+ private view;
35
+ constructor();
36
+ private apply;
37
+ static ɵfac: i0.ɵɵFactoryDeclaration<MmActivity, never>;
38
+ static ɵdir: i0.ɵɵDirectiveDeclaration<MmActivity, "[mmActivity]", never, { "visible": { "alias": "mmActivity"; "required": true; "isSignal": true; }; }, {}, never, never, true, never>;
39
+ }
40
+ /**
41
+ * Inject the nearest paused-state signal — `true` while the surrounding subtree is paused (hidden by
42
+ * an Activity boundary). Defaults to a never-paused signal, so callers outside an Activity are
43
+ * unaffected; on the server it is always never-paused, so server-side work (e.g. connector fetches)
44
+ * isn't suppressed. This is the public way to read pause state; the underlying token is intentionally
45
+ * not exported.
46
+ */
47
+ export declare function injectPaused(): Signal<boolean>;
48
+ /**
49
+ * Build a provider that supplies a paused-state signal to a subtree — the public way to set up an
50
+ * Activity-style pause boundary (used by `MmActivity` and the app-builder's per-branch injectors).
51
+ */
52
+ export declare function providePaused(source: Signal<boolean>): Provider;
@@ -0,0 +1,14 @@
1
+ import { type Signal } from '@angular/core';
2
+ /**
3
+ * Structural hold-and-swap as a signal. Given a `target` (the desired value — e.g. the
4
+ * subtree/def/key you want to show) and a `ready` predicate, returns a signal that keeps
5
+ * yielding its PREVIOUS value until `ready()` is true, then swaps to the current target.
6
+ *
7
+ * This is the structural counterpart to `keepPrevious`/`commit`: where those hold a *value*
8
+ * through a reload, this holds a *structure* through a swap. The caller mounts the incoming
9
+ * structure off to the side (so its resources can settle and flip `ready`), keeps showing the
10
+ * held previous structure meanwhile, and lets the old one go once `ready` releases the swap.
11
+ *
12
+ * The very first value passes straight through (nothing to hold yet).
13
+ */
14
+ export declare function holdUntilReady<T>(target: Signal<T>, ready: () => boolean): Signal<T>;
@@ -0,0 +1,7 @@
1
+ export { injectPaused, MmActivity, providePaused } from './activity';
2
+ export * from './hold-until-ready';
3
+ export * from './pausable';
4
+ export * from './start-transition';
5
+ export * from './suspense-boundary';
6
+ export * from './transaction';
7
+ export * from './transition-scope';
@@ -0,0 +1,65 @@
1
+ import { type CreateComputedOptions, type CreateEffectOptions, type CreateSignalOptions, type EffectCleanupRegisterFn, type EffectRef, type Injector, type Signal, type WritableSignal } from '@angular/core';
2
+ /**
3
+ * How a pausable primitive decides whether it is currently paused:
4
+ * - omitted (the default) or `true` — read the ambient {@link PAUSED_CONTEXT} (via `injector`, or the
5
+ * current injection context). Reaching for a `pausable*` primitive means you want it pausable, so
6
+ * this is the default; outside an Activity boundary there's no `PAUSED_CONTEXT`, so the primitive is
7
+ * returned unwrapped (never pauses, zero overhead). On the server it never pauses either.
8
+ * - a predicate `() => boolean` — used directly. A `Signal<boolean>` satisfies this (signals are
9
+ * callable), and a plain function works OUTSIDE an injection context.
10
+ * - `false` — the explicit opt-out: the primitive is returned UNWRAPPED (no `linkedSignal`, no gate),
11
+ * i.e. exactly the plain primitive with zero overhead.
12
+ */
13
+ export type PauseOption = boolean | (() => boolean);
14
+ export type PausableOptions = {
15
+ /** Pause source — see {@link PauseOption}. Defaults to `true` (read the ambient `PAUSED_CONTEXT`). */
16
+ readonly pause?: PauseOption;
17
+ /**
18
+ * Injector used to resolve {@link PAUSED_CONTEXT} when `pause` is `true`/omitted and the primitive
19
+ * is created outside an injection context. Ignored for the `false` / predicate forms.
20
+ */
21
+ readonly injector?: Injector;
22
+ };
23
+ /**
24
+ * Resolve a {@link PauseOption} into a pause predicate, or `null` meaning "do not pause".
25
+ * `null` tells the caller to return the bare primitive — no wrapper is created.
26
+ *
27
+ * - omitted/`true` → the ambient {@link PAUSED_CONTEXT} if an Activity boundary provides one (via
28
+ * `opt.injector` or the current injection context), else `null` (the bare primitive, no allocation).
29
+ * The default, because an explicit `pausable*` call wants to be pausable. An explicit `pause: true`
30
+ * with no boundary dev-warns; the omitted default stays quiet. SSR → `null`.
31
+ * - a function → returned as-is (covers `Signal<boolean>`; usable outside an injection context).
32
+ * SSR → `null` here too, detected via `opt.injector` if given, else a `globalThis.window` probe.
33
+ * - `false` → `null` (the explicit opt-out).
34
+ *
35
+ * Encapsulating this here keeps every pausable primitive's branching identical and in one place.
36
+ */
37
+ export declare function resolvePause(opt?: PausableOptions): (() => boolean) | null;
38
+ /**
39
+ * Like {@link nestedEffect}, but pausable. While paused the effect does NOT run its body — and,
40
+ * crucially, it reads the pause predicate FIRST, so while paused its dependency set collapses to just
41
+ * the predicate (no churn from the real deps); on resume it re-runs and re-tracks. With no `pause`
42
+ * option it defaults to the ambient `PAUSED_CONTEXT`; `pause: false` makes it a plain `nestedEffect`
43
+ * with zero added overhead.
44
+ */
45
+ export declare function pausableEffect(effectFn: (registerCleanup: EffectCleanupRegisterFn) => void, options?: CreateEffectOptions & PausableOptions): EffectRef;
46
+ /**
47
+ * Like `signal`, but pausable. While paused, READS hold the last value; writes still land on the
48
+ * underlying signal and surface on resume. Built on the `keepPrevious`/`hold` shape — a
49
+ * `linkedSignal` gated on the pause predicate, with `set`/`update`/`asReadonly` forwarded to the
50
+ * source signal. With no `pause` option it defaults to the ambient `PAUSED_CONTEXT`; `pause: false`
51
+ * makes it a plain `signal` — no `linkedSignal` is created.
52
+ *
53
+ * NOTE: while paused, `set(x)` followed by a read returns the *held* (pre-pause) value, not `x` — the
54
+ * write lands on the source and surfaces on resume. That is the "freeze the displayed value while
55
+ * hidden" semantics; do not rely on read-after-write while paused.
56
+ */
57
+ export declare function pausableSignal<T>(initialValue: T, options?: CreateSignalOptions<T> & PausableOptions): WritableSignal<T>;
58
+ /**
59
+ * Like `computed`, but pausable. While paused it holds its last value AND does not recompute: the
60
+ * computation's dependencies are not read while paused, so a dependency change can't trigger work —
61
+ * on resume it recomputes and re-tracks. The very first read always computes, to seed a value. With
62
+ * no `pause` option it defaults to the ambient `PAUSED_CONTEXT`; `pause: false` makes it a plain
63
+ * `computed`.
64
+ */
65
+ export declare function pausableComputed<T>(computation: () => T, options?: CreateComputedOptions<T> & PausableOptions): Signal<T>;
@@ -0,0 +1,20 @@
1
+ import { type Signal } from '@angular/core';
2
+ /**
3
+ * Handle for an in-progress transition: a `pending` signal (true while the transition's
4
+ * resources are in flight) and a `done` promise that resolves once they all settle.
5
+ */
6
+ export type TransitionRef = {
7
+ readonly pending: Signal<boolean>;
8
+ readonly done: Promise<void>;
9
+ };
10
+ /**
11
+ * Returns a `startTransition(fn)` bound to the nearest transition scope. `fn` runs its state
12
+ * mutations (which commit immediately); any resource that reloads as a result holds its value
13
+ * (when `coordinate`/`commit`-wrapped) and reveals together once everything settles. The
14
+ * returned handle exposes a unified `pending` + `done` for the whole operation — for imperative
15
+ * coordination (disable a control, await completion) on top of the declarative hold-and-commit.
16
+ *
17
+ * Must be called in an injection context. This is the *async* generalization (Tier 2): it adds
18
+ * no rendering cost and needs no fork — holding direct/sync readers is a separate, deferred tier.
19
+ */
20
+ export declare function injectStartTransition(): (fn: () => void) => TransitionRef;
@@ -0,0 +1,46 @@
1
+ import { type SuspendType } from './transition-scope';
2
+ import * as i0 from "@angular/core";
3
+ /**
4
+ * Shared **suspense** (readiness) boundary behaviour: reads the *nearest* transition scope and exposes
5
+ * its `pending`/`suspended` state. This is the readiness gate — distinct from the hold-stale *swap*
6
+ * primitives (`TransitionRouterOutlet`, `ab-transition`), which are the actual "transitions". The two
7
+ * concrete components below differ only by whether they provide their own scope, so the logic (and
8
+ * template) live here once.
9
+ *
10
+ * - **First load** (`suspended()`): no value yet → show the `[placeholder]` fallback.
11
+ * - **Reload** (`pending()` but a value is held via `keepPrevious`): keep the real content mounted and
12
+ * surface a busy indicator (`aria-busy`, and an optional `[busy]` slot) instead of flashing back to
13
+ * the placeholder.
14
+ *
15
+ * `type` selects what "not ready" means: `'value'` (default) suspends only until a first value lands
16
+ * then holds through reloads; `'loading'` suspends on every in-flight load (strict suspense).
17
+ */
18
+ export declare abstract class SuspenseBoundaryBase {
19
+ protected readonly scope: import("@mmstack/primitives").TransitionScope;
20
+ /** What counts as "not ready" for the first-load placeholder. Defaults to value-presence. */
21
+ readonly type: import("@angular/core").InputSignal<SuspendType>;
22
+ protected readonly pending: import("@angular/core").Signal<boolean>;
23
+ protected readonly suspended: import("@angular/core").Signal<boolean>;
24
+ static ɵfac: i0.ɵɵFactoryDeclaration<SuspenseBoundaryBase, never>;
25
+ static ɵdir: i0.ɵɵDirectiveDeclaration<SuspenseBoundaryBase, never, never, { "type": { "alias": "type"; "required": false; "isSignal": true; }; }, {}, never, never, true, never>;
26
+ }
27
+ /**
28
+ * Standalone suspense boundary — **provides its own scope**, so dropping a `<mm-suspense>` anywhere
29
+ * just works: the resources created in its subtree register into it without any extra
30
+ * `provideTransitionScope()`. The common case.
31
+ */
32
+ export declare class SuspenseBoundary extends SuspenseBoundaryBase {
33
+ static ɵfac: i0.ɵɵFactoryDeclaration<SuspenseBoundary, never>;
34
+ static ɵcmp: i0.ɵɵComponentDeclaration<SuspenseBoundary, "mm-suspense", never, {}, {}, never, ["[placeholder]", "[busy]", "*"], true, never>;
35
+ }
36
+ /**
37
+ * Unscoped suspense boundary — **reads the ambient scope** instead of providing one. For cases where
38
+ * the resources to coordinate are registered *above* the boundary (e.g. an app-builder page whose
39
+ * manifests/connectors register at a higher injector), so the boundary observes that outer scope
40
+ * rather than opening a fresh one. Pair with a `provideTransitionScope()` (or another boundary) in an
41
+ * ancestor.
42
+ */
43
+ export declare class UnscopedSuspenseBoundary extends SuspenseBoundaryBase {
44
+ static ɵfac: i0.ɵɵFactoryDeclaration<UnscopedSuspenseBoundary, never>;
45
+ static ɵcmp: i0.ɵɵComponentDeclaration<UnscopedSuspenseBoundary, "mm-unscoped-suspense", never, {}, {}, never, ["[placeholder]", "[busy]", "*"], true, never>;
46
+ }
@@ -0,0 +1,36 @@
1
+ import { type Signal, type WritableSignal } from '@angular/core';
2
+ /**
3
+ * An undo log for a transactional transition. Stateful writes made while the transaction is the
4
+ * active one record their PRE-write value here (once, on first touch); `restore()` rolls them all
5
+ * back (abort), `clear()` keeps them (commit — the writes already landed live).
6
+ */
7
+ export type Transaction = {
8
+ /** Record a signal's current value as its rollback point (no-op if already recorded). */
9
+ record(sig: WritableSignal<unknown>): void;
10
+ /** Roll every recorded signal back to its pre-write value (abort). */
11
+ restore(): void;
12
+ /** Drop the log, keeping live writes (commit). */
13
+ clear(): void;
14
+ };
15
+ export declare function createTransaction(): Transaction;
16
+ /** The transaction in effect right now, or `null`. Stateful actions consult this to record undo. */
17
+ export declare function activeTransaction(): Transaction | null;
18
+ /** Handle for an in-progress transaction (Tier 3): the transition `pending`/`done`, plus `abort`. */
19
+ export type TransactionRef = {
20
+ readonly pending: Signal<boolean>;
21
+ readonly done: Promise<void>;
22
+ /** Roll back the staged writes and release the hold without committing. */
23
+ abort(): void;
24
+ };
25
+ /**
26
+ * Returns a `startTransaction(fn)` bound to the nearest transition scope — the Tier 3 sibling of
27
+ * `injectStartTransition`. It HOLDS the scope's synchronous display reads from before `fn` runs
28
+ * (so a state write inside `fn` doesn't flash through), records those writes in an undo log, then:
29
+ * - on settle (the scope's resources go in flight and drain) → release the hold + keep the writes;
30
+ * - on `abort()` → roll the writes back and release the hold.
31
+ *
32
+ * The writes land on LIVE state immediately (so derived variables and connector requests see the
33
+ * new values and refetch); only the *display* is held, via `scope.hold`. Must run in an injection
34
+ * context.
35
+ */
36
+ export declare function injectStartTransaction(): (fn: () => void) => TransactionRef;
@@ -0,0 +1,74 @@
1
+ import { type Provider, type ResourceRef, type Signal } from '@angular/core';
2
+ /**
3
+ * What "not ready" means for first-load suspense:
4
+ * - `'value'`: the resource has no value yet (`!hasValue()`). With `keepPrevious`,
5
+ * this stays false through a reload — the previous value holds — so a transition
6
+ * does NOT re-suspend; only the genuine first load shows a placeholder.
7
+ * - `'loading'`: any in-flight request suspends, even a background reload.
8
+ */
9
+ export type SuspendType = 'value' | 'loading';
10
+ export type RegisterOptions = {
11
+ /**
12
+ * Whether this resource blocks the boundary's first paint (`suspended()`).
13
+ * `true` for things the subtree can't render without (e.g. lazily-loaded component
14
+ * code); `false` for in-region data, which should drive the transition indicator
15
+ * (`pending`) and hold-stale, but NOT blank the whole boundary while it first loads.
16
+ */
17
+ readonly suspends?: boolean;
18
+ };
19
+ /**
20
+ * A transition scope: the set of resources whose async state a boundary coordinates.
21
+ * Provided per-boundary (so nested boundaries are independent — the transition-scoped,
22
+ * not global, registry) with a root default so registration always lands somewhere.
23
+ */
24
+ export type TransitionScope = {
25
+ /** The currently-registered resources (read-only view). */
26
+ readonly resources: Signal<readonly ResourceRef<any>[]>;
27
+ /**
28
+ * Any registered resource has a request in flight (`status` is `loading`/`reloading`).
29
+ * This is the transition indicator — true during a reload while `keepPrevious` holds
30
+ * the visible value, so the UI can show "updating…" without unmounting.
31
+ */
32
+ readonly pending: Signal<boolean>;
33
+ /** Any *suspending* resource is not ready — drives the first-load placeholder. */
34
+ suspended(type: SuspendType): boolean;
35
+ add(res: ResourceRef<any>, opt?: RegisterOptions): void;
36
+ remove(res: ResourceRef<any>): void;
37
+ /**
38
+ * Coordinated commit: wraps a value signal so it FREEZES at its last-settled value
39
+ * while the scope is `pending`, then reveals the current value once *everything*
40
+ * settles. Multiple values wrapped this way release together — one consistent frame,
41
+ * never a torn mix of new + stale across resources. Compose over a `keepPrevious`
42
+ * value: keepPrevious holds per-resource, `commit` gates the reveal on the aggregate.
43
+ */
44
+ commit<T>(value: Signal<T>): Signal<T>;
45
+ /**
46
+ * Whether a transaction is currently HOLDING this scope's synchronous display reads (Tier 3).
47
+ * A counter under the hood, so nested transactions compose. Distinct from `pending` (a resource
48
+ * is in flight): `holding` brackets a whole transaction from start to settle.
49
+ */
50
+ readonly holding: Signal<boolean>;
51
+ /** Begin a transaction hold (increment the counter). */
52
+ beginHold(): void;
53
+ /** End a transaction hold (decrement); reveals held values when the counter reaches 0. */
54
+ endHold(): void;
55
+ /**
56
+ * Tier 3 display hold: wraps a value so it FREEZES at its pre-hold value while the scope is
57
+ * `holding`, then reveals the live value when the hold ends. Unlike `commit` (gates on
58
+ * `pending`), this brackets the whole transaction — so a *synchronous* state write made inside
59
+ * the transaction stays visually held until the transaction settles, with no torn frame.
60
+ */
61
+ hold<T>(value: Signal<T>): Signal<T>;
62
+ };
63
+ export declare function createTransitionScope(): TransitionScope;
64
+ /** Provide a fresh transition scope at a boundary so its subtree's resources are tracked independently. */
65
+ export declare function provideTransitionScope(): Provider;
66
+ export declare function injectTransitionScope(): TransitionScope;
67
+ /**
68
+ * Returns a register function bound to the nearest transition scope: it adds a resource
69
+ * to the scope and removes it when the caller's injection context is destroyed. Pass any
70
+ * `ResourceRef` (a query, mutation, or plain Angular resource) through it.
71
+ */
72
+ export declare function injectRegisterResource(): <T extends ResourceRef<any>>(res: T, opt?: RegisterOptions) => T;
73
+ /** Convenience: register a resource with the nearest transition scope. Must run in an injection context. */
74
+ export declare function registerResource<T extends ResourceRef<any>>(res: T, opt?: RegisterOptions): T;
@@ -0,0 +1,20 @@
1
+ import { type CreateSignalOptions, type Signal, type WritableSignal } from '@angular/core';
2
+ import { type DerivedSignal } from './derived';
3
+ import { type MutableSignal } from './mutable';
4
+ /**
5
+ * Wraps a signal so it HOLDS its last defined value whenever the source becomes
6
+ * `undefined`, yielding that value instead of the gap. This is the foundation of
7
+ * stale-while-revalidate: a source that drops to `undefined` mid-reload keeps
8
+ * surfacing its previous result rather than flashing empty.
9
+ *
10
+ * Built on `linkedSignal` — the only primitive that hands a computation its own
11
+ * previous output, which is exactly what "hold the previous value" needs.
12
+ *
13
+ * If the source is writable, the wrapper forwards `set`/`update`/`asReadonly` to it,
14
+ * so it stays a drop-in replacement. (Angular's `resource` is itself linkedSignal-backed
15
+ * and exposes a writable `value` for optimistic updates; this preserves that.)
16
+ */
17
+ export declare function keepPrevious<T>(value: MutableSignal<T>, opt?: CreateSignalOptions<T>): MutableSignal<T>;
18
+ export declare function keepPrevious<T, U>(value: DerivedSignal<T, U>, opt?: CreateSignalOptions<U>): DerivedSignal<T, U>;
19
+ export declare function keepPrevious<T>(value: WritableSignal<T>, opt?: CreateSignalOptions<T>): WritableSignal<T>;
20
+ export declare function keepPrevious<T>(value: Signal<T>, opt?: CreateSignalOptions<T>): Signal<T>;
@@ -0,0 +1,79 @@
1
+ import { type Injector } from '@angular/core';
2
+ import { type Vivify } from '../util';
3
+ import { type WritableSignalStore } from './store';
4
+ /**
5
+ * A 3-way merge of a forked value against a changed base: given the common `ancestor` (the base
6
+ * value the fork last diverged from), `mine` (the fork's current value), and `theirs` (the base
7
+ * now), return the reconciled value. Used when the base changes mid-fork — and at `commit`.
8
+ */
9
+ export type ReconcileFn<T> = (ancestor: T, mine: T, theirs: T) => T;
10
+ /**
11
+ * How a fork reconciles when the base changes underneath it:
12
+ * - `'fine'` (default for immutable stores) — per-path 3-way merge ({@link merge3}): keep the
13
+ * paths the fork edited, take the base's live values for paths it didn't. Survives concurrent
14
+ * base changes. UNSUPPORTED on a mutable base (in-place mutation defeats `merge3`'s
15
+ * reference-identity checks — `fork` warns and falls back to `'coarse'`).
16
+ * - `'coarse'` — whole-value re-link: any base change resets the WHOLE fork (drops staged writes).
17
+ * The cheapest strategy; correct when the base is held for the fork's lifetime (transitions).
18
+ * The default for a MUTABLE base.
19
+ * - a {@link ReconcileFn} — bring your own merge (e.g. Immer patches, array-by-id, CRDT-ish).
20
+ * NOTE: any reference-based 3-way merge has the same mutable-store problem as `'fine'`; on a
21
+ * mutable base a custom fn receives `ancestor === theirs` (the same mutated object).
22
+ */
23
+ export type ForkStrategy<T> = 'fine' | 'coarse' | ReconcileFn<T>;
24
+ /**
25
+ * A forked store: an isolated, writable overlay on a base store. Writes stay LOCAL to the fork
26
+ * (the base is untouched); unedited paths read through to the base. `commit()` flushes the fork's
27
+ * value onto the base; `discard()` drops the staged writes.
28
+ *
29
+ * The mechanism is `linkedSignal`: it holds local writes until its source (the base) changes, then
30
+ * runs the {@link ForkStrategy} to reconcile. The store interface, deep reads, and deep
31
+ * copy-on-write writes all come from `toStore` unchanged — the only fork-specific logic is the
32
+ * reconcile on a base change.
33
+ *
34
+ * Reactivity note: the fork reads through a single staged signal, so a read subscribes to the
35
+ * whole record (coarser than the base store's per-leaf tracking) and the strategy re-runs on any
36
+ * base change. Free when the base is held (it never ticks); on a live base, `'fine'`'s {@link
37
+ * merge3} is identity-pruned so it only walks paths that both sides changed.
38
+ */
39
+ export type Fork<T> = {
40
+ /** The forked store — use it like any store (read/write/extend). */
41
+ readonly store: WritableSignalStore<T>;
42
+ /** Apply the fork's staged value onto the base, then re-link (fork now mirrors the base). */
43
+ commit(): void;
44
+ /** Drop staged writes — the fork reads through to the base again. */
45
+ discard(): void;
46
+ };
47
+ /**
48
+ * Per-path 3-way merge. Reference-equality short-circuits do the work: a subtree the fork never
49
+ * touched satisfies `mine === ancestor` (structural sharing keeps its identity) → take the live
50
+ * base; a subtree the base never changed satisfies `theirs === ancestor` → keep the fork's. So it
51
+ * only deep-walks paths that BOTH sides changed, and on a leaf/array conflict the fork wins.
52
+ * Arrays are treated atomically (no positional merge — index shifts make that unsafe); supply a
53
+ * {@link ReconcileFn} for array-aware merging.
54
+ *
55
+ * CONTRACT: "unchanged" is detected by REFERENCE identity, not deep equality. `mine` must be a
56
+ * copy-on-write derivative of `ancestor` — i.e. untouched nodes keep their reference — which the
57
+ * fork guarantees because writes flow through `toStore` (it rebuilds only the edited path and
58
+ * shares everything else). Feed it a structurally-equal-but-fresh-reference node for an untouched
59
+ * path and it will treat that node as edited (recursion/leaf-value checks usually still reconcile,
60
+ * but a fresh-ref clean node vs a base type-change resolves to the fork's stale value). Primitive
61
+ * leaves compare by value, so equal primitives are correctly seen as unchanged.
62
+ */
63
+ export declare function merge3<T>(ancestor: T, mine: T, theirs: T): T;
64
+ export declare function forkStore<T extends Record<string, any>>(base: WritableSignalStore<T>, opt?: {
65
+ strategy?: ForkStrategy<T>;
66
+ injector?: Injector;
67
+ /**
68
+ * Store config for the FORK's store — NOT inherited from `base` (it's closed over inside
69
+ * the base's `toStore` and can't be read back). If the base was created with these, pass
70
+ * the same values or the fork's write semantics will differ:
71
+ * - `vivify`: without it, a write through a `null`/`undefined` path is silently dropped on
72
+ * the fork even though the base would have created the container. Match the base.
73
+ * - `noUnionLeaves`: a perf promise; off just means the slower reactive leaf-probe. NOTE it
74
+ * is a whole-store guarantee — a fork that flips a node's type (leaf↔substore) violates it,
75
+ * and on `commit` the base receives the flipped value with stale cached leaf-ness.
76
+ */
77
+ vivify?: Vivify;
78
+ noUnionLeaves?: boolean;
79
+ }): Fork<T>;
@@ -0,0 +1,2 @@
1
+ export * from './fork-store';
2
+ export { isLeaf, isOpaque, isStore, mutableStore, opaque, store, toStore, type MutableSignalStore, type Opaque, type SignalStore, type WritableSignalStore, } from './store';
@@ -1,6 +1,7 @@
1
1
  import { Injector, type CreateSignalOptions, type Signal, type WritableSignal } from '@angular/core';
2
- import { type MutableSignal } from './mutable';
3
- import { type Vivify } from './util';
2
+ import { type MutableSignal } from '../mutable';
3
+ import { type Vivify } from '../util';
4
+ export declare function isWritableSignal<T>(value: Signal<T>): value is WritableSignal<T>;
4
5
  /**
5
6
  * Runtime marker + compile-time brand for an opaque value. A `const`-declared `Symbol`
6
7
  * has a `unique symbol` type, so the same symbol serves as both the property key written
@@ -156,17 +157,17 @@ type MutableSignalStoreObject<T> = Simplify<Readonly<{
156
157
  }>;
157
158
  export type SignalStore<T> = Signal<UnwrapOpqaue<T>> & (IsAny<T> extends true ? SignalStoreObject<T> : NonNullable<T> extends BaseType ? {
158
159
  readonly [LEAF]: () => boolean;
159
- } : NonNullable<T> extends Array<any> ? SignalArrayStore<NonNullable<T>> : SignalStoreObject<T>);
160
+ } : NonNullable<T> extends any[] ? SignalArrayStore<NonNullable<T>> : SignalStoreObject<T>);
160
161
  export type WritableSignalStore<T> = WritableSignal<UnwrapOpqaue<T>> & {
161
162
  readonly asReadonlyStore: () => SignalStore<T>;
162
163
  } & (IsAny<T> extends true ? WritableSignalStoreObject<T> : NonNullable<T> extends BaseType ? {
163
164
  readonly [LEAF]: () => boolean;
164
- } : NonNullable<T> extends Array<any> ? WritableArrayStore<NonNullable<T>> : WritableSignalStoreObject<T>);
165
+ } : NonNullable<T> extends any[] ? WritableArrayStore<NonNullable<T>> : WritableSignalStoreObject<T>);
165
166
  export type MutableSignalStore<T> = MutableSignal<UnwrapOpqaue<T>> & {
166
167
  readonly asReadonlyStore: () => SignalStore<T>;
167
168
  } & (IsAny<T> extends true ? MutableSignalStoreObject<T> : NonNullable<T> extends BaseType ? {
168
169
  readonly [LEAF]: () => boolean;
169
- } : NonNullable<T> extends Array<any> ? MutableArrayStore<NonNullable<T>> : MutableSignalStoreObject<T>);
170
+ } : NonNullable<T> extends any[] ? MutableArrayStore<NonNullable<T>> : MutableSignalStoreObject<T>);
170
171
  export declare function toStore<T extends AnyRecord>(source: MutableSignal<T>, injector?: Injector, vivify?: Vivify, noUnionLeaves?: boolean): MutableSignalStore<T>;
171
172
  export declare function toStore<T extends AnyRecord>(source: WritableSignal<T>, injector?: Injector, vivify?: Vivify, noUnionLeaves?: boolean): WritableSignalStore<T>;
172
173
  export declare function toStore<T extends AnyRecord>(source: Signal<T>, injector?: Injector, vivify?: Vivify, noUnionLeaves?: boolean): SignalStore<T>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mmstack/primitives",
3
- "version": "19.3.11",
3
+ "version": "19.4.0",
4
4
  "keywords": [
5
5
  "angular",
6
6
  "signals",