@zubra/runtime 5.8.0 → 5.9.0
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/dist/production/effect.js +18 -1
- package/dist/production/element.js +69 -15
- package/dist/production/resource.js +6 -2
- package/dist/production/track.js +78 -0
- package/package.json +6 -1
- package/src/effect.js +18 -1
- package/src/element.js +69 -15
- package/src/resource.js +6 -2
- 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
|
@@ -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.
|
|
@@ -29,6 +34,12 @@ let scheduled = null; // promise for the pending microtask flush
|
|
|
29
34
|
let flushing = false;
|
|
30
35
|
const effectOf = new WeakMap();
|
|
31
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');
|
|
42
|
+
|
|
32
43
|
|
|
33
44
|
const watcher = new Signal.subtle.Watcher(() => {
|
|
34
45
|
scheduled ??= new Promise((resolve) => queueMicrotask(() => {
|
|
@@ -93,6 +104,10 @@ class Owner {
|
|
|
93
104
|
this.parent?.children.delete(this);
|
|
94
105
|
}
|
|
95
106
|
|
|
107
|
+
[DISPOSE]() {
|
|
108
|
+
this.dispose();
|
|
109
|
+
}
|
|
110
|
+
|
|
96
111
|
pause() {
|
|
97
112
|
if (this.paused || this.disposed) return;
|
|
98
113
|
this.paused = true;
|
|
@@ -183,7 +198,9 @@ class Scope extends Owner {
|
|
|
183
198
|
export function effect(fn) {
|
|
184
199
|
const e = new Effect(fn, owner);
|
|
185
200
|
e.run(); // if this throws, the effect stays registered and re-runs when its dependencies change
|
|
186
|
-
|
|
201
|
+
const dispose = () => e.dispose();
|
|
202
|
+
dispose[DISPOSE] = dispose;
|
|
203
|
+
return dispose;
|
|
187
204
|
}
|
|
188
205
|
|
|
189
206
|
/**
|
|
@@ -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;
|
|
@@ -546,8 +558,27 @@ function departBetween(open, close, motion) {
|
|
|
546
558
|
}
|
|
547
559
|
|
|
548
560
|
/** Remove everything between `open` and `close` in one operation. */
|
|
561
|
+
const nativeTextContent = typeof Node !== 'undefined' ? Object.getOwnPropertyDescriptor(Node.prototype, 'textContent').set : undefined;
|
|
549
562
|
function deleteBetween(open, close) {
|
|
550
563
|
if (open.nextSibling === close) return;
|
|
564
|
+
// The region is everything its parent holds but blank text (a list's own <tbody> or <ul>):
|
|
565
|
+
// emptying the parent and putting the markers and that text back is several times cheaper than
|
|
566
|
+
// deleting a range's contents.
|
|
567
|
+
const parent = open.parentNode;
|
|
568
|
+
const before = [];
|
|
569
|
+
const after = [];
|
|
570
|
+
const blank = (node) => node.nodeType === TEXT && node.data.trim() === '';
|
|
571
|
+
let edge = open.previousSibling;
|
|
572
|
+
while (edge && blank(edge) && before.length < 4) { before.unshift(edge); edge = edge.previousSibling; }
|
|
573
|
+
if (parent && !edge) {
|
|
574
|
+
edge = close.nextSibling;
|
|
575
|
+
while (edge && blank(edge) && after.length < 4) { after.push(edge); edge = edge.nextSibling; }
|
|
576
|
+
if (!edge) {
|
|
577
|
+
nativeTextContent.call(parent, '');
|
|
578
|
+
for (const node of [...before, open, close, ...after]) Node.prototype.insertBefore.call(parent, node, null);
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
551
582
|
const range = document.createRange();
|
|
552
583
|
range.setStartAfter(open);
|
|
553
584
|
range.setEndBefore(close);
|
|
@@ -1374,6 +1405,7 @@ function bindEach(part, open, hydrating, context) {
|
|
|
1374
1405
|
}
|
|
1375
1406
|
// `animate`: the rows that stay glide from where they were (@zubra/runtime/motion).
|
|
1376
1407
|
const reorder = () => {
|
|
1408
|
+
const was = new Map(rows.map((row, index) => [row, index]));
|
|
1377
1409
|
const next = list.map((value, index) => {
|
|
1378
1410
|
const row = byKey.get(keys[index]);
|
|
1379
1411
|
if (!row) return createRow(value, index, keys[index]);
|
|
@@ -1383,17 +1415,14 @@ function bindEach(part, open, hydrating, context) {
|
|
|
1383
1415
|
return row;
|
|
1384
1416
|
});
|
|
1385
1417
|
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);
|
|
1418
|
+
// The longest run of rows still in their old order stays put; every other row (and every
|
|
1419
|
+
// new one) goes before the row after it, from the end. A swap moves two rows, not the
|
|
1420
|
+
// rows between them.
|
|
1421
|
+
const stay = inOrder(next.map((row) => was.get(row) ?? -1));
|
|
1422
|
+
let before = close;
|
|
1423
|
+
for (let index = next.length - 1; index >= 0; index--) {
|
|
1424
|
+
if (!stay.has(index)) placeRow(next[index], before);
|
|
1425
|
+
before = next[index].open;
|
|
1397
1426
|
}
|
|
1398
1427
|
rows = next;
|
|
1399
1428
|
};
|
|
@@ -1403,6 +1432,31 @@ function bindEach(part, open, hydrating, context) {
|
|
|
1403
1432
|
});
|
|
1404
1433
|
}
|
|
1405
1434
|
|
|
1435
|
+
/**
|
|
1436
|
+
* The indexes of a longest strictly increasing run in `sequence` (negative entries never count):
|
|
1437
|
+
* for a keyed list, which rows are already in order and needn't move. O(n log n).
|
|
1438
|
+
*/
|
|
1439
|
+
function inOrder(sequence) {
|
|
1440
|
+
const tails = []; // tails[length - 1]: the index ending the best run of that length so far
|
|
1441
|
+
const previous = new Array(sequence.length);
|
|
1442
|
+
for (let index = 0; index < sequence.length; index++) {
|
|
1443
|
+
const value = sequence[index];
|
|
1444
|
+
if (value < 0) continue;
|
|
1445
|
+
let low = 0;
|
|
1446
|
+
let high = tails.length;
|
|
1447
|
+
while (low < high) {
|
|
1448
|
+
const middle = (low + high) >> 1;
|
|
1449
|
+
if (sequence[tails[middle]] < value) low = middle + 1;
|
|
1450
|
+
else high = middle;
|
|
1451
|
+
}
|
|
1452
|
+
previous[index] = low > 0 ? tails[low - 1] : -1;
|
|
1453
|
+
tails[low] = index;
|
|
1454
|
+
}
|
|
1455
|
+
const kept = new Set();
|
|
1456
|
+
for (let index = tails.length ? tails[tails.length - 1] : -1; index >= 0; index = previous[index]) kept.add(index);
|
|
1457
|
+
return kept;
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1406
1460
|
const hasContent = (root) =>
|
|
1407
1461
|
[...root.childNodes].some((node) => node.nodeType !== TEXT || node.data.trim() !== '');
|
|
1408
1462
|
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Generated by scripts/build-production.js from src/track.js: the runtime without hot reloading or the DevTools hook. Do not edit.
|
|
2
|
+
// Reads after an `await` in a resource's fetcher: `resource(tracked(fetcher))`.
|
|
3
|
+
//
|
|
4
|
+
// const list = resource(tracked(async ({ signal, track }) => {
|
|
5
|
+
// const token = await auth(signal)
|
|
6
|
+
// return load(token, track(() => page.get()), signal)
|
|
7
|
+
// }))
|
|
8
|
+
//
|
|
9
|
+
// The Signals API tracks a read only while a Computed is running. A resource's fetcher runs
|
|
10
|
+
// inside its effect until its first `await`; the code after that runs later, outside it, so its
|
|
11
|
+
// reads are not followed. `tracked()` gives each run `track(read)`: it reads, and when what
|
|
12
|
+
// `read` returns changes, the resource runs the fetcher again, as for a read before the
|
|
13
|
+
// `await`. The compiler writes `tracked()` and the `track()` calls for an async fetcher in a
|
|
14
|
+
// `.zb` script, so this module is loaded only by components that have one.
|
|
15
|
+
//
|
|
16
|
+
// A run that was replaced or has settled, or a resource that was disposed, still reads through
|
|
17
|
+
// `track()`, but no longer subscribes. The reads are watched by an effect owned where `tracked()` was called (a
|
|
18
|
+
// component's setup), so a detached element holds none of them and catches up when it returns.
|
|
19
|
+
import { Signal } from './signal.js';
|
|
20
|
+
import { effect } from './effect.js';
|
|
21
|
+
|
|
22
|
+
const { untrack, currentComputed } = Signal.subtle;
|
|
23
|
+
const THREW = Symbol('threw');
|
|
24
|
+
|
|
25
|
+
function readOnce(computed) {
|
|
26
|
+
try { return computed.get(); } catch { return THREW; }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Wraps a resource's fetcher so it is also given `track(read)`, for reads after an `await`. */
|
|
30
|
+
export function tracked(fetcher) {
|
|
31
|
+
if (typeof fetcher !== 'function') throw new TypeError('zubra: tracked() takes a function');
|
|
32
|
+
// Read by each run before it can wait, so the resource's effect runs again when it changes.
|
|
33
|
+
const again = new Signal.State(0);
|
|
34
|
+
// The run whose later reads are watched: set when it makes one, and when it settles.
|
|
35
|
+
const tail = new Signal.State(null);
|
|
36
|
+
let latest = null;
|
|
37
|
+
|
|
38
|
+
// When a read changes, run again. A new read is appended (and seen) without re-running.
|
|
39
|
+
const stop = effect(() => {
|
|
40
|
+
const watched = tail.get();
|
|
41
|
+
if (!watched) return;
|
|
42
|
+
let changed = false;
|
|
43
|
+
for (const read of watched.run.reads) {
|
|
44
|
+
if (!Object.is(readOnce(read.computed), read.seen)) changed = true; // read each, to keep watching it
|
|
45
|
+
}
|
|
46
|
+
if (changed && watched.run === latest) untrack(() => again.set(again.get() + 1));
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
return (context) => {
|
|
50
|
+
again.get();
|
|
51
|
+
const { signal } = context;
|
|
52
|
+
const run = { reads: [] };
|
|
53
|
+
latest = run;
|
|
54
|
+
// The resource aborts a run when the next one starts, or when it is disposed: then no run
|
|
55
|
+
// follows, and nothing is watched any more.
|
|
56
|
+
signal?.addEventListener('abort', () => queueMicrotask(() => { if (latest === run) stop(); }), { once: true });
|
|
57
|
+
const track = (read) => {
|
|
58
|
+
if (typeof read !== 'function') throw new TypeError('zubra: track() takes a function that reads signals');
|
|
59
|
+
// Before the first `await` the resource's effect is running, and tracks the read itself.
|
|
60
|
+
if (currentComputed()) return read();
|
|
61
|
+
// A replaced or finished run (a timer the fetcher left), or a disposed resource: its value
|
|
62
|
+
// can't depend on the read any more, so read without subscribing.
|
|
63
|
+
if (run !== latest || run.done || signal?.aborted) return untrack(read);
|
|
64
|
+
const computed = new Signal.Computed(read);
|
|
65
|
+
run.reads.push({ computed, seen: readOnce(computed) });
|
|
66
|
+
tail.set({ run });
|
|
67
|
+
return computed.get(); // rethrows what `read` threw
|
|
68
|
+
};
|
|
69
|
+
const settled = () => {
|
|
70
|
+
run.done = true;
|
|
71
|
+
// The newest run has finished: stop watching an older run's later reads.
|
|
72
|
+
if (run === latest && untrack(() => tail.get())?.run !== run) tail.set(run.reads.length ? { run } : null);
|
|
73
|
+
};
|
|
74
|
+
const result = fetcher({ ...context, track });
|
|
75
|
+
Promise.resolve(result).then(settled, settled);
|
|
76
|
+
return result;
|
|
77
|
+
};
|
|
78
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zubra/runtime",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.9.0",
|
|
4
4
|
"description": "Browser runtime for Zubra compiled components, built on the TC39 Signals API",
|
|
5
5
|
"license": "ISC",
|
|
6
6
|
"repository": {
|
|
@@ -30,6 +30,11 @@
|
|
|
30
30
|
"production": "./dist/production/spread.js",
|
|
31
31
|
"default": "./src/spread.js"
|
|
32
32
|
},
|
|
33
|
+
"./track": {
|
|
34
|
+
"types": "./types/track.d.ts",
|
|
35
|
+
"production": "./dist/production/track.js",
|
|
36
|
+
"default": "./src/track.js"
|
|
37
|
+
},
|
|
33
38
|
"./zag": {
|
|
34
39
|
"types": "./types/zag.d.ts",
|
|
35
40
|
"production": "./dist/production/zag.js",
|
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.
|
|
@@ -28,6 +33,12 @@ let scheduled = null; // promise for the pending microtask flush
|
|
|
28
33
|
let flushing = false;
|
|
29
34
|
const effectOf = new WeakMap();
|
|
30
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');
|
|
41
|
+
|
|
31
42
|
// @dev
|
|
32
43
|
/**
|
|
33
44
|
* For the DevTools hook (devtools.js), in development only: `label` is what the effect being
|
|
@@ -100,6 +111,10 @@ class Owner {
|
|
|
100
111
|
this.parent?.children.delete(this);
|
|
101
112
|
}
|
|
102
113
|
|
|
114
|
+
[DISPOSE]() {
|
|
115
|
+
this.dispose();
|
|
116
|
+
}
|
|
117
|
+
|
|
103
118
|
pause() {
|
|
104
119
|
if (this.paused || this.disposed) return;
|
|
105
120
|
this.paused = true;
|
|
@@ -193,7 +208,9 @@ class Scope extends Owner {
|
|
|
193
208
|
export function effect(fn) {
|
|
194
209
|
const e = new Effect(fn, owner);
|
|
195
210
|
e.run(); // if this throws, the effect stays registered and re-runs when its dependencies change
|
|
196
|
-
|
|
211
|
+
const dispose = () => e.dispose();
|
|
212
|
+
dispose[DISPOSE] = dispose;
|
|
213
|
+
return dispose;
|
|
197
214
|
}
|
|
198
215
|
|
|
199
216
|
/**
|
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;
|
|
@@ -565,8 +577,27 @@ function departBetween(open, close, motion) {
|
|
|
565
577
|
}
|
|
566
578
|
|
|
567
579
|
/** Remove everything between `open` and `close` in one operation. */
|
|
580
|
+
const nativeTextContent = typeof Node !== 'undefined' ? Object.getOwnPropertyDescriptor(Node.prototype, 'textContent').set : undefined;
|
|
568
581
|
function deleteBetween(open, close) {
|
|
569
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
|
+
}
|
|
570
601
|
const range = document.createRange();
|
|
571
602
|
range.setStartAfter(open);
|
|
572
603
|
range.setEndBefore(close);
|
|
@@ -1402,6 +1433,7 @@ function bindEach(part, open, hydrating, context) {
|
|
|
1402
1433
|
}
|
|
1403
1434
|
// `animate`: the rows that stay glide from where they were (@zubra/runtime/motion).
|
|
1404
1435
|
const reorder = () => {
|
|
1436
|
+
const was = new Map(rows.map((row, index) => [row, index]));
|
|
1405
1437
|
const next = list.map((value, index) => {
|
|
1406
1438
|
const row = byKey.get(keys[index]);
|
|
1407
1439
|
if (!row) return createRow(value, index, keys[index]);
|
|
@@ -1411,17 +1443,14 @@ function bindEach(part, open, hydrating, context) {
|
|
|
1411
1443
|
return row;
|
|
1412
1444
|
});
|
|
1413
1445
|
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);
|
|
1446
|
+
// The longest run of rows still in their old order stays put; every other row (and every
|
|
1447
|
+
// new one) goes before the row after it, from the end. A swap moves two rows, not the
|
|
1448
|
+
// rows between them.
|
|
1449
|
+
const stay = inOrder(next.map((row) => was.get(row) ?? -1));
|
|
1450
|
+
let before = close;
|
|
1451
|
+
for (let index = next.length - 1; index >= 0; index--) {
|
|
1452
|
+
if (!stay.has(index)) placeRow(next[index], before);
|
|
1453
|
+
before = next[index].open;
|
|
1425
1454
|
}
|
|
1426
1455
|
rows = next;
|
|
1427
1456
|
};
|
|
@@ -1431,6 +1460,31 @@ function bindEach(part, open, hydrating, context) {
|
|
|
1431
1460
|
});
|
|
1432
1461
|
}
|
|
1433
1462
|
|
|
1463
|
+
/**
|
|
1464
|
+
* The indexes of a longest strictly increasing run in `sequence` (negative entries never count):
|
|
1465
|
+
* for a keyed list, which rows are already in order and needn't move. O(n log n).
|
|
1466
|
+
*/
|
|
1467
|
+
function inOrder(sequence) {
|
|
1468
|
+
const tails = []; // tails[length - 1]: the index ending the best run of that length so far
|
|
1469
|
+
const previous = new Array(sequence.length);
|
|
1470
|
+
for (let index = 0; index < sequence.length; index++) {
|
|
1471
|
+
const value = sequence[index];
|
|
1472
|
+
if (value < 0) continue;
|
|
1473
|
+
let low = 0;
|
|
1474
|
+
let high = tails.length;
|
|
1475
|
+
while (low < high) {
|
|
1476
|
+
const middle = (low + high) >> 1;
|
|
1477
|
+
if (sequence[tails[middle]] < value) low = middle + 1;
|
|
1478
|
+
else high = middle;
|
|
1479
|
+
}
|
|
1480
|
+
previous[index] = low > 0 ? tails[low - 1] : -1;
|
|
1481
|
+
tails[low] = index;
|
|
1482
|
+
}
|
|
1483
|
+
const kept = new Set();
|
|
1484
|
+
for (let index = tails.length ? tails[tails.length - 1] : -1; index >= 0; index = previous[index]) kept.add(index);
|
|
1485
|
+
return kept;
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1434
1488
|
const hasContent = (root) =>
|
|
1435
1489
|
[...root.childNodes].some((node) => node.nodeType !== TEXT || node.data.trim() !== '');
|
|
1436
1490
|
|
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
|
}
|
package/src/track.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Reads after an `await` in a resource's fetcher: `resource(tracked(fetcher))`.
|
|
2
|
+
//
|
|
3
|
+
// const list = resource(tracked(async ({ signal, track }) => {
|
|
4
|
+
// const token = await auth(signal)
|
|
5
|
+
// return load(token, track(() => page.get()), signal)
|
|
6
|
+
// }))
|
|
7
|
+
//
|
|
8
|
+
// The Signals API tracks a read only while a Computed is running. A resource's fetcher runs
|
|
9
|
+
// inside its effect until its first `await`; the code after that runs later, outside it, so its
|
|
10
|
+
// reads are not followed. `tracked()` gives each run `track(read)`: it reads, and when what
|
|
11
|
+
// `read` returns changes, the resource runs the fetcher again, as for a read before the
|
|
12
|
+
// `await`. The compiler writes `tracked()` and the `track()` calls for an async fetcher in a
|
|
13
|
+
// `.zb` script, so this module is loaded only by components that have one.
|
|
14
|
+
//
|
|
15
|
+
// A run that was replaced or has settled, or a resource that was disposed, still reads through
|
|
16
|
+
// `track()`, but no longer subscribes. The reads are watched by an effect owned where `tracked()` was called (a
|
|
17
|
+
// component's setup), so a detached element holds none of them and catches up when it returns.
|
|
18
|
+
import { Signal } from './signal.js';
|
|
19
|
+
import { effect } from './effect.js';
|
|
20
|
+
|
|
21
|
+
const { untrack, currentComputed } = Signal.subtle;
|
|
22
|
+
const THREW = Symbol('threw');
|
|
23
|
+
|
|
24
|
+
function readOnce(computed) {
|
|
25
|
+
try { return computed.get(); } catch { return THREW; }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Wraps a resource's fetcher so it is also given `track(read)`, for reads after an `await`. */
|
|
29
|
+
export function tracked(fetcher) {
|
|
30
|
+
if (typeof fetcher !== 'function') throw new TypeError('zubra: tracked() takes a function');
|
|
31
|
+
// Read by each run before it can wait, so the resource's effect runs again when it changes.
|
|
32
|
+
const again = new Signal.State(0);
|
|
33
|
+
// The run whose later reads are watched: set when it makes one, and when it settles.
|
|
34
|
+
const tail = new Signal.State(null);
|
|
35
|
+
let latest = null;
|
|
36
|
+
|
|
37
|
+
// When a read changes, run again. A new read is appended (and seen) without re-running.
|
|
38
|
+
const stop = effect(() => {
|
|
39
|
+
const watched = tail.get();
|
|
40
|
+
if (!watched) return;
|
|
41
|
+
let changed = false;
|
|
42
|
+
for (const read of watched.run.reads) {
|
|
43
|
+
if (!Object.is(readOnce(read.computed), read.seen)) changed = true; // read each, to keep watching it
|
|
44
|
+
}
|
|
45
|
+
if (changed && watched.run === latest) untrack(() => again.set(again.get() + 1));
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
return (context) => {
|
|
49
|
+
again.get();
|
|
50
|
+
const { signal } = context;
|
|
51
|
+
const run = { reads: [] };
|
|
52
|
+
latest = run;
|
|
53
|
+
// The resource aborts a run when the next one starts, or when it is disposed: then no run
|
|
54
|
+
// follows, and nothing is watched any more.
|
|
55
|
+
signal?.addEventListener('abort', () => queueMicrotask(() => { if (latest === run) stop(); }), { once: true });
|
|
56
|
+
const track = (read) => {
|
|
57
|
+
if (typeof read !== 'function') throw new TypeError('zubra: track() takes a function that reads signals');
|
|
58
|
+
// Before the first `await` the resource's effect is running, and tracks the read itself.
|
|
59
|
+
if (currentComputed()) return read();
|
|
60
|
+
// A replaced or finished run (a timer the fetcher left), or a disposed resource: its value
|
|
61
|
+
// can't depend on the read any more, so read without subscribing.
|
|
62
|
+
if (run !== latest || run.done || signal?.aborted) return untrack(read);
|
|
63
|
+
const computed = new Signal.Computed(read);
|
|
64
|
+
run.reads.push({ computed, seen: readOnce(computed) });
|
|
65
|
+
tail.set({ run });
|
|
66
|
+
return computed.get(); // rethrows what `read` threw
|
|
67
|
+
};
|
|
68
|
+
const settled = () => {
|
|
69
|
+
run.done = true;
|
|
70
|
+
// The newest run has finished: stop watching an older run's later reads.
|
|
71
|
+
if (run === latest && untrack(() => tail.get())?.run !== run) tail.set(run.reads.length ? { run } : null);
|
|
72
|
+
};
|
|
73
|
+
const result = fetcher({ ...context, track });
|
|
74
|
+
Promise.resolve(result).then(settled, settled);
|
|
75
|
+
return result;
|
|
76
|
+
};
|
|
77
|
+
}
|
package/types/effect.d.ts
CHANGED
|
@@ -1,15 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `[Symbol.dispose]()`, so `using` and `DisposableStack` accept it, when the project's `lib`
|
|
3
|
+
* has Explicit Resource Management (`esnext.disposable`, or `esnext`); nothing otherwise, so
|
|
4
|
+
* older `lib` settings still type-check.
|
|
5
|
+
*/
|
|
6
|
+
export type Disposal = SymbolConstructor extends { readonly dispose: infer K extends symbol }
|
|
7
|
+
? { [P in K]: () => void }
|
|
8
|
+
: {};
|
|
9
|
+
|
|
1
10
|
/** Something to run when an effect re-runs or is disposed. */
|
|
2
11
|
export type Cleanup = () => void;
|
|
3
12
|
|
|
4
13
|
/**
|
|
5
14
|
* Run `fn` now and again whenever a signal it read changes (batched: several writes in one task
|
|
6
15
|
* cause one re-run, in a microtask). `fn` may return a cleanup. Returns a function that disposes
|
|
7
|
-
* the effect
|
|
16
|
+
* the effect, and is also disposable (`using stop = effect(…)`). Effects created inside another
|
|
17
|
+
* effect or scope belong to it.
|
|
8
18
|
*/
|
|
9
|
-
export declare function effect(fn: () => void | Cleanup): () => void;
|
|
19
|
+
export declare function effect(fn: () => void | Cleanup): (() => void) & Disposal;
|
|
10
20
|
|
|
11
|
-
/**
|
|
12
|
-
|
|
21
|
+
/**
|
|
22
|
+
* An ownership root: disposing, pausing or resuming it applies to every effect inside. It's
|
|
23
|
+
* disposable too (`using s = scope(…)`); pausing isn't disposal, and a paused scope resumes.
|
|
24
|
+
*/
|
|
25
|
+
export interface Scope extends Disposal {
|
|
13
26
|
run<T>(fn: () => T): T;
|
|
14
27
|
dispose(): void;
|
|
15
28
|
pause(): void;
|
package/types/index.d.ts
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
import type { Signal } from './signal.js';
|
|
3
3
|
|
|
4
4
|
export { Signal, isNative } from './signal.js';
|
|
5
|
-
export { effect, scope, flush, tick, type Cleanup, type Scope } from './effect.js';
|
|
5
|
+
export { effect, scope, flush, tick, type Cleanup, type Disposal, type Scope } from './effect.js';
|
|
6
|
+
import type { Disposal } from './effect.js';
|
|
6
7
|
|
|
7
8
|
/** A value, or a signal holding one: what templates and most hooks accept. */
|
|
8
9
|
export type MaybeSignal<T> = T | Signal.State<T> | Signal.Computed<T>;
|
|
@@ -23,7 +24,7 @@ export declare function computed<T>(fn: () => T, options?: Signal.Options<T>): S
|
|
|
23
24
|
export declare function watch<T>(source: (() => T) | MaybeSignal<T>, callback: (next: T, previous: T) => void): void;
|
|
24
25
|
|
|
25
26
|
/** Async data as signals; see `resource()`. Awaitable: resolves to the current run's value. */
|
|
26
|
-
export interface Resource<T> extends PromiseLike<T
|
|
27
|
+
export interface Resource<T> extends PromiseLike<T>, Disposal {
|
|
27
28
|
/** The latest run's value (or `initial`); kept while the next run is pending. */
|
|
28
29
|
readonly value: T | undefined;
|
|
29
30
|
readonly error: unknown;
|
|
@@ -31,6 +32,8 @@ export interface Resource<T> extends PromiseLike<T> {
|
|
|
31
32
|
readonly loading: boolean;
|
|
32
33
|
/** Run the fetcher again. */
|
|
33
34
|
refresh(): void;
|
|
35
|
+
/** Stop: abort the run in flight and never run again. `using` does this through `Symbol.dispose`. */
|
|
36
|
+
dispose(): void;
|
|
34
37
|
}
|
|
35
38
|
/**
|
|
36
39
|
* Run `fetcher` now and whenever a signal it read before its first `await` changes; a new run
|
|
@@ -55,7 +58,7 @@ export declare function host<E extends HTMLElement = HTMLElement>(): E;
|
|
|
55
58
|
* declares every event it sees. Returns false if a listener called `preventDefault()` on a
|
|
56
59
|
* cancelable (`before…`) event.
|
|
57
60
|
*/
|
|
58
|
-
export declare function emit(name: string, detail?:
|
|
61
|
+
export declare function emit<Detail = unknown>(name: string, detail?: Detail): boolean;
|
|
59
62
|
|
|
60
63
|
/**
|
|
61
64
|
* The signal itself, where a script passes a signal `const` along: `watch(signal(count), …)`.
|
package/types/track.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// @zubra/runtime/track: reads after an `await` in a resource's fetcher.
|
|
2
|
+
|
|
3
|
+
/** What a fetcher wrapped by `tracked()` is given for each run. */
|
|
4
|
+
export interface TrackedRun {
|
|
5
|
+
/** Aborted when a newer run starts or the resource is disposed. */
|
|
6
|
+
signal: AbortSignal;
|
|
7
|
+
/**
|
|
8
|
+
* Read signals and follow them, after an `await` too: when what `read` returns changes, the
|
|
9
|
+
* resource runs the fetcher again. A replaced run still reads, without subscribing.
|
|
10
|
+
*/
|
|
11
|
+
track<V>(read: () => V): V;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Wraps a resource's fetcher so it is also given `track()`:
|
|
16
|
+
* `resource(tracked(async ({ signal, track }) => …))`. The compiler writes it for an async
|
|
17
|
+
* fetcher in a `.zb` script.
|
|
18
|
+
*/
|
|
19
|
+
export declare function tracked<T>(fetcher: (run: TrackedRun) => T | PromiseLike<T>): (context: { signal: AbortSignal }) => T | PromiseLike<T>;
|