@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/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);
package/src/effect.js CHANGED
@@ -8,6 +8,11 @@
8
8
  // scope(fn) an ownership root: effects created inside belong to it
9
9
  // flush() / tick() apply pending re-runs now / await them
10
10
  //
11
+ // Disposal: effect()'s dispose function and every scope are also disposables
12
+ // (`[Symbol.dispose]`), so `using` and `DisposableStack` can own them where the browser has
13
+ // Explicit Resource Management. Nothing here depends on it. Pausing is not disposal: a paused
14
+ // scope resumes; only dispose() (or Symbol.dispose) ends one.
15
+ //
11
16
  // Ownership: an effect created while another effect (or a scope) is running belongs to it. When
12
17
  // the owner re-runs or is disposed, everything it owns is disposed first, so nothing outlives
13
18
  // the code that created it.
@@ -26,7 +31,13 @@ let owner = null; // the Effect or Scope currently running
26
31
  let nextId = 0;
27
32
  let scheduled = null; // promise for the pending microtask flush
28
33
  let flushing = false;
29
- const effectOf = new WeakMap();
34
+ const EFFECT = Symbol('effect');
35
+
36
+ /**
37
+ * `Symbol.dispose`, or, where the engine doesn't have it yet, the symbol TypeScript's downlevel
38
+ * `using` and the polyfills look up (`Symbol.for('Symbol.dispose')`).
39
+ */
40
+ export const DISPOSE = Symbol.dispose ?? Symbol.for('Symbol.dispose');
30
41
 
31
42
  // @dev
32
43
  /**
@@ -81,15 +92,18 @@ function report(error) {
81
92
  class Owner {
82
93
  constructor(parent) {
83
94
  this.parent = parent;
84
- this.children = new Set();
95
+ this.children = null; // created on first child: most effects have none
85
96
  this.disposed = false;
86
97
  this.paused = false;
87
- parent?.children.add(this);
98
+ if (parent) (parent.children ??= new Set()).add(this);
88
99
  }
89
100
 
90
101
  disposeChildren() {
102
+ const children = this.children;
103
+ if (children === null || children.size === 0) return;
104
+ this.children = null;
91
105
  batch(() => {
92
- for (const child of [...this.children]) child.dispose();
106
+ for (const child of children) { child.parent = null; child.dispose(); }
93
107
  });
94
108
  }
95
109
 
@@ -97,13 +111,17 @@ class Owner {
97
111
  if (this.disposed) return;
98
112
  this.disposed = true;
99
113
  this.disposeChildren();
100
- this.parent?.children.delete(this);
114
+ this.parent?.children?.delete(this);
115
+ }
116
+
117
+ [DISPOSE]() {
118
+ this.dispose();
101
119
  }
102
120
 
103
121
  pause() {
104
122
  if (this.paused || this.disposed) return;
105
123
  this.paused = true;
106
- batch(() => {
124
+ if (this.children) batch(() => {
107
125
  for (const child of [...this.children]) child.pause();
108
126
  });
109
127
  }
@@ -113,7 +131,7 @@ class Owner {
113
131
  this.paused = false;
114
132
  // Children may be replaced while their parent resumes, so iterate a snapshot and skip
115
133
  // anything that was disposed along the way.
116
- for (const child of [...this.children]) if (!child.disposed) child.resume();
134
+ if (this.children) for (const child of [...this.children]) if (!child.disposed) child.resume();
117
135
  }
118
136
  }
119
137
 
@@ -137,7 +155,7 @@ class Effect extends Owner {
137
155
  owner = previous;
138
156
  }
139
157
  });
140
- effectOf.set(this.computed, this);
158
+ this.computed[EFFECT] = this;
141
159
  watcher.watch(this.computed);
142
160
  }
143
161
 
@@ -172,7 +190,7 @@ class Effect extends Owner {
172
190
  this.paused = false;
173
191
  watchAgain(this.computed);
174
192
  try { this.run(); } catch (error) { report(error); }
175
- for (const child of [...this.children]) if (!child.disposed) child.resume();
193
+ if (this.children) for (const child of [...this.children]) if (!child.disposed) child.resume();
176
194
  }
177
195
  }
178
196
 
@@ -193,7 +211,9 @@ class Scope extends Owner {
193
211
  export function effect(fn) {
194
212
  const e = new Effect(fn, owner);
195
213
  e.run(); // if this throws, the effect stays registered and re-runs when its dependencies change
196
- return () => e.dispose();
214
+ const dispose = () => e.dispose();
215
+ dispose[DISPOSE] = dispose;
216
+ return dispose;
197
217
  }
198
218
 
199
219
  /**
@@ -238,7 +258,7 @@ export function flush() {
238
258
  report(new Error('zubra: effects keep re-triggering each other; stopped after 100 rounds'));
239
259
  return;
240
260
  }
241
- const effects = pending.map((computed) => effectOf.get(computed)).sort((a, b) => a.id - b.id);
261
+ const effects = pending.map((computed) => computed[EFFECT]).sort((a, b) => a.id - b.id);
242
262
  for (const e of effects) {
243
263
  if (e.disposed || e.paused) continue;
244
264
  try { e.run(); } catch (error) { report(error); }
package/src/element.js CHANGED
@@ -215,12 +215,13 @@ export function emit(name) {
215
215
 
216
216
  /**
217
217
  * An id unique to this element and call, for `id`/`for`/`aria-*` attributes. Stable for the
218
- * element's life; based on the host's own `id` when it has one, so it can be made to match
219
- * server-rendered markup.
218
+ * element's life. Based on the host's own `id` when it has one, else on the base the server
219
+ * rendered it with (`data-zb-id`), so the ids in server-rendered markup stay as they are.
220
220
  */
221
221
  export function id() {
222
222
  const component = hooks('id');
223
- const base = component.host.id || `${component.host.localName}-${component.instance}`;
223
+ const host = component.host;
224
+ const base = host.id || host.getAttribute('data-zb-id') || `${host.localName}-${component.instance}`;
224
225
  return `${base}-${++component.ids}`;
225
226
  }
226
227
 
@@ -354,7 +355,18 @@ export function provide(context, value) {
354
355
  * key as `context`.
355
356
  */
356
357
  export function inject(context, fallback = context?.defaultValue) {
357
- const host = hooks('inject').host;
358
+ const component = hooks('inject');
359
+ const host = component.host;
360
+ // The value the server rendered with (`data-zb-inject`, by call number) is where it starts,
361
+ // so hydrating doesn't show the default until the provider answers.
362
+ const number = (component.injects = (component.injects ?? 0) + 1);
363
+ const rendered = host.getAttribute('data-zb-inject');
364
+ if (rendered) {
365
+ try {
366
+ const values = JSON.parse(rendered);
367
+ if (Object.hasOwn(values, number)) fallback = values[number];
368
+ } catch { /* not ours: keep the default */ }
369
+ }
358
370
  const value = new Signal.State(fallback);
359
371
  let unsubscribe = null;
360
372
  let answered = false;
@@ -547,6 +559,25 @@ function blueprint(source, parent = null) {
547
559
  return content;
548
560
  }
549
561
 
562
+ // For a blueprint with no custom elements: a copy owned by this document, made once, which rows
563
+ // clone. Its clones need no adopting when they're inserted, and nothing in it has to upgrade.
564
+ const imported = new WeakMap();
565
+ const upgrades = (content) =>
566
+ [...content.querySelectorAll('*')].some((element) => element.localName.includes('-') || element.hasAttribute('is'));
567
+
568
+ /** A fresh copy of `block`'s template for `parent`, ready for its parts to be found and bound. */
569
+ function freshCopy(block, parent) {
570
+ const content = blueprint(block, parent);
571
+ let copy = imported.get(content);
572
+ if (copy === undefined) {
573
+ // importNode: custom elements inside upgrade now, so their properties exist before parts
574
+ // bind (a plain clone stays in the template's inert document).
575
+ copy = upgrades(content) ? null : document.importNode(content, true);
576
+ imported.set(content, copy);
577
+ }
578
+ return copy === null ? document.importNode(content, true) : copy.cloneNode(true);
579
+ }
580
+
550
581
  function clearBetween(open, close) {
551
582
  while (open.nextSibling && open.nextSibling !== close) open.nextSibling.remove();
552
583
  }
@@ -565,8 +596,27 @@ function departBetween(open, close, motion) {
565
596
  }
566
597
 
567
598
  /** Remove everything between `open` and `close` in one operation. */
599
+ const nativeTextContent = typeof Node !== 'undefined' ? Object.getOwnPropertyDescriptor(Node.prototype, 'textContent').set : undefined;
568
600
  function deleteBetween(open, close) {
569
601
  if (open.nextSibling === close) return;
602
+ // The region is everything its parent holds but blank text (a list's own <tbody> or <ul>):
603
+ // emptying the parent and putting the markers and that text back is several times cheaper than
604
+ // deleting a range's contents.
605
+ const parent = open.parentNode;
606
+ const before = [];
607
+ const after = [];
608
+ const blank = (node) => node.nodeType === TEXT && node.data.trim() === '';
609
+ let edge = open.previousSibling;
610
+ while (edge && blank(edge) && before.length < 4) { before.unshift(edge); edge = edge.previousSibling; }
611
+ if (parent && !edge) {
612
+ edge = close.nextSibling;
613
+ while (edge && blank(edge) && after.length < 4) { after.push(edge); edge = edge.nextSibling; }
614
+ if (!edge) {
615
+ nativeTextContent.call(parent, '');
616
+ for (const node of [...before, open, close, ...after]) Node.prototype.insertBefore.call(parent, node, null);
617
+ return;
618
+ }
619
+ }
570
620
  const range = document.createRange();
571
621
  range.setStartAfter(open);
572
622
  range.setEndBefore(close);
@@ -588,10 +638,8 @@ function mountBlock(block, args, open, close, hydrating, context) {
588
638
  clearBetween(open, close);
589
639
  // Find and bind the parts in the fragment, then insert it: child components inside see
590
640
  // their props from the start, and can't move slotted nodes before they're found.
591
- // importNode, not cloneNode: defined custom elements upgrade now, so their properties exist
592
- // before parts bind (a clone stays in the template's inert document).
593
641
  const parent = parentOf(close);
594
- const fragment = document.importNode(blueprint(block, parent), true);
642
+ const fragment = freshCopy(block, parent);
595
643
  fragmentParents.set(fragment, parent);
596
644
  const fresh = locate(childRange(fragment), parts);
597
645
  parts.forEach((part, index) => bind(part, fresh[index], false, context));
@@ -725,7 +773,12 @@ function bind(part, node, hydrating, context) {
725
773
  if (part.text) {
726
774
  effect(() => {
727
775
  const text = part.text();
728
- if (node.textContent !== text) node.textContent = text;
776
+ // A lone Text child is compared and written in place (`.data`): reading `textContent`
777
+ // walks the subtree, and setting it replaces the Text node.
778
+ const child = node.firstChild;
779
+ if (child !== null && child === node.lastChild && child.nodeType === TEXT) {
780
+ if (child.data !== text) child.data = text;
781
+ } else if (node.textContent !== text) node.textContent = text;
729
782
  });
730
783
  } else if (part.anchor) {
731
784
  effect(() => {
@@ -1286,19 +1339,27 @@ function bindEach(part, open, hydrating, context) {
1286
1339
  const rowsOwner = scope();
1287
1340
  let rows = [];
1288
1341
 
1342
+ // A row is the nodes from `open` to `close`. Rows the server rendered keep the markers it put
1343
+ // around them. A row made here is its block's own nodes, from first to last, with no markers:
1344
+ // what a block's nodes are at the top never changes after it's bound (a region inside one keeps
1345
+ // its own markers), so the first and last stay the row's ends. Only an empty block needs one.
1289
1346
  const createRow = (value, index, key, rowOpen, rowClose, hydrateRow) => {
1290
- if (!rowOpen) {
1291
- // A new row is assembled in a fragment, then placed.
1292
- rowOpen = document.createComment('[');
1293
- rowClose = document.createComment(']');
1294
- const fragment = new DocumentFragment();
1295
- fragmentParents.set(fragment, parentOf(open));
1296
- fragment.append(rowOpen, rowClose);
1297
- }
1298
1347
  const item = new Signal.State(value);
1299
1348
  const position = new Signal.State(index);
1300
- const owner = rowsOwner.run(() => scope(() => mountBlock(part.block, [item, position], rowOpen, rowClose, hydrateRow, context)));
1301
- return { key, item, position, open: rowOpen, close: rowClose, owner };
1349
+ if (rowOpen) {
1350
+ const owner = rowsOwner.run(() => scope(() => mountBlock(part.block, [item, position], rowOpen, rowClose, hydrateRow, context)));
1351
+ return { key, item, position, open: rowOpen, close: rowClose, owner };
1352
+ }
1353
+ const parent = parentOf(open);
1354
+ const fragment = freshCopy(part.block, parent);
1355
+ fragmentParents.set(fragment, parent);
1356
+ const owner = rowsOwner.run(() => scope(() => {
1357
+ const parts = part.block.parts(item, position);
1358
+ const nodes = locate(childRange(fragment), parts);
1359
+ parts.forEach((each, at) => bind(each, nodes[at], false, context));
1360
+ }));
1361
+ if (!fragment.firstChild) fragment.append(document.createComment(''));
1362
+ return { key, item, position, open: fragment.firstChild, close: fragment.lastChild, owner };
1302
1363
  };
1303
1364
  const nodesOf = (row) => {
1304
1365
  const nodes = [];
@@ -1402,6 +1463,7 @@ function bindEach(part, open, hydrating, context) {
1402
1463
  }
1403
1464
  // `animate`: the rows that stay glide from where they were (@zubra/runtime/motion).
1404
1465
  const reorder = () => {
1466
+ const was = new Map(rows.map((row, index) => [row, index]));
1405
1467
  const next = list.map((value, index) => {
1406
1468
  const row = byKey.get(keys[index]);
1407
1469
  if (!row) return createRow(value, index, keys[index]);
@@ -1411,17 +1473,14 @@ function bindEach(part, open, hydrating, context) {
1411
1473
  return row;
1412
1474
  });
1413
1475
  batch(() => { for (const row of byKey.values()) removeRow(row); });
1414
- // Text between rows, and rows on their way out, aren't rows to place against.
1415
- const skip = (node) => node && (node.nodeType === TEXT || part.animate?.isLeaving(node));
1416
- let at = open.nextSibling;
1417
- while (skip(at)) at = at.nextSibling;
1418
- for (const row of next) {
1419
- if (row.open === at) {
1420
- at = row.close.nextSibling;
1421
- while (skip(at)) at = at.nextSibling;
1422
- continue;
1423
- }
1424
- placeRow(row, at ?? close);
1476
+ // The longest run of rows still in their old order stays put; every other row (and every
1477
+ // new one) goes before the row after it, from the end. A swap moves two rows, not the
1478
+ // rows between them.
1479
+ const stay = inOrder(next.map((row) => was.get(row) ?? -1));
1480
+ let before = close;
1481
+ for (let index = next.length - 1; index >= 0; index--) {
1482
+ if (!stay.has(index)) placeRow(next[index], before);
1483
+ before = next[index].open;
1425
1484
  }
1426
1485
  rows = next;
1427
1486
  };
@@ -1431,6 +1490,31 @@ function bindEach(part, open, hydrating, context) {
1431
1490
  });
1432
1491
  }
1433
1492
 
1493
+ /**
1494
+ * The indexes of a longest strictly increasing run in `sequence` (negative entries never count):
1495
+ * for a keyed list, which rows are already in order and needn't move. O(n log n).
1496
+ */
1497
+ function inOrder(sequence) {
1498
+ const tails = []; // tails[length - 1]: the index ending the best run of that length so far
1499
+ const previous = new Array(sequence.length);
1500
+ for (let index = 0; index < sequence.length; index++) {
1501
+ const value = sequence[index];
1502
+ if (value < 0) continue;
1503
+ let low = 0;
1504
+ let high = tails.length;
1505
+ while (low < high) {
1506
+ const middle = (low + high) >> 1;
1507
+ if (sequence[tails[middle]] < value) low = middle + 1;
1508
+ else high = middle;
1509
+ }
1510
+ previous[index] = low > 0 ? tails[low - 1] : -1;
1511
+ tails[low] = index;
1512
+ }
1513
+ const kept = new Set();
1514
+ for (let index = tails.length ? tails[tails.length - 1] : -1; index >= 0; index = previous[index]) kept.add(index);
1515
+ return kept;
1516
+ }
1517
+
1434
1518
  const hasContent = (root) =>
1435
1519
  [...root.childNodes].some((node) => node.nodeType !== TEXT || node.data.trim() !== '');
1436
1520
 
package/src/resource.js CHANGED
@@ -8,8 +8,10 @@
8
8
  // `AbortSignal` it was given, and so does the element's removal; a result that arrives after a
9
9
  // newer run started is dropped, so the value is always the latest request's. While a new run is
10
10
  // pending the previous value stays (`loading` is true), so the UI doesn't flash empty.
11
+ // `dispose()` (or `using`, through `Symbol.dispose`) stops it early: the run in flight is
12
+ // aborted and nothing re-runs it. Otherwise it lives as long as the scope that created it.
11
13
  import { Signal } from './signal.js';
12
- import { effect } from './effect.js';
14
+ import { DISPOSE, effect } from './effect.js';
13
15
 
14
16
  const { untrack } = Signal.subtle;
15
17
 
@@ -29,7 +31,7 @@ export function resource(fetcher, { initial } = {}) {
29
31
  const version = new Signal.State(0);
30
32
  let current = null; // the latest run's promise
31
33
 
32
- effect(() => {
34
+ const stop = effect(() => {
33
35
  version.get();
34
36
  const controller = new AbortController();
35
37
  let result;
@@ -71,5 +73,7 @@ export function resource(fetcher, { initial } = {}) {
71
73
  get loading() { return status.get() === 'pending'; },
72
74
  refresh() { version.set(untrack(() => version.get()) + 1); },
73
75
  then(resolve, reject) { return current.then(resolve, reject); },
76
+ dispose: stop,
77
+ [DISPOSE]: stop,
74
78
  };
75
79
  }