@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 CHANGED
@@ -46,7 +46,7 @@ including values, recompute counts, the order watchers are notified, the order `
46
46
 
47
47
  ### Deliberate differences from the reference polyfill
48
48
 
49
- Each one only affects programs that break a rule the proposal states, so correct programs
49
+ The first three only affect programs that break a rule the proposal states, so correct programs
50
50
  behave the same on both. All are tested in `test/signal.test.js`.
51
51
 
52
52
  1. **Reads and writes inside `watched`/`unwatched` hooks throw.** The proposal says hooks run
@@ -57,6 +57,12 @@ behave the same on both. All are tested in `test/signal.test.js`.
57
57
  keeps throwing it after the notification is over.
58
58
  3. **`new Signal.Computed(x)` requires a function** (TypeError), instead of failing on first read.
59
59
 
60
+ One difference is visible to correct programs: **`Watcher.getPending()` isn't in watch order.**
61
+ The watcher keeps the computeds it watches as they're marked dirty, instead of scanning everything
62
+ it watches, so `getPending()` costs O(pending), not O(watched), and lists them roughly in the order
63
+ they were marked. The proposal doesn't state an order (the reference's is the watch order); the
64
+ computeds, and how many times each is listed, are the same. Also tested in `test/signal.test.js`.
65
+
60
66
  ## Effects
61
67
 
62
68
  ```js
@@ -9,6 +9,11 @@
9
9
  // scope(fn) an ownership root: effects created inside belong to it
10
10
  // flush() / tick() apply pending re-runs now / await them
11
11
  //
12
+ // Disposal: effect()'s dispose function and every scope are also disposables
13
+ // (`[Symbol.dispose]`), so `using` and `DisposableStack` can own them where the browser has
14
+ // Explicit Resource Management. Nothing here depends on it. Pausing is not disposal: a paused
15
+ // scope resumes; only dispose() (or Symbol.dispose) ends one.
16
+ //
12
17
  // Ownership: an effect created while another effect (or a scope) is running belongs to it. When
13
18
  // the owner re-runs or is disposed, everything it owns is disposed first, so nothing outlives
14
19
  // the code that created it.
@@ -27,7 +32,13 @@ let owner = null; // the Effect or Scope currently running
27
32
  let nextId = 0;
28
33
  let scheduled = null; // promise for the pending microtask flush
29
34
  let flushing = false;
30
- const effectOf = new WeakMap();
35
+ const EFFECT = Symbol('effect');
36
+
37
+ /**
38
+ * `Symbol.dispose`, or, where the engine doesn't have it yet, the symbol TypeScript's downlevel
39
+ * `using` and the polyfills look up (`Symbol.for('Symbol.dispose')`).
40
+ */
41
+ export const DISPOSE = Symbol.dispose ?? Symbol.for('Symbol.dispose');
31
42
 
32
43
 
33
44
  const watcher = new Signal.subtle.Watcher(() => {
@@ -74,15 +85,18 @@ function report(error) {
74
85
  class Owner {
75
86
  constructor(parent) {
76
87
  this.parent = parent;
77
- this.children = new Set();
88
+ this.children = null; // created on first child: most effects have none
78
89
  this.disposed = false;
79
90
  this.paused = false;
80
- parent?.children.add(this);
91
+ if (parent) (parent.children ??= new Set()).add(this);
81
92
  }
82
93
 
83
94
  disposeChildren() {
95
+ const children = this.children;
96
+ if (children === null || children.size === 0) return;
97
+ this.children = null;
84
98
  batch(() => {
85
- for (const child of [...this.children]) child.dispose();
99
+ for (const child of children) { child.parent = null; child.dispose(); }
86
100
  });
87
101
  }
88
102
 
@@ -90,13 +104,17 @@ class Owner {
90
104
  if (this.disposed) return;
91
105
  this.disposed = true;
92
106
  this.disposeChildren();
93
- this.parent?.children.delete(this);
107
+ this.parent?.children?.delete(this);
108
+ }
109
+
110
+ [DISPOSE]() {
111
+ this.dispose();
94
112
  }
95
113
 
96
114
  pause() {
97
115
  if (this.paused || this.disposed) return;
98
116
  this.paused = true;
99
- batch(() => {
117
+ if (this.children) batch(() => {
100
118
  for (const child of [...this.children]) child.pause();
101
119
  });
102
120
  }
@@ -106,7 +124,7 @@ class Owner {
106
124
  this.paused = false;
107
125
  // Children may be replaced while their parent resumes, so iterate a snapshot and skip
108
126
  // anything that was disposed along the way.
109
- for (const child of [...this.children]) if (!child.disposed) child.resume();
127
+ if (this.children) for (const child of [...this.children]) if (!child.disposed) child.resume();
110
128
  }
111
129
  }
112
130
 
@@ -127,7 +145,7 @@ class Effect extends Owner {
127
145
  owner = previous;
128
146
  }
129
147
  });
130
- effectOf.set(this.computed, this);
148
+ this.computed[EFFECT] = this;
131
149
  watcher.watch(this.computed);
132
150
  }
133
151
 
@@ -162,7 +180,7 @@ class Effect extends Owner {
162
180
  this.paused = false;
163
181
  watchAgain(this.computed);
164
182
  try { this.run(); } catch (error) { report(error); }
165
- for (const child of [...this.children]) if (!child.disposed) child.resume();
183
+ if (this.children) for (const child of [...this.children]) if (!child.disposed) child.resume();
166
184
  }
167
185
  }
168
186
 
@@ -183,7 +201,9 @@ class Scope extends Owner {
183
201
  export function effect(fn) {
184
202
  const e = new Effect(fn, owner);
185
203
  e.run(); // if this throws, the effect stays registered and re-runs when its dependencies change
186
- return () => e.dispose();
204
+ const dispose = () => e.dispose();
205
+ dispose[DISPOSE] = dispose;
206
+ return dispose;
187
207
  }
188
208
 
189
209
  /**
@@ -222,7 +242,7 @@ export function flush() {
222
242
  report(new Error('zubra: effects keep re-triggering each other; stopped after 100 rounds'));
223
243
  return;
224
244
  }
225
- const effects = pending.map((computed) => effectOf.get(computed)).sort((a, b) => a.id - b.id);
245
+ const effects = pending.map((computed) => computed[EFFECT]).sort((a, b) => a.id - b.id);
226
246
  for (const e of effects) {
227
247
  if (e.disposed || e.paused) continue;
228
248
  try { e.run(); } catch (error) { report(error); }
@@ -196,12 +196,13 @@ export function emit(name) {
196
196
 
197
197
  /**
198
198
  * An id unique to this element and call, for `id`/`for`/`aria-*` attributes. Stable for the
199
- * element's life; based on the host's own `id` when it has one, so it can be made to match
200
- * server-rendered markup.
199
+ * element's life. Based on the host's own `id` when it has one, else on the base the server
200
+ * rendered it with (`data-zb-id`), so the ids in server-rendered markup stay as they are.
201
201
  */
202
202
  export function id() {
203
203
  const component = hooks('id');
204
- const base = component.host.id || `${component.host.localName}-${component.instance}`;
204
+ const host = component.host;
205
+ const base = host.id || host.getAttribute('data-zb-id') || `${host.localName}-${component.instance}`;
205
206
  return `${base}-${++component.ids}`;
206
207
  }
207
208
 
@@ -335,7 +336,18 @@ export function provide(context, value) {
335
336
  * key as `context`.
336
337
  */
337
338
  export function inject(context, fallback = context?.defaultValue) {
338
- const host = hooks('inject').host;
339
+ const component = hooks('inject');
340
+ const host = component.host;
341
+ // The value the server rendered with (`data-zb-inject`, by call number) is where it starts,
342
+ // so hydrating doesn't show the default until the provider answers.
343
+ const number = (component.injects = (component.injects ?? 0) + 1);
344
+ const rendered = host.getAttribute('data-zb-inject');
345
+ if (rendered) {
346
+ try {
347
+ const values = JSON.parse(rendered);
348
+ if (Object.hasOwn(values, number)) fallback = values[number];
349
+ } catch { /* not ours: keep the default */ }
350
+ }
339
351
  const value = new Signal.State(fallback);
340
352
  let unsubscribe = null;
341
353
  let answered = false;
@@ -528,6 +540,25 @@ function blueprint(source, parent = null) {
528
540
  return content;
529
541
  }
530
542
 
543
+ // For a blueprint with no custom elements: a copy owned by this document, made once, which rows
544
+ // clone. Its clones need no adopting when they're inserted, and nothing in it has to upgrade.
545
+ const imported = new WeakMap();
546
+ const upgrades = (content) =>
547
+ [...content.querySelectorAll('*')].some((element) => element.localName.includes('-') || element.hasAttribute('is'));
548
+
549
+ /** A fresh copy of `block`'s template for `parent`, ready for its parts to be found and bound. */
550
+ function freshCopy(block, parent) {
551
+ const content = blueprint(block, parent);
552
+ let copy = imported.get(content);
553
+ if (copy === undefined) {
554
+ // importNode: custom elements inside upgrade now, so their properties exist before parts
555
+ // bind (a plain clone stays in the template's inert document).
556
+ copy = upgrades(content) ? null : document.importNode(content, true);
557
+ imported.set(content, copy);
558
+ }
559
+ return copy === null ? document.importNode(content, true) : copy.cloneNode(true);
560
+ }
561
+
531
562
  function clearBetween(open, close) {
532
563
  while (open.nextSibling && open.nextSibling !== close) open.nextSibling.remove();
533
564
  }
@@ -546,8 +577,27 @@ function departBetween(open, close, motion) {
546
577
  }
547
578
 
548
579
  /** Remove everything between `open` and `close` in one operation. */
580
+ const nativeTextContent = typeof Node !== 'undefined' ? Object.getOwnPropertyDescriptor(Node.prototype, 'textContent').set : undefined;
549
581
  function deleteBetween(open, close) {
550
582
  if (open.nextSibling === close) return;
583
+ // The region is everything its parent holds but blank text (a list's own <tbody> or <ul>):
584
+ // emptying the parent and putting the markers and that text back is several times cheaper than
585
+ // deleting a range's contents.
586
+ const parent = open.parentNode;
587
+ const before = [];
588
+ const after = [];
589
+ const blank = (node) => node.nodeType === TEXT && node.data.trim() === '';
590
+ let edge = open.previousSibling;
591
+ while (edge && blank(edge) && before.length < 4) { before.unshift(edge); edge = edge.previousSibling; }
592
+ if (parent && !edge) {
593
+ edge = close.nextSibling;
594
+ while (edge && blank(edge) && after.length < 4) { after.push(edge); edge = edge.nextSibling; }
595
+ if (!edge) {
596
+ nativeTextContent.call(parent, '');
597
+ for (const node of [...before, open, close, ...after]) Node.prototype.insertBefore.call(parent, node, null);
598
+ return;
599
+ }
600
+ }
551
601
  const range = document.createRange();
552
602
  range.setStartAfter(open);
553
603
  range.setEndBefore(close);
@@ -569,10 +619,8 @@ function mountBlock(block, args, open, close, hydrating, context) {
569
619
  clearBetween(open, close);
570
620
  // Find and bind the parts in the fragment, then insert it: child components inside see
571
621
  // their props from the start, and can't move slotted nodes before they're found.
572
- // importNode, not cloneNode: defined custom elements upgrade now, so their properties exist
573
- // before parts bind (a clone stays in the template's inert document).
574
622
  const parent = parentOf(close);
575
- const fragment = document.importNode(blueprint(block, parent), true);
623
+ const fragment = freshCopy(block, parent);
576
624
  fragmentParents.set(fragment, parent);
577
625
  const fresh = locate(childRange(fragment), parts);
578
626
  parts.forEach((part, index) => bind(part, fresh[index], false, context));
@@ -700,7 +748,12 @@ function bind(part, node, hydrating, context) {
700
748
  if (part.text) {
701
749
  effect(() => {
702
750
  const text = part.text();
703
- if (node.textContent !== text) node.textContent = text;
751
+ // A lone Text child is compared and written in place (`.data`): reading `textContent`
752
+ // walks the subtree, and setting it replaces the Text node.
753
+ const child = node.firstChild;
754
+ if (child !== null && child === node.lastChild && child.nodeType === TEXT) {
755
+ if (child.data !== text) child.data = text;
756
+ } else if (node.textContent !== text) node.textContent = text;
704
757
  });
705
758
  } else if (part.anchor) {
706
759
  effect(() => {
@@ -1258,19 +1311,27 @@ function bindEach(part, open, hydrating, context) {
1258
1311
  const rowsOwner = scope();
1259
1312
  let rows = [];
1260
1313
 
1314
+ // A row is the nodes from `open` to `close`. Rows the server rendered keep the markers it put
1315
+ // around them. A row made here is its block's own nodes, from first to last, with no markers:
1316
+ // what a block's nodes are at the top never changes after it's bound (a region inside one keeps
1317
+ // its own markers), so the first and last stay the row's ends. Only an empty block needs one.
1261
1318
  const createRow = (value, index, key, rowOpen, rowClose, hydrateRow) => {
1262
- if (!rowOpen) {
1263
- // A new row is assembled in a fragment, then placed.
1264
- rowOpen = document.createComment('[');
1265
- rowClose = document.createComment(']');
1266
- const fragment = new DocumentFragment();
1267
- fragmentParents.set(fragment, parentOf(open));
1268
- fragment.append(rowOpen, rowClose);
1269
- }
1270
1319
  const item = new Signal.State(value);
1271
1320
  const position = new Signal.State(index);
1272
- const owner = rowsOwner.run(() => scope(() => mountBlock(part.block, [item, position], rowOpen, rowClose, hydrateRow, context)));
1273
- return { key, item, position, open: rowOpen, close: rowClose, owner };
1321
+ if (rowOpen) {
1322
+ const owner = rowsOwner.run(() => scope(() => mountBlock(part.block, [item, position], rowOpen, rowClose, hydrateRow, context)));
1323
+ return { key, item, position, open: rowOpen, close: rowClose, owner };
1324
+ }
1325
+ const parent = parentOf(open);
1326
+ const fragment = freshCopy(part.block, parent);
1327
+ fragmentParents.set(fragment, parent);
1328
+ const owner = rowsOwner.run(() => scope(() => {
1329
+ const parts = part.block.parts(item, position);
1330
+ const nodes = locate(childRange(fragment), parts);
1331
+ parts.forEach((each, at) => bind(each, nodes[at], false, context));
1332
+ }));
1333
+ if (!fragment.firstChild) fragment.append(document.createComment(''));
1334
+ return { key, item, position, open: fragment.firstChild, close: fragment.lastChild, owner };
1274
1335
  };
1275
1336
  const nodesOf = (row) => {
1276
1337
  const nodes = [];
@@ -1374,6 +1435,7 @@ function bindEach(part, open, hydrating, context) {
1374
1435
  }
1375
1436
  // `animate`: the rows that stay glide from where they were (@zubra/runtime/motion).
1376
1437
  const reorder = () => {
1438
+ const was = new Map(rows.map((row, index) => [row, index]));
1377
1439
  const next = list.map((value, index) => {
1378
1440
  const row = byKey.get(keys[index]);
1379
1441
  if (!row) return createRow(value, index, keys[index]);
@@ -1383,17 +1445,14 @@ function bindEach(part, open, hydrating, context) {
1383
1445
  return row;
1384
1446
  });
1385
1447
  batch(() => { for (const row of byKey.values()) removeRow(row); });
1386
- // Text between rows, and rows on their way out, aren't rows to place against.
1387
- const skip = (node) => node && (node.nodeType === TEXT || part.animate?.isLeaving(node));
1388
- let at = open.nextSibling;
1389
- while (skip(at)) at = at.nextSibling;
1390
- for (const row of next) {
1391
- if (row.open === at) {
1392
- at = row.close.nextSibling;
1393
- while (skip(at)) at = at.nextSibling;
1394
- continue;
1395
- }
1396
- placeRow(row, at ?? close);
1448
+ // The longest run of rows still in their old order stays put; every other row (and every
1449
+ // new one) goes before the row after it, from the end. A swap moves two rows, not the
1450
+ // rows between them.
1451
+ const stay = inOrder(next.map((row) => was.get(row) ?? -1));
1452
+ let before = close;
1453
+ for (let index = next.length - 1; index >= 0; index--) {
1454
+ if (!stay.has(index)) placeRow(next[index], before);
1455
+ before = next[index].open;
1397
1456
  }
1398
1457
  rows = next;
1399
1458
  };
@@ -1403,6 +1462,31 @@ function bindEach(part, open, hydrating, context) {
1403
1462
  });
1404
1463
  }
1405
1464
 
1465
+ /**
1466
+ * The indexes of a longest strictly increasing run in `sequence` (negative entries never count):
1467
+ * for a keyed list, which rows are already in order and needn't move. O(n log n).
1468
+ */
1469
+ function inOrder(sequence) {
1470
+ const tails = []; // tails[length - 1]: the index ending the best run of that length so far
1471
+ const previous = new Array(sequence.length);
1472
+ for (let index = 0; index < sequence.length; index++) {
1473
+ const value = sequence[index];
1474
+ if (value < 0) continue;
1475
+ let low = 0;
1476
+ let high = tails.length;
1477
+ while (low < high) {
1478
+ const middle = (low + high) >> 1;
1479
+ if (sequence[tails[middle]] < value) low = middle + 1;
1480
+ else high = middle;
1481
+ }
1482
+ previous[index] = low > 0 ? tails[low - 1] : -1;
1483
+ tails[low] = index;
1484
+ }
1485
+ const kept = new Set();
1486
+ for (let index = tails.length ? tails[tails.length - 1] : -1; index >= 0; index = previous[index]) kept.add(index);
1487
+ return kept;
1488
+ }
1489
+
1406
1490
  const hasContent = (root) =>
1407
1491
  [...root.childNodes].some((node) => node.nodeType !== TEXT || node.data.trim() !== '');
1408
1492
 
@@ -9,8 +9,10 @@
9
9
  // `AbortSignal` it was given, and so does the element's removal; a result that arrives after a
10
10
  // newer run started is dropped, so the value is always the latest request's. While a new run is
11
11
  // pending the previous value stays (`loading` is true), so the UI doesn't flash empty.
12
+ // `dispose()` (or `using`, through `Symbol.dispose`) stops it early: the run in flight is
13
+ // aborted and nothing re-runs it. Otherwise it lives as long as the scope that created it.
12
14
  import { Signal } from './signal.js';
13
- import { effect } from './effect.js';
15
+ import { DISPOSE, effect } from './effect.js';
14
16
 
15
17
  const { untrack } = Signal.subtle;
16
18
 
@@ -30,7 +32,7 @@ export function resource(fetcher, { initial } = {}) {
30
32
  const version = new Signal.State(0);
31
33
  let current = null; // the latest run's promise
32
34
 
33
- effect(() => {
35
+ const stop = effect(() => {
34
36
  version.get();
35
37
  const controller = new AbortController();
36
38
  let result;
@@ -72,5 +74,7 @@ export function resource(fetcher, { initial } = {}) {
72
74
  get loading() { return status.get() === 'pending'; },
73
75
  refresh() { version.set(untrack(() => version.get()) + 1); },
74
76
  then(resolve, reject) { return current.then(resolve, reject); },
77
+ dispose: stop,
78
+ [DISPOSE]: stop,
75
79
  };
76
80
  }