@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/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
|
-
|
|
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
|
|
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 =
|
|
88
|
+
this.children = null; // created on first child: most effects have none
|
|
78
89
|
this.disposed = false;
|
|
79
90
|
this.paused = false;
|
|
80
|
-
parent
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
1273
|
-
|
|
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
|
-
//
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
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
|
}
|