@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 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
@@ -32,7 +32,7 @@ let owner = null; // the Effect or Scope currently running
32
32
  let nextId = 0;
33
33
  let scheduled = null; // promise for the pending microtask flush
34
34
  let flushing = false;
35
- const effectOf = new WeakMap();
35
+ const EFFECT = Symbol('effect');
36
36
 
37
37
  /**
38
38
  * `Symbol.dispose`, or, where the engine doesn't have it yet, the symbol TypeScript's downlevel
@@ -85,15 +85,18 @@ function report(error) {
85
85
  class Owner {
86
86
  constructor(parent) {
87
87
  this.parent = parent;
88
- this.children = new Set();
88
+ this.children = null; // created on first child: most effects have none
89
89
  this.disposed = false;
90
90
  this.paused = false;
91
- parent?.children.add(this);
91
+ if (parent) (parent.children ??= new Set()).add(this);
92
92
  }
93
93
 
94
94
  disposeChildren() {
95
+ const children = this.children;
96
+ if (children === null || children.size === 0) return;
97
+ this.children = null;
95
98
  batch(() => {
96
- for (const child of [...this.children]) child.dispose();
99
+ for (const child of children) { child.parent = null; child.dispose(); }
97
100
  });
98
101
  }
99
102
 
@@ -101,7 +104,7 @@ class Owner {
101
104
  if (this.disposed) return;
102
105
  this.disposed = true;
103
106
  this.disposeChildren();
104
- this.parent?.children.delete(this);
107
+ this.parent?.children?.delete(this);
105
108
  }
106
109
 
107
110
  [DISPOSE]() {
@@ -111,7 +114,7 @@ class Owner {
111
114
  pause() {
112
115
  if (this.paused || this.disposed) return;
113
116
  this.paused = true;
114
- batch(() => {
117
+ if (this.children) batch(() => {
115
118
  for (const child of [...this.children]) child.pause();
116
119
  });
117
120
  }
@@ -121,7 +124,7 @@ class Owner {
121
124
  this.paused = false;
122
125
  // Children may be replaced while their parent resumes, so iterate a snapshot and skip
123
126
  // anything that was disposed along the way.
124
- 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();
125
128
  }
126
129
  }
127
130
 
@@ -142,7 +145,7 @@ class Effect extends Owner {
142
145
  owner = previous;
143
146
  }
144
147
  });
145
- effectOf.set(this.computed, this);
148
+ this.computed[EFFECT] = this;
146
149
  watcher.watch(this.computed);
147
150
  }
148
151
 
@@ -177,7 +180,7 @@ class Effect extends Owner {
177
180
  this.paused = false;
178
181
  watchAgain(this.computed);
179
182
  try { this.run(); } catch (error) { report(error); }
180
- 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();
181
184
  }
182
185
  }
183
186
 
@@ -239,7 +242,7 @@ export function flush() {
239
242
  report(new Error('zubra: effects keep re-triggering each other; stopped after 100 rounds'));
240
243
  return;
241
244
  }
242
- 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);
243
246
  for (const e of effects) {
244
247
  if (e.disposed || e.paused) continue;
245
248
  try { e.run(); } catch (error) { report(error); }
@@ -540,6 +540,25 @@ function blueprint(source, parent = null) {
540
540
  return content;
541
541
  }
542
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
+
543
562
  function clearBetween(open, close) {
544
563
  while (open.nextSibling && open.nextSibling !== close) open.nextSibling.remove();
545
564
  }
@@ -600,10 +619,8 @@ function mountBlock(block, args, open, close, hydrating, context) {
600
619
  clearBetween(open, close);
601
620
  // Find and bind the parts in the fragment, then insert it: child components inside see
602
621
  // their props from the start, and can't move slotted nodes before they're found.
603
- // importNode, not cloneNode: defined custom elements upgrade now, so their properties exist
604
- // before parts bind (a clone stays in the template's inert document).
605
622
  const parent = parentOf(close);
606
- const fragment = document.importNode(blueprint(block, parent), true);
623
+ const fragment = freshCopy(block, parent);
607
624
  fragmentParents.set(fragment, parent);
608
625
  const fresh = locate(childRange(fragment), parts);
609
626
  parts.forEach((part, index) => bind(part, fresh[index], false, context));
@@ -731,7 +748,12 @@ function bind(part, node, hydrating, context) {
731
748
  if (part.text) {
732
749
  effect(() => {
733
750
  const text = part.text();
734
- 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;
735
757
  });
736
758
  } else if (part.anchor) {
737
759
  effect(() => {
@@ -1289,19 +1311,27 @@ function bindEach(part, open, hydrating, context) {
1289
1311
  const rowsOwner = scope();
1290
1312
  let rows = [];
1291
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.
1292
1318
  const createRow = (value, index, key, rowOpen, rowClose, hydrateRow) => {
1293
- if (!rowOpen) {
1294
- // A new row is assembled in a fragment, then placed.
1295
- rowOpen = document.createComment('[');
1296
- rowClose = document.createComment(']');
1297
- const fragment = new DocumentFragment();
1298
- fragmentParents.set(fragment, parentOf(open));
1299
- fragment.append(rowOpen, rowClose);
1300
- }
1301
1319
  const item = new Signal.State(value);
1302
1320
  const position = new Signal.State(index);
1303
- const owner = rowsOwner.run(() => scope(() => mountBlock(part.block, [item, position], rowOpen, rowClose, hydrateRow, context)));
1304
- 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 };
1305
1335
  };
1306
1336
  const nodesOf = (row) => {
1307
1337
  const nodes = [];