@adbl/cells 0.0.20 → 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.
- package/README.md +293 -131
- package/dist/library/classes.d.ts +339 -101
- package/dist/library/classes.js +748 -359
- package/dist/library/classes.js.map +1 -1
- package/index.js +1 -0
- package/package.json +1 -1
package/dist/library/classes.js
CHANGED
|
@@ -1,29 +1,21 @@
|
|
|
1
1
|
/// <reference types="./classes.d.ts" />
|
|
2
2
|
/**
|
|
3
|
-
* @template
|
|
4
|
-
* @typedef {
|
|
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 {
|
|
26
|
-
* @typedef
|
|
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
|
-
|
|
113
|
+
let UPDATE_BUFFER = [];
|
|
126
114
|
let IS_UPDATING = false;
|
|
127
115
|
/** @type {object[]} */
|
|
128
116
|
const CONTEXT_STACK = [GlobalTrackingContext];
|
|
@@ -140,51 +128,76 @@ const cellErrors = [];
|
|
|
140
128
|
function triggerUpdate() {
|
|
141
129
|
IS_UPDATING = true;
|
|
142
130
|
let currentDepth = 0;
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
const
|
|
147
|
-
if (
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
131
|
+
let lastProcessedCellIndex = 0;
|
|
132
|
+
while (lastProcessedCellIndex < UPDATE_BUFFER.length) {
|
|
133
|
+
for (let i = lastProcessedCellIndex; i < UPDATE_BUFFER.length; i++) {
|
|
134
|
+
const cell = UPDATE_BUFFER[i];
|
|
135
|
+
if (cell instanceof DerivedCell) {
|
|
136
|
+
const depth = cell[Depth];
|
|
137
|
+
if (depth > currentDepth + 1) {
|
|
138
|
+
if (cell[Deferred]) {
|
|
139
|
+
currentDepth++;
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
cell[Deferred] = true;
|
|
143
|
+
}
|
|
144
|
+
// Move nodes with higher depths to the end of the array so they
|
|
145
|
+
// are processed last.
|
|
146
|
+
UPDATE_BUFFER.push(cell);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
cell[Deferred] = false;
|
|
150
|
+
if (depth > currentDepth)
|
|
151
|
+
currentDepth = depth;
|
|
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
|
+
}
|
|
167
|
+
// @ts-expect-error: wvalue is protected.
|
|
168
|
+
if (deepEqual(cell.wvalue, newValue)) {
|
|
169
|
+
cell[IsScheduled] = false;
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
// @ts-expect-error: wvalue is protected.
|
|
173
|
+
cell.wvalue = newValue;
|
|
159
174
|
}
|
|
160
|
-
//
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
for (const computedCell of computedDependents) {
|
|
166
|
-
if (computedCell[IsScheduled])
|
|
167
|
-
continue;
|
|
168
|
-
if (BATCH_NESTING_LEVEL > 0)
|
|
169
|
-
BATCHED_EFFECTS.set(() => UPDATE_BUFFER.push(computedCell), undefined);
|
|
170
|
-
else
|
|
175
|
+
// Run computed dependents.
|
|
176
|
+
const computedDependents = cell.derivations;
|
|
177
|
+
for (const computedCell of computedDependents) {
|
|
178
|
+
if (computedCell[IsScheduled])
|
|
179
|
+
continue;
|
|
171
180
|
UPDATE_BUFFER.push(computedCell);
|
|
172
|
-
|
|
181
|
+
computedCell[IsScheduled] = true;
|
|
182
|
+
}
|
|
173
183
|
}
|
|
174
|
-
//
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
184
|
+
// A cell can update in another's effect, triggering a rerun
|
|
185
|
+
// of the whole process. Since the UPDATE_BUFFER is the same array,
|
|
186
|
+
// we need to know where to continue iteration from.
|
|
187
|
+
let i = lastProcessedCellIndex;
|
|
188
|
+
lastProcessedCellIndex = UPDATE_BUFFER.length;
|
|
189
|
+
for (; i < UPDATE_BUFFER.length; i++) {
|
|
190
|
+
const cell = UPDATE_BUFFER[i];
|
|
191
|
+
if (cell[IsScheduled]) {
|
|
192
|
+
// @ts-expect-error: Cell.update is protected.
|
|
193
|
+
cell.update();
|
|
194
|
+
cell[IsScheduled] = false;
|
|
195
|
+
}
|
|
178
196
|
}
|
|
179
197
|
}
|
|
180
|
-
for (const cell of UPDATE_BUFFER) {
|
|
181
|
-
// @ts-expect-error: Cell.update is protected.
|
|
182
|
-
if (cell[IsScheduled])
|
|
183
|
-
cell.update();
|
|
184
|
-
cell[IsScheduled] = false;
|
|
185
|
-
}
|
|
186
|
-
UPDATE_BUFFER.length = 0;
|
|
187
198
|
IS_UPDATING = false;
|
|
199
|
+
UPDATE_BUFFER.length = 0;
|
|
200
|
+
lastProcessedCellIndex = 0;
|
|
188
201
|
throwAnyErrors();
|
|
189
202
|
}
|
|
190
203
|
function throwAnyErrors() {
|
|
@@ -196,27 +209,7 @@ function throwAnyErrors() {
|
|
|
196
209
|
throw new CellUpdateError(errors);
|
|
197
210
|
}
|
|
198
211
|
}
|
|
199
|
-
|
|
200
|
-
const mutativeSetMethods = new Set(['add', 'delete', 'clear']);
|
|
201
|
-
const mutativeArrayMethods = new Set([
|
|
202
|
-
'push',
|
|
203
|
-
'pop',
|
|
204
|
-
'shift',
|
|
205
|
-
'unshift',
|
|
206
|
-
'splice',
|
|
207
|
-
'sort',
|
|
208
|
-
'reverse',
|
|
209
|
-
]);
|
|
210
|
-
const mutativeDateMethods = new Set([
|
|
211
|
-
'setDate',
|
|
212
|
-
'setMonth',
|
|
213
|
-
'setFullYear',
|
|
214
|
-
'setHours',
|
|
215
|
-
'setMinutes',
|
|
216
|
-
'setSeconds',
|
|
217
|
-
'setMilliseconds',
|
|
218
|
-
]);
|
|
219
|
-
/** @template T */
|
|
212
|
+
/** @template {*} out T */
|
|
220
213
|
class Effect {
|
|
221
214
|
/**
|
|
222
215
|
* @type {EffectOptions | undefined}
|
|
@@ -263,6 +256,8 @@ export class LocalContext {
|
|
|
263
256
|
for (const source of sources) {
|
|
264
257
|
source.derivations.delete(derivation);
|
|
265
258
|
}
|
|
259
|
+
if (derivation instanceof AsyncCell)
|
|
260
|
+
derivation[DisposeAsyncCell]();
|
|
266
261
|
}
|
|
267
262
|
for (const [cell, effects] of this.effects) {
|
|
268
263
|
if (cell instanceof DerivedCell && this.derivationSourceMap.has(cell)) {
|
|
@@ -351,13 +346,6 @@ export class Cell {
|
|
|
351
346
|
* @protected @type T
|
|
352
347
|
*/
|
|
353
348
|
wvalue = /** @type {T} */ (null);
|
|
354
|
-
/**
|
|
355
|
-
* @protected
|
|
356
|
-
* @param {T} value
|
|
357
|
-
*/
|
|
358
|
-
setValue(value) {
|
|
359
|
-
this.wvalue = value;
|
|
360
|
-
}
|
|
361
349
|
/**
|
|
362
350
|
* Overrides `Object.prototype.valueOf()` to return the value stored in the Cell.
|
|
363
351
|
* @returns {T} The value of the Cell.
|
|
@@ -424,9 +412,6 @@ export class Cell {
|
|
|
424
412
|
this.ignore(effect);
|
|
425
413
|
};
|
|
426
414
|
}
|
|
427
|
-
if (options?.name && this.isListeningTo(options.name)) {
|
|
428
|
-
throw new Error(`An effect with the name "${options.name}" is already listening to this cell.`);
|
|
429
|
-
}
|
|
430
415
|
const isAlreadySubscribed = this.#effects.some((effect) => {
|
|
431
416
|
return effect.callback === callback;
|
|
432
417
|
});
|
|
@@ -468,10 +453,6 @@ export class Cell {
|
|
|
468
453
|
});
|
|
469
454
|
if (options?.once)
|
|
470
455
|
return () => this.ignore(cb);
|
|
471
|
-
if (options?.name && this.isListeningTo(options.name)) {
|
|
472
|
-
const message = `An effect with the name "${options.name}" is already listening to this cell.`;
|
|
473
|
-
throw new Error(message);
|
|
474
|
-
}
|
|
475
456
|
const isAlreadySubscribed = this.#effects.some((e) => {
|
|
476
457
|
return e.callback === callback;
|
|
477
458
|
});
|
|
@@ -502,28 +483,6 @@ export class Cell {
|
|
|
502
483
|
return;
|
|
503
484
|
this.#effects.splice(index, 1);
|
|
504
485
|
}
|
|
505
|
-
/**
|
|
506
|
-
* Checks if the cell is listening to a watcher with the specified name.
|
|
507
|
-
* @param {string} name - The name of the watcher to check for.
|
|
508
|
-
* @returns {boolean} `true` if the cell is listening to a watcher with the specified name, `false` otherwise.
|
|
509
|
-
*/
|
|
510
|
-
isListeningTo(name) {
|
|
511
|
-
return this.#effects.some((effect) => {
|
|
512
|
-
return effect?.options?.name === name && effect.callback;
|
|
513
|
-
});
|
|
514
|
-
}
|
|
515
|
-
/**
|
|
516
|
-
* Removes the watcher with the specified name from the list of effects for this cell.
|
|
517
|
-
* @param {string} name - The name of the watcher to stop listening to.
|
|
518
|
-
*/
|
|
519
|
-
stopListeningTo(name) {
|
|
520
|
-
const effectIndex = this.#effects.findIndex((e) => {
|
|
521
|
-
return e.options?.name === name;
|
|
522
|
-
});
|
|
523
|
-
if (effectIndex === -1)
|
|
524
|
-
return;
|
|
525
|
-
this.#effects.splice(effectIndex, 1);
|
|
526
|
-
}
|
|
527
486
|
/**
|
|
528
487
|
* @protected
|
|
529
488
|
* Updates the root object and notifies any registered watchers and computed dependents.
|
|
@@ -532,14 +491,12 @@ export class Cell {
|
|
|
532
491
|
update() {
|
|
533
492
|
// Run watchers.
|
|
534
493
|
const wvalue = this.wvalue;
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
494
|
+
// Make a copy to avoid issues if effects are removed during iteration (e.g., once: true)
|
|
495
|
+
const effects = [...this.#effects];
|
|
496
|
+
let hasUndefinedEffect = false;
|
|
497
|
+
for (const { callback: watcher } of effects) {
|
|
539
498
|
if (watcher === undefined) {
|
|
540
|
-
|
|
541
|
-
i--;
|
|
542
|
-
len--;
|
|
499
|
+
hasUndefinedEffect = true;
|
|
543
500
|
continue;
|
|
544
501
|
}
|
|
545
502
|
if (BATCH_NESTING_LEVEL > 0) {
|
|
@@ -556,6 +513,9 @@ export class Cell {
|
|
|
556
513
|
}
|
|
557
514
|
}
|
|
558
515
|
}
|
|
516
|
+
if (hasUndefinedEffect) {
|
|
517
|
+
this.#effects = this.#effects.filter((effect) => effect.callback !== undefined);
|
|
518
|
+
}
|
|
559
519
|
}
|
|
560
520
|
/**
|
|
561
521
|
* Returns the current value of the cell without registering dependencies.
|
|
@@ -609,6 +569,168 @@ export class Cell {
|
|
|
609
569
|
* @returns {LocalContext} A new LocalContext instance.
|
|
610
570
|
*/
|
|
611
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
|
+
};
|
|
612
734
|
/**
|
|
613
735
|
* Executes a function within a specific LocalContext.
|
|
614
736
|
* Any effects (`.listen`) or derived cells (`Cell.derived`) created synchronously
|
|
@@ -635,27 +757,60 @@ export class Cell {
|
|
|
635
757
|
* @returns {X} The return value of the callback.
|
|
636
758
|
*/
|
|
637
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;
|
|
638
766
|
BATCH_NESTING_LEVEL++;
|
|
767
|
+
BATCHED_EFFECTS = new Map();
|
|
639
768
|
/** @type {X | undefined} */
|
|
640
769
|
let value;
|
|
641
|
-
let error;
|
|
642
770
|
try {
|
|
643
|
-
|
|
771
|
+
try {
|
|
772
|
+
value = callback();
|
|
773
|
+
}
|
|
774
|
+
catch (e) {
|
|
775
|
+
if (e instanceof Error)
|
|
776
|
+
cellErrors.push(e);
|
|
777
|
+
}
|
|
778
|
+
if (!wasUpdating)
|
|
779
|
+
triggerUpdate();
|
|
644
780
|
}
|
|
645
781
|
catch (e) {
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
BATCH_NESTING_LEVEL--;
|
|
649
|
-
if (error instanceof Error) {
|
|
650
|
-
cellErrors.push(error);
|
|
782
|
+
if (e instanceof Error)
|
|
783
|
+
cellErrors.push(e);
|
|
651
784
|
}
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
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
|
+
}
|
|
655
797
|
}
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
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;
|
|
659
814
|
}
|
|
660
815
|
throwAnyErrors();
|
|
661
816
|
return /** @type {X} */ (value);
|
|
@@ -668,147 +823,6 @@ export class Cell {
|
|
|
668
823
|
* @returns {value is Cell<T>} True if the value is an instance of Cell, false otherwise.
|
|
669
824
|
*/
|
|
670
825
|
static isCell = (value) => value instanceof Cell;
|
|
671
|
-
/**
|
|
672
|
-
* @template T
|
|
673
|
-
* 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.
|
|
674
|
-
* @param {T | Cell<T>} value - The value to be flattened.
|
|
675
|
-
* @returns {T} The flattened value.
|
|
676
|
-
*/
|
|
677
|
-
static flatten = (value) => {
|
|
678
|
-
if (value instanceof Cell) {
|
|
679
|
-
if (value instanceof DerivedCell) {
|
|
680
|
-
return Cell.flatten(value.wvalue);
|
|
681
|
-
}
|
|
682
|
-
return Cell.flatten(value.wvalue);
|
|
683
|
-
}
|
|
684
|
-
if (Array.isArray(value)) {
|
|
685
|
-
// @ts-expect-error:
|
|
686
|
-
return Cell.flattenArray(value);
|
|
687
|
-
}
|
|
688
|
-
if (value instanceof Object) {
|
|
689
|
-
// @ts-expect-error:
|
|
690
|
-
return Cell.flattenObject(value);
|
|
691
|
-
}
|
|
692
|
-
return value;
|
|
693
|
-
};
|
|
694
|
-
/**
|
|
695
|
-
* Flattens an array by applying the `flatten` function to each element.
|
|
696
|
-
* @template T
|
|
697
|
-
* @param {Array<T | Cell<T>>} array - The array to be flattened.
|
|
698
|
-
* @returns {Array<T>} A new array with the flattened elements.
|
|
699
|
-
*/
|
|
700
|
-
static flattenArray = (array) => array.map(Cell.flatten);
|
|
701
|
-
/**
|
|
702
|
-
* Flattens an object by applying the `flatten` function to each value.
|
|
703
|
-
* @template {object} T
|
|
704
|
-
* @param {T} object - The object to be flattened.
|
|
705
|
-
* @returns {{ [K in keyof T]: T[K] extends Cell<infer U> ? U : T[K] }} A new object with the flattened values.
|
|
706
|
-
*/
|
|
707
|
-
static flattenObject = (object) => {
|
|
708
|
-
const result =
|
|
709
|
-
/** @type {{ [K in keyof T]: T[K] extends Cell<infer U> ? U : T[K] }} */ ({});
|
|
710
|
-
for (const [key, value] of Object.entries(object)) {
|
|
711
|
-
Reflect.set(result, key, Cell.flatten(value));
|
|
712
|
-
}
|
|
713
|
-
return result;
|
|
714
|
-
};
|
|
715
|
-
/**
|
|
716
|
-
* Wraps an asynchronous function with managed state.
|
|
717
|
-
*
|
|
718
|
-
* @template {(...args: any[]) => Promise<any>} Getter - A function that performs the asynchronous operation.
|
|
719
|
-
* @template {Parameters<Getter>[0]} [X=Parameters<Getter>[0]]
|
|
720
|
-
* @template {Awaited<ReturnType<Getter>>} [Y=Awaited<ReturnType<Getter>>]
|
|
721
|
-
* @param {Getter} getter - A function that performs the asynchronous operation.
|
|
722
|
-
* @returns {AsyncRequestAtoms<Parameters<Getter>[0], Awaited<ReturnType<Getter>>, Getter>} An object containing cells for pending, data, and error states,
|
|
723
|
-
* as well as functions to run and reload the operation.
|
|
724
|
-
*
|
|
725
|
-
* @example
|
|
726
|
-
* const { pending, data, error, run, reload } = Cell.async(async (input) => {
|
|
727
|
-
* const response = await fetch(`https://example.com/api/data?input=${input}`);
|
|
728
|
-
* return response.json();
|
|
729
|
-
* });
|
|
730
|
-
*
|
|
731
|
-
* run('input');
|
|
732
|
-
*/
|
|
733
|
-
static async(getter) {
|
|
734
|
-
const pending = Cell.source(false);
|
|
735
|
-
const data = Cell.source(/** @type {Y | null} */ (null));
|
|
736
|
-
const error = Cell.source(/** @type {Error | null} */ (null));
|
|
737
|
-
/** @type {X | undefined} */
|
|
738
|
-
let initialInput;
|
|
739
|
-
/** @type {AbortController | undefined} */
|
|
740
|
-
let controller;
|
|
741
|
-
async function run(input = initialInput) {
|
|
742
|
-
if (controller)
|
|
743
|
-
controller.abort();
|
|
744
|
-
controller = new AbortController();
|
|
745
|
-
pending.set(true);
|
|
746
|
-
error.set(null);
|
|
747
|
-
data.set(null);
|
|
748
|
-
await Cell.batch(async () => {
|
|
749
|
-
const currentController = controller;
|
|
750
|
-
try {
|
|
751
|
-
initialInput = input;
|
|
752
|
-
const _input = /** @type {X} */ (input);
|
|
753
|
-
const result = await getter.bind(currentController)(_input);
|
|
754
|
-
if (currentController?.signal.aborted)
|
|
755
|
-
return;
|
|
756
|
-
data.set(result);
|
|
757
|
-
}
|
|
758
|
-
catch (e) {
|
|
759
|
-
if (e instanceof Error) {
|
|
760
|
-
error.set(e);
|
|
761
|
-
}
|
|
762
|
-
else {
|
|
763
|
-
throw e;
|
|
764
|
-
}
|
|
765
|
-
}
|
|
766
|
-
pending.set(false);
|
|
767
|
-
});
|
|
768
|
-
return data.get();
|
|
769
|
-
}
|
|
770
|
-
/**
|
|
771
|
-
* @param {X} [newInput]
|
|
772
|
-
* @param {boolean} [changeLoadingState]
|
|
773
|
-
*/
|
|
774
|
-
async function reload(newInput, changeLoadingState = true) {
|
|
775
|
-
if (controller)
|
|
776
|
-
controller.abort();
|
|
777
|
-
controller = new AbortController();
|
|
778
|
-
if (changeLoadingState) {
|
|
779
|
-
pending.set(true);
|
|
780
|
-
}
|
|
781
|
-
await Cell.batch(async () => {
|
|
782
|
-
const currentController = controller;
|
|
783
|
-
try {
|
|
784
|
-
const result = await getter.bind(currentController)(
|
|
785
|
-
/** @type {X} */ (newInput ?? initialInput));
|
|
786
|
-
if (currentController?.signal.aborted)
|
|
787
|
-
return;
|
|
788
|
-
data.set(result);
|
|
789
|
-
}
|
|
790
|
-
catch (e) {
|
|
791
|
-
if (e instanceof Error) {
|
|
792
|
-
error.set(e);
|
|
793
|
-
}
|
|
794
|
-
else {
|
|
795
|
-
throw e;
|
|
796
|
-
}
|
|
797
|
-
}
|
|
798
|
-
if (changeLoadingState) {
|
|
799
|
-
pending.set(false);
|
|
800
|
-
}
|
|
801
|
-
});
|
|
802
|
-
}
|
|
803
|
-
return {
|
|
804
|
-
pending,
|
|
805
|
-
data,
|
|
806
|
-
error,
|
|
807
|
-
// @ts-expect-error
|
|
808
|
-
run,
|
|
809
|
-
reload,
|
|
810
|
-
};
|
|
811
|
-
}
|
|
812
826
|
}
|
|
813
827
|
/**
|
|
814
828
|
* A class that represents a computed value that depends on other reactive values.
|
|
@@ -818,6 +832,7 @@ export class Cell {
|
|
|
818
832
|
*/
|
|
819
833
|
export class DerivedCell extends Cell {
|
|
820
834
|
[Depth] = 0;
|
|
835
|
+
[Deferred] = false;
|
|
821
836
|
/**
|
|
822
837
|
* @param {() => T} computedFn - A function that generates the value of the computed.
|
|
823
838
|
*/
|
|
@@ -829,20 +844,23 @@ export class DerivedCell extends Cell {
|
|
|
829
844
|
// Ensures that the cell is derived every time the computing function is called.
|
|
830
845
|
const derivationWrapper = () => {
|
|
831
846
|
ACTIVE_DERIVED_CTX.push([this, 0]);
|
|
832
|
-
let value = this.wvalue;
|
|
833
847
|
try {
|
|
834
|
-
|
|
848
|
+
return computedFn();
|
|
835
849
|
}
|
|
836
850
|
catch (e) {
|
|
837
851
|
if (e instanceof Error)
|
|
838
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;
|
|
839
860
|
}
|
|
840
|
-
const [, depth] = /** @type {[this, number]} */ (ACTIVE_DERIVED_CTX.pop());
|
|
841
|
-
if (depth + 1 > this[Depth])
|
|
842
|
-
this[Depth] = depth + 1;
|
|
843
|
-
return value;
|
|
844
861
|
};
|
|
845
|
-
|
|
862
|
+
/** @protected @type {T} */
|
|
863
|
+
this.wvalue = derivationWrapper();
|
|
846
864
|
this.computedFn = /** @type {() => T} */ (derivationWrapper);
|
|
847
865
|
throwAnyErrors();
|
|
848
866
|
}
|
|
@@ -862,21 +880,11 @@ export class DerivedCell extends Cell {
|
|
|
862
880
|
* @extends {Cell<T>}
|
|
863
881
|
* A cell whose value can be directly modified.
|
|
864
882
|
* Source cells are the primary way to introduce reactivity.
|
|
865
|
-
* They can hold any value type and will automatically handle proxying of objects
|
|
866
|
-
* to enable deep reactivity when needed.
|
|
867
883
|
*
|
|
868
884
|
* @example
|
|
869
885
|
* ```typescript
|
|
870
886
|
* const count = Cell.source(0);
|
|
871
887
|
* ```
|
|
872
|
-
*
|
|
873
|
-
* @example
|
|
874
|
-
* ```typescript
|
|
875
|
-
* // With options
|
|
876
|
-
* const immutableCell = Cell.source(42, { immutable: true });
|
|
877
|
-
* // Will throw error:
|
|
878
|
-
* immutableCell.set(43);
|
|
879
|
-
* ```
|
|
880
888
|
*/
|
|
881
889
|
export class SourceCell extends Cell {
|
|
882
890
|
/**
|
|
@@ -886,9 +894,9 @@ export class SourceCell extends Cell {
|
|
|
886
894
|
*/
|
|
887
895
|
constructor(value, options) {
|
|
888
896
|
super();
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
this.
|
|
897
|
+
/** @protected */
|
|
898
|
+
this.wvalue = value;
|
|
899
|
+
this.options = options;
|
|
892
900
|
}
|
|
893
901
|
peek() {
|
|
894
902
|
return this.wvalue;
|
|
@@ -898,88 +906,469 @@ export class SourceCell extends Cell {
|
|
|
898
906
|
* @returns {T} The value of the Cell.
|
|
899
907
|
*/
|
|
900
908
|
get() {
|
|
901
|
-
return this
|
|
909
|
+
return this.revalued;
|
|
902
910
|
}
|
|
903
911
|
/**
|
|
904
912
|
* Sets the value stored in the Cell and triggers an update.
|
|
905
913
|
* @param {T} value
|
|
906
914
|
*/
|
|
907
915
|
set(value) {
|
|
908
|
-
if (this.options?.immutable) {
|
|
909
|
-
throw new Error('Cannot set the value of an immutable cell.');
|
|
910
|
-
}
|
|
911
916
|
const oldValue = this.wvalue;
|
|
912
917
|
const isEqual = this.options?.equals
|
|
913
918
|
? this.options.equals(oldValue, value)
|
|
914
919
|
: deepEqual(oldValue, value);
|
|
915
920
|
if (isEqual)
|
|
916
921
|
return;
|
|
917
|
-
this.
|
|
922
|
+
this.wvalue = value;
|
|
918
923
|
this[IsScheduled] = true;
|
|
919
924
|
UPDATE_BUFFER.push(this);
|
|
920
925
|
if (!IS_UPDATING)
|
|
921
926
|
triggerUpdate();
|
|
922
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;
|
|
923
942
|
/**
|
|
924
|
-
*
|
|
925
|
-
*
|
|
926
|
-
* @
|
|
927
|
-
|
|
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}
|
|
954
|
+
*/
|
|
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>}
|
|
928
970
|
*/
|
|
929
|
-
|
|
930
|
-
|
|
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();
|
|
931
998
|
return value;
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
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
|
+
});
|
|
938
1038
|
}
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
if (isMutativeMethod) {
|
|
946
|
-
// @ts-expect-error: Direct access is faster than Reflection here.
|
|
947
|
-
return (...args) => {
|
|
948
|
-
// @ts-expect-error: Direct access is faster than Reflection here.
|
|
949
|
-
const result = target[prop](...args);
|
|
950
|
-
UPDATE_BUFFER.push(this);
|
|
951
|
-
this[IsScheduled] = true;
|
|
952
|
-
if (!IS_UPDATING)
|
|
953
|
-
triggerUpdate();
|
|
954
|
-
return result;
|
|
955
|
-
};
|
|
956
|
-
}
|
|
1039
|
+
return lastStablePromise;
|
|
1040
|
+
})
|
|
1041
|
+
.then(async (value) => {
|
|
1042
|
+
if (currentRunId === runId) {
|
|
1043
|
+
this.pending.set(false);
|
|
1044
|
+
resolveChangedState?.(!deepEqual(await lastStablePromise, value));
|
|
957
1045
|
}
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
if (typeof value === 'function') {
|
|
961
|
-
value = value.bind(target);
|
|
1046
|
+
else {
|
|
1047
|
+
resolveChangedState?.(false);
|
|
962
1048
|
}
|
|
963
1049
|
return value;
|
|
964
|
-
}
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
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)
|
|
975
1102
|
triggerUpdate();
|
|
976
|
-
}
|
|
977
1103
|
}
|
|
978
|
-
|
|
979
|
-
|
|
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);
|
|
1143
|
+
});
|
|
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
|
+
}
|
|
980
1165
|
});
|
|
981
1166
|
}
|
|
982
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
|
+
}
|
|
1371
|
+
}
|
|
983
1372
|
/**
|
|
984
1373
|
* An error class that aggregates multiple errors thrown during a cell update cycle.
|
|
985
1374
|
*
|