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