@adbl/cells 0.0.21 → 0.0.23

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,29 +1,21 @@
1
1
  /// <reference types="./classes.d.ts" />
2
2
  /**
3
- * @template Input, Output, Getter
4
- * @typedef {Object} AsyncRequestAtoms
5
- *
6
- * @property {SourceCell<boolean>} pending
7
- * Represents the loading state of an asynchronous request.
8
- *
9
- * @property {SourceCell<Output|null>} data
10
- * Represents the data returned by the asynchronous request.
11
- *
12
- * @property {SourceCell<Error | null>} error
13
- * Represents the errors returned by the asynchronous request, if any.
14
- *
15
- * @property {Getter extends (...args: infer P) => any ? P['length'] extends 0 ? SimplePromiseFn<Output>: PromiseFnWithArgs<P, Output> : SimplePromiseFn<Output>} run
16
- * Triggers the asynchronous request.
17
- *
18
- * @property {(newInput?: Input, changeLoadingState?: boolean) => Promise<void>} reload Triggers the asynchronous request again with an optional new input and optionally changes the loading state.
19
- */
20
- /**
21
- * @template Output
22
- * @typedef {() => Promise<Output | null>} SimplePromiseFn
3
+ * @template T
4
+ * @typedef {(track: <T>(cell: Cell<T>) => T) => T} ComputedFn
23
5
  */
24
6
  /**
25
- * @template {Array<any>} Args, Output
26
- * @typedef {(...args: Args) => Promise<Output | null>} PromiseFnWithArgs
7
+ * @template {Record<string, Cell<any>>} CellData
8
+ * @typedef Composite
9
+ * An object of barrier-synchronized async derived cells. Each member waits for
10
+ * *all* inputs to settle before yielding.
11
+ * @property {{
12
+ * [key in keyof CellData]:
13
+ * CellData[key] extends AsyncDerivedCell<infer T>
14
+ * ? AsyncDerivedCell<T> :
15
+ * CellData[key] extends Cell<infer X> ? AsyncDerivedCell<X> : never }} values
16
+ * @property {Cell<boolean>} pending
17
+ * @property {Cell<Error | null>} error
18
+ * @property {Cell<boolean>} loaded Whether the composite has completed its initial load.
27
19
  */
28
20
  /**
29
21
  * @typedef {object} EffectOptions
@@ -31,8 +23,6 @@
31
23
  * Whether the effect should be removed after the first run.
32
24
  * @property {AbortSignal} [signal]
33
25
  * An AbortSignal to be used to ignore the effect if it is aborted.
34
- * @property {string} [name]
35
- * The name of the effect for debugging purposes.
36
26
  * @property {boolean} [weak]
37
27
  * Whether the effect should be weakly referenced. This means that the effect will be garbage collected if there are no other references to it.
38
28
  * @property {number} [priority]
@@ -41,10 +31,6 @@
41
31
  /**
42
32
  * @template T
43
33
  * @typedef {object} CellOptions
44
- * @property {boolean} [immutable]
45
- * Whether the cell should be immutable. If set to true, the cell will not allow updates and will throw an error if the value is changed.
46
- * @property {boolean} [deep]
47
- * Whether the cell should watch for changes deep into the given value. By default the cell only reacts to changes at the top level.
48
34
  * @property {(oldValue: T, newValue: T) => boolean} [equals]
49
35
  * A function that determines whether two values are equal. If not provided, the default equality function will be used.
50
36
  */
@@ -117,12 +103,14 @@ const GlobalTrackingContext = {};
117
103
  let CurrentTrackingContext = GlobalTrackingContext;
118
104
  const Depth = Symbol();
119
105
  const IsScheduled = Symbol();
106
+ const Deferred = Symbol();
107
+ const DisposeAsyncCell = Symbol();
120
108
  /**
121
109
  * Tracks cells that need to be updated during the update cycle.
122
110
  * Cells are added to this stack to be processed and updated sequentially.
123
111
  * @type {Array<Cell<any>>}
124
112
  */
125
- const UPDATE_BUFFER = [];
113
+ let UPDATE_BUFFER = [];
126
114
  let IS_UPDATING = false;
127
115
  /** @type {object[]} */
128
116
  const CONTEXT_STACK = [GlobalTrackingContext];
@@ -147,14 +135,35 @@ function triggerUpdate() {
147
135
  if (cell instanceof DerivedCell) {
148
136
  const depth = cell[Depth];
149
137
  if (depth > currentDepth + 1) {
138
+ if (cell[Deferred]) {
139
+ currentDepth++;
140
+ }
141
+ else {
142
+ cell[Deferred] = true;
143
+ }
150
144
  // Move nodes with higher depths to the end of the array so they
151
145
  // are processed last.
152
146
  UPDATE_BUFFER.push(cell);
153
147
  continue;
154
148
  }
149
+ cell[Deferred] = false;
155
150
  if (depth > currentDepth)
156
151
  currentDepth = depth;
157
152
  const newValue = cell.computedFn();
153
+ if (cell instanceof AsyncDerivedCell) {
154
+ // async cells will handle propagation manually.
155
+ cell[IsScheduled] = false;
156
+ const computedDependents = cell.derivations;
157
+ for (const computedCell of computedDependents) {
158
+ if (computedCell instanceof AsyncDerivedCell)
159
+ continue;
160
+ if (computedCell[IsScheduled])
161
+ continue;
162
+ UPDATE_BUFFER.push(computedCell);
163
+ computedCell[IsScheduled] = true;
164
+ }
165
+ continue;
166
+ }
158
167
  // @ts-expect-error: wvalue is protected.
159
168
  if (deepEqual(cell.wvalue, newValue)) {
160
169
  cell[IsScheduled] = false;
@@ -168,17 +177,9 @@ function triggerUpdate() {
168
177
  for (const computedCell of computedDependents) {
169
178
  if (computedCell[IsScheduled])
170
179
  continue;
171
- if (BATCH_NESTING_LEVEL > 0)
172
- BATCHED_EFFECTS.set(() => UPDATE_BUFFER.push(computedCell), undefined);
173
- else
174
- UPDATE_BUFFER.push(computedCell);
180
+ UPDATE_BUFFER.push(computedCell);
175
181
  computedCell[IsScheduled] = true;
176
182
  }
177
- // Check the last cell.
178
- const last = UPDATE_BUFFER[UPDATE_BUFFER.length - 1];
179
- if (last instanceof DerivedCell && last[Depth] - 1 > currentDepth) {
180
- currentDepth = last[Depth] - 1;
181
- }
182
183
  }
183
184
  // A cell can update in another's effect, triggering a rerun
184
185
  // of the whole process. Since the UPDATE_BUFFER is the same array,
@@ -208,27 +209,7 @@ function throwAnyErrors() {
208
209
  throw new CellUpdateError(errors);
209
210
  }
210
211
  }
211
- const mutativeMapMethods = new Set(['set', 'delete', 'clear']);
212
- const mutativeSetMethods = new Set(['add', 'delete', 'clear']);
213
- const mutativeArrayMethods = new Set([
214
- 'push',
215
- 'pop',
216
- 'shift',
217
- 'unshift',
218
- 'splice',
219
- 'sort',
220
- 'reverse',
221
- ]);
222
- const mutativeDateMethods = new Set([
223
- 'setDate',
224
- 'setMonth',
225
- 'setFullYear',
226
- 'setHours',
227
- 'setMinutes',
228
- 'setSeconds',
229
- 'setMilliseconds',
230
- ]);
231
- /** @template T */
212
+ /** @template {*} out T */
232
213
  class Effect {
233
214
  /**
234
215
  * @type {EffectOptions | undefined}
@@ -275,6 +256,8 @@ export class LocalContext {
275
256
  for (const source of sources) {
276
257
  source.derivations.delete(derivation);
277
258
  }
259
+ if (derivation instanceof AsyncCell)
260
+ derivation[DisposeAsyncCell]();
278
261
  }
279
262
  for (const [cell, effects] of this.effects) {
280
263
  if (cell instanceof DerivedCell && this.derivationSourceMap.has(cell)) {
@@ -363,13 +346,6 @@ export class Cell {
363
346
  * @protected @type T
364
347
  */
365
348
  wvalue = /** @type {T} */ (null);
366
- /**
367
- * @protected
368
- * @param {T} value
369
- */
370
- setValue(value) {
371
- this.wvalue = value;
372
- }
373
349
  /**
374
350
  * Overrides `Object.prototype.valueOf()` to return the value stored in the Cell.
375
351
  * @returns {T} The value of the Cell.
@@ -436,9 +412,6 @@ export class Cell {
436
412
  this.ignore(effect);
437
413
  };
438
414
  }
439
- if (options?.name && this.isListeningTo(options.name)) {
440
- throw new Error(`An effect with the name "${options.name}" is already listening to this cell.`);
441
- }
442
415
  const isAlreadySubscribed = this.#effects.some((effect) => {
443
416
  return effect.callback === callback;
444
417
  });
@@ -480,10 +453,6 @@ export class Cell {
480
453
  });
481
454
  if (options?.once)
482
455
  return () => this.ignore(cb);
483
- if (options?.name && this.isListeningTo(options.name)) {
484
- const message = `An effect with the name "${options.name}" is already listening to this cell.`;
485
- throw new Error(message);
486
- }
487
456
  const isAlreadySubscribed = this.#effects.some((e) => {
488
457
  return e.callback === callback;
489
458
  });
@@ -514,28 +483,6 @@ export class Cell {
514
483
  return;
515
484
  this.#effects.splice(index, 1);
516
485
  }
517
- /**
518
- * Checks if the cell is listening to a watcher with the specified name.
519
- * @param {string} name - The name of the watcher to check for.
520
- * @returns {boolean} `true` if the cell is listening to a watcher with the specified name, `false` otherwise.
521
- */
522
- isListeningTo(name) {
523
- return this.#effects.some((effect) => {
524
- return effect?.options?.name === name && effect.callback;
525
- });
526
- }
527
- /**
528
- * Removes the watcher with the specified name from the list of effects for this cell.
529
- * @param {string} name - The name of the watcher to stop listening to.
530
- */
531
- stopListeningTo(name) {
532
- const effectIndex = this.#effects.findIndex((e) => {
533
- return e.options?.name === name;
534
- });
535
- if (effectIndex === -1)
536
- return;
537
- this.#effects.splice(effectIndex, 1);
538
- }
539
486
  /**
540
487
  * @protected
541
488
  * Updates the root object and notifies any registered watchers and computed dependents.
@@ -544,7 +491,8 @@ export class Cell {
544
491
  update() {
545
492
  // Run watchers.
546
493
  const wvalue = this.wvalue;
547
- const effects = this.#effects;
494
+ // Make a copy to avoid issues if effects are removed during iteration (e.g., once: true)
495
+ const effects = [...this.#effects];
548
496
  let hasUndefinedEffect = false;
549
497
  for (const { callback: watcher } of effects) {
550
498
  if (watcher === undefined) {
@@ -621,6 +569,168 @@ export class Cell {
621
569
  * @returns {LocalContext} A new LocalContext instance.
622
570
  */
623
571
  static context = () => new LocalContext();
572
+ /**
573
+ * Creates a new AsyncDerivedCell that computes its value asynchronously.
574
+ * The cell automatically re-computes when any of its dependencies change,
575
+ * with built-in support for cancellation, loading state, and error handling.
576
+ *
577
+ * @template U
578
+ * @param {(get: <T>(cell: Cell<T>) => T, signal: AbortSignal) => Promise<U>} callback - An async function that computes the derived value.
579
+ * - `get`: A function to read cell values while tracking them as dependencies.
580
+ * - `signal`: An AbortSignal that is aborted when a new computation starts,
581
+ * useful for cancelling in-flight requests.
582
+ * @returns {AsyncDerivedCell<U>} A new AsyncDerivedCell instance.
583
+ *
584
+ * @example
585
+ * ```javascript
586
+ * import { Cell } from '@adbl/cells';
587
+ *
588
+ * const userId = Cell.source(1);
589
+ *
590
+ * const userData = Cell.derivedAsync(async (get, signal) => {
591
+ * const id = get(userId); // Tracks userId as a dependency
592
+ * const response = await fetch(`/api/users/${id}`, { signal });
593
+ * return response.json();
594
+ * });
595
+ *
596
+ * // Access loading and error states
597
+ * userData.pending.listen((loading) => console.log('Loading:', loading));
598
+ * userData.error.listen((err) => err && console.error(err));
599
+ *
600
+ * // Get the async value
601
+ * const data = await userData.get();
602
+ * ```
603
+ */
604
+ static derivedAsync = (callback) => new AsyncDerivedCell(callback);
605
+ /**
606
+ * Creates a new AsyncTaskCell that represents a one-time asynchronous computation.
607
+ * Unlike derivedAsync which re-computes automatically when dependencies change,
608
+ * a task only executes when explicitly called via `runWith(input)`.
609
+ *
610
+ * Tasks are ideal for:
611
+ * - Form submissions
612
+ * - One-time API calls
613
+ * - User-triggered actions
614
+ * - Operations that should not auto-execute
615
+ *
616
+ * @template Input, Output
617
+ * @param {(input: Input, signal: AbortSignal) => Promise<Output>} fn - An async function that performs the task.
618
+ * - `input`: The input value passed when calling `runWith(input)`.
619
+ * - `signal`: An AbortSignal that is aborted when a new execution starts,
620
+ * useful for cancelling in-flight requests.
621
+ * @returns {AsyncTaskCell<Input, Output>} A new AsyncTaskCell instance.
622
+ *
623
+ * @example
624
+ * ```javascript
625
+ * // Create a task for submitting form data
626
+ * const submitTask = Cell.task(async (formData, signal) => {
627
+ * const response = await fetch('/api/submit', {
628
+ * method: 'POST',
629
+ * body: formData,
630
+ * signal
631
+ * });
632
+ * return response.json();
633
+ * });
634
+ *
635
+ * // Execute the task
636
+ * const result = await submitTask.runWith({ name: 'John' });
637
+ *
638
+ * // Access loading and error states
639
+ * submitTask.pending.listen((isPending) => {
640
+ * console.log('Submitting:', isPending);
641
+ * });
642
+ *
643
+ * submitTask.error.listen((err) => {
644
+ * if (err) console.error('Submission failed:', err);
645
+ * });
646
+ * ```
647
+ *
648
+ * @example
649
+ * ```javascript
650
+ * // Tasks can be used with Cell.composite for managing multiple operations
651
+ * const uploadTask = Cell.task(async (file) => {
652
+ * // Upload logic
653
+ * });
654
+ *
655
+ * const deleteTask = Cell.task(async (id) => {
656
+ * // Delete logic
657
+ * });
658
+ *
659
+ * const operations = Cell.composite({ upload: uploadTask, delete: deleteTask });
660
+ *
661
+ * // Track overall pending state
662
+ * operations.pending.listen((isPending) => {
663
+ * console.log('Operations in progress:', isPending);
664
+ * });
665
+ * ```
666
+ */
667
+ static task = (fn) => new AsyncTaskCell(fn);
668
+ /**
669
+ * Joins multiple cells into a single “all-or-nothing” async unit.
670
+ *
671
+ * Each returned property only produces a new value after **every** input cell
672
+ * has settled for the current round, so reads like:
673
+ *
674
+ * ```js
675
+ * const u = await group.values.user.get();
676
+ * const n = await group.values.notifications.get();
677
+ * ```
678
+ *
679
+ * won’t observe partial updates.
680
+ *
681
+ * @template {Record<string, Cell<any>>} CellData
682
+ * @param {CellData} input Cells to join (may include async and sync cells).
683
+ * @returns {Composite<CellData>}
684
+ *
685
+ * @example
686
+ * const user = Cell.derivedAsync(async (get) => fetchUser(get(id)));
687
+ * const notifications = Cell.derivedAsync(async (get) => fetchNotifs(get(id)));
688
+ *
689
+ * const group = Cell.composite({ user, notifications });
690
+ *
691
+ * group.pending.listen(showSpinner);
692
+ * group.error.listen(showError);
693
+ * group.loaded.listen((isLoaded) => isLoaded && hideInitialSkeleton());
694
+ *
695
+ * const u = await group.values.user.get();
696
+ * const n = await group.values.notifications.get();
697
+ */
698
+ static composite = (input) => {
699
+ const output = /** @type {Composite<CellData>['values']} */ ({});
700
+ const error = Cell.derived(() => {
701
+ return (Object.values(input)
702
+ .map((cell) => (cell instanceof AsyncCell ? cell.error.get() : null))
703
+ .find(Boolean) || null);
704
+ });
705
+ const pending = Cell.derived(() => {
706
+ return Object.values(input)
707
+ .map((cell) => (cell instanceof AsyncCell ? cell.pending.get() : false))
708
+ .some(Boolean);
709
+ });
710
+ const loaded = Cell.source(!pending.peek());
711
+ pending.listen((isPending) => {
712
+ if (!isPending && !loaded.peek()) {
713
+ loaded.set(true);
714
+ }
715
+ });
716
+ const barrier = Cell.derivedAsync((get) => {
717
+ return Promise.all(Object.values(input).map(get));
718
+ });
719
+ const values = Object.keys(input).reduce((output, key) => {
720
+ const value = Cell.derivedAsync(async (get) => {
721
+ await get(barrier);
722
+ const err = error.peek();
723
+ if (err)
724
+ throw err;
725
+ const nextValue = await get(input[key]);
726
+ await get(barrier);
727
+ return nextValue;
728
+ });
729
+ Reflect.set(output, key, value);
730
+ return output;
731
+ }, output);
732
+ return { values, pending, error, loaded };
733
+ };
624
734
  /**
625
735
  * Executes a function within a specific LocalContext.
626
736
  * Any effects (`.listen`) or derived cells (`Cell.derived`) created synchronously
@@ -647,27 +757,60 @@ export class Cell {
647
757
  * @returns {X} The return value of the callback.
648
758
  */
649
759
  static batch = (callback) => {
760
+ const currentBatchLevel = BATCH_NESTING_LEVEL;
761
+ const currentUpdateBuffer = UPDATE_BUFFER;
762
+ const wasUpdating = IS_UPDATING;
763
+ const currentBatchedEffects = BATCHED_EFFECTS;
764
+ UPDATE_BUFFER = [];
765
+ IS_UPDATING = true;
650
766
  BATCH_NESTING_LEVEL++;
767
+ BATCHED_EFFECTS = new Map();
651
768
  /** @type {X | undefined} */
652
769
  let value;
653
- let error;
654
770
  try {
655
- value = callback();
771
+ try {
772
+ value = callback();
773
+ }
774
+ catch (e) {
775
+ if (e instanceof Error)
776
+ cellErrors.push(e);
777
+ }
778
+ if (!wasUpdating)
779
+ triggerUpdate();
656
780
  }
657
781
  catch (e) {
658
- error = e;
659
- }
660
- BATCH_NESTING_LEVEL--;
661
- if (error instanceof Error) {
662
- cellErrors.push(error);
782
+ if (e instanceof Error)
783
+ cellErrors.push(e);
663
784
  }
664
- if (BATCH_NESTING_LEVEL === 0) {
665
- for (const [effect, args] of BATCHED_EFFECTS) {
666
- effect(args);
785
+ finally {
786
+ BATCH_NESTING_LEVEL = currentBatchLevel;
787
+ if (BATCH_NESTING_LEVEL === 0) {
788
+ for (const [effect, value] of BATCHED_EFFECTS) {
789
+ try {
790
+ effect(value);
791
+ }
792
+ catch (e) {
793
+ if (e instanceof Error)
794
+ cellErrors.push(e);
795
+ }
796
+ }
667
797
  }
668
- if (!IS_UPDATING)
669
- triggerUpdate();
670
- BATCHED_EFFECTS = new Map();
798
+ else {
799
+ // Merge nested batch effects into parent batch so they're not lost
800
+ for (const [effect, value] of BATCHED_EFFECTS) {
801
+ currentBatchedEffects.set(effect, value);
802
+ }
803
+ }
804
+ // Merge any cells scheduled for update into the parent buffer
805
+ for (const cell of UPDATE_BUFFER) {
806
+ if (!currentUpdateBuffer.includes(cell)) {
807
+ currentUpdateBuffer.push(cell);
808
+ }
809
+ }
810
+ UPDATE_BUFFER = currentUpdateBuffer;
811
+ IS_UPDATING = wasUpdating;
812
+ BATCH_NESTING_LEVEL = currentBatchLevel;
813
+ BATCHED_EFFECTS = currentBatchedEffects;
671
814
  }
672
815
  throwAnyErrors();
673
816
  return /** @type {X} */ (value);
@@ -680,145 +823,6 @@ export class Cell {
680
823
  * @returns {value is Cell<T>} True if the value is an instance of Cell, false otherwise.
681
824
  */
682
825
  static isCell = (value) => value instanceof Cell;
683
- /**
684
- * @template T
685
- * Flattens the provided value by returning the value if it is not a Cell instance, or the value of the Cell instance if it is.
686
- * @param {T | Cell<T>} value - The value to be flattened.
687
- * @returns {T} The flattened value.
688
- */
689
- static flatten = (value) => {
690
- if (value instanceof Cell) {
691
- if (value instanceof DerivedCell) {
692
- return Cell.flatten(value.wvalue);
693
- }
694
- return Cell.flatten(value.wvalue);
695
- }
696
- if (Array.isArray(value)) {
697
- // @ts-expect-error:
698
- return Cell.flattenArray(value);
699
- }
700
- if (value instanceof Object) {
701
- // @ts-expect-error:
702
- return Cell.flattenObject(value);
703
- }
704
- return value;
705
- };
706
- /**
707
- * Flattens an array by applying the `flatten` function to each element.
708
- * @template T
709
- * @param {Array<T | Cell<T>>} array - The array to be flattened.
710
- * @returns {Array<T>} A new array with the flattened elements.
711
- */
712
- static flattenArray = (array) => array.map(Cell.flatten);
713
- /**
714
- * Flattens an object by applying the `flatten` function to each value.
715
- * @template {object} T
716
- * @param {T} object - The object to be flattened.
717
- * @returns {{ [K in keyof T]: T[K] extends Cell<infer U> ? U : T[K] }} A new object with the flattened values.
718
- */
719
- static flattenObject = (object) => {
720
- const result =
721
- /** @type {{ [K in keyof T]: T[K] extends Cell<infer U> ? U : T[K] }} */ ({});
722
- for (const [key, value] of Object.entries(object)) {
723
- Reflect.set(result, key, Cell.flatten(value));
724
- }
725
- return result;
726
- };
727
- /**
728
- * Wraps an asynchronous function with managed state.
729
- *
730
- * @template {(...args: any[]) => Promise<any>} Getter - A function that performs the asynchronous operation.
731
- * @template {Parameters<Getter>[0]} [X=Parameters<Getter>[0]]
732
- * @template {Awaited<ReturnType<Getter>>} [Y=Awaited<ReturnType<Getter>>]
733
- * @param {Getter} getter - A function that performs the asynchronous operation.
734
- * @returns {AsyncRequestAtoms<Parameters<Getter>[0], Awaited<ReturnType<Getter>>, Getter>} An object containing cells for pending, data, and error states,
735
- * as well as functions to run and reload the operation.
736
- *
737
- * @example
738
- * const { pending, data, error, run, reload } = Cell.async(async (input) => {
739
- * const response = await fetch(`https://example.com/api/data?input=${input}`);
740
- * return response.json();
741
- * });
742
- *
743
- * run('input');
744
- */
745
- static async(getter) {
746
- const pending = Cell.source(false);
747
- const data = Cell.source(/** @type {Y | null} */ (null));
748
- const error = Cell.source(/** @type {Error | null} */ (null));
749
- /** @type {X | undefined} */
750
- let initialInput;
751
- /** @type {AbortController | undefined} */
752
- let controller;
753
- async function run(input = initialInput) {
754
- if (controller)
755
- controller.abort();
756
- controller = new AbortController();
757
- pending.set(true);
758
- error.set(null);
759
- data.set(null);
760
- const currentController = controller;
761
- try {
762
- initialInput = input;
763
- const _input = /** @type {X} */ (input);
764
- const result = await getter.bind(currentController)(_input);
765
- if (currentController?.signal.aborted)
766
- return;
767
- data.set(result);
768
- }
769
- catch (e) {
770
- if (e instanceof Error) {
771
- error.set(e);
772
- }
773
- else {
774
- throw e;
775
- }
776
- }
777
- pending.set(false);
778
- return data.get();
779
- }
780
- /**
781
- * @param {X} [newInput]
782
- * @param {boolean} [changeLoadingState]
783
- */
784
- async function reload(newInput, changeLoadingState = true) {
785
- if (controller)
786
- controller.abort();
787
- controller = new AbortController();
788
- if (changeLoadingState) {
789
- pending.set(true);
790
- }
791
- await Cell.batch(async () => {
792
- const currentController = controller;
793
- try {
794
- const result = await getter.bind(currentController)(
795
- /** @type {X} */ (newInput ?? initialInput));
796
- if (currentController?.signal.aborted)
797
- return;
798
- data.set(result);
799
- }
800
- catch (e) {
801
- if (e instanceof Error) {
802
- error.set(e);
803
- }
804
- else {
805
- throw e;
806
- }
807
- }
808
- if (changeLoadingState) {
809
- pending.set(false);
810
- }
811
- });
812
- }
813
- return {
814
- pending,
815
- data,
816
- error,
817
- // @ts-expect-error
818
- run,
819
- reload,
820
- };
821
- }
822
826
  }
823
827
  /**
824
828
  * A class that represents a computed value that depends on other reactive values.
@@ -828,6 +832,7 @@ export class Cell {
828
832
  */
829
833
  export class DerivedCell extends Cell {
830
834
  [Depth] = 0;
835
+ [Deferred] = false;
831
836
  /**
832
837
  * @param {() => T} computedFn - A function that generates the value of the computed.
833
838
  */
@@ -839,20 +844,23 @@ export class DerivedCell extends Cell {
839
844
  // Ensures that the cell is derived every time the computing function is called.
840
845
  const derivationWrapper = () => {
841
846
  ACTIVE_DERIVED_CTX.push([this, 0]);
842
- let value = this.wvalue;
843
847
  try {
844
- value = computedFn();
848
+ return computedFn();
845
849
  }
846
850
  catch (e) {
847
851
  if (e instanceof Error)
848
852
  cellErrors.push(e);
853
+ return this.wvalue;
854
+ }
855
+ finally {
856
+ const i = /** @type {[this, number]} */ (ACTIVE_DERIVED_CTX.pop());
857
+ const [, depth] = i;
858
+ if (depth + 1 > this[Depth])
859
+ this[Depth] = depth + 1;
849
860
  }
850
- const [, depth] = /** @type {[this, number]} */ (ACTIVE_DERIVED_CTX.pop());
851
- if (depth + 1 > this[Depth])
852
- this[Depth] = depth + 1;
853
- return value;
854
861
  };
855
- this.setValue(derivationWrapper());
862
+ /** @protected @type {T} */
863
+ this.wvalue = derivationWrapper();
856
864
  this.computedFn = /** @type {() => T} */ (derivationWrapper);
857
865
  throwAnyErrors();
858
866
  }
@@ -872,21 +880,11 @@ export class DerivedCell extends Cell {
872
880
  * @extends {Cell<T>}
873
881
  * A cell whose value can be directly modified.
874
882
  * Source cells are the primary way to introduce reactivity.
875
- * They can hold any value type and will automatically handle proxying of objects
876
- * to enable deep reactivity when needed.
877
883
  *
878
884
  * @example
879
885
  * ```typescript
880
886
  * const count = Cell.source(0);
881
887
  * ```
882
- *
883
- * @example
884
- * ```typescript
885
- * // With options
886
- * const immutableCell = Cell.source(42, { immutable: true });
887
- * // Will throw error:
888
- * immutableCell.set(43);
889
- * ```
890
888
  */
891
889
  export class SourceCell extends Cell {
892
890
  /**
@@ -896,9 +894,9 @@ export class SourceCell extends Cell {
896
894
  */
897
895
  constructor(value, options) {
898
896
  super();
899
- if (options !== undefined)
900
- this.options = options;
901
- this.setValue(value);
897
+ /** @protected */
898
+ this.wvalue = value;
899
+ this.options = options;
902
900
  }
903
901
  peek() {
904
902
  return this.wvalue;
@@ -908,87 +906,468 @@ export class SourceCell extends Cell {
908
906
  * @returns {T} The value of the Cell.
909
907
  */
910
908
  get() {
911
- return this.#proxy(this.revalued);
909
+ return this.revalued;
912
910
  }
913
911
  /**
914
912
  * Sets the value stored in the Cell and triggers an update.
915
913
  * @param {T} value
916
914
  */
917
915
  set(value) {
918
- if (this.options?.immutable) {
919
- throw new Error('Cannot set the value of an immutable cell.');
920
- }
921
916
  const oldValue = this.wvalue;
922
917
  const isEqual = this.options?.equals
923
918
  ? this.options.equals(oldValue, value)
924
919
  : deepEqual(oldValue, value);
925
920
  if (isEqual)
926
921
  return;
927
- this.setValue(value);
922
+ this.wvalue = value;
928
923
  this[IsScheduled] = true;
929
924
  UPDATE_BUFFER.push(this);
930
925
  if (!IS_UPDATING)
931
926
  triggerUpdate();
932
927
  }
928
+ }
929
+ /**
930
+ * @template {*} out T - The type of the resolved async value.
931
+ * @extends {DerivedCell<Promise<T>>}
932
+ */
933
+ export class AsyncCell extends DerivedCell {
934
+ /** @type {Set<Promise<any>>} */
935
+ #upstream = new Set();
936
+ /** @type {Set<AsyncCell<any>>} */
937
+ #consumed = new Set();
938
+ /** @type {undefined | (() => void)} */
939
+ #abandonLastComputation;
940
+ /** @type {AbortController | undefined} */
941
+ #controller;
933
942
  /**
934
- * Proxies the provided value deeply, allowing it to be observed and updated.
935
- * @template T
936
- * @param {T} value - The value to be proxied.
937
- * @returns {T} - The proxied value.
943
+ * @protected
944
+ * Aborts the current computation if one is running.
945
+ * @returns {void}
946
+ */
947
+ abort() {
948
+ this.#controller?.abort();
949
+ }
950
+ /**
951
+ * Gets the AbortSignal for the current computation.
952
+ * @protected
953
+ * @returns {AbortSignal}
938
954
  */
939
- #proxy(value) {
940
- if (typeof value !== 'object' || value === null)
955
+ get _signal() {
956
+ if (!this.#controller) {
957
+ this.#controller = new AbortController();
958
+ }
959
+ return this.#controller.signal;
960
+ }
961
+ /**
962
+ * A cell that indicates whether the async computation is currently running.
963
+ * @type {SourceCell<boolean>}
964
+ */
965
+ pending = Cell.source(true);
966
+ /**
967
+ * A cell that holds any error thrown during the async computation.
968
+ * Resets to `null` when a new computation starts.
969
+ * @type {SourceCell<Error | null>}
970
+ */
971
+ error = Cell.source(null);
972
+ /**
973
+ * @param {(get: <T>(cell: Cell<T>) => T, signal: AbortSignal) => Promise<T>} fn
974
+ */
975
+ constructor(fn) {
976
+ const initialState = /** @type {Promise<T>} */ (Promise.resolve(null));
977
+ super(() => initialState);
978
+ let lastStablePromise = initialState;
979
+ /** @type [this, number] */
980
+ let derivedCtx = [this, this[Depth]];
981
+ let runId = 0;
982
+ /**
983
+ * @template T
984
+ * @param {Cell<T>} cell
985
+ * @returns {T}
986
+ */
987
+ const get = (cell) => {
988
+ ACTIVE_DERIVED_CTX.push(derivedCtx);
989
+ const value = cell.get();
990
+ if (cell instanceof AsyncCell && value instanceof Promise) {
991
+ const currentRunId = runId;
992
+ value.then(() => {
993
+ if (runId === currentRunId)
994
+ this.#consumed.add(cell);
995
+ });
996
+ }
997
+ ACTIVE_DERIVED_CTX.pop();
941
998
  return value;
942
- return new Proxy(value, {
943
- get: (target, prop) => {
944
- this.revalued;
945
- if (this.options?.deep) {
946
- // @ts-expect-error: Direct access is faster than Reflection here.
947
- return this.#proxy(target[prop]);
999
+ };
1000
+ this.computedFn = async () => {
1001
+ const currentRunId = ++runId;
1002
+ this.#consumed.clear();
1003
+ derivedCtx = [this, this[Depth]];
1004
+ Cell.batch(() => {
1005
+ this.pending.set(true);
1006
+ this.error.set(null);
1007
+ });
1008
+ this.#controller?.abort();
1009
+ this.#controller = new AbortController();
1010
+ /** @type {null | ((value: boolean) => void)} */
1011
+ let resolveChangedState = null;
1012
+ /** @type {Promise<boolean>} */
1013
+ const valueHasChanged = new Promise((resolve) => {
1014
+ resolveChangedState = resolve;
1015
+ });
1016
+ // if this cell discards this promise and starts another,
1017
+ // we do not want to its children to be stuck waiting for the old.
1018
+ // We are not using signal.addEventListener('abort') here because
1019
+ // the controller aborts too early (before the next promise even starts),
1020
+ // and we want the next promise to already be notified to the children,
1021
+ // so they don't resolve prematurely.
1022
+ /** @type {undefined | (() => void)} */
1023
+ let abandonComputation;
1024
+ /** @type {Promise<T | null>} */
1025
+ const tripwire = new Promise((resolve) => {
1026
+ abandonComputation = () => resolve(lastStablePromise);
1027
+ });
1028
+ const current = Promise.race([
1029
+ tripwire,
1030
+ new Promise((resolve) => resolve(fn(get, this._signal))),
1031
+ ])
1032
+ .catch((error) => {
1033
+ if (currentRunId === runId) {
1034
+ Cell.batch(() => {
1035
+ this.pending.set(false);
1036
+ this.error.set(error);
1037
+ });
948
1038
  }
949
- if (typeof prop === 'string') {
950
- const isMutativeMethod = (target instanceof Map && mutativeMapMethods.has(prop)) ||
951
- (target instanceof Set && mutativeSetMethods.has(prop)) ||
952
- (target instanceof Date && mutativeDateMethods.has(prop)) ||
953
- ((ArrayBuffer.isView(target) || Array.isArray(target)) &&
954
- mutativeArrayMethods.has(prop));
955
- if (isMutativeMethod) {
956
- // @ts-expect-error: Direct access is faster than Reflection here.
957
- return (...args) => {
958
- // @ts-expect-error: Direct access is faster than Reflection here.
959
- const result = target[prop](...args);
960
- UPDATE_BUFFER.push(this);
961
- this[IsScheduled] = true;
962
- if (!IS_UPDATING)
963
- triggerUpdate();
964
- return result;
965
- };
966
- }
1039
+ return lastStablePromise;
1040
+ })
1041
+ .then(async (value) => {
1042
+ if (currentRunId === runId) {
1043
+ this.pending.set(false);
1044
+ resolveChangedState?.(!deepEqual(await lastStablePromise, value));
967
1045
  }
968
- // @ts-expect-error: Direct access is faster than Reflection here.
969
- let value = target[prop];
970
- if (typeof value === 'function') {
971
- value = value.bind(target);
1046
+ else {
1047
+ resolveChangedState?.(false);
972
1048
  }
973
1049
  return value;
974
- },
975
- set: (target, prop, value) => {
976
- // @ts-expect-error: dynamic object access.
977
- const formerValue = target[prop];
978
- const isEqual = deepEqual(formerValue, value);
979
- if (!isEqual) {
980
- // @ts-expect-error: dynamic object access.
981
- target[prop] = value;
982
- UPDATE_BUFFER.push(this);
983
- this[IsScheduled] = true;
984
- if (!IS_UPDATING) {
1050
+ });
1051
+ this.wvalue = current;
1052
+ this.#notify(current, valueHasChanged, lastStablePromise, initialState);
1053
+ this.#abandonLastComputation?.();
1054
+ this.#abandonLastComputation = abandonComputation;
1055
+ current.finally(async () => {
1056
+ if (currentRunId !== runId)
1057
+ return;
1058
+ if (lastStablePromise === initialState) {
1059
+ // We only run update() for subsequent changes, not initial resolution.
1060
+ lastStablePromise = current;
1061
+ return;
1062
+ }
1063
+ lastStablePromise = current;
1064
+ if (derivedCtx[1] + 1 > this[Depth])
1065
+ this[Depth] = derivedCtx[1] + 1;
1066
+ if (await valueHasChanged)
1067
+ this.update();
1068
+ });
1069
+ return this.wvalue;
1070
+ };
1071
+ // First call.
1072
+ this.computedFn();
1073
+ }
1074
+ /**
1075
+ * @param {Promise<any>} promise
1076
+ * @param {Promise<boolean>} valueHasChanged
1077
+ * @param {Promise<any>} lastStablePromise
1078
+ * @param {Promise<any>} initialState
1079
+ */
1080
+ #notify(promise, valueHasChanged, lastStablePromise, initialState) {
1081
+ for (const child of this.derivations) {
1082
+ if (!(child instanceof AsyncDerivedCell))
1083
+ continue;
1084
+ if (child.#upstream.has(promise))
1085
+ continue;
1086
+ // Only direct children should be scheduled based on this cell's valueHasChanged.
1087
+ // Grandchildren will be scheduled by their direct parent when it computes.
1088
+ promise.then(async () => {
1089
+ child.#upstream.delete(promise);
1090
+ if (lastStablePromise === initialState) {
1091
+ return;
1092
+ }
1093
+ // If the child is already computing and it has not tried to read the parent,
1094
+ // it need not be restarted. When it tries to access the parent,
1095
+ // it will receive the most recent value.
1096
+ if (child.pending.peek() && !child.#consumed.has(this)) {
1097
+ return;
1098
+ }
1099
+ if (!child[IsScheduled] && (await valueHasChanged)) {
1100
+ UPDATE_BUFFER.push(child);
1101
+ if (!IS_UPDATING)
985
1102
  triggerUpdate();
986
- }
987
1103
  }
988
- return true;
989
- },
1104
+ });
1105
+ child.#upstream.add(promise);
1106
+ // Propagate ONLY the upstream waiting to grandchildren (not the scheduling).
1107
+ // This ensures grandchildren wait for this ancestor to complete,
1108
+ // but they'll be scheduled by their direct parent's #notify, not ours.
1109
+ child.#notifyUpstreamOnly(promise);
1110
+ }
1111
+ }
1112
+ /**
1113
+ * Propagates upstream tracking to grandchildren without scheduling them.
1114
+ * This ensures they wait for the ancestor to complete when calling .get().
1115
+ * @param {Promise<any>} promise
1116
+ */
1117
+ #notifyUpstreamOnly(promise) {
1118
+ for (const child of this.derivations) {
1119
+ if (!(child instanceof AsyncDerivedCell))
1120
+ continue;
1121
+ if (child.#upstream.has(promise))
1122
+ continue;
1123
+ child.#upstream.add(promise);
1124
+ promise.finally(() => child.#upstream.delete(promise));
1125
+ // Continue propagating upstream tracking down the chain
1126
+ child.#notifyUpstreamOnly(promise);
1127
+ }
1128
+ }
1129
+ /**
1130
+ * Returns the current value of the async cell.
1131
+ * @returns {Promise<T>}
1132
+ */
1133
+ async get() {
1134
+ super.get(); // Forces a dependency registration in sync time.
1135
+ while (this.#upstream.size)
1136
+ await Promise.allSettled([...this.#upstream]);
1137
+ return new Promise((resolve) => {
1138
+ if (this.pending.peek()) {
1139
+ this.pending.listen(() => resolve(this.wvalue), { once: true });
1140
+ }
1141
+ else
1142
+ resolve(this.wvalue);
990
1143
  });
991
1144
  }
1145
+ [DisposeAsyncCell]() {
1146
+ this.#controller?.abort();
1147
+ this.#abandonLastComputation?.();
1148
+ }
1149
+ /**
1150
+ * Returns the current value of the async cell without registering dependencies.
1151
+ * Like get(), this waits for upstream promises and pending state to resolve,
1152
+ * but it does not register this cell as a dependency of the calling context.
1153
+ * @returns {Promise<T>} A promise that resolves to the current value.
1154
+ */
1155
+ async peek() {
1156
+ while (this.#upstream.size)
1157
+ await Promise.allSettled([...this.#upstream]);
1158
+ return new Promise((resolve) => {
1159
+ if (this.pending.peek()) {
1160
+ this.pending.listen(() => resolve(this.wvalue), { once: true });
1161
+ }
1162
+ else {
1163
+ resolve(this.wvalue);
1164
+ }
1165
+ });
1166
+ }
1167
+ }
1168
+ /**
1169
+ * A derived cell that computes its value asynchronously.
1170
+ *
1171
+ * AsyncDerivedCell extends the reactive paradigm to asynchronous operations,
1172
+ * automatically re-running the async computation when dependencies change.
1173
+ * It provides built-in state management for loading and error states.
1174
+ *
1175
+ * Key features:
1176
+ * - Automatic dependency tracking via the `get` function
1177
+ * - Automatic cancellation of in-flight operations when dependencies change
1178
+ * - Built-in `pending` cell for loading state
1179
+ * - Built-in `error` cell for error handling
1180
+ * - Race condition prevention through AbortSignal
1181
+ *
1182
+ * @template {*} out T - The type of the resolved async value.
1183
+ * @extends {AsyncCell<T>}
1184
+ *
1185
+ * @example
1186
+ * ```javascript
1187
+ * const searchQuery = Cell.source('');
1188
+ *
1189
+ * const searchResults = Cell.derivedAsync(async (get, signal) => {
1190
+ * const query = get(searchQuery);
1191
+ * if (!query) return [];
1192
+ *
1193
+ * const response = await fetch(`/api/search?q=${query}`, { signal });
1194
+ * return response.json();
1195
+ * });
1196
+ *
1197
+ * // React to state changes
1198
+ * searchResults.pending.listen((loading) => {
1199
+ * showSpinner(loading);
1200
+ * });
1201
+ *
1202
+ * searchResults.error.listen((error) => {
1203
+ * if (error) showError(error.message);
1204
+ * });
1205
+ * ```
1206
+ */
1207
+ export class AsyncDerivedCell extends AsyncCell {
1208
+ /**
1209
+ * Revalidates the async cell by recomputing its value.
1210
+ * This will abort any in-flight computation and start a new one.
1211
+ * @returns {void}
1212
+ */
1213
+ revalidate() {
1214
+ this.computedFn();
1215
+ }
1216
+ }
1217
+ /**
1218
+ * @template I, O
1219
+ * @typedef {(input: I, signal: AbortSignal) => Promise<O>} MutatorFn
1220
+ */
1221
+ /**
1222
+ * A task cell that performs one-time asynchronous computations.
1223
+ *
1224
+ * AsyncTaskCell is designed for operations that should only execute when explicitly
1225
+ * triggered, unlike AsyncDerivedCell which re-computes automatically. This makes it
1226
+ * ideal for form submissions, button actions, or any user-triggered operations.
1227
+ *
1228
+ * Key features:
1229
+ * - Only executes when `runWith(input)` is called
1230
+ * - Does not deduplicate concurrent `runWith(input)` calls; each call creates
1231
+ * an independent execution with no caching
1232
+ * - Built-in `pending` cell for loading state (false until first execution)
1233
+ * - Built-in `error` cell for error handling
1234
+ * - Supports cancellation via AbortSignal (concurrent cancellations must be
1235
+ * handled in the task function)
1236
+ * - Can be used with Cell.composite for grouping multiple tasks
1237
+ *
1238
+ * @template {*} out I - The input type of the task function.
1239
+ * @template {*} out T - The type of the resolved async value.
1240
+ * @extends {AsyncCell<T>}
1241
+ *
1242
+ * @example
1243
+ * ```javascript
1244
+ * import { Cell } from '@adbl/cells';
1245
+ *
1246
+ * // Create a task for user login
1247
+ * const loginTask = Cell.task(async (credentials, signal) => {
1248
+ * const response = await fetch('/api/login', {
1249
+ * method: 'POST',
1250
+ * headers: { 'Content-Type': 'application/json' },
1251
+ * body: JSON.stringify(credentials),
1252
+ * signal
1253
+ * });
1254
+ *
1255
+ * if (!response.ok) {
1256
+ * throw new Error('Login failed');
1257
+ * }
1258
+ *
1259
+ * return response.json();
1260
+ * });
1261
+ *
1262
+ * // Execute the task
1263
+ * loginTask.runWith({ username: 'john', password: 'secret' });
1264
+ *
1265
+ * // Monitor states
1266
+ * loginTask.pending.listen((isPending) => {
1267
+ * submitButton.disabled = isPending;
1268
+ * submitButton.textContent = isPending ? 'Logging in...' : 'Login';
1269
+ * });
1270
+ *
1271
+ * loginTask.error.listen((error) => {
1272
+ * if (error) {
1273
+ * errorMessage.textContent = error.message;
1274
+ * }
1275
+ * });
1276
+ *
1277
+ * loginTask.listen(async (promise) => {
1278
+ * const user = await promise;
1279
+ * console.log('Logged in as:', user.name);
1280
+ * });
1281
+ * ```
1282
+ *
1283
+ * @example
1284
+ * ```javascript
1285
+ * const fetchTask = Cell.task(async (id) => {
1286
+ * console.log('Fetching user', id);
1287
+ * await delay(1000);
1288
+ * return { id, name: 'User ' + id };
1289
+ * });
1290
+ *
1291
+ * // Execute the task
1292
+ * const user = await fetchTask.runWith(1);
1293
+ * console.log(user.name);
1294
+ *
1295
+ * // Execute again - each call creates a new execution
1296
+ * const anotherUser = await fetchTask.runWith(2);
1297
+ * ```
1298
+ */
1299
+ export class AsyncTaskCell extends AsyncCell {
1300
+ /** @param {MutatorFn<I, T>} fn */
1301
+ constructor(fn) {
1302
+ let currentInput = /** @type {I} */ (null);
1303
+ // currentInput may be null; hasInput indicates whether runWith has been called.
1304
+ /** @type {boolean} */
1305
+ let hasInput = false;
1306
+ const computedFn = () => {
1307
+ if (!hasInput)
1308
+ return Promise.resolve(/** @type {T} */ (null));
1309
+ const capturedInput = currentInput;
1310
+ return fn(capturedInput, this._signal);
1311
+ };
1312
+ super(computedFn);
1313
+ // AsyncTaskCell should not be pending until runWith is called
1314
+ this.pending.set(false);
1315
+ let hasExecuted = false;
1316
+ this.update = this.update.bind(this);
1317
+ /**
1318
+ * Executes the task with the provided input.
1319
+ *
1320
+ * Each call to runWith creates a new execution of the task function.
1321
+ * Concurrent calls are not deduplicated or cached. If you need to cancel
1322
+ * work in progress, use the provided AbortSignal and handle it inside the
1323
+ * task function.
1324
+ *
1325
+ * @param {I} input - The input value to pass to the task function.
1326
+ * @returns {Promise<T | null>} A promise that resolves with the task result,
1327
+ * or null if the task hasn't been executed yet.
1328
+ *
1329
+ * @example
1330
+ * ```javascript
1331
+ * const task = Cell.task(async (userId) => {
1332
+ * const response = await fetch(`/api/users/${userId}`);
1333
+ * return response.json();
1334
+ * });
1335
+ *
1336
+ * // Execute the task
1337
+ * const user = await task.runWith(123);
1338
+ * console.log(user.name);
1339
+ *
1340
+ * // Execute again with different input
1341
+ * const anotherUser = await task.runWith(456);
1342
+ * ```
1343
+ */
1344
+ this.runWith = async (input) => {
1345
+ const isFirstExecution = !hasExecuted;
1346
+ this.abort();
1347
+ currentInput = input;
1348
+ hasInput = true;
1349
+ const value = this.computedFn();
1350
+ hasExecuted = true;
1351
+ // For the first execution, we need to manually trigger an update
1352
+ // since AsyncCell skips update() for the initial state.
1353
+ // We also need to schedule AsyncDerivedCell children for recomputation.
1354
+ if (isFirstExecution) {
1355
+ value.then(() => {
1356
+ this.update();
1357
+ // Schedule AsyncDerivedCell children for recomputation
1358
+ for (const child of this.derivations) {
1359
+ if (child instanceof AsyncDerivedCell && !child[IsScheduled]) {
1360
+ UPDATE_BUFFER.push(child);
1361
+ child[IsScheduled] = true;
1362
+ }
1363
+ }
1364
+ if (!IS_UPDATING)
1365
+ triggerUpdate();
1366
+ });
1367
+ }
1368
+ return value;
1369
+ };
1370
+ }
992
1371
  }
993
1372
  /**
994
1373
  * An error class that aggregates multiple errors thrown during a cell update cycle.