@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.
@@ -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 exactly: the differential tests in runtime/test compare this
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 (all stricter, none observable in correct programs) are listed in
22
- // runtime/README.md.
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
- let activeConsumer = null; // the node whose reads are currently being recorded
32
- let frozen = false; // true while watchers are notified or hooks run
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
- // ---- graph nodes ---------------------------------------------------------------------------
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.sinks = [];
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.dirty = true;
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.equals = options?.equals ?? Object.is;
83
- this.onWatched = options?.[watched];
84
- this.onUnwatched = options?.[unwatched];
85
- }
86
- }
87
-
88
- class WatcherNode extends Node {
89
- constructor(wrapper, notify) {
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
- // ---- liveness: linking and unlinking back-references ---------------------------------------
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
- for (let i = 0; i < producer.sources.length; i++) {
110
- producer.sourceSlots[i] = link(producer.sources[i], producer, i);
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
- if (producer.sinks.length === 1) {
97
+ const sinks = producer.sinks;
98
+ if (sinks.length === 1) {
120
99
  runFrozen(producer.onUnwatched, producer.wrapper);
121
- for (let i = 0; i < producer.sources.length; i++) unlink(producer.sources[i], producer.sourceSlots[i]);
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 = producer.sinks.length - 1;
124
- producer.sinks[slot] = producer.sinks[last];
125
- producer.sinkSlots[slot] = producer.sinkSlots[last];
126
- producer.sinks.length = last;
127
- producer.sinkSlots.length = last;
128
- if (slot < last) producer.sinks[slot].sourceSlots[producer.sinkSlots[slot]] = slot;
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 (!consumer) return;
118
+ if (consumer === null) return;
119
+ if (consumer.sources === null) { consumer.sources = []; consumer.sourceVersions = []; consumer.sourceSlots = []; }
137
120
  const i = consumer.nextSource++;
138
- const previous = consumer.sources[i];
121
+ const sources = consumer.sources;
122
+ const previous = sources[i];
139
123
  if (previous !== producer) {
140
- if (previous !== undefined && consumer.live) unlink(previous, consumer.sourceSlots[i]);
141
- consumer.sources[i] = producer;
142
- consumer.sourceSlots[i] = consumer.live ? link(producer, consumer, i) : 0;
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 (consumer.live) {
151
- for (let i = count; i < consumer.sources.length; i++) unlink(consumer.sources[i], consumer.sourceSlots[i]);
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
- // ---- push: a write marks live consumers dirty and notifies watchers ------------------------
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 (!(node instanceof ComputedNode)) return;
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
- for (let i = 0; i < node.sources.length; i++) {
193
- const source = node.sources[i];
194
- const seen = node.sourceVersions[i];
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
- const nodes = new WeakMap();
233
- const nodeFor = (receiver, Kind, method) => {
234
- const node = nodes.get(receiver);
235
- if (!(node instanceof Kind)) throw new TypeError(`Wrong receiver type for ${method}`);
236
- return node;
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
- nodes.set(this, new StateNode(this, initialValue, options));
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 = nodeFor(this, StateNode, 'Signal.State.prototype.get');
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 = nodeFor(this, StateNode, 'Signal.State.prototype.set');
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
- nodes.set(this, new ComputedNode(this, computation, options));
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 = nodeFor(this, ComputedNode, 'Signal.Computed.prototype.get');
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
- nodes.set(this, new WatcherNode(this, notify));
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 = nodeFor(this, WatcherNode, 'Signal.subtle.Watcher.prototype.watch');
294
- if (!signals.every(isSignal)) throw new TypeError('Called watch/unwatch without a Computed or State argument');
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 (const signal of signals) track(nodes.get(signal));
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 = nodeFor(this, WatcherNode, 'Signal.subtle.Watcher.prototype.unwatch');
308
- if (!signals.every(isSignal)) throw new TypeError('Called watch/unwatch without a Computed or State argument');
309
- const removing = new Set(signals.map((signal) => nodes.get(signal)));
310
- for (let i = node.sources.length - 1; i >= 0; i--) {
311
- if (!removing.has(node.sources[i])) continue;
312
- unlink(node.sources[i], node.sourceSlots[i]);
313
- const last = node.sources.length - 1;
314
- node.sources[i] = node.sources[last];
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
- node.sources.length = last;
318
- node.sourceSlots.length = last;
319
- node.sourceVersions.length = last;
323
+ sources.pop();
324
+ node.sourceSlots.pop();
325
+ node.sourceVersions.pop();
320
326
  node.nextSource--;
321
- if (i < last) node.sources[i].sinkSlots[node.sourceSlots[i]] = i;
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
- /** The watched computeds that have been marked dirty and not yet re-read. */
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 = nodeFor(this, WatcherNode, 'Signal.subtle.Watcher.prototype.getPending');
328
- return node.sources.filter((source) => source.dirty).map((source) => source.wrapper);
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 instanceof ComputedNode ? activeConsumer.wrapper : undefined;
413
+ return activeConsumer !== null && activeConsumer.kind === COMPUTED ? activeConsumer.wrapper : undefined;
340
414
  }
341
415
 
342
416
  function introspectSources(sink) {
343
- if (!isSink(sink)) throw new TypeError('Called introspectSources without a Computed or Watcher argument');
344
- return nodes.get(sink).sources.map((source) => source.wrapper);
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
- if (!isSignal(signal)) throw new TypeError('Called introspectSinks without a Signal argument');
349
- return nodes.get(signal).sinks.map((consumer) => consumer.wrapper);
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
- if (!isSignal(signal)) throw new TypeError('Called hasSinks without a Signal argument');
354
- return nodes.get(signal).sinks.length > 0;
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
- if (!isSink(sink)) throw new TypeError('Called hasSources without a Computed or Watcher argument');
359
- return nodes.get(sink).sources.length > 0;
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
  });
@@ -5,6 +5,11 @@
5
5
  import { $r, isSafeSrcset, isSafeUrl } from './element.js';
6
6
  import { effect } from './effect.js';
7
7
 
8
+ // The names a spread may write, as the server checks them: XML's attribute names, and CSS
9
+ // property names (custom ones included).
10
+ const ATTRIBUTE_NAME = /^[A-Za-z_:][A-Za-z0-9_.:-]*$/;
11
+ const CSS_PROPERTY = /^(?:--[A-Za-z0-9_-]+|[A-Za-z][A-Za-z0-9-]*)$/;
12
+
8
13
  const see = (section) => ` (see https://github.com/journey-to-code-org/zubra#${section})`;
9
14
 
10
15
  // ---- {...spread}: an object's entries as attributes, properties and listeners --------------
@@ -80,6 +85,12 @@ export function spread(part, node) {
80
85
  console.error(`zubra: a spread can't set "${key}"${see('safety')}`);
81
86
  continue;
82
87
  }
88
+ // A key from a value (maybe a user's) that can't be an attribute name: left out, as the
89
+ // server leaves it out of the HTML.
90
+ if (!ATTRIBUTE_NAME.test(name)) {
91
+ console.error(`zubra: a spread's key isn't an attribute name, so it was left out${see('safety')}:`, key);
92
+ continue;
93
+ }
83
94
  if (name === 'class') {
84
95
  nextClasses = String(value ?? '').split(/\s+/).filter(Boolean);
85
96
  continue;
@@ -92,6 +103,10 @@ export function spread(part, node) {
92
103
  }
93
104
  for (const [property, propertyValue] of Object.entries(value)) {
94
105
  const css = kebab(property);
106
+ if (!CSS_PROPERTY.test(css)) {
107
+ console.error(`zubra: a spread's style key isn't a CSS property name, so it was left out${see('safety')}:`, property);
108
+ continue;
109
+ }
95
110
  if (own.has(`style:${css}`)) continue;
96
111
  if (propertyValue == null || propertyValue === false) continue;
97
112
  nextStyles.add(css);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zubra/runtime",
3
- "version": "5.9.0",
3
+ "version": "5.9.2",
4
4
  "description": "Browser runtime for Zubra compiled components, built on the TC39 Signals API",
5
5
  "license": "ISC",
6
6
  "repository": {
package/src/devtools.js CHANGED
@@ -77,7 +77,7 @@ const up = (node) => node.parentNode ?? node.host ?? null;
77
77
 
78
78
  /** Every effect `owner` owns, depth first (a component's own: child components have their own roots). */
79
79
  function effectsOf(owner, out = []) {
80
- for (const child of owner.children) {
80
+ for (const child of owner.children ?? []) {
81
81
  if (child.disposed) continue;
82
82
  if (child.computed) out.push(child);
83
83
  effectsOf(child, out);