@mmstack/primitives 22.0.2 → 22.1.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/README.md +229 -1
- package/fesm2022/mmstack-primitives.mjs +739 -71
- package/fesm2022/mmstack-primitives.mjs.map +1 -1
- package/package.json +1 -1
- package/types/mmstack-primitives.d.ts +451 -11
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as i0 from '@angular/core';
|
|
2
|
-
import { isDevMode, inject, Injector, untracked, effect, DestroyRef, linkedSignal, computed, signal, isWritableSignal as isWritableSignal$
|
|
2
|
+
import { isDevMode, inject, Injector, untracked, effect, DestroyRef, linkedSignal, InjectionToken, TemplateRef, ViewContainerRef, input, computed, Directive, signal, PLATFORM_ID, runInInjectionContext, afterNextRender, Component, isWritableSignal as isWritableSignal$2, isSignal, ElementRef, Injectable } from '@angular/core';
|
|
3
3
|
import { isPlatformServer } from '@angular/common';
|
|
4
4
|
import { SIGNAL } from '@angular/core/primitives/signals';
|
|
5
5
|
|
|
@@ -212,6 +212,561 @@ function chunked(source, options) {
|
|
|
212
212
|
return internal.asReadonly();
|
|
213
213
|
}
|
|
214
214
|
|
|
215
|
+
/**
|
|
216
|
+
* Whether the subtree a resource/component lives in is currently PAUSED, for Activity / keep-alive.
|
|
217
|
+
* Provided by an Activity boundary (`MmActivity`, or the app-builder's per-branch injector) and read
|
|
218
|
+
* — only at instantiation — by anything that should pause its background work while paused (a resource
|
|
219
|
+
* returning its `paused` token, a `<video>` pausing playback, the pausable primitives, …). Absent
|
|
220
|
+
* unless an Activity boundary provides one — read it via `injectPaused()`, which falls back to a
|
|
221
|
+
* never-paused signal, so code that isn't inside an Activity boundary is unaffected.
|
|
222
|
+
*/
|
|
223
|
+
const PAUSED_CONTEXT = new InjectionToken('@mmstack/primitives:paused-context');
|
|
224
|
+
/**
|
|
225
|
+
* Keep-alive (the Angular analog of React's `<Activity>` / Vue's `<keep-alive>`): the wrapped
|
|
226
|
+
* subtree is mounted ONCE and kept — when `[mmActivity]` is false it's hidden (`display:none`) and
|
|
227
|
+
* its change detection is paused, preserving state (scroll, inputs, a video's position, loaded
|
|
228
|
+
* data); when true it's shown and CD resumes. It is never destroyed until the directive is.
|
|
229
|
+
*
|
|
230
|
+
* It also provides {@link PAUSED_CONTEXT} to the content (= the negation of `visible`), so descendants
|
|
231
|
+
* can pause *effect-driven* or *Observable* work while hidden (CD-detach alone pauses pull-based/template work, not
|
|
232
|
+
* effects/polling). If you're using the pausable primitives this is done automatically
|
|
233
|
+
*
|
|
234
|
+
* ```html
|
|
235
|
+
* <section *mmActivity="tab() === 'editor'"> ...heavy stateful editor... </section>
|
|
236
|
+
* ```
|
|
237
|
+
*/
|
|
238
|
+
class MmActivity {
|
|
239
|
+
tpl = inject(TemplateRef);
|
|
240
|
+
vcr = inject(ViewContainerRef);
|
|
241
|
+
parent = inject(Injector);
|
|
242
|
+
/** When false, keep the content mounted but hidden + CD-detached. */
|
|
243
|
+
visible = input.required({ ...(ngDevMode ? { debugName: "visible" } : /* istanbul ignore next */ {}), alias: 'mmActivity' });
|
|
244
|
+
/** Paused == not visible — handed to the kept subtree as PAUSED_CONTEXT. */
|
|
245
|
+
paused = computed(() => !this.visible(), /* @ts-ignore */
|
|
246
|
+
...(ngDevMode ? [{ debugName: "paused" }] : /* istanbul ignore next */ []));
|
|
247
|
+
view = null;
|
|
248
|
+
constructor() {
|
|
249
|
+
effect(() => {
|
|
250
|
+
const visible = this.visible();
|
|
251
|
+
untracked(() => this.apply(visible));
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
apply(visible) {
|
|
255
|
+
if (!this.view) {
|
|
256
|
+
// Created once, kept for the directive's lifetime. The content gets PAUSED_CONTEXT = !visible,
|
|
257
|
+
// so resources/components inside can pause their effect-driven work while hidden.
|
|
258
|
+
this.view = this.vcr.createEmbeddedView(this.tpl, {}, {
|
|
259
|
+
injector: Injector.create({
|
|
260
|
+
parent: this.parent,
|
|
261
|
+
providers: [providePaused(this.paused)],
|
|
262
|
+
}),
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
for (const node of this.view.rootNodes) {
|
|
266
|
+
if (node instanceof HTMLElement)
|
|
267
|
+
node.style.display = visible ? '' : 'none';
|
|
268
|
+
}
|
|
269
|
+
if (visible)
|
|
270
|
+
this.view.reattach();
|
|
271
|
+
else
|
|
272
|
+
this.view.detach();
|
|
273
|
+
}
|
|
274
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.0", ngImport: i0, type: MmActivity, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
275
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.0.0", type: MmActivity, isStandalone: true, selector: "[mmActivity]", inputs: { visible: { classPropertyName: "visible", publicName: "mmActivity", isSignal: true, isRequired: true, transformFunction: null } }, ngImport: i0 });
|
|
276
|
+
}
|
|
277
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.0", ngImport: i0, type: MmActivity, decorators: [{
|
|
278
|
+
type: Directive,
|
|
279
|
+
args: [{
|
|
280
|
+
selector: '[mmActivity]',
|
|
281
|
+
}]
|
|
282
|
+
}], ctorParameters: () => [], propDecorators: { visible: [{ type: i0.Input, args: [{ isSignal: true, alias: "mmActivity", required: true }] }] } });
|
|
283
|
+
// Shared never-paused signal returned outside a boundary / on the server (SSR renders the full tree,
|
|
284
|
+
// nothing is paused). Readonly so a consumer can't cast-and-`.set()` the shared default for everyone.
|
|
285
|
+
const NEVER_PAUSED = signal(false).asReadonly();
|
|
286
|
+
/**
|
|
287
|
+
* Inject the nearest paused-state signal — `true` while the surrounding subtree is paused (hidden by
|
|
288
|
+
* an Activity boundary). Defaults to a never-paused signal, so callers outside an Activity are
|
|
289
|
+
* unaffected; on the server it is always never-paused, so server-side work (e.g. connector fetches)
|
|
290
|
+
* isn't suppressed. This is the public way to read pause state; the underlying token is intentionally
|
|
291
|
+
* not exported.
|
|
292
|
+
*/
|
|
293
|
+
function injectPaused() {
|
|
294
|
+
if (isPlatformServer(inject(PLATFORM_ID, { optional: true }) ?? 'browser'))
|
|
295
|
+
return NEVER_PAUSED;
|
|
296
|
+
return inject(PAUSED_CONTEXT, { optional: true }) ?? NEVER_PAUSED;
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Build a provider that supplies a paused-state signal to a subtree — the public way to set up an
|
|
300
|
+
* Activity-style pause boundary (used by `MmActivity` and the app-builder's per-branch injectors).
|
|
301
|
+
*/
|
|
302
|
+
function providePaused(source) {
|
|
303
|
+
return { provide: PAUSED_CONTEXT, useValue: source };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Structural hold-and-swap as a signal. Given a `target` (the desired value — e.g. the
|
|
308
|
+
* subtree/def/key you want to show) and a `ready` predicate, returns a signal that keeps
|
|
309
|
+
* yielding its PREVIOUS value until `ready()` is true, then swaps to the current target.
|
|
310
|
+
*
|
|
311
|
+
* This is the structural counterpart to `keepPrevious`/`commit`: where those hold a *value*
|
|
312
|
+
* through a reload, this holds a *structure* through a swap. The caller mounts the incoming
|
|
313
|
+
* structure off to the side (so its resources can settle and flip `ready`), keeps showing the
|
|
314
|
+
* held previous structure meanwhile, and lets the old one go once `ready` releases the swap.
|
|
315
|
+
*
|
|
316
|
+
* The very first value passes straight through (nothing to hold yet).
|
|
317
|
+
*/
|
|
318
|
+
function holdUntilReady(target, ready) {
|
|
319
|
+
return linkedSignal({
|
|
320
|
+
source: () => ({ t: target(), ready: ready() }),
|
|
321
|
+
computation: (curr, prev) => (prev === undefined || curr.ready ? curr.t : prev.value),
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Resolve a {@link PauseOption} into a pause predicate, or `null` meaning "do not pause".
|
|
327
|
+
* `null` tells the caller to return the bare primitive — no wrapper is created.
|
|
328
|
+
*
|
|
329
|
+
* - omitted/`true` → the ambient {@link PAUSED_CONTEXT} if an Activity boundary provides one (via
|
|
330
|
+
* `opt.injector` or the current injection context), else `null` (the bare primitive, no allocation).
|
|
331
|
+
* The default, because an explicit `pausable*` call wants to be pausable. An explicit `pause: true`
|
|
332
|
+
* with no boundary dev-warns; the omitted default stays quiet. SSR → `null`.
|
|
333
|
+
* - a function → returned as-is (covers `Signal<boolean>`; usable outside an injection context).
|
|
334
|
+
* SSR → `null` here too, detected via `opt.injector` if given, else a `globalThis.window` probe.
|
|
335
|
+
* - `false` → `null` (the explicit opt-out).
|
|
336
|
+
*
|
|
337
|
+
* Encapsulating this here keeps every pausable primitive's branching identical and in one place.
|
|
338
|
+
*/
|
|
339
|
+
function resolvePause(opt) {
|
|
340
|
+
const explicit = opt?.pause; // distinguish explicit `true` from the omitted default
|
|
341
|
+
const pause = explicit ?? true; // explicit pausable* calls default to pausing
|
|
342
|
+
if (pause === false)
|
|
343
|
+
return null;
|
|
344
|
+
const run = (fn) => opt?.injector ? runInInjectionContext(opt.injector, fn) : fn();
|
|
345
|
+
const onServer = () => typeof pause === 'function' && !opt?.injector
|
|
346
|
+
? typeof globalThis.window === 'undefined'
|
|
347
|
+
: run(() => isPlatformServer(inject(PLATFORM_ID, { optional: true }) ?? 'browser'));
|
|
348
|
+
if (typeof pause === 'function')
|
|
349
|
+
return onServer() ? null : pause;
|
|
350
|
+
if (onServer())
|
|
351
|
+
return null;
|
|
352
|
+
const paused = run(() => inject(PAUSED_CONTEXT, { optional: true }));
|
|
353
|
+
if (!paused) {
|
|
354
|
+
if (explicit === true && isDevMode())
|
|
355
|
+
console.warn('[pausable] `pause: true` but no PAUSED_CONTEXT in scope — not pausing. Provide one via an ' +
|
|
356
|
+
'Activity boundary (`MmActivity` / `providePaused`), or pass a predicate / `pause: false`.');
|
|
357
|
+
return null;
|
|
358
|
+
}
|
|
359
|
+
return paused;
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Like {@link nestedEffect}, but pausable. While paused the effect does NOT run its body — and,
|
|
363
|
+
* crucially, it reads the pause predicate FIRST, so while paused its dependency set collapses to just
|
|
364
|
+
* the predicate (no churn from the real deps); on resume it re-runs and re-tracks. With no `pause`
|
|
365
|
+
* option it defaults to the ambient `PAUSED_CONTEXT`; `pause: false` makes it a plain `nestedEffect`
|
|
366
|
+
* with zero added overhead.
|
|
367
|
+
*/
|
|
368
|
+
function pausableEffect(effectFn, options) {
|
|
369
|
+
const paused = resolvePause(options);
|
|
370
|
+
if (!paused)
|
|
371
|
+
return nestedEffect(effectFn, options);
|
|
372
|
+
return nestedEffect((registerCleanup) => {
|
|
373
|
+
if (paused())
|
|
374
|
+
return; // read FIRST → while paused, deps collapse to just the predicate
|
|
375
|
+
effectFn(registerCleanup);
|
|
376
|
+
}, options);
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* Like `signal`, but pausable. While paused, READS hold the last value; writes still land on the
|
|
380
|
+
* underlying signal and surface on resume. Built on the `keepPrevious`/`hold` shape — a
|
|
381
|
+
* `linkedSignal` gated on the pause predicate, with `set`/`update`/`asReadonly` forwarded to the
|
|
382
|
+
* source signal. With no `pause` option it defaults to the ambient `PAUSED_CONTEXT`; `pause: false`
|
|
383
|
+
* makes it a plain `signal` — no `linkedSignal` is created.
|
|
384
|
+
*
|
|
385
|
+
* NOTE: while paused, `set(x)` followed by a read returns the *held* (pre-pause) value, not `x` — the
|
|
386
|
+
* write lands on the source and surfaces on resume. That is the "freeze the displayed value while
|
|
387
|
+
* hidden" semantics; do not rely on read-after-write while paused.
|
|
388
|
+
*/
|
|
389
|
+
function pausableSignal(initialValue, options) {
|
|
390
|
+
const paused = resolvePause(options);
|
|
391
|
+
const src = signal(initialValue, options);
|
|
392
|
+
if (!paused)
|
|
393
|
+
return src;
|
|
394
|
+
const read = linkedSignal({ ...(ngDevMode ? { debugName: "read" } : /* istanbul ignore next */ {}), source: () => ({ v: src(), paused: paused() }),
|
|
395
|
+
computation: (curr, prev) => prev !== undefined && curr.paused ? prev.value : curr.v,
|
|
396
|
+
equal: options?.equal });
|
|
397
|
+
read.set = src.set;
|
|
398
|
+
read.update = src.update;
|
|
399
|
+
read.asReadonly = src.asReadonly;
|
|
400
|
+
return read;
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Like `computed`, but pausable. While paused it holds its last value AND does not recompute: the
|
|
404
|
+
* computation's dependencies are not read while paused, so a dependency change can't trigger work —
|
|
405
|
+
* on resume it recomputes and re-tracks. The very first read always computes, to seed a value. With
|
|
406
|
+
* no `pause` option it defaults to the ambient `PAUSED_CONTEXT`; `pause: false` makes it a plain
|
|
407
|
+
* `computed`.
|
|
408
|
+
*/
|
|
409
|
+
function pausableComputed(computation, options) {
|
|
410
|
+
const paused = resolvePause(options);
|
|
411
|
+
if (!paused)
|
|
412
|
+
return computed(computation, options);
|
|
413
|
+
const HELD = Symbol('paused-hold');
|
|
414
|
+
const ls = linkedSignal({ ...(ngDevMode ? { debugName: "ls" } : /* istanbul ignore next */ {}), source: () => (paused() ? HELD : computation()),
|
|
415
|
+
computation: (next, prev) => next !== HELD ? next : prev !== undefined ? prev.value : computation(),
|
|
416
|
+
equal: options?.equal });
|
|
417
|
+
return ls.asReadonly();
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const { is } = Object;
|
|
421
|
+
function mutable(initial, opt) {
|
|
422
|
+
const baseEqual = opt?.equal ?? is;
|
|
423
|
+
let cnt = 0;
|
|
424
|
+
const equal = (a, b) => {
|
|
425
|
+
if (cnt > 0)
|
|
426
|
+
return false;
|
|
427
|
+
return baseEqual(a, b);
|
|
428
|
+
};
|
|
429
|
+
const sig = signal(initial, {
|
|
430
|
+
...opt,
|
|
431
|
+
equal,
|
|
432
|
+
});
|
|
433
|
+
const internalUpdate = sig.update;
|
|
434
|
+
sig.mutate = (updater) => {
|
|
435
|
+
cnt++;
|
|
436
|
+
internalUpdate(updater);
|
|
437
|
+
cnt--;
|
|
438
|
+
};
|
|
439
|
+
sig.inline = (updater) => {
|
|
440
|
+
sig.mutate((prev) => {
|
|
441
|
+
updater(prev);
|
|
442
|
+
return prev;
|
|
443
|
+
});
|
|
444
|
+
};
|
|
445
|
+
return sig;
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* Type guard function to check if a given `WritableSignal` is a `MutableSignal`. This is useful
|
|
449
|
+
* for situations where you need to conditionally use the `mutate` or `inline` methods.
|
|
450
|
+
*
|
|
451
|
+
* @typeParam T - The type of the signal's value (optional, defaults to `any`).
|
|
452
|
+
* @param value - The `WritableSignal` to check.
|
|
453
|
+
* @returns `true` if the signal is a `MutableSignal`, `false` otherwise.
|
|
454
|
+
*
|
|
455
|
+
* @example
|
|
456
|
+
* const mySignal = signal(0);
|
|
457
|
+
* const myMutableSignal = mutable(0);
|
|
458
|
+
*
|
|
459
|
+
* if (isMutable(mySignal)) {
|
|
460
|
+
* mySignal.mutate(x => x + 1); // This would cause a type error, as mySignal is not a MutableSignal.
|
|
461
|
+
* }
|
|
462
|
+
*
|
|
463
|
+
* if (isMutable(myMutableSignal)) {
|
|
464
|
+
* myMutableSignal.mutate(x => x + 1); // This is safe.
|
|
465
|
+
* }
|
|
466
|
+
*/
|
|
467
|
+
function isMutable(value) {
|
|
468
|
+
return 'mutate' in value && typeof value.mutate === 'function';
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function createTransitionScope() {
|
|
472
|
+
const list = mutable([]);
|
|
473
|
+
const pending = computed(() => list().some(({ ref }) => {
|
|
474
|
+
const s = ref.status();
|
|
475
|
+
return s === 'loading' || s === 'reloading';
|
|
476
|
+
}), /* @ts-ignore */
|
|
477
|
+
...(ngDevMode ? [{ debugName: "pending" }] : /* istanbul ignore next */ []));
|
|
478
|
+
const holdCount = signal(0, /* @ts-ignore */
|
|
479
|
+
...(ngDevMode ? [{ debugName: "holdCount" }] : /* istanbul ignore next */ []));
|
|
480
|
+
const holding = computed(() => holdCount() > 0, /* @ts-ignore */
|
|
481
|
+
...(ngDevMode ? [{ debugName: "holding" }] : /* istanbul ignore next */ []));
|
|
482
|
+
return {
|
|
483
|
+
resources: computed(() => list().map((e) => e.ref)),
|
|
484
|
+
pending,
|
|
485
|
+
suspended: (type) => list().some(({ ref, suspends }) => suspends && (type === 'loading' ? ref.isLoading() : !ref.hasValue())),
|
|
486
|
+
add: (ref, opt) => untracked(() => list.inline((c) => c.push({ ref, suspends: opt?.suspends ?? true }))),
|
|
487
|
+
remove: (ref) => untracked(() => list.inline((c) => {
|
|
488
|
+
const i = c.findIndex((e) => e.ref === ref);
|
|
489
|
+
if (i !== -1)
|
|
490
|
+
c.splice(i, 1);
|
|
491
|
+
})),
|
|
492
|
+
commit: (value) => linkedSignal({
|
|
493
|
+
source: () => ({ v: value(), settled: !pending() }),
|
|
494
|
+
computation: (curr, prev) => curr.settled || prev === undefined ? curr.v : prev.value,
|
|
495
|
+
}),
|
|
496
|
+
holding,
|
|
497
|
+
beginHold: () => untracked(() => holdCount.update((c) => c + 1)),
|
|
498
|
+
endHold: () => untracked(() => holdCount.update((c) => (c > 0 ? c - 1 : 0))),
|
|
499
|
+
hold: (value) => linkedSignal({
|
|
500
|
+
source: () => ({ v: value(), held: holding() }),
|
|
501
|
+
computation: (curr, prev) => prev !== undefined && curr.held ? prev.value : curr.v,
|
|
502
|
+
}),
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
function createNoopScope() {
|
|
506
|
+
return {
|
|
507
|
+
resources: computed(() => []),
|
|
508
|
+
pending: computed(() => false),
|
|
509
|
+
suspended: () => false,
|
|
510
|
+
add: () => {
|
|
511
|
+
// noop
|
|
512
|
+
},
|
|
513
|
+
remove: () => {
|
|
514
|
+
// noop
|
|
515
|
+
},
|
|
516
|
+
commit: (value) => value,
|
|
517
|
+
holding: computed(() => false),
|
|
518
|
+
beginHold: () => {
|
|
519
|
+
// noop
|
|
520
|
+
},
|
|
521
|
+
endHold: () => {
|
|
522
|
+
// noop
|
|
523
|
+
},
|
|
524
|
+
hold: (value) => value,
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
const TRANSITION_SCOPE = new InjectionToken('@mmstack/resource:transition-scope');
|
|
528
|
+
/** Provide a fresh transition scope at a boundary so its subtree's resources are tracked independently. */
|
|
529
|
+
function provideTransitionScope() {
|
|
530
|
+
return { provide: TRANSITION_SCOPE, useFactory: createTransitionScope };
|
|
531
|
+
}
|
|
532
|
+
function injectTransitionScope() {
|
|
533
|
+
const scope = inject(TRANSITION_SCOPE, { optional: true });
|
|
534
|
+
if (!scope) {
|
|
535
|
+
if (isDevMode())
|
|
536
|
+
console.warn('[mmstack/resource] No transition scope in context — registration/tracking here is a no-op. ' +
|
|
537
|
+
'Use a <mm-suspense> boundary or provideTransitionScope() in an ancestor.');
|
|
538
|
+
return createNoopScope();
|
|
539
|
+
}
|
|
540
|
+
return scope;
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* Returns a register function bound to the nearest transition scope: it adds a resource
|
|
544
|
+
* to the scope and removes it when the caller's injection context is destroyed. Pass any
|
|
545
|
+
* `ResourceRef` (a query, mutation, or plain Angular resource) through it.
|
|
546
|
+
*/
|
|
547
|
+
function injectRegisterResource() {
|
|
548
|
+
const scope = injectTransitionScope();
|
|
549
|
+
const destroyRef = inject(DestroyRef);
|
|
550
|
+
return (res, opt) => {
|
|
551
|
+
scope.add(res, opt);
|
|
552
|
+
destroyRef.onDestroy(() => scope.remove(res));
|
|
553
|
+
return res;
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
/** Convenience: register a resource with the nearest transition scope. Must run in an injection context. */
|
|
557
|
+
function registerResource(res, opt) {
|
|
558
|
+
return injectRegisterResource()(res, opt);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Returns a `startTransition(fn)` bound to the nearest transition scope. `fn` runs its state
|
|
563
|
+
* mutations (which commit immediately); any resource that reloads as a result holds its value
|
|
564
|
+
* (when `coordinate`/`commit`-wrapped) and reveals together once everything settles. The
|
|
565
|
+
* returned handle exposes a unified `pending` + `done` for the whole operation — for imperative
|
|
566
|
+
* coordination (disable a control, await completion) on top of the declarative hold-and-commit.
|
|
567
|
+
*
|
|
568
|
+
* Must be called in an injection context. This is the *async* generalization (Tier 2): it adds
|
|
569
|
+
* no rendering cost and needs no fork — holding direct/sync readers is a separate, deferred tier.
|
|
570
|
+
*/
|
|
571
|
+
function injectStartTransition() {
|
|
572
|
+
const scope = injectTransitionScope();
|
|
573
|
+
const injector = inject(Injector);
|
|
574
|
+
return (fn) => {
|
|
575
|
+
untracked(fn);
|
|
576
|
+
let sawPending = false;
|
|
577
|
+
const done = new Promise((resolve) => {
|
|
578
|
+
const watcher = effect(() => {
|
|
579
|
+
const p = scope.pending();
|
|
580
|
+
if (p)
|
|
581
|
+
sawPending = true;
|
|
582
|
+
// settle: requests went in flight and then drained
|
|
583
|
+
if (sawPending && !p) {
|
|
584
|
+
watcher.destroy();
|
|
585
|
+
resolve();
|
|
586
|
+
}
|
|
587
|
+
}, { ...(ngDevMode ? { debugName: "watcher" } : /* istanbul ignore next */ {}), injector });
|
|
588
|
+
// no-async fallback: once the reactive system has processed the writes (afterNextRender),
|
|
589
|
+
// if nothing ever went in flight, the transition is already complete.
|
|
590
|
+
afterNextRender(() => {
|
|
591
|
+
if (!sawPending && !untracked(scope.pending)) {
|
|
592
|
+
watcher.destroy();
|
|
593
|
+
resolve();
|
|
594
|
+
}
|
|
595
|
+
}, { injector });
|
|
596
|
+
});
|
|
597
|
+
return { pending: scope.pending, done };
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* Shared **suspense** (readiness) boundary behaviour: reads the *nearest* transition scope and exposes
|
|
603
|
+
* its `pending`/`suspended` state. This is the readiness gate — distinct from the hold-stale *swap*
|
|
604
|
+
* primitives (`TransitionRouterOutlet`, `ab-transition`), which are the actual "transitions". The two
|
|
605
|
+
* concrete components below differ only by whether they provide their own scope, so the logic (and
|
|
606
|
+
* template) live here once.
|
|
607
|
+
*
|
|
608
|
+
* - **First load** (`suspended()`): no value yet → show the `[placeholder]` fallback.
|
|
609
|
+
* - **Reload** (`pending()` but a value is held via `keepPrevious`): keep the real content mounted and
|
|
610
|
+
* surface a busy indicator (`aria-busy`, and an optional `[busy]` slot) instead of flashing back to
|
|
611
|
+
* the placeholder.
|
|
612
|
+
*
|
|
613
|
+
* `type` selects what "not ready" means: `'value'` (default) suspends only until a first value lands
|
|
614
|
+
* then holds through reloads; `'loading'` suspends on every in-flight load (strict suspense).
|
|
615
|
+
*/
|
|
616
|
+
class SuspenseBoundaryBase {
|
|
617
|
+
scope = injectTransitionScope();
|
|
618
|
+
/** What counts as "not ready" for the first-load placeholder. Defaults to value-presence. */
|
|
619
|
+
type = input('value', /* @ts-ignore */
|
|
620
|
+
...(ngDevMode ? [{ debugName: "type" }] : /* istanbul ignore next */ []));
|
|
621
|
+
pending = this.scope.pending;
|
|
622
|
+
suspended = computed(() => this.scope.suspended(this.type()), /* @ts-ignore */
|
|
623
|
+
...(ngDevMode ? [{ debugName: "suspended" }] : /* istanbul ignore next */ []));
|
|
624
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.0", ngImport: i0, type: SuspenseBoundaryBase, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
625
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.0.0", type: SuspenseBoundaryBase, isStandalone: true, inputs: { type: { classPropertyName: "type", publicName: "type", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0 });
|
|
626
|
+
}
|
|
627
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.0", ngImport: i0, type: SuspenseBoundaryBase, decorators: [{
|
|
628
|
+
type: Directive
|
|
629
|
+
}], propDecorators: { type: [{ type: i0.Input, args: [{ isSignal: true, alias: "type", required: false }] }] } });
|
|
630
|
+
const SUSPENSE_TEMPLATE = `
|
|
631
|
+
@if (suspended()) {
|
|
632
|
+
<ng-content select="[placeholder]"><span>Loading…</span></ng-content>
|
|
633
|
+
} @else {
|
|
634
|
+
@if (pending()) {
|
|
635
|
+
<ng-content select="[busy]" />
|
|
636
|
+
}
|
|
637
|
+
<ng-content />
|
|
638
|
+
}
|
|
639
|
+
`;
|
|
640
|
+
// `display: contents` so the boundary adds no box of its own.
|
|
641
|
+
const SUSPENSE_STYLES = `
|
|
642
|
+
:host {
|
|
643
|
+
display: contents;
|
|
644
|
+
}
|
|
645
|
+
`;
|
|
646
|
+
const SUSPENSE_HOST = {
|
|
647
|
+
'[attr.aria-busy]': 'pending() ? true : null',
|
|
648
|
+
};
|
|
649
|
+
/**
|
|
650
|
+
* Standalone suspense boundary — **provides its own scope**, so dropping a `<mm-suspense>` anywhere
|
|
651
|
+
* just works: the resources created in its subtree register into it without any extra
|
|
652
|
+
* `provideTransitionScope()`. The common case.
|
|
653
|
+
*/
|
|
654
|
+
class SuspenseBoundary extends SuspenseBoundaryBase {
|
|
655
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.0", ngImport: i0, type: SuspenseBoundary, deps: null, target: i0.ɵɵFactoryTarget.Component });
|
|
656
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.0", type: SuspenseBoundary, isStandalone: true, selector: "mm-suspense", host: { properties: { "attr.aria-busy": "pending() ? true : null" } }, providers: [provideTransitionScope()], usesInheritance: true, ngImport: i0, template: "\n @if (suspended()) {\n <ng-content select=\"[placeholder]\"><span>Loading\u2026</span></ng-content>\n } @else {\n @if (pending()) {\n <ng-content select=\"[busy]\" />\n }\n <ng-content />\n }\n", isInline: true, styles: [":host{display:contents}\n"] });
|
|
657
|
+
}
|
|
658
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.0", ngImport: i0, type: SuspenseBoundary, decorators: [{
|
|
659
|
+
type: Component,
|
|
660
|
+
args: [{ selector: 'mm-suspense', template: SUSPENSE_TEMPLATE, host: SUSPENSE_HOST, providers: [provideTransitionScope()], styles: [":host{display:contents}\n"] }]
|
|
661
|
+
}] });
|
|
662
|
+
/**
|
|
663
|
+
* Unscoped suspense boundary — **reads the ambient scope** instead of providing one. For cases where
|
|
664
|
+
* the resources to coordinate are registered *above* the boundary (e.g. an app-builder page whose
|
|
665
|
+
* manifests/connectors register at a higher injector), so the boundary observes that outer scope
|
|
666
|
+
* rather than opening a fresh one. Pair with a `provideTransitionScope()` (or another boundary) in an
|
|
667
|
+
* ancestor.
|
|
668
|
+
*/
|
|
669
|
+
class UnscopedSuspenseBoundary extends SuspenseBoundaryBase {
|
|
670
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.0", ngImport: i0, type: UnscopedSuspenseBoundary, deps: null, target: i0.ɵɵFactoryTarget.Component });
|
|
671
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.0", type: UnscopedSuspenseBoundary, isStandalone: true, selector: "mm-unscoped-suspense", host: { properties: { "attr.aria-busy": "pending() ? true : null" } }, usesInheritance: true, ngImport: i0, template: "\n @if (suspended()) {\n <ng-content select=\"[placeholder]\"><span>Loading\u2026</span></ng-content>\n } @else {\n @if (pending()) {\n <ng-content select=\"[busy]\" />\n }\n <ng-content />\n }\n", isInline: true, styles: [":host{display:contents}\n"] });
|
|
672
|
+
}
|
|
673
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.0", ngImport: i0, type: UnscopedSuspenseBoundary, decorators: [{
|
|
674
|
+
type: Component,
|
|
675
|
+
args: [{ selector: 'mm-unscoped-suspense', template: SUSPENSE_TEMPLATE, host: SUSPENSE_HOST, styles: [":host{display:contents}\n"] }]
|
|
676
|
+
}] });
|
|
677
|
+
|
|
678
|
+
function createTransaction() {
|
|
679
|
+
const log = new Map();
|
|
680
|
+
return {
|
|
681
|
+
record: (sig) => {
|
|
682
|
+
if (!log.has(sig))
|
|
683
|
+
log.set(sig, untracked(sig));
|
|
684
|
+
},
|
|
685
|
+
restore: () => untracked(() => {
|
|
686
|
+
for (const [sig, old] of log)
|
|
687
|
+
sig.set(old);
|
|
688
|
+
log.clear();
|
|
689
|
+
}),
|
|
690
|
+
clear: () => log.clear(),
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
// The currently-active transaction, set only for the synchronous duration of a `startTransaction`
|
|
694
|
+
// body (so stateful actions running inside it can record their writes). Module-level + sync
|
|
695
|
+
// set/reset is the honest shape: a transaction is call-scoped, not structural-per-injector.
|
|
696
|
+
let active = null;
|
|
697
|
+
/** The transaction in effect right now, or `null`. Stateful actions consult this to record undo. */
|
|
698
|
+
function activeTransaction() {
|
|
699
|
+
return active;
|
|
700
|
+
}
|
|
701
|
+
function runInTransaction(txn, fn) {
|
|
702
|
+
const prev = active;
|
|
703
|
+
active = txn;
|
|
704
|
+
try {
|
|
705
|
+
untracked(fn);
|
|
706
|
+
}
|
|
707
|
+
finally {
|
|
708
|
+
active = prev;
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
/**
|
|
712
|
+
* Returns a `startTransaction(fn)` bound to the nearest transition scope — the Tier 3 sibling of
|
|
713
|
+
* `injectStartTransition`. It HOLDS the scope's synchronous display reads from before `fn` runs
|
|
714
|
+
* (so a state write inside `fn` doesn't flash through), records those writes in an undo log, then:
|
|
715
|
+
* - on settle (the scope's resources go in flight and drain) → release the hold + keep the writes;
|
|
716
|
+
* - on `abort()` → roll the writes back and release the hold.
|
|
717
|
+
*
|
|
718
|
+
* The writes land on LIVE state immediately (so derived variables and connector requests see the
|
|
719
|
+
* new values and refetch); only the *display* is held, via `scope.hold`. Must run in an injection
|
|
720
|
+
* context.
|
|
721
|
+
*/
|
|
722
|
+
function injectStartTransaction() {
|
|
723
|
+
const scope = injectTransitionScope();
|
|
724
|
+
const injector = inject(Injector);
|
|
725
|
+
return (fn) => {
|
|
726
|
+
const txn = createTransaction();
|
|
727
|
+
// Hold BEFORE the writes, so the display freezes at pre-transaction values.
|
|
728
|
+
scope.beginHold();
|
|
729
|
+
let finished = false;
|
|
730
|
+
let watcher;
|
|
731
|
+
const finish = (restore) => {
|
|
732
|
+
if (finished)
|
|
733
|
+
return;
|
|
734
|
+
finished = true;
|
|
735
|
+
watcher?.destroy();
|
|
736
|
+
if (restore)
|
|
737
|
+
txn.restore();
|
|
738
|
+
else
|
|
739
|
+
txn.clear();
|
|
740
|
+
scope.endHold();
|
|
741
|
+
};
|
|
742
|
+
runInTransaction(txn, fn);
|
|
743
|
+
let sawPending = false;
|
|
744
|
+
const done = new Promise((resolve) => {
|
|
745
|
+
watcher = effect(() => {
|
|
746
|
+
const p = scope.pending();
|
|
747
|
+
if (p)
|
|
748
|
+
sawPending = true;
|
|
749
|
+
if (sawPending && !p) {
|
|
750
|
+
finish(false);
|
|
751
|
+
resolve();
|
|
752
|
+
}
|
|
753
|
+
}, { injector });
|
|
754
|
+
// no-async fallback: if nothing ever went in flight, settle once the writes are processed.
|
|
755
|
+
afterNextRender(() => {
|
|
756
|
+
if (!sawPending && !untracked(scope.pending)) {
|
|
757
|
+
finish(false);
|
|
758
|
+
resolve();
|
|
759
|
+
}
|
|
760
|
+
}, { injector });
|
|
761
|
+
});
|
|
762
|
+
return {
|
|
763
|
+
pending: scope.pending,
|
|
764
|
+
done,
|
|
765
|
+
abort: () => finish(true),
|
|
766
|
+
};
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
|
|
215
770
|
/**
|
|
216
771
|
* Converts a read-only `Signal` into a `WritableSignal` by providing custom `set` and, optionally, `update` functions.
|
|
217
772
|
* This can be useful for creating controlled write access to a signal that is otherwise read-only.
|
|
@@ -347,57 +902,6 @@ function debounce(source, opt) {
|
|
|
347
902
|
return writable;
|
|
348
903
|
}
|
|
349
904
|
|
|
350
|
-
const { is } = Object;
|
|
351
|
-
function mutable(initial, opt) {
|
|
352
|
-
const baseEqual = opt?.equal ?? is;
|
|
353
|
-
let cnt = 0;
|
|
354
|
-
const equal = (a, b) => {
|
|
355
|
-
if (cnt > 0)
|
|
356
|
-
return false;
|
|
357
|
-
return baseEqual(a, b);
|
|
358
|
-
};
|
|
359
|
-
const sig = signal(initial, {
|
|
360
|
-
...opt,
|
|
361
|
-
equal,
|
|
362
|
-
});
|
|
363
|
-
const internalUpdate = sig.update;
|
|
364
|
-
sig.mutate = (updater) => {
|
|
365
|
-
cnt++;
|
|
366
|
-
internalUpdate(updater);
|
|
367
|
-
cnt--;
|
|
368
|
-
};
|
|
369
|
-
sig.inline = (updater) => {
|
|
370
|
-
sig.mutate((prev) => {
|
|
371
|
-
updater(prev);
|
|
372
|
-
return prev;
|
|
373
|
-
});
|
|
374
|
-
};
|
|
375
|
-
return sig;
|
|
376
|
-
}
|
|
377
|
-
/**
|
|
378
|
-
* Type guard function to check if a given `WritableSignal` is a `MutableSignal`. This is useful
|
|
379
|
-
* for situations where you need to conditionally use the `mutate` or `inline` methods.
|
|
380
|
-
*
|
|
381
|
-
* @typeParam T - The type of the signal's value (optional, defaults to `any`).
|
|
382
|
-
* @param value - The `WritableSignal` to check.
|
|
383
|
-
* @returns `true` if the signal is a `MutableSignal`, `false` otherwise.
|
|
384
|
-
*
|
|
385
|
-
* @example
|
|
386
|
-
* const mySignal = signal(0);
|
|
387
|
-
* const myMutableSignal = mutable(0);
|
|
388
|
-
*
|
|
389
|
-
* if (isMutable(mySignal)) {
|
|
390
|
-
* mySignal.mutate(x => x + 1); // This would cause a type error, as mySignal is not a MutableSignal.
|
|
391
|
-
* }
|
|
392
|
-
*
|
|
393
|
-
* if (isMutable(myMutableSignal)) {
|
|
394
|
-
* myMutableSignal.mutate(x => x + 1); // This is safe.
|
|
395
|
-
* }
|
|
396
|
-
*/
|
|
397
|
-
function isMutable(value) {
|
|
398
|
-
return 'mutate' in value && typeof value.mutate === 'function';
|
|
399
|
-
}
|
|
400
|
-
|
|
401
905
|
/**
|
|
402
906
|
* @internal
|
|
403
907
|
* Type guard for an array-index-like property key: a non-empty string that parses to a finite
|
|
@@ -621,8 +1125,27 @@ function isDerivation(sig) {
|
|
|
621
1125
|
return 'from' in sig;
|
|
622
1126
|
}
|
|
623
1127
|
|
|
624
|
-
function
|
|
625
|
-
|
|
1128
|
+
function keepPrevious(src, opt) {
|
|
1129
|
+
const persisted = linkedSignal({ ...(ngDevMode ? { debugName: "persisted" } : /* istanbul ignore next */ {}), ...opt,
|
|
1130
|
+
source: () => src(),
|
|
1131
|
+
computation: (next, prev) => next === undefined && prev !== undefined ? prev.value : next });
|
|
1132
|
+
if (isWritableSignal$2(src)) {
|
|
1133
|
+
persisted.set = src.set;
|
|
1134
|
+
persisted.update = src.update;
|
|
1135
|
+
persisted.asReadonly = src.asReadonly;
|
|
1136
|
+
if (isMutable(src)) {
|
|
1137
|
+
persisted.mutate = src.mutate;
|
|
1138
|
+
persisted.inline = src.inline;
|
|
1139
|
+
}
|
|
1140
|
+
if (isDerivation(src)) {
|
|
1141
|
+
persisted.from = src.from;
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
return persisted;
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
function isWritableSignal$1(value) {
|
|
1148
|
+
return isWritableSignal$2(value);
|
|
626
1149
|
}
|
|
627
1150
|
/**
|
|
628
1151
|
* @internal
|
|
@@ -631,7 +1154,7 @@ function isWritableSignal(value) {
|
|
|
631
1154
|
* @returns
|
|
632
1155
|
*/
|
|
633
1156
|
function createSetter(source) {
|
|
634
|
-
if (!isWritableSignal(source))
|
|
1157
|
+
if (!isWritableSignal$1(source))
|
|
635
1158
|
return () => {
|
|
636
1159
|
// noop;
|
|
637
1160
|
};
|
|
@@ -665,12 +1188,12 @@ function indexArray(source, map, opt = {}) {
|
|
|
665
1188
|
const len = computed(() => data().length, /* @ts-ignore */
|
|
666
1189
|
...(ngDevMode ? [{ debugName: "len" }] : /* istanbul ignore next */ []));
|
|
667
1190
|
const setter = createSetter(data);
|
|
668
|
-
const writableData = isWritableSignal(data)
|
|
1191
|
+
const writableData = isWritableSignal$1(data)
|
|
669
1192
|
? data
|
|
670
1193
|
: toWritable(data, () => {
|
|
671
1194
|
// noop
|
|
672
1195
|
});
|
|
673
|
-
if (isWritableSignal(data) && isMutable(data) && !opt.equal) {
|
|
1196
|
+
if (isWritableSignal$1(data) && isMutable(data) && !opt.equal) {
|
|
674
1197
|
opt.equal = (a, b) => {
|
|
675
1198
|
if (typeof a !== typeof b)
|
|
676
1199
|
return false;
|
|
@@ -882,7 +1405,7 @@ function pooledKeys(src) {
|
|
|
882
1405
|
}
|
|
883
1406
|
function mapObject(source, mapFn, options = {}) {
|
|
884
1407
|
const src = isSignal(source) ? source : computed(source);
|
|
885
|
-
const writable = (isWritableSignal(src)
|
|
1408
|
+
const writable = (isWritableSignal$1(src)
|
|
886
1409
|
? src
|
|
887
1410
|
: toWritable(src, () => {
|
|
888
1411
|
// noop
|
|
@@ -2418,6 +2941,9 @@ function signalFromEvent(target, eventName, initial, projectOrOpt, maybeOpt) {
|
|
|
2418
2941
|
return untracked(() => state.asReadonly());
|
|
2419
2942
|
}
|
|
2420
2943
|
|
|
2944
|
+
function isWritableSignal(value) {
|
|
2945
|
+
return isWritableSignal$2(value);
|
|
2946
|
+
}
|
|
2421
2947
|
/**
|
|
2422
2948
|
* Runtime marker + compile-time brand for an opaque value. A `const`-declared `Symbol`
|
|
2423
2949
|
* has a `unique symbol` type, so the same symbol serves as both the property key written
|
|
@@ -2459,6 +2985,82 @@ function isOpaque(value) {
|
|
|
2459
2985
|
value !== null &&
|
|
2460
2986
|
value[OPAQUE] === true);
|
|
2461
2987
|
}
|
|
2988
|
+
/**
|
|
2989
|
+
* @internal Runtime brand carrying a store node's lazily-built leaf probe. Exported (like
|
|
2990
|
+
* {@link OPAQUE}) only so the `{ readonly [LEAF]: () => boolean }` brand on the store types is
|
|
2991
|
+
* nameable in the emitted declarations — not part of the supported surface; use {@link isLeaf}.
|
|
2992
|
+
*/
|
|
2993
|
+
const LEAF = Symbol('@mmstack/primitives::store/LEAF');
|
|
2994
|
+
/**
|
|
2995
|
+
* @internal Whether a value is a terminal leaf: a concrete non-record/non-array value always is;
|
|
2996
|
+
* `null`/`undefined` is a leaf only when vivification is disabled (with vivify on it can still
|
|
2997
|
+
* materialize a container, so it stays a descendable substore).
|
|
2998
|
+
*/
|
|
2999
|
+
function isLeafValue(value, vivifyEnabled) {
|
|
3000
|
+
if (value == null)
|
|
3001
|
+
return !vivifyEnabled;
|
|
3002
|
+
if (isOpaque(value))
|
|
3003
|
+
return true; // opaque always wins — even arrays
|
|
3004
|
+
return !Array.isArray(value) && !isRecord(value);
|
|
3005
|
+
}
|
|
3006
|
+
/**
|
|
3007
|
+
* @internal Constant leaf probes for nodes whose leaf-ness is statically known, so the reactive
|
|
3008
|
+
* `computed` can be skipped entirely.
|
|
3009
|
+
*/
|
|
3010
|
+
function alwaysTrue() {
|
|
3011
|
+
return true;
|
|
3012
|
+
}
|
|
3013
|
+
function alwaysFalse() {
|
|
3014
|
+
return false;
|
|
3015
|
+
}
|
|
3016
|
+
/**
|
|
3017
|
+
* @internal Attaches a lazy, memoized leaf probe to a store node. The probe (`() => boolean`)
|
|
3018
|
+
* closes over the node's value signal and its (stable) vivify setting, building the backing
|
|
3019
|
+
* `computed` on first call so leaf-ness tracks the live value reactively without taxing every
|
|
3020
|
+
* node access. Idempotent.
|
|
3021
|
+
*/
|
|
3022
|
+
function markAsLeaf(sig, value, vivifyEnabled, noUnionLeaves) {
|
|
3023
|
+
if (typeof sig[LEAF] !== 'function') {
|
|
3024
|
+
let memo;
|
|
3025
|
+
const probe = () => {
|
|
3026
|
+
if (memo)
|
|
3027
|
+
return memo();
|
|
3028
|
+
const v = untracked(value);
|
|
3029
|
+
memo =
|
|
3030
|
+
isOpaque(v) || (v == null && !vivifyEnabled) || noUnionLeaves
|
|
3031
|
+
? isLeafValue(v, vivifyEnabled)
|
|
3032
|
+
? alwaysTrue
|
|
3033
|
+
: alwaysFalse
|
|
3034
|
+
: computed(() => isLeafValue(value(), vivifyEnabled));
|
|
3035
|
+
return memo();
|
|
3036
|
+
};
|
|
3037
|
+
Object.defineProperty(sig, LEAF, {
|
|
3038
|
+
value: probe,
|
|
3039
|
+
enumerable: false,
|
|
3040
|
+
configurable: true,
|
|
3041
|
+
});
|
|
3042
|
+
}
|
|
3043
|
+
return sig;
|
|
3044
|
+
}
|
|
3045
|
+
/**
|
|
3046
|
+
* Reports whether a store node is currently a **leaf** — a terminal value the store does not
|
|
3047
|
+
* descend into (a primitive, `Date`, `RegExp`, {@link opaque} object, class instance, or a
|
|
3048
|
+
* `null`/`undefined` hole when vivification is off) rather than a record/array substore.
|
|
3049
|
+
*
|
|
3050
|
+
* Leaf-ness reflects the node's **live** value: the probe is reactive and memoized, so calling
|
|
3051
|
+
* `isLeaf` inside a `computed`/`effect` re-evaluates when the node's shape changes.
|
|
3052
|
+
*
|
|
3053
|
+
* @internal Exposed for advanced/niche interop only — not part of the supported public surface
|
|
3054
|
+
* and may change without a major version bump.
|
|
3055
|
+
*
|
|
3056
|
+
* @example
|
|
3057
|
+
* const s = store({ name: 'Ada', address: { city: 'London' } });
|
|
3058
|
+
* isLeaf(s.name); // true
|
|
3059
|
+
* isLeaf(s.address); // false — a substore
|
|
3060
|
+
*/
|
|
3061
|
+
function isLeaf(value) {
|
|
3062
|
+
return isStore(value) && value[LEAF]?.() === true;
|
|
3063
|
+
}
|
|
2462
3064
|
const IS_STORE = Symbol('@mmstack/primitives::store/IS_STORE');
|
|
2463
3065
|
const SCOPE_PARENT = Symbol('@mmstack/primitives::store/SCOPE_PARENT');
|
|
2464
3066
|
/**
|
|
@@ -2521,7 +3123,7 @@ function hasOwnKey(value, key) {
|
|
|
2521
3123
|
* @internal
|
|
2522
3124
|
* Makes an array store
|
|
2523
3125
|
*/
|
|
2524
|
-
function toArrayStore(source, injector, vivify) {
|
|
3126
|
+
function toArrayStore(source, injector, vivify, noUnionLeaves = false) {
|
|
2525
3127
|
if (isStore(source))
|
|
2526
3128
|
return source;
|
|
2527
3129
|
const isMutableSource = isMutable(source);
|
|
@@ -2632,9 +3234,10 @@ function toArrayStore(source, injector, vivify) {
|
|
|
2632
3234
|
});
|
|
2633
3235
|
const childSample = untracked(computation);
|
|
2634
3236
|
const childVivify = resolveVivify(childSample, vivify);
|
|
2635
|
-
const proxy = Array.isArray(childSample)
|
|
2636
|
-
? toArrayStore(computation, injector, childVivify)
|
|
2637
|
-
: toStore(computation, injector, childVivify);
|
|
3237
|
+
const proxy = Array.isArray(childSample) && !isOpaque(childSample)
|
|
3238
|
+
? toArrayStore(computation, injector, childVivify, noUnionLeaves)
|
|
3239
|
+
: toStore(computation, injector, childVivify, noUnionLeaves);
|
|
3240
|
+
markAsLeaf(proxy, computation, childVivify !== false, noUnionLeaves);
|
|
2638
3241
|
const ref = new WeakRef(proxy);
|
|
2639
3242
|
storeCache.set(idx, ref);
|
|
2640
3243
|
PROXY_CLEANUP.register(proxy, { target, prop: idx }, ref);
|
|
@@ -2651,7 +3254,7 @@ function toArrayStore(source, injector, vivify) {
|
|
|
2651
3254
|
* const state = store({ user: { name: 'John' } });
|
|
2652
3255
|
* const nameSignal = state.user.name; // WritableSignal<string>
|
|
2653
3256
|
*/
|
|
2654
|
-
function toStore(source, injector, vivify = false) {
|
|
3257
|
+
function toStore(source, injector, vivify = false, noUnionLeaves = false) {
|
|
2655
3258
|
if (isStore(source))
|
|
2656
3259
|
return source;
|
|
2657
3260
|
if (!injector)
|
|
@@ -2692,7 +3295,7 @@ function toStore(source, injector, vivify = false) {
|
|
|
2692
3295
|
return () => {
|
|
2693
3296
|
if (!isWritableSource)
|
|
2694
3297
|
return s;
|
|
2695
|
-
return untracked(() => toStore(source.asReadonly(), injector, vivify));
|
|
3298
|
+
return untracked(() => toStore(source.asReadonly(), injector, vivify, noUnionLeaves));
|
|
2696
3299
|
};
|
|
2697
3300
|
if (prop === 'extend')
|
|
2698
3301
|
return (seed) => scopedStore(s, seed, isMutableSource
|
|
@@ -2745,9 +3348,10 @@ function toStore(source, injector, vivify = false) {
|
|
|
2745
3348
|
});
|
|
2746
3349
|
const childSample = untracked(computation);
|
|
2747
3350
|
const childVivify = resolveVivify(childSample, vivify);
|
|
2748
|
-
const proxy = Array.isArray(childSample)
|
|
2749
|
-
? toArrayStore(computation, injector, childVivify)
|
|
2750
|
-
: toStore(computation, injector, childVivify);
|
|
3351
|
+
const proxy = Array.isArray(childSample) && !isOpaque(childSample)
|
|
3352
|
+
? toArrayStore(computation, injector, childVivify, noUnionLeaves)
|
|
3353
|
+
: toStore(computation, injector, childVivify, noUnionLeaves);
|
|
3354
|
+
markAsLeaf(proxy, computation, childVivify !== false, noUnionLeaves);
|
|
2751
3355
|
const ref = new WeakRef(proxy);
|
|
2752
3356
|
storeCache.set(prop, ref);
|
|
2753
3357
|
PROXY_CLEANUP.register(proxy, { target, prop }, ref);
|
|
@@ -2792,7 +3396,9 @@ function scopedStore(parent, seed, kind, injector) {
|
|
|
2792
3396
|
layer[key].set(next[key]);
|
|
2793
3397
|
}
|
|
2794
3398
|
};
|
|
2795
|
-
const base = toWritable(view, kind === 'readonly' ? () => undefined : splitSet, undefined, {
|
|
3399
|
+
const base = toWritable(view, kind === 'readonly' ? () => undefined : splitSet, undefined, {
|
|
3400
|
+
pure: false,
|
|
3401
|
+
});
|
|
2796
3402
|
if (kind === 'mutable') {
|
|
2797
3403
|
base.mutate = (updater) => splitSet(updater(untracked(view)));
|
|
2798
3404
|
base.inline = (updater) => base.mutate((prev) => {
|
|
@@ -2841,14 +3447,76 @@ function scopedStore(parent, seed, kind, injector) {
|
|
|
2841
3447
|
* @see {@link toStore}
|
|
2842
3448
|
*/
|
|
2843
3449
|
function store(value, opt) {
|
|
2844
|
-
return toStore(signal(value, opt), opt?.injector, opt?.vivify ?? false);
|
|
3450
|
+
return toStore(signal(value, opt), opt?.injector, opt?.vivify ?? false, opt?.noUnionLeaves ?? false);
|
|
2845
3451
|
}
|
|
2846
3452
|
/**
|
|
2847
3453
|
* Creates a MutableSignalStore from a value.
|
|
2848
3454
|
* @see {@link toStore}
|
|
2849
3455
|
*/
|
|
2850
3456
|
function mutableStore(value, opt) {
|
|
2851
|
-
return toStore(mutable(value, opt), opt?.injector, opt?.vivify ?? false);
|
|
3457
|
+
return toStore(mutable(value, opt), opt?.injector, opt?.vivify ?? false, opt?.noUnionLeaves ?? false);
|
|
3458
|
+
}
|
|
3459
|
+
|
|
3460
|
+
function isPlainRecord(value) {
|
|
3461
|
+
if (value === null || typeof value !== 'object')
|
|
3462
|
+
return false;
|
|
3463
|
+
const proto = Object.getPrototypeOf(value);
|
|
3464
|
+
return proto === Object.prototype || proto === null;
|
|
3465
|
+
}
|
|
3466
|
+
/**
|
|
3467
|
+
* Per-path 3-way merge. Reference-equality short-circuits do the work: a subtree the fork never
|
|
3468
|
+
* touched satisfies `mine === ancestor` (structural sharing keeps its identity) → take the live
|
|
3469
|
+
* base; a subtree the base never changed satisfies `theirs === ancestor` → keep the fork's. So it
|
|
3470
|
+
* only deep-walks paths that BOTH sides changed, and on a leaf/array conflict the fork wins.
|
|
3471
|
+
* Arrays are treated atomically (no positional merge — index shifts make that unsafe); supply a
|
|
3472
|
+
* {@link ReconcileFn} for array-aware merging.
|
|
3473
|
+
*
|
|
3474
|
+
* CONTRACT: "unchanged" is detected by REFERENCE identity, not deep equality. `mine` must be a
|
|
3475
|
+
* copy-on-write derivative of `ancestor` — i.e. untouched nodes keep their reference — which the
|
|
3476
|
+
* fork guarantees because writes flow through `toStore` (it rebuilds only the edited path and
|
|
3477
|
+
* shares everything else). Feed it a structurally-equal-but-fresh-reference node for an untouched
|
|
3478
|
+
* path and it will treat that node as edited (recursion/leaf-value checks usually still reconcile,
|
|
3479
|
+
* but a fresh-ref clean node vs a base type-change resolves to the fork's stale value). Primitive
|
|
3480
|
+
* leaves compare by value, so equal primitives are correctly seen as unchanged.
|
|
3481
|
+
*/
|
|
3482
|
+
function merge3(ancestor, mine, theirs) {
|
|
3483
|
+
if (Object.is(mine, theirs) || Object.is(mine, ancestor))
|
|
3484
|
+
return theirs; // unedited → live base
|
|
3485
|
+
if (Object.is(theirs, ancestor))
|
|
3486
|
+
return mine; // base unchanged here → keep the fork's edit
|
|
3487
|
+
if (isPlainRecord(mine) && isPlainRecord(theirs) && isPlainRecord(ancestor)) {
|
|
3488
|
+
const out = { ...theirs };
|
|
3489
|
+
for (const key of new Set([...Object.keys(mine), ...Object.keys(theirs)])) {
|
|
3490
|
+
out[key] = merge3(ancestor[key], mine[key], theirs[key]);
|
|
3491
|
+
}
|
|
3492
|
+
return out;
|
|
3493
|
+
}
|
|
3494
|
+
return mine; // leaf / array / type-mismatch conflict → local wins
|
|
3495
|
+
}
|
|
3496
|
+
function forkStore(base, opt) {
|
|
3497
|
+
// A mutable base mutates in place, so its value reference is stable across changes — which defeats merge3's identity-based change detection
|
|
3498
|
+
const mutableBase = typeof base.mutate === 'function';
|
|
3499
|
+
let strategy = opt?.strategy ?? (mutableBase ? 'coarse' : 'fine');
|
|
3500
|
+
if (mutableBase && strategy === 'fine') {
|
|
3501
|
+
if (isDevMode())
|
|
3502
|
+
console.warn("[fork] strategy 'fine' relies on reference-identity change detection, but the base is a " +
|
|
3503
|
+
"mutable store (in-place mutation keeps the same reference) — falling back to 'coarse'.");
|
|
3504
|
+
strategy = 'coarse';
|
|
3505
|
+
}
|
|
3506
|
+
const reconcile = strategy === 'coarse'
|
|
3507
|
+
? (_ancestor, _mine, theirs) => theirs // re-link to the new base (whole-value reset)
|
|
3508
|
+
: strategy === 'fine'
|
|
3509
|
+
? merge3
|
|
3510
|
+
: strategy;
|
|
3511
|
+
const merge = reconcile;
|
|
3512
|
+
const staged = linkedSignal({ ...(ngDevMode ? { debugName: "staged" } : /* istanbul ignore next */ {}), source: () => base(),
|
|
3513
|
+
computation: (theirs, prev) => prev === undefined ? theirs : merge(prev.source, prev.value, theirs) });
|
|
3514
|
+
const store = toStore(staged, opt?.injector, opt?.vivify, opt?.noUnionLeaves);
|
|
3515
|
+
return {
|
|
3516
|
+
store,
|
|
3517
|
+
commit: () => base.set(untracked(staged)),
|
|
3518
|
+
discard: () => staged.set(untracked(base)),
|
|
3519
|
+
};
|
|
2852
3520
|
}
|
|
2853
3521
|
|
|
2854
3522
|
// Internal dummy store for server-side rendering
|
|
@@ -3329,5 +3997,5 @@ function withHistory(sourceOrValue, opt) {
|
|
|
3329
3997
|
* Generated bundle index. Do not edit.
|
|
3330
3998
|
*/
|
|
3331
3999
|
|
|
3332
|
-
export { batteryStatus, chunked, clipboard, combineWith, debounce, debounced, derived, distinct, elementSize, elementVisibility, filter, filterWith, focusWithin, geolocation, idle, indexArray, isDerivation, isMutable, isOpaque, isStore, keyArray, map, mapArray, mapObject, mediaQuery, mousePosition, mutable, mutableStore, nestedEffect, networkStatus, opaque, orientation, pageVisibility, pairwise, pipeable, piped, pooled, pooledArray, pooledMap, pooledSet, prefersDarkMode, prefersReducedMotion, scan, scrollPosition, select, sensor, sensors, signalFromEvent, startWith, store, stored, tabSync, tap, throttle, throttled, toFakeDerivation, toFakeSignalDerivation, toStore, toWritable, until, windowSize, withHistory };
|
|
4000
|
+
export { MmActivity, SuspenseBoundary, SuspenseBoundaryBase, UnscopedSuspenseBoundary, activeTransaction, batteryStatus, chunked, clipboard, combineWith, createTransaction, createTransitionScope, debounce, debounced, derived, distinct, elementSize, elementVisibility, filter, filterWith, focusWithin, forkStore, geolocation, 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, providePaused, provideTransitionScope, registerResource, resolvePause, scan, scrollPosition, select, sensor, sensors, signalFromEvent, startWith, store, stored, tabSync, tap, throttle, throttled, toFakeDerivation, toFakeSignalDerivation, toStore, toWritable, until, windowSize, withHistory };
|
|
3333
4001
|
//# sourceMappingURL=mmstack-primitives.mjs.map
|