@esmj/signals 0.0.5 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.mts CHANGED
@@ -1,6 +1,10 @@
1
1
  import { Observable, Observer } from '@esmj/observable';
2
2
 
3
3
  let context = null;
4
+ let batchDepth = 0;
5
+ let batchQueue = new Set();
6
+ let flushScheduled = false;
7
+ let flushCallbacks = [];
4
8
 
5
9
  function untrack(callback) {
6
10
  const prevContext = context;
@@ -11,6 +15,57 @@ function untrack(callback) {
11
15
  return result;
12
16
  }
13
17
 
18
+ function flushPending() {
19
+ flushScheduled = false;
20
+ const pendings = getPending();
21
+ for (const pending of pendings) {
22
+ pending.get();
23
+ }
24
+
25
+ // Run all onFlush callbacks once, then clear
26
+ const callbacks = flushCallbacks;
27
+ flushCallbacks = [];
28
+ for (const cb of callbacks) {
29
+ cb();
30
+ }
31
+ }
32
+
33
+ function scheduleFlush() {
34
+ if (!flushScheduled) {
35
+ flushScheduled = true;
36
+ queueMicrotask(flushPending);
37
+ }
38
+ }
39
+
40
+ function onFlush(callback) {
41
+ flushCallbacks.push(callback);
42
+
43
+ // Ensure a flush is scheduled so the callback actually runs
44
+ scheduleFlush();
45
+ }
46
+
47
+ function afterFlush() {
48
+ return new Promise((resolve) => onFlush(resolve));
49
+ }
50
+
51
+ function batch(callback) {
52
+ batchDepth++;
53
+ try {
54
+ callback();
55
+ } finally {
56
+ batchDepth--;
57
+ if (batchDepth === 0) {
58
+ const queue = batchQueue;
59
+ batchQueue = new Set();
60
+ for (const observable of queue) {
61
+ observable.next();
62
+ }
63
+
64
+ scheduleFlush();
65
+ }
66
+ }
67
+ }
68
+
14
69
  const INTERNAL_OBSERVABLE = Symbol('internal observable');
15
70
  const ORIGINAL_FUNCTION = Symbol('original function');
16
71
 
@@ -75,29 +130,37 @@ class Watcher extends Observable {
75
130
  }
76
131
 
77
132
  getPending() {
78
- const pendings = Array.from(this.#pendings).map((pending) => {
133
+ const pendings = [];
134
+ for (const pending of this.#pendings) {
79
135
  if (!pending.get[ORIGINAL_FUNCTION]) {
80
- const originalGet = pending.get.bind(pending);
136
+ const originalGet = pending.get;
137
+ const boundGet = originalGet.bind(pending);
81
138
 
82
139
  pending.get = () => {
83
140
  return untrack(() => {
84
141
  this.#pendings.delete(pending);
85
- return originalGet();
142
+ return boundGet();
86
143
  });
87
144
  };
88
145
 
89
146
  pending.get[ORIGINAL_FUNCTION] = originalGet;
90
147
  }
91
148
 
92
- return pending;
93
- });
149
+ pendings.push(pending);
150
+ }
94
151
 
95
152
  return pendings;
96
153
  }
97
154
 
98
155
  unwatch(signal) {
99
- signal.next = signal.next[ORIGINAL_FUNCTION];
100
- signal.get = signal.get[ORIGINAL_FUNCTION];
156
+ signal.next = signal.next[ORIGINAL_FUNCTION]
157
+ ? signal.next[ORIGINAL_FUNCTION]
158
+ : signal.next;
159
+ signal.get = signal.get[ORIGINAL_FUNCTION]
160
+ ? signal.get[ORIGINAL_FUNCTION]
161
+ : signal.get;
162
+
163
+ this.#pendings.delete(signal);
101
164
 
102
165
  return this.unsubscribe(signal);
103
166
  }
@@ -108,15 +171,9 @@ let w = null;
108
171
  function createWatcher(notify) {
109
172
  w = new Watcher(notify);
110
173
  }
111
- let timer = null;
174
+
112
175
  createWatcher(() => {
113
- // TODO performance improvement
114
- clearTimeout(timer);
115
- timer = setTimeout(() => {
116
- getPending().forEach((pending) => {
117
- pending.get();
118
- });
119
- }, 0);
176
+ scheduleFlush();
120
177
  });
121
178
 
122
179
  function getPending() {
@@ -131,13 +188,18 @@ function unwatch(signal) {
131
188
  return w.unwatch(signal);
132
189
  }
133
190
 
191
+ const NO_ERROR = Symbol('no error');
192
+
134
193
  class Computed extends Observer {
135
194
  #dirty = true;
136
- #prevContext = null;
195
+ #running = false;
137
196
  #signal = null;
138
197
  #context = this.#createNewContext();
139
198
  #callback = null;
140
199
  #options = null;
200
+ #revision = 0;
201
+ #sourceRevisions = new Map();
202
+ #error = NO_ERROR;
141
203
 
142
204
  constructor(callback, options) {
143
205
  super();
@@ -151,85 +213,144 @@ class Computed extends Observer {
151
213
  }
152
214
 
153
215
  #clearContextDependencies() {
154
- Array.from(this.#context.dependencies.values()).forEach(
155
- ({ unsubscribe }) => {
156
- unsubscribe();
157
- },
158
- );
216
+ for (const { unsubscribe } of this.#context.dependencies.values()) {
217
+ unsubscribe();
218
+ }
159
219
  this.#context.dependencies.clear();
160
220
  }
161
221
 
162
222
  #createNewContext() {
163
223
  const context = {
164
224
  dependencies: new Map(),
225
+ sourceRevisions: new Map(),
165
226
  observer: this,
166
227
  };
167
228
 
168
229
  return context;
169
230
  }
170
231
 
171
- #savePrevContext() {
172
- this.#prevContext = context;
173
- context = this.#context;
232
+ getRevision() {
233
+ return this.#revision;
174
234
  }
175
235
 
176
- #restorePrevContext() {
177
- context = this.#prevContext;
236
+ #needsRecompute() {
237
+ for (const [source, savedRevision] of this.#sourceRevisions) {
238
+ if (source instanceof Computed) {
239
+ untrack(() => source.get());
240
+ }
241
+
242
+ if (source.getRevision() !== savedRevision) {
243
+ return true;
244
+ }
245
+ }
246
+ return false;
178
247
  }
179
248
 
180
249
  next() {
181
250
  this.#dirty = true;
251
+ watch(this);
182
252
  this.#signal[INTERNAL_OBSERVABLE].next();
183
253
  }
184
254
 
185
255
  get() {
256
+ if (this.#running) {
257
+ throw new Error('Cycle detected in computed signal');
258
+ }
259
+
186
260
  if (!this.#signal) {
187
261
  this.#signal = createSignal(this.#run(), this.#options);
188
262
  }
189
263
 
190
- if (this.#dirty) {
191
- this.#run();
264
+ if (this.#dirty || this.#error !== NO_ERROR) {
265
+ if (this.#sourceRevisions.size === 0 || this.#needsRecompute()) {
266
+ unwatch(this);
267
+ this.#run();
268
+ } else {
269
+ this.#dirty = false;
270
+ }
271
+ }
272
+
273
+ if (context) {
274
+ context.sourceRevisions.set(this, this.#revision);
275
+ }
276
+
277
+ if (this.#error !== NO_ERROR) {
278
+ throw this.#error;
192
279
  }
193
280
 
194
281
  return this.#signal.get();
195
282
  }
196
283
 
284
+ peek() {
285
+ if (!this.#signal) {
286
+ this.#signal = createSignal(this.#run(), this.#options);
287
+ }
288
+
289
+ if (this.#dirty || this.#error !== NO_ERROR) {
290
+ if (this.#sourceRevisions.size === 0 || this.#needsRecompute()) {
291
+ unwatch(this);
292
+ this.#run();
293
+ } else {
294
+ this.#dirty = false;
295
+ }
296
+ }
297
+
298
+ if (this.#error !== NO_ERROR) {
299
+ throw this.#error;
300
+ }
301
+
302
+ return this.#signal.peek();
303
+ }
304
+
197
305
  #run() {
306
+ this.#running = true;
198
307
  this.#dirty = false;
308
+ this.#error = NO_ERROR;
199
309
 
200
310
  this.#clearContextDependencies();
201
- this.#savePrevContext();
311
+ this.#context.sourceRevisions.clear();
312
+
313
+ const prevContext = context;
314
+ context = this.#context;
202
315
 
203
316
  let result;
204
317
  try {
205
318
  result = this.#callback();
206
319
  } catch (e) {
207
- result = e;
320
+ this.#error = e;
208
321
  }
209
322
 
210
- this.#restorePrevContext();
323
+ context = prevContext;
324
+ this.#sourceRevisions = this.#context.sourceRevisions;
325
+ this.#context.sourceRevisions = new Map();
326
+ this.#running = false;
211
327
 
212
- // todo test it
213
328
  if (result instanceof Promise) {
214
329
  result = result
215
330
  .then((value) => {
216
- return value;
331
+ this.#error = NO_ERROR;
332
+ this.#signal.set(value);
217
333
  })
218
334
  .catch((e) => {
219
- throw e;
335
+ this.#error = e;
220
336
  });
221
337
  }
222
338
 
223
- if (this.#signal) {
339
+ if (this.#error === NO_ERROR && this.#signal) {
224
340
  this.#signal.set(result);
225
341
  }
226
342
 
227
- if (result instanceof Error) {
228
- throw result;
229
- }
343
+ this.#revision++;
230
344
 
231
345
  return result;
232
346
  }
347
+
348
+ destroy() {
349
+ this.#clearContextDependencies();
350
+ this.#sourceRevisions.clear();
351
+ this.#dirty = true;
352
+ this.#error = NO_ERROR;
353
+ }
233
354
  }
234
355
 
235
356
  function computed(callback, options) {
@@ -251,46 +372,71 @@ function effect(callback, options) {
251
372
  c.get();
252
373
 
253
374
  watch(c);
254
- return () => {
375
+
376
+ const dispose = () => {
255
377
  destructor?.();
378
+ c.destroy();
256
379
  unwatch(c);
257
380
  };
381
+
382
+ dispose[Symbol.dispose] = dispose;
383
+
384
+ return dispose;
258
385
  }
259
386
 
260
387
  function createSignal(value, options = {}) {
261
388
  const equals = options?.equals ?? Object.is;
389
+ let revision = 0;
262
390
 
263
391
  const observable = new Observable();
264
392
  function get() {
265
- if (typeof context === 'object' && context !== null) {
393
+ if (context) {
266
394
  context.dependencies.set(
267
395
  observable,
268
396
  observable.subscribe(context.observer),
269
397
  );
270
- }
271
398
 
272
- if (value instanceof Error) {
273
- throw value;
399
+ context.sourceRevisions.set(signal, revision);
274
400
  }
275
401
 
276
402
  return value;
277
403
  }
278
404
 
279
- // TODO implement batch updates
405
+ function peek() {
406
+ return value;
407
+ }
408
+
280
409
  function set(_value) {
281
410
  if (!equals(value, _value)) {
282
411
  value = _value;
412
+ revision++;
283
413
 
284
- observable.next();
414
+ if (batchDepth > 0) {
415
+ batchQueue.add(observable);
416
+ } else {
417
+ observable.next();
418
+ }
285
419
  }
286
420
 
287
421
  return value;
288
422
  }
289
423
 
290
- return { get, set, [INTERNAL_OBSERVABLE]: observable };
424
+ function getRevision() {
425
+ return revision;
426
+ }
427
+
428
+ const signal = {
429
+ get,
430
+ set,
431
+ peek,
432
+ getRevision,
433
+ [INTERNAL_OBSERVABLE]: observable,
434
+ };
435
+
436
+ return signal;
291
437
  }
292
438
 
293
439
  // alias
294
440
  const state = createSignal;
295
441
 
296
- export { computed, createSignal, createWatcher, effect, getPending, state, untrack, unwatch, watch };
442
+ export { afterFlush, batch, computed, createSignal, createWatcher, effect, getPending, onFlush, state, untrack, unwatch, watch };