@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 +7 -1
- package/dist/production/effect.js +31 -11
- package/dist/production/element.js +113 -29
- package/dist/production/resource.js +6 -2
- package/dist/production/signal-impl.js +235 -165
- package/dist/production/track.js +78 -0
- package/package.json +6 -1
- package/src/devtools.js +1 -1
- package/src/effect.js +31 -11
- package/src/element.js +113 -29
- package/src/resource.js +6 -2
- package/src/signal-impl.js +235 -165
- package/src/track.js +77 -0
- package/types/effect.d.ts +17 -4
- package/types/index.d.ts +6 -3
- package/types/track.d.ts +19 -0
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
|
|
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 =
|
|
95
|
+
this.children = null; // created on first child: most effects have none
|
|
85
96
|
this.disposed = false;
|
|
86
97
|
this.paused = false;
|
|
87
|
-
parent
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
1301
|
-
|
|
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
|
-
//
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
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
|
}
|