@zubra/runtime 5.9.0 → 5.9.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/README.md +7 -1
- package/dist/production/effect.js +13 -10
- package/dist/production/element.js +44 -14
- package/dist/production/signal-impl.js +235 -165
- package/dist/production/spread.js +15 -0
- package/package.json +1 -1
- package/src/devtools.js +1 -1
- package/src/effect.js +13 -10
- package/src/element.js +44 -14
- package/src/signal-impl.js +235 -165
- package/src/spread.js +15 -0
package/src/signal-impl.js
CHANGED
|
@@ -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
|
|
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
|
|
21
|
-
//
|
|
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
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
82
|
-
this.
|
|
83
|
-
this.
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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
|
-
|
|
109
|
-
|
|
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
|
-
|
|
96
|
+
const sinks = producer.sinks;
|
|
97
|
+
if (sinks.length === 1) {
|
|
119
98
|
runFrozen(producer.onUnwatched, producer.wrapper);
|
|
120
|
-
|
|
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 =
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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 (
|
|
117
|
+
if (consumer === null) return;
|
|
118
|
+
if (consumer.sources === null) { consumer.sources = []; consumer.sourceVersions = []; consumer.sourceSlots = []; }
|
|
136
119
|
const i = consumer.nextSource++;
|
|
137
|
-
const
|
|
120
|
+
const sources = consumer.sources;
|
|
121
|
+
const previous = sources[i];
|
|
138
122
|
if (previous !== producer) {
|
|
139
|
-
|
|
140
|
-
consumer.
|
|
141
|
-
|
|
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 (
|
|
150
|
-
|
|
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
|
-
//
|
|
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 (
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
293
|
-
|
|
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 (
|
|
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 =
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
-
|
|
317
|
-
node.sourceSlots.
|
|
318
|
-
node.sourceVersions.
|
|
322
|
+
sources.pop();
|
|
323
|
+
node.sourceSlots.pop();
|
|
324
|
+
node.sourceVersions.pop();
|
|
319
325
|
node.nextSource--;
|
|
320
|
-
if (i < last)
|
|
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
|
-
|
|
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 =
|
|
327
|
-
|
|
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
|
|
412
|
+
return activeConsumer !== null && activeConsumer.kind === COMPUTED ? activeConsumer.wrapper : undefined;
|
|
339
413
|
}
|
|
340
414
|
|
|
341
415
|
function introspectSources(sink) {
|
|
342
|
-
|
|
343
|
-
|
|
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
|
-
|
|
348
|
-
|
|
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
|
-
|
|
353
|
-
|
|
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
|
-
|
|
358
|
-
|
|
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/spread.js
CHANGED
|
@@ -4,6 +4,11 @@
|
|
|
4
4
|
import { $r, isSafeSrcset, isSafeUrl } from './element.js';
|
|
5
5
|
import { effect } from './effect.js';
|
|
6
6
|
|
|
7
|
+
// The names a spread may write, as the server checks them: XML's attribute names, and CSS
|
|
8
|
+
// property names (custom ones included).
|
|
9
|
+
const ATTRIBUTE_NAME = /^[A-Za-z_:][A-Za-z0-9_.:-]*$/;
|
|
10
|
+
const CSS_PROPERTY = /^(?:--[A-Za-z0-9_-]+|[A-Za-z][A-Za-z0-9-]*)$/;
|
|
11
|
+
|
|
7
12
|
const see = (section) => ` (see https://github.com/journey-to-code-org/zubra#${section})`;
|
|
8
13
|
|
|
9
14
|
// ---- {...spread}: an object's entries as attributes, properties and listeners --------------
|
|
@@ -79,6 +84,12 @@ export function spread(part, node) {
|
|
|
79
84
|
console.error(`zubra: a spread can't set "${key}"${see('safety')}`);
|
|
80
85
|
continue;
|
|
81
86
|
}
|
|
87
|
+
// A key from a value (maybe a user's) that can't be an attribute name: left out, as the
|
|
88
|
+
// server leaves it out of the HTML.
|
|
89
|
+
if (!ATTRIBUTE_NAME.test(name)) {
|
|
90
|
+
console.error(`zubra: a spread's key isn't an attribute name, so it was left out${see('safety')}:`, key);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
82
93
|
if (name === 'class') {
|
|
83
94
|
nextClasses = String(value ?? '').split(/\s+/).filter(Boolean);
|
|
84
95
|
continue;
|
|
@@ -91,6 +102,10 @@ export function spread(part, node) {
|
|
|
91
102
|
}
|
|
92
103
|
for (const [property, propertyValue] of Object.entries(value)) {
|
|
93
104
|
const css = kebab(property);
|
|
105
|
+
if (!CSS_PROPERTY.test(css)) {
|
|
106
|
+
console.error(`zubra: a spread's style key isn't a CSS property name, so it was left out${see('safety')}:`, property);
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
94
109
|
if (own.has(`style:${css}`)) continue;
|
|
95
110
|
if (propertyValue == null || propertyValue === false) continue;
|
|
96
111
|
nextStyles.add(css);
|