@zubra/runtime 5.8.0 → 5.9.1

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.
@@ -14,12 +14,19 @@
14
14
  // back-links, so an unwatched graph is garbage-collectable and costs nothing on write.
15
15
  //
16
16
  // Dependency bookkeeping is positional, with back-indices for O(1) unlinking, and follows the
17
- // proposal's reference polyfill exactly: the differential tests in runtime/test compare this
17
+ // proposal's reference polyfill: the differential tests in runtime/test compare this
18
18
  // implementation with `signal-polyfill` on hand-written scenarios and hundreds of random graphs,
19
19
  // including the order in which watchers are notified and watched/unwatched hooks fire.
20
- // Deliberate differences (all stricter, none observable in correct programs) are listed in
21
- // runtime/README.md.
22
-
20
+ // Deliberate differences are listed in runtime/README.md.
21
+ //
22
+ // Laid out for speed (each measured; see docs/performance.md):
23
+ // - Each public object holds its node in a private field, which is also the brand check. No
24
+ // WeakMap from object to node: one with tens of thousands of keys is a table the GC walks.
25
+ // - One node class with every field, so the graph code sees one shape and dispatches on a
26
+ // small integer, not `instanceof`. Dependency arrays are created on first use.
27
+ // - A Watcher records computeds as they're marked dirty, so getPending() costs O(pending),
28
+ // not O(watched). Effects built the proposal's way (one Watcher, getPending() per flush)
29
+ // would otherwise pay for every effect on the page on every update.
23
30
  const UNSET = Symbol('unset');
24
31
  const COMPUTING = Symbol('computing');
25
32
  const ERRORED = Symbol('errored');
@@ -27,72 +34,43 @@ const ERRORED = Symbol('errored');
27
34
  const watched = Symbol('watched');
28
35
  const unwatched = Symbol('unwatched');
29
36
 
30
- let activeConsumer = null; // the node whose reads are currently being recorded
31
- let frozen = false; // true while watchers are notified or hooks run
37
+ const STATE = 0, COMPUTED = 1, WATCHER = 2;
38
+
39
+ let activeConsumer = null;
40
+ let frozen = false;
32
41
  let epoch = 0;
33
42
 
34
43
  const frozenError = () =>
35
44
  new Error('Signals cannot be read or written during a Watcher notification or a watched/unwatched callback');
36
45
 
37
- // ---- graph nodes ---------------------------------------------------------------------------
38
- //
39
- // Consumer side (Computed, Watcher):
40
- // sources[i] the i-th dependency read (duplicates allowed, as in the reference)
41
- // sourceVersions[i] that dependency's version when it was read
42
- // sourceSlots[i] where this consumer sits in sources[i].sinks (only while live)
43
- // nextSource read cursor during a computation / append cursor for a Watcher
44
- // Producer side (State, Computed):
45
- // sinks[j] live consumers
46
- // sinkSlots[j] where this producer sits in sinks[j].sources
46
+ const EMPTY = [];
47
47
 
48
48
  class Node {
49
- constructor(wrapper) {
49
+ constructor(kind, wrapper) {
50
+ this.kind = kind;
50
51
  this.wrapper = wrapper;
51
52
  this.version = 0;
52
53
  this.dirty = false;
53
- this.sinks = [];
54
- this.sinkSlots = [];
55
- this.sources = [];
56
- this.sourceVersions = [];
57
- this.sourceSlots = [];
58
- this.nextSource = 0;
59
- }
60
- get live() { return this.sinks.length > 0; }
61
- }
62
-
63
- class StateNode extends Node {
64
- constructor(wrapper, value, options) {
65
- super(wrapper);
66
- this.value = value;
67
- this.equals = options?.equals ?? Object.is;
68
- this.onWatched = options?.[watched];
69
- this.onUnwatched = options?.[unwatched];
70
- }
71
- }
72
-
73
- class ComputedNode extends Node {
74
- constructor(wrapper, computation, options) {
75
- super(wrapper);
76
- this.computation = computation;
77
- this.value = UNSET;
54
+ this.value = undefined;
78
55
  this.error = undefined;
79
- this.dirty = true;
56
+ this.equals = Object.is;
57
+ this.computation = null;
58
+ this.notify = null;
59
+ this.onWatched = undefined;
60
+ this.onUnwatched = undefined;
80
61
  this.lastCleanEpoch = -1;
81
- this.equals = options?.equals ?? Object.is;
82
- this.onWatched = options?.[watched];
83
- this.onUnwatched = options?.[unwatched];
84
- }
85
- }
86
-
87
- class WatcherNode extends Node {
88
- constructor(wrapper, notify) {
89
- super(wrapper);
90
- this.notify = notify;
62
+ this.sinks = null; // live consumers (lazy)
63
+ this.sinkSlots = null;
64
+ this.sources = null; // dependencies (consumers only, lazy)
65
+ this.sourceVersions = null;
66
+ this.sourceSlots = null;
67
+ this.nextSource = 0;
68
+ this.pending = null; // Watcher only: computeds marked dirty since the list was compacted
69
+ this.seen = 0; // de-duplication stamp for getPending()
91
70
  }
92
- get live() { return true; }
93
71
  }
94
72
 
95
- // ---- liveness: linking and unlinking back-references ---------------------------------------
73
+ const isLive = (node) => node.kind === WATCHER || (node.sinks !== null && node.sinks.length > 0);
96
74
 
97
75
  function runFrozen(hook, receiver) {
98
76
  if (typeof hook !== 'function') return;
@@ -101,86 +79,90 @@ function runFrozen(hook, receiver) {
101
79
  try { hook.call(receiver); } finally { frozen = previous; }
102
80
  }
103
81
 
104
- /** Add `consumer` as a live sink of `producer`; returns the slot it occupies. */
105
82
  function link(producer, consumer, slotInConsumer) {
83
+ if (producer.sinks === null) { producer.sinks = []; producer.sinkSlots = []; }
106
84
  if (producer.sinks.length === 0) {
107
85
  runFrozen(producer.onWatched, producer.wrapper);
108
- for (let i = 0; i < producer.sources.length; i++) {
109
- producer.sourceSlots[i] = link(producer.sources[i], producer, i);
86
+ const sources = producer.sources;
87
+ if (sources !== null) {
88
+ for (let i = 0; i < sources.length; i++) producer.sourceSlots[i] = link(sources[i], producer, i);
110
89
  }
111
90
  }
112
91
  producer.sinkSlots.push(slotInConsumer);
113
92
  return producer.sinks.push(consumer) - 1;
114
93
  }
115
94
 
116
- /** Remove the live sink at `slot` of `producer` (swap-remove, fixing the moved back-index). */
117
95
  function unlink(producer, slot) {
118
- if (producer.sinks.length === 1) {
96
+ const sinks = producer.sinks;
97
+ if (sinks.length === 1) {
119
98
  runFrozen(producer.onUnwatched, producer.wrapper);
120
- for (let i = 0; i < producer.sources.length; i++) unlink(producer.sources[i], producer.sourceSlots[i]);
99
+ const sources = producer.sources;
100
+ if (sources !== null) for (let i = 0; i < sources.length; i++) unlink(sources[i], producer.sourceSlots[i]);
121
101
  }
122
- const last = producer.sinks.length - 1;
123
- producer.sinks[slot] = producer.sinks[last];
124
- producer.sinkSlots[slot] = producer.sinkSlots[last];
125
- producer.sinks.length = last;
126
- producer.sinkSlots.length = last;
127
- if (slot < last) producer.sinks[slot].sourceSlots[producer.sinkSlots[slot]] = slot;
102
+ const last = sinks.length - 1;
103
+ const slots = producer.sinkSlots;
104
+ if (slot < last) {
105
+ const moved = sinks[last];
106
+ sinks[slot] = moved;
107
+ slots[slot] = slots[last];
108
+ moved.sourceSlots[slots[slot]] = slot;
109
+ }
110
+ sinks.pop();
111
+ slots.pop();
128
112
  }
129
113
 
130
- // ---- tracking reads ------------------------------------------------------------------------
131
-
132
114
  function track(producer) {
133
115
  if (frozen) throw frozenError();
134
116
  const consumer = activeConsumer;
135
- if (!consumer) return;
117
+ if (consumer === null) return;
118
+ if (consumer.sources === null) { consumer.sources = []; consumer.sourceVersions = []; consumer.sourceSlots = []; }
136
119
  const i = consumer.nextSource++;
137
- const previous = consumer.sources[i];
120
+ const sources = consumer.sources;
121
+ const previous = sources[i];
138
122
  if (previous !== producer) {
139
- if (previous !== undefined && consumer.live) unlink(previous, consumer.sourceSlots[i]);
140
- consumer.sources[i] = producer;
141
- consumer.sourceSlots[i] = consumer.live ? link(producer, consumer, i) : 0;
123
+ const live = isLive(consumer);
124
+ if (previous !== undefined && live) unlink(previous, consumer.sourceSlots[i]);
125
+ sources[i] = producer;
126
+ consumer.sourceSlots[i] = live ? link(producer, consumer, i) : 0;
142
127
  }
143
128
  consumer.sourceVersions[i] = producer.version;
144
129
  }
145
130
 
146
- /** Drop dependencies from the last run that this run didn't read. */
147
131
  function trimSources(consumer) {
132
+ const sources = consumer.sources;
133
+ if (sources === null) return;
148
134
  const count = consumer.nextSource;
149
- if (consumer.live) {
150
- for (let i = count; i < consumer.sources.length; i++) unlink(consumer.sources[i], consumer.sourceSlots[i]);
151
- }
152
- consumer.sources.length = count;
135
+ if (count === sources.length) return;
136
+ if (isLive(consumer)) for (let i = count; i < sources.length; i++) unlink(sources[i], consumer.sourceSlots[i]);
137
+ sources.length = count;
153
138
  consumer.sourceVersions.length = count;
154
139
  consumer.sourceSlots.length = count;
155
140
  }
156
141
 
157
- // ---- push: a write marks live consumers dirty and notifies watchers ------------------------
142
+ // Called with `frozen` already set.
143
+ function markSinks(producer) {
144
+ const sinks = producer.sinks;
145
+ if (sinks === null) return;
146
+ const record = producer.kind === COMPUTED;
147
+ for (let i = 0; i < sinks.length; i++) {
148
+ const consumer = sinks[i];
149
+ if (record && consumer.kind === WATCHER) addPending(consumer, producer);
150
+ if (consumer.dirty) continue;
151
+ consumer.dirty = true;
152
+ markSinks(consumer);
153
+ if (consumer.kind === WATCHER && typeof consumer.notify === 'function') consumer.notify.call(consumer.wrapper);
154
+ }
155
+ }
158
156
 
159
157
  function notifySinks(producer) {
158
+ if (producer.sinks === null || producer.sinks.length === 0) return;
160
159
  const previous = frozen;
161
160
  frozen = true;
162
- try {
163
- for (const consumer of producer.sinks) if (!consumer.dirty) markDirty(consumer);
164
- } finally {
165
- frozen = previous;
166
- }
167
- }
168
-
169
- function markDirty(consumer) {
170
- consumer.dirty = true;
171
- notifySinks(consumer);
172
- if (consumer instanceof WatcherNode && typeof consumer.notify === 'function') {
173
- consumer.notify.call(consumer.wrapper);
174
- }
161
+ try { markSinks(producer); } finally { frozen = previous; }
175
162
  }
176
163
 
177
- // ---- pull: bringing a computed up to date --------------------------------------------------
178
-
179
164
  function refresh(node) {
180
- if (!(node instanceof ComputedNode)) return;
181
- // Validated during this epoch and not marked dirty since: nothing can have changed.
182
- // (No shortcut for live nodes: a node that became live while stale was never marked dirty,
183
- // and marking it would stop later writes from reaching its watchers.)
165
+ if (node.kind !== COMPUTED) return;
184
166
  if (!node.dirty && node.lastCleanEpoch === epoch) return;
185
167
  if (node.value === UNSET || node.value === COMPUTING || sourcesChanged(node)) recompute(node);
186
168
  node.dirty = false;
@@ -188,9 +170,12 @@ function refresh(node) {
188
170
  }
189
171
 
190
172
  function sourcesChanged(node) {
191
- for (let i = 0; i < node.sources.length; i++) {
192
- const source = node.sources[i];
193
- const seen = node.sourceVersions[i];
173
+ const sources = node.sources;
174
+ if (sources === null) return false;
175
+ const versions = node.sourceVersions;
176
+ for (let i = 0; i < sources.length; i++) {
177
+ const source = sources[i];
178
+ const seen = versions[i];
194
179
  if (source.version !== seen) return true;
195
180
  refresh(source);
196
181
  if (source.version !== seen) return true;
@@ -218,44 +203,41 @@ function recompute(node) {
218
203
  activeConsumer = previousConsumer;
219
204
  trimSources(node);
220
205
  }
221
- if (equal) {
222
- node.value = previousValue;
223
- return;
224
- }
206
+ if (equal) { node.value = previousValue; return; }
225
207
  node.value = value;
226
208
  node.version++;
227
209
  }
228
210
 
229
211
  // ---- public API ----------------------------------------------------------------------------
230
212
 
231
- const nodes = new WeakMap();
232
- const nodeFor = (receiver, Kind, method) => {
233
- const node = nodes.get(receiver);
234
- if (!(node instanceof Kind)) throw new TypeError(`Wrong receiver type for ${method}`);
235
- return node;
236
- };
237
- const isSignal = (value) => {
238
- const node = nodes.get(value);
239
- return node instanceof StateNode || node instanceof ComputedNode;
240
- };
241
- const isSink = (value) => {
242
- const node = nodes.get(value);
243
- return node instanceof ComputedNode || node instanceof WatcherNode;
244
- };
213
+ // Each class exports a reader that returns its node, or undefined for anything else.
214
+ let stateNode, computedNode, watcherNode;
215
+ const isObject = (value) => value !== null && typeof value === 'object';
216
+ const signalNode = (value) => isObject(value) ? (stateNode(value) ?? computedNode(value)) : undefined;
217
+ const sinkNode = (value) => isObject(value) ? (computedNode(value) ?? watcherNode(value)) : undefined;
245
218
 
246
219
  class State {
220
+ #node;
247
221
  constructor(initialValue, options) {
248
- nodes.set(this, new StateNode(this, initialValue, options));
222
+ const node = new Node(STATE, this);
223
+ node.value = initialValue;
224
+ if (options) {
225
+ if (options.equals) node.equals = options.equals;
226
+ node.onWatched = options[watched];
227
+ node.onUnwatched = options[unwatched];
228
+ }
229
+ this.#node = node;
249
230
  }
231
+ static { stateNode = (x) => (#node in x ? x.#node : undefined); }
250
232
 
251
233
  get() {
252
- const node = nodeFor(this, StateNode, 'Signal.State.prototype.get');
234
+ const node = this.#node;
253
235
  track(node);
254
236
  return node.value;
255
237
  }
256
238
 
257
239
  set(value) {
258
- const node = nodeFor(this, StateNode, 'Signal.State.prototype.set');
240
+ const node = this.#node;
259
241
  if (frozen) throw new Error('Writes to signals not permitted during Watcher callback');
260
242
  if (node.equals.call(this, node.value, value)) return;
261
243
  node.value = value;
@@ -266,14 +248,24 @@ class State {
266
248
  }
267
249
 
268
250
  class Computed {
251
+ #node;
269
252
  constructor(computation, options) {
270
253
  if (typeof computation !== 'function') throw new TypeError('Signal.Computed requires a function');
271
- nodes.set(this, new ComputedNode(this, computation, options));
254
+ const node = new Node(COMPUTED, this);
255
+ node.computation = computation;
256
+ node.value = UNSET;
257
+ node.dirty = true;
258
+ if (options) {
259
+ if (options.equals) node.equals = options.equals;
260
+ node.onWatched = options[watched];
261
+ node.onUnwatched = options[unwatched];
262
+ }
263
+ this.#node = node;
272
264
  }
265
+ static { computedNode = (x) => (#node in x ? x.#node : undefined); }
273
266
 
274
267
  get() {
275
- const node = nodeFor(this, ComputedNode, 'Signal.Computed.prototype.get');
276
- // Checked before refreshing, so a forbidden read can't poison the cached value.
268
+ const node = this.#node;
277
269
  if (frozen) throw frozenError();
278
270
  refresh(node);
279
271
  track(node);
@@ -282,50 +274,132 @@ class Computed {
282
274
  }
283
275
  }
284
276
 
277
+ const notSignal = () => new TypeError('Called watch/unwatch without a Computed or State argument');
278
+
285
279
  class Watcher {
280
+ #node;
286
281
  constructor(notify) {
287
- nodes.set(this, new WatcherNode(this, notify));
282
+ const node = new Node(WATCHER, this);
283
+ node.notify = notify;
284
+ this.#node = node;
288
285
  }
286
+ static { watcherNode = (x) => (#node in x ? x.#node : undefined); }
289
287
 
290
- /** Add signals to the watched set and re-arm notification. */
291
288
  watch(...signals) {
292
- const node = nodeFor(this, WatcherNode, 'Signal.subtle.Watcher.prototype.watch');
293
- if (!signals.every(isSignal)) throw new TypeError('Called watch/unwatch without a Computed or State argument');
289
+ const node = this.#node;
290
+ const resolved = signals.map(signalNode);
291
+ if (resolved.includes(undefined)) throw notSignal();
294
292
  node.dirty = false;
295
293
  const previousConsumer = activeConsumer;
296
294
  activeConsumer = node;
297
295
  try {
298
- for (const signal of signals) track(nodes.get(signal));
296
+ for (let i = 0; i < resolved.length; i++) {
297
+ const source = resolved[i];
298
+ track(source);
299
+ if (source.kind === COMPUTED && source.dirty) addPending(node, source);
300
+ }
299
301
  } finally {
300
302
  activeConsumer = previousConsumer;
301
303
  }
302
304
  }
303
305
 
304
- /** Remove every occurrence of the given signals from the watched set. */
305
306
  unwatch(...signals) {
306
- const node = nodeFor(this, WatcherNode, 'Signal.subtle.Watcher.prototype.unwatch');
307
- if (!signals.every(isSignal)) throw new TypeError('Called watch/unwatch without a Computed or State argument');
308
- const removing = new Set(signals.map((signal) => nodes.get(signal)));
309
- for (let i = node.sources.length - 1; i >= 0; i--) {
310
- if (!removing.has(node.sources[i])) continue;
311
- unlink(node.sources[i], node.sourceSlots[i]);
312
- const last = node.sources.length - 1;
313
- node.sources[i] = node.sources[last];
307
+ const node = this.#node;
308
+ const resolved = signals.map(signalNode);
309
+ if (resolved.includes(undefined)) throw notSignal();
310
+ const sources = node.sources;
311
+ if (sources === null) return;
312
+ const removing = resolved.length === 1 ? null : new Set(resolved);
313
+ const only = resolved[0];
314
+ for (let i = sources.length - 1; i >= 0; i--) {
315
+ const source = sources[i];
316
+ if (removing === null ? source !== only : !removing.has(source)) continue;
317
+ unlink(source, node.sourceSlots[i]);
318
+ const last = sources.length - 1;
319
+ sources[i] = sources[last];
314
320
  node.sourceSlots[i] = node.sourceSlots[last];
315
321
  node.sourceVersions[i] = node.sourceVersions[last];
316
- node.sources.length = last;
317
- node.sourceSlots.length = last;
318
- node.sourceVersions.length = last;
322
+ sources.pop();
323
+ node.sourceSlots.pop();
324
+ node.sourceVersions.pop();
319
325
  node.nextSource--;
320
- if (i < last) node.sources[i].sinkSlots[node.sourceSlots[i]] = i;
326
+ if (i < last) sources[i].sinkSlots[node.sourceSlots[i]] = i;
327
+ }
328
+ // Drop what was unwatched now, however short the list: an unwatched computed (an effect's,
329
+ // with its closure) mustn't wait for the list to grow before it can be collected. (One
330
+ // unwatch removes every link to a signal, as in the reference polyfill.)
331
+ const pending = node.pending;
332
+ if (pending !== null) {
333
+ let kept = 0;
334
+ for (let i = 0; i < pending.length; i++) {
335
+ const entry = pending[i];
336
+ if (removing === null ? entry !== only : !removing.has(entry)) pending[kept++] = entry;
337
+ }
338
+ pending.length = kept;
321
339
  }
322
340
  }
323
341
 
324
- /** The watched computeds that have been marked dirty and not yet re-read. */
342
+ // O(pending), not O(watched): the list is filled as computeds are marked, and compacted here
343
+ // to the ones still dirty and still watched. Order is the order they were marked.
325
344
  getPending() {
326
- const node = nodeFor(this, WatcherNode, 'Signal.subtle.Watcher.prototype.getPending');
327
- return node.sources.filter((source) => source.dirty).map((source) => source.wrapper);
345
+ const node = this.#node;
346
+ const pending = node.pending;
347
+ if (pending === null || pending.length === 0) return [];
348
+ const stamp = ++pendingStamp;
349
+ const kept = [];
350
+ const out = [];
351
+ for (let i = 0; i < pending.length; i++) {
352
+ const source = pending[i];
353
+ if (!source.dirty || source.seen === stamp) continue;
354
+ const times = watchedBy(source, node);
355
+ if (times === 0) continue;
356
+ source.seen = stamp;
357
+ kept.push(source);
358
+ for (let t = 0; t < times; t++) out.push(source.wrapper); // watched twice, listed twice
359
+ }
360
+ node.pending = kept;
361
+ return out;
362
+ }
363
+ }
364
+
365
+ let pendingStamp = 0;
366
+
367
+ // Entries go stale when a computed is read (no longer dirty), and repeat when it's marked dirty
368
+ // again before getPending() runs. Past 64 entries, a push that finds the list larger than twice
369
+ // the watched set drops stale entries and repeats, leaving at most one per watched computed: the
370
+ // list stays bounded, and pushes cost amortised O(1). unwatch() drops what it unwatched at once.
371
+ function addPending(watcher, source) {
372
+ const pending = (watcher.pending ??= []);
373
+ pending.push(source);
374
+ if (pending.length > 64) compactPending(watcher);
375
+ }
376
+ function compactPending(watcher) {
377
+ const pending = watcher.pending;
378
+ if (pending === null || pending.length <= 2 * watcher.sources.length) return;
379
+ const stamp = ++pendingStamp;
380
+ let kept = 0;
381
+ for (let i = 0; i < pending.length; i++) {
382
+ const entry = pending[i];
383
+ if (!entry.dirty || entry.seen === stamp || !watchedBy(entry, watcher)) continue;
384
+ entry.seen = stamp;
385
+ pending[kept++] = entry;
386
+ }
387
+ pending.length = kept;
388
+ }
389
+ // How many times `watcher` watches `source`. Each link is in both the source's sinks and the
390
+ // watcher's sources, so count in the shorter: one computed shared by many watchers, or one
391
+ // watcher of many computeds, stays O(1) per entry.
392
+ function watchedBy(source, watcher) {
393
+ const sinks = source.sinks;
394
+ const sources = watcher.sources;
395
+ if (sinks === null || sources === null) return 0;
396
+ let times = 0;
397
+ if (sinks.length <= sources.length) {
398
+ for (let i = 0; i < sinks.length; i++) if (sinks[i] === watcher) times++;
399
+ } else {
400
+ for (let i = 0; i < sources.length; i++) if (sources[i] === source) times++;
328
401
  }
402
+ return times;
329
403
  }
330
404
 
331
405
  function untrack(callback) {
@@ -335,41 +409,37 @@ function untrack(callback) {
335
409
  }
336
410
 
337
411
  function currentComputed() {
338
- return activeConsumer instanceof ComputedNode ? activeConsumer.wrapper : undefined;
412
+ return activeConsumer !== null && activeConsumer.kind === COMPUTED ? activeConsumer.wrapper : undefined;
339
413
  }
340
414
 
341
415
  function introspectSources(sink) {
342
- if (!isSink(sink)) throw new TypeError('Called introspectSources without a Computed or Watcher argument');
343
- return nodes.get(sink).sources.map((source) => source.wrapper);
416
+ const node = sinkNode(sink);
417
+ if (!node) throw new TypeError('Called introspectSources without a Computed or Watcher argument');
418
+ return (node.sources ?? EMPTY).map((source) => source.wrapper);
344
419
  }
345
420
 
346
421
  function introspectSinks(signal) {
347
- if (!isSignal(signal)) throw new TypeError('Called introspectSinks without a Signal argument');
348
- return nodes.get(signal).sinks.map((consumer) => consumer.wrapper);
422
+ const node = signalNode(signal);
423
+ if (!node) throw new TypeError('Called introspectSinks without a Signal argument');
424
+ return (node.sinks ?? EMPTY).map((consumer) => consumer.wrapper);
349
425
  }
350
426
 
351
427
  function hasSinks(signal) {
352
- if (!isSignal(signal)) throw new TypeError('Called hasSinks without a Signal argument');
353
- return nodes.get(signal).sinks.length > 0;
428
+ const node = signalNode(signal);
429
+ if (!node) throw new TypeError('Called hasSinks without a Signal argument');
430
+ return node.sinks !== null && node.sinks.length > 0;
354
431
  }
355
432
 
356
433
  function hasSources(sink) {
357
- if (!isSink(sink)) throw new TypeError('Called hasSources without a Computed or Watcher argument');
358
- return nodes.get(sink).sources.length > 0;
434
+ const node = sinkNode(sink);
435
+ if (!node) throw new TypeError('Called hasSources without a Computed or Watcher argument');
436
+ return node.sources !== null && node.sources.length > 0;
359
437
  }
360
438
 
361
439
  export const Signal = Object.freeze({
362
440
  State,
363
441
  Computed,
364
442
  subtle: Object.freeze({
365
- Watcher,
366
- untrack,
367
- currentComputed,
368
- introspectSources,
369
- introspectSinks,
370
- hasSinks,
371
- hasSources,
372
- watched,
373
- unwatched,
443
+ Watcher, untrack, currentComputed, introspectSources, introspectSinks, hasSinks, hasSources, watched, unwatched,
374
444
  }),
375
445
  });
package/src/track.js ADDED
@@ -0,0 +1,77 @@
1
+ // Reads after an `await` in a resource's fetcher: `resource(tracked(fetcher))`.
2
+ //
3
+ // const list = resource(tracked(async ({ signal, track }) => {
4
+ // const token = await auth(signal)
5
+ // return load(token, track(() => page.get()), signal)
6
+ // }))
7
+ //
8
+ // The Signals API tracks a read only while a Computed is running. A resource's fetcher runs
9
+ // inside its effect until its first `await`; the code after that runs later, outside it, so its
10
+ // reads are not followed. `tracked()` gives each run `track(read)`: it reads, and when what
11
+ // `read` returns changes, the resource runs the fetcher again, as for a read before the
12
+ // `await`. The compiler writes `tracked()` and the `track()` calls for an async fetcher in a
13
+ // `.zb` script, so this module is loaded only by components that have one.
14
+ //
15
+ // A run that was replaced or has settled, or a resource that was disposed, still reads through
16
+ // `track()`, but no longer subscribes. The reads are watched by an effect owned where `tracked()` was called (a
17
+ // component's setup), so a detached element holds none of them and catches up when it returns.
18
+ import { Signal } from './signal.js';
19
+ import { effect } from './effect.js';
20
+
21
+ const { untrack, currentComputed } = Signal.subtle;
22
+ const THREW = Symbol('threw');
23
+
24
+ function readOnce(computed) {
25
+ try { return computed.get(); } catch { return THREW; }
26
+ }
27
+
28
+ /** Wraps a resource's fetcher so it is also given `track(read)`, for reads after an `await`. */
29
+ export function tracked(fetcher) {
30
+ if (typeof fetcher !== 'function') throw new TypeError('zubra: tracked() takes a function');
31
+ // Read by each run before it can wait, so the resource's effect runs again when it changes.
32
+ const again = new Signal.State(0);
33
+ // The run whose later reads are watched: set when it makes one, and when it settles.
34
+ const tail = new Signal.State(null);
35
+ let latest = null;
36
+
37
+ // When a read changes, run again. A new read is appended (and seen) without re-running.
38
+ const stop = effect(() => {
39
+ const watched = tail.get();
40
+ if (!watched) return;
41
+ let changed = false;
42
+ for (const read of watched.run.reads) {
43
+ if (!Object.is(readOnce(read.computed), read.seen)) changed = true; // read each, to keep watching it
44
+ }
45
+ if (changed && watched.run === latest) untrack(() => again.set(again.get() + 1));
46
+ });
47
+
48
+ return (context) => {
49
+ again.get();
50
+ const { signal } = context;
51
+ const run = { reads: [] };
52
+ latest = run;
53
+ // The resource aborts a run when the next one starts, or when it is disposed: then no run
54
+ // follows, and nothing is watched any more.
55
+ signal?.addEventListener('abort', () => queueMicrotask(() => { if (latest === run) stop(); }), { once: true });
56
+ const track = (read) => {
57
+ if (typeof read !== 'function') throw new TypeError('zubra: track() takes a function that reads signals');
58
+ // Before the first `await` the resource's effect is running, and tracks the read itself.
59
+ if (currentComputed()) return read();
60
+ // A replaced or finished run (a timer the fetcher left), or a disposed resource: its value
61
+ // can't depend on the read any more, so read without subscribing.
62
+ if (run !== latest || run.done || signal?.aborted) return untrack(read);
63
+ const computed = new Signal.Computed(read);
64
+ run.reads.push({ computed, seen: readOnce(computed) });
65
+ tail.set({ run });
66
+ return computed.get(); // rethrows what `read` threw
67
+ };
68
+ const settled = () => {
69
+ run.done = true;
70
+ // The newest run has finished: stop watching an older run's later reads.
71
+ if (run === latest && untrack(() => tail.get())?.run !== run) tail.set(run.reads.length ? { run } : null);
72
+ };
73
+ const result = fetcher({ ...context, track });
74
+ Promise.resolve(result).then(settled, settled);
75
+ return result;
76
+ };
77
+ }