@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.
@@ -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
- return () => e.dispose();
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; based on the host's own `id` when it has one, so it can be made to match
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 base = component.host.id || `${component.host.localName}-${component.instance}`;
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 host = hooks('inject').host;
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
- // Text between rows, and rows on their way out, aren't rows to place against.
1387
- const skip = (node) => node && (node.nodeType === TEXT || part.animate?.isLeaving(node));
1388
- let at = open.nextSibling;
1389
- while (skip(at)) at = at.nextSibling;
1390
- for (const row of next) {
1391
- if (row.open === at) {
1392
- at = row.close.nextSibling;
1393
- while (skip(at)) at = at.nextSibling;
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.8.0",
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
- return () => e.dispose();
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; 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;
@@ -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
- // 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);
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. Effects created inside another effect or scope belong to it.
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
- /** An ownership root: disposing, pausing or resuming it applies to every effect inside. */
12
- export interface Scope {
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?: unknown): boolean;
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), …)`.
@@ -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>;