@zubra/runtime 0.0.0-stage → 5.0.3
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/LICENSE +15 -0
- package/README.md +97 -2
- package/dist/production/deep.js +88 -0
- package/dist/production/effect.js +234 -0
- package/dist/production/element.js +1761 -0
- package/dist/production/index.js +12 -0
- package/dist/production/primitives.js +108 -0
- package/dist/production/react.js +125 -0
- package/dist/production/remark.js +173 -0
- package/dist/production/resource.js +76 -0
- package/dist/production/server.js +98 -0
- package/dist/production/signal-impl.js +376 -0
- package/dist/production/signal.js +10 -0
- package/dist/production/spread.js +155 -0
- package/dist/production/vue.js +126 -0
- package/dist/production/zag.js +59 -0
- package/fesm2022/zubra-runtime-angular.mjs +118 -0
- package/package.json +126 -4
- package/src/deep.js +87 -0
- package/src/effect.js +233 -0
- package/src/element.js +1837 -0
- package/src/index.js +11 -0
- package/src/primitives.js +107 -0
- package/src/react.js +124 -0
- package/src/remark.js +172 -0
- package/src/resource.js +75 -0
- package/src/server.js +97 -0
- package/src/signal-impl.js +375 -0
- package/src/signal.js +9 -0
- package/src/spread.js +154 -0
- package/src/vue.js +125 -0
- package/src/zag.js +58 -0
- package/types/angular.d.ts +20 -0
- package/types/effect.d.ts +28 -0
- package/types/index.d.ts +205 -0
- package/types/primitives.d.ts +15 -0
- package/types/react.d.ts +13 -0
- package/types/server.d.ts +27 -0
- package/types/signal.d.ts +46 -0
- package/types/spread.d.ts +5 -0
- package/types/vue.d.ts +8 -0
- package/types/zag.d.ts +9 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
ISC License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, Mikey Nichols
|
|
4
|
+
|
|
5
|
+
Permission to use, copy, modify, and/or distribute this software for any
|
|
6
|
+
purpose with or without fee is hereby granted, provided that the above
|
|
7
|
+
copyright notice and this permission notice appear in all copies.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
|
10
|
+
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
|
11
|
+
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
|
12
|
+
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
|
13
|
+
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
|
14
|
+
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
|
15
|
+
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,98 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @zubra/runtime
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The browser runtime that compiled Zubra components run on. It is a **polyfill boundary**: everything
|
|
4
|
+
compiled code depends on is shaped like a proposed web platform primitive, so when the platform
|
|
5
|
+
ships one, this package deletes code instead of components being rewritten.
|
|
6
|
+
|
|
7
|
+
| Module | Shaped like | Status |
|
|
8
|
+
|---|---|---|
|
|
9
|
+
| `src/signal.js` | [TC39 Signals](https://github.com/tc39/proposal-signals) (`Signal.State`, `Signal.Computed`, `Signal.subtle`) | Done. Uses native `globalThis.Signal` when present, otherwise `src/signal-impl.js` |
|
|
10
|
+
| `src/effect.js` | Not a proposal — the Signals proposal leaves effects to libraries. Built only on `Signal.subtle.Watcher` and microtasks | Done |
|
|
11
|
+
| `src/element.js` parts (`walk`/`bind`) | [DOM Parts](https://github.com/WICG/webcomponents/blob/gh-pages/proposals/DOM-Parts.md): a template plus the locations of its dynamic parts | Done: text, attributes, properties, events, `zb-if`/`zb-each` regions (keyed rows move with `moveBefore()`), slots; paths count elements and comments only, and a region counts once |
|
|
12
|
+
| `src/element.js` `define` | Custom elements, light DOM by default, shadow opt-in | Done: signal-backed props, typed attributes, pre-upgrade capture, hydration without re-rendering, pause on detach |
|
|
13
|
+
| `src/element.js` hooks | Plain functions (see the project's hooks decision) | `state`, `from`, `computed`, `watch`, `onMount`, `onUnmount`, `id`, `props`, `host`, `emit`, `hydrated` |
|
|
14
|
+
| `src/element.js` slots | Light-DOM slotting with the host's child methods routed to the slots, so frameworks can keep managing the children; native `<slot>` in shadow DOM | Done |
|
|
15
|
+
| `src/react.js`, `src/vue.js` | Adapters: an element as a React (18, 19) or Vue 3 component | Done |
|
|
16
|
+
| `types/` | TypeScript declarations for every entry point | Done |
|
|
17
|
+
| `src/element.js` forms | Form-associated custom elements (`ElementInternals`) | `formValue`, `validity`, `onFormReset`, `onFormRestore`, `onFormDisabled`; native control API on the element |
|
|
18
|
+
| `src/element.js` context | [Context Protocol](https://github.com/webcomponents-cg/community-protocols/blob/main/proposals/context.md) (`context-request` / `context-provider` events) | `createContext`, `provide`, `inject`; interoperates with any protocol implementation |
|
|
19
|
+
|
|
20
|
+
## The production build
|
|
21
|
+
|
|
22
|
+
`dist/production/` is the same modules without hot reloading (the code between `// @hot` and
|
|
23
|
+
`// @hot-end` in `src/`). Bundlers pick it through the `production` export condition (Vite and
|
|
24
|
+
webpack do in production builds), and `@zubra/server` serves it when `NODE_ENV` is `production`.
|
|
25
|
+
It's generated by `npm run build:production` and checked in; `npm run test:tools` fails if it's
|
|
26
|
+
out of date, and measures both builds against their size budgets (13.5 KB and 13 KB gzipped).
|
|
27
|
+
|
|
28
|
+
## Signals
|
|
29
|
+
|
|
30
|
+
`src/signal-impl.js` is an independent implementation of the proposed API. It is held to the
|
|
31
|
+
proposal's reference polyfill, [`signal-polyfill`](https://github.com/proposal-signals/signal-polyfill),
|
|
32
|
+
by **differential tests**: each scenario runs against both, and the logs must be identical,
|
|
33
|
+
including values, recompute counts, the order watchers are notified, the order `watched` and
|
|
34
|
+
`unwatched` hooks fire, pending lists and introspection.
|
|
35
|
+
|
|
36
|
+
- 26 hand-written scenarios: caching, equality cut-off, glitch-free diamonds, dynamic
|
|
37
|
+
dependencies, cached errors, cycles, `equals` semantics, watcher re-arming, frozen-graph rules,
|
|
38
|
+
liveness hooks, introspection, receiver checks.
|
|
39
|
+
- 300 seeded random graphs (states, computeds with data-dependent branches and errors, two
|
|
40
|
+
watchers, random reads, writes, watch/unwatch). A one-off run of 5,000 seeds also matched.
|
|
41
|
+
|
|
42
|
+
### Deliberate differences from the reference polyfill
|
|
43
|
+
|
|
44
|
+
Each one only affects programs that break a rule the proposal states, so correct programs
|
|
45
|
+
behave the same on both. All are tested in `test/signal.test.js`.
|
|
46
|
+
|
|
47
|
+
1. **Reads and writes inside `watched`/`unwatched` hooks throw.** The proposal says hooks run
|
|
48
|
+
with the graph frozen; the reference doesn't enforce it, and a hook that reads its own signal
|
|
49
|
+
recurses until the stack overflows.
|
|
50
|
+
2. **Reading a computed inside a Watcher's `notify` throws before recomputing.** The reference
|
|
51
|
+
starts recomputing first, so the computed caches the "read during notification" error and
|
|
52
|
+
keeps throwing it after the notification is over.
|
|
53
|
+
3. **`new Signal.Computed(x)` requires a function** (TypeError), instead of failing on first read.
|
|
54
|
+
|
|
55
|
+
## Effects
|
|
56
|
+
|
|
57
|
+
```js
|
|
58
|
+
import { Signal } from '@zubra/runtime/signal';
|
|
59
|
+
import { effect, scope, flush, tick } from '@zubra/runtime/effect';
|
|
60
|
+
|
|
61
|
+
const count = new Signal.State(0);
|
|
62
|
+
const dispose = effect(() => {
|
|
63
|
+
console.log(count.get());
|
|
64
|
+
return () => { /* cleanup before the next run, and on dispose */ };
|
|
65
|
+
});
|
|
66
|
+
count.set(1); count.set(2); // one re-run, in a microtask
|
|
67
|
+
await tick();
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
- **Batched**: any number of writes in a task cause one re-run per effect.
|
|
71
|
+
- **Owned**: an effect created inside another effect (or a `scope`) is disposed when its owner
|
|
72
|
+
re-runs or is disposed. A child's reads never become its parent's dependencies.
|
|
73
|
+
- **Ordered**: parents re-run before children; a child replaced by its parent never runs.
|
|
74
|
+
- **Isolated**: an error in a re-run is reported (`reportError`, else `console.error`) and the
|
|
75
|
+
other effects still run. Effects that keep re-triggering each other are stopped after 100
|
|
76
|
+
rounds.
|
|
77
|
+
- **Pausable**: `scope.pause()` (an element left the DOM) unwatches everything inside it, so
|
|
78
|
+
long-lived signals hold no reference to it; `scope.resume()` re-runs only the effects whose
|
|
79
|
+
inputs changed while paused. This is wc.js v4's leak fix, now provided by `Signal.Computed`'s
|
|
80
|
+
own version checks.
|
|
81
|
+
|
|
82
|
+
A write to a signal an effect has already read in the same run does not re-run it (same as a
|
|
83
|
+
computed in the reference). Derive the value instead of writing it back.
|
|
84
|
+
|
|
85
|
+
## Tests
|
|
86
|
+
|
|
87
|
+
```sh
|
|
88
|
+
npm install
|
|
89
|
+
npm test # Signals, effects (Node only)
|
|
90
|
+
npm run test:browser # compiled components in Chromium; needs Rust and Playwright
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
The browser tests compile real `.zb` files with the Rust CLI and check fresh rendering, batched updates, pre-upgrade properties, hydration that keeps every server node, anchored text, mismatched markup, a `state()` + `onclick` counter, mount/unmount hooks across a detach and re-attach, and runtime URL blocking; and for regions (`regions.test.js`): lists growing, shrinking and updating rows in place, branches switching (including a list inside a hidden branch), a branch not re-rendering when its test re-runs to the same result, per-row updates that leave other rows' nodes alone, text directly inside rows, and hydration of server-rendered branches, rows and else branches; and for keyed lists (`keyed.test.js`): reordering that keeps each row's element, typed input and focus, middle inserts and removals, in-place updates under the same key, runtime duplicate keys, and hydrated keyed lists; and for slots (`slots.test.js`): light-DOM projection that moves the element's own nodes, live fallbacks, slotted content surviving an `zb-if` toggle, unknown slot names, hydration of supplied content and fallbacks, and native slots in shadow components, fresh and from a declarative shadow root; and for context (`context.test.js`): nearest-provider answers, nested providers, consumers defined before providers, late provider announcements, moving and removing consumers (including unsubscribing), interop with bare-protocol providers and consumers in both directions, `id()`, and the error for hooks called outside setup; and for forms (`forms.test.js`): submitted values under `name`, `form` and `labels`, reset, validation blocking submission with `:invalid`/`:valid`, disabled fieldsets (including starting and being inserted inside one), restore, and the error for form hooks without `form-associated`. They were mutation-checked: each of twenty-seven deliberate runtime breakages (always rewriting text, never hydrating, not pausing on disconnect, skipping the runtime URL check, counting text nodes in paths, re-rendering a branch on every test run, letting the list effect own its rows, not stepping over regions in paths, never hydrating rows, recreating rows instead of updating them, ignoring keys, not using `moveBefore`, losing keys on hydrated rows, leaving moved rows with a stale index, forgetting slotted content after first use, treating supplied server content as fallback, never slotting the element's children, not recognizing `[!` as a region, letting context requests bubble past a provider, ignoring late providers, not asking again after a move, not unsubscribing on disconnect, providers not pushing updates, `id()` ignoring the host id, not being form-associated, never clearing validity, ignoring reset) fails at least one test. One guard (a form control disabled before its setup ran) never runs in Chromium and is kept, documented as untested, for engines that order those callbacks differently.
|
|
94
|
+
|
|
95
|
+
The effect tests were mutation-checked: removing the parent-first sort, the `untrack` around
|
|
96
|
+
child runs, child disposal on re-run, or unwatching on pause each makes at least one test fail.
|
|
97
|
+
|
|
98
|
+
Later suites cover composition (`composition.test.js`: props passed as values, slotted content from the parent's scope, keyed child components, hydration of nested server markup, other libraries' elements getting properties once defined), events (`events.test.js`: `CustomEvent` shape, cancelable `before…` events, inner same-named events stopped at the host, shadow components' composed events, a parent re-firing a child's event), slots under framework-style DOM calls (`slots.test.js`), and the adapters in real React 19, React 18 and Vue 3 apps (`adapters.test.js`). Each was mutation-checked the same way; mutations that turned out equivalent were removed from the code or documented where they are.
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Generated by scripts/build-production.js from src/deep.js: the runtime without hot reloading. Do not edit.
|
|
2
|
+
// Deep reactivity for state: `let items = []` followed by `items.push(x)` updates the DOM.
|
|
3
|
+
//
|
|
4
|
+
// A `state()` holding an array or a plain object holds a proxy of it instead. Each property the
|
|
5
|
+
// template (or a computed) reads through the proxy is tracked by its own `Signal.State`, created
|
|
6
|
+
// on first read, and each write through the proxy bumps that property's signal, so only what
|
|
7
|
+
// read the property re-runs. Nested arrays and objects are wrapped lazily, on read. Nothing here
|
|
8
|
+
// is a new runtime concept: it's `Signal.State` per property, over the standard API.
|
|
9
|
+
//
|
|
10
|
+
// What's wrapped: arrays and plain objects (prototype `Object.prototype` or `null`). Class
|
|
11
|
+
// instances, `Map`/`Set`, dates, DOM nodes, functions and promises are left as they are, since a
|
|
12
|
+
// proxy would break their internals. `raw(value)` gives the original object back.
|
|
13
|
+
// (`plan/decisions/deep-reactivity.md`.)
|
|
14
|
+
|
|
15
|
+
import { Signal } from './signal.js';
|
|
16
|
+
|
|
17
|
+
const RAW = Symbol('zubra.raw');
|
|
18
|
+
const ITERATE = Symbol('zubra.iterate'); // "the set of keys" (ownKeys, `in`, array length)
|
|
19
|
+
const proxies = new WeakMap(); // raw → proxy
|
|
20
|
+
const signals = new WeakMap(); // raw → Map<key, Signal.State<number>>
|
|
21
|
+
|
|
22
|
+
const isPlain = (value) => {
|
|
23
|
+
if (value === null || typeof value !== 'object') return false;
|
|
24
|
+
if (Array.isArray(value)) return true;
|
|
25
|
+
const proto = Object.getPrototypeOf(value);
|
|
26
|
+
return proto === Object.prototype || proto === null;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** The object behind a reactive proxy, or the value itself. */
|
|
30
|
+
export const raw = (value) => (value && typeof value === 'object' && value[RAW]) || value;
|
|
31
|
+
|
|
32
|
+
function track(target, key) {
|
|
33
|
+
let map = signals.get(target);
|
|
34
|
+
if (!map) signals.set(target, (map = new Map()));
|
|
35
|
+
let signal = map.get(key);
|
|
36
|
+
if (!signal) map.set(key, (signal = new Signal.State(0)));
|
|
37
|
+
signal.get();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function trigger(target, key) {
|
|
41
|
+
const signal = signals.get(target)?.get(key);
|
|
42
|
+
if (signal) signal.set(signal.get() + 1);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const handler = {
|
|
46
|
+
get(target, key, receiver) {
|
|
47
|
+
if (key === RAW) return target;
|
|
48
|
+
if (typeof key !== 'symbol') track(target, key);
|
|
49
|
+
return wrap(Reflect.get(target, key, receiver));
|
|
50
|
+
},
|
|
51
|
+
has(target, key) {
|
|
52
|
+
if (typeof key !== 'symbol') track(target, key);
|
|
53
|
+
return Reflect.has(target, key);
|
|
54
|
+
},
|
|
55
|
+
ownKeys(target) {
|
|
56
|
+
track(target, ITERATE);
|
|
57
|
+
return Reflect.ownKeys(target);
|
|
58
|
+
},
|
|
59
|
+
set(target, key, value, receiver) {
|
|
60
|
+
value = raw(value);
|
|
61
|
+
const had = Object.prototype.hasOwnProperty.call(target, key);
|
|
62
|
+
const before = target[key];
|
|
63
|
+
const length = Array.isArray(target) ? target.length : -1;
|
|
64
|
+
const ok = Reflect.set(target, key, value, receiver);
|
|
65
|
+
if (!had) trigger(target, ITERATE);
|
|
66
|
+
if (!had || !Object.is(before, value)) trigger(target, key);
|
|
67
|
+
// Setting an index past the end grows the array without a `length` write.
|
|
68
|
+
if (length >= 0 && target.length !== length && key !== 'length') trigger(target, 'length');
|
|
69
|
+
return ok;
|
|
70
|
+
},
|
|
71
|
+
deleteProperty(target, key) {
|
|
72
|
+
const had = Object.prototype.hasOwnProperty.call(target, key);
|
|
73
|
+
const ok = Reflect.deleteProperty(target, key);
|
|
74
|
+
if (had) {
|
|
75
|
+
trigger(target, ITERATE);
|
|
76
|
+
trigger(target, key);
|
|
77
|
+
}
|
|
78
|
+
return ok;
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/** `value` as a reactive proxy, if it's an array or a plain object; otherwise `value`. */
|
|
83
|
+
export function wrap(value) {
|
|
84
|
+
if (!isPlain(value) || value[RAW]) return value;
|
|
85
|
+
let proxy = proxies.get(value);
|
|
86
|
+
if (!proxy) proxies.set(value, (proxy = new Proxy(value, handler)));
|
|
87
|
+
return proxy;
|
|
88
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// Generated by scripts/build-production.js from src/effect.js: the runtime without hot reloading. Do not edit.
|
|
2
|
+
// Effects and ownership, built only on the Signals API.
|
|
3
|
+
//
|
|
4
|
+
// The Signals proposal intentionally leaves effects and scheduling to libraries; this is ours.
|
|
5
|
+
// It follows the pattern from the proposal's README (one Watcher, flushed in a microtask) and
|
|
6
|
+
// adds what components need:
|
|
7
|
+
//
|
|
8
|
+
// effect(fn) run fn now, re-run it (batched) when what it read changes; returns dispose
|
|
9
|
+
// scope(fn) an ownership root: effects created inside belong to it
|
|
10
|
+
// flush() / tick() apply pending re-runs now / await them
|
|
11
|
+
//
|
|
12
|
+
// Ownership: an effect created while another effect (or a scope) is running belongs to it. When
|
|
13
|
+
// the owner re-runs or is disposed, everything it owns is disposed first, so nothing outlives
|
|
14
|
+
// the code that created it.
|
|
15
|
+
//
|
|
16
|
+
// Pausing: a scope can be paused (an element left the DOM) and resumed (it came back). Paused
|
|
17
|
+
// effects are unwatched, so long-lived signals hold no reference to them, but each keeps its
|
|
18
|
+
// cached result and the versions it last saw. On resume it is re-validated and re-runs only if
|
|
19
|
+
// something it depends on changed while it was away — the version check in Signal.Computed does
|
|
20
|
+
// exactly that, so no extra bookkeeping is needed here.
|
|
21
|
+
import { Signal } from './signal.js';
|
|
22
|
+
|
|
23
|
+
const { untrack } = Signal.subtle;
|
|
24
|
+
const MAX_ROUNDS = 100;
|
|
25
|
+
|
|
26
|
+
let owner = null; // the Effect or Scope currently running
|
|
27
|
+
let nextId = 0;
|
|
28
|
+
let scheduled = null; // promise for the pending microtask flush
|
|
29
|
+
let flushing = false;
|
|
30
|
+
const effectOf = new WeakMap();
|
|
31
|
+
|
|
32
|
+
const watcher = new Signal.subtle.Watcher(() => {
|
|
33
|
+
scheduled ??= new Promise((resolve) => queueMicrotask(() => {
|
|
34
|
+
try { flush(); } finally { scheduled = null; resolve(); }
|
|
35
|
+
}));
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// Unwatching is batched: disposing or pausing a subtree (removing a list of 1,000 rows), or
|
|
39
|
+
// anything inside `batch()`, unwatches everything in one pass over the watcher's signals,
|
|
40
|
+
// instead of one pass per effect. Outside a batch it happens at once.
|
|
41
|
+
const unwatching = new Set();
|
|
42
|
+
let batching = 0;
|
|
43
|
+
|
|
44
|
+
function unwatchLater(computed) {
|
|
45
|
+
if (batching) unwatching.add(computed);
|
|
46
|
+
else watcher.unwatch(computed);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Run `fn`, unwatching what it disposes or pauses in one pass at the end. */
|
|
50
|
+
export function batch(fn) {
|
|
51
|
+
batching++;
|
|
52
|
+
try {
|
|
53
|
+
return fn();
|
|
54
|
+
} finally {
|
|
55
|
+
if (--batching === 0 && unwatching.size) {
|
|
56
|
+
const all = [...unwatching];
|
|
57
|
+
unwatching.clear();
|
|
58
|
+
for (let i = 0; i < all.length; i += 10000) watcher.unwatch(...all.slice(i, i + 10000));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Watch again (a resumed effect): if its unwatch is still pending, just cancel that. */
|
|
64
|
+
function watchAgain(computed) {
|
|
65
|
+
if (!unwatching.delete(computed)) watcher.watch(computed);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function report(error) {
|
|
69
|
+
if (typeof globalThis.reportError === 'function') globalThis.reportError(error);
|
|
70
|
+
else console.error(error);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
class Owner {
|
|
74
|
+
constructor(parent) {
|
|
75
|
+
this.parent = parent;
|
|
76
|
+
this.children = new Set();
|
|
77
|
+
this.disposed = false;
|
|
78
|
+
this.paused = false;
|
|
79
|
+
parent?.children.add(this);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
disposeChildren() {
|
|
83
|
+
batch(() => {
|
|
84
|
+
for (const child of [...this.children]) child.dispose();
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
dispose() {
|
|
89
|
+
if (this.disposed) return;
|
|
90
|
+
this.disposed = true;
|
|
91
|
+
this.disposeChildren();
|
|
92
|
+
this.parent?.children.delete(this);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
pause() {
|
|
96
|
+
if (this.paused || this.disposed) return;
|
|
97
|
+
this.paused = true;
|
|
98
|
+
batch(() => {
|
|
99
|
+
for (const child of [...this.children]) child.pause();
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
resume() {
|
|
104
|
+
if (!this.paused || this.disposed) return;
|
|
105
|
+
this.paused = false;
|
|
106
|
+
// Children may be replaced while their parent resumes, so iterate a snapshot and skip
|
|
107
|
+
// anything that was disposed along the way.
|
|
108
|
+
for (const child of [...this.children]) if (!child.disposed) child.resume();
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
class Effect extends Owner {
|
|
113
|
+
constructor(fn, parent) {
|
|
114
|
+
super(parent);
|
|
115
|
+
this.id = ++nextId;
|
|
116
|
+
this.cleanup = undefined;
|
|
117
|
+
this.computed = new Signal.Computed(() => {
|
|
118
|
+
this.disposeChildren();
|
|
119
|
+
this.runCleanup();
|
|
120
|
+
const previous = owner;
|
|
121
|
+
owner = this;
|
|
122
|
+
try {
|
|
123
|
+
const result = fn();
|
|
124
|
+
this.cleanup = typeof result === 'function' ? result : undefined;
|
|
125
|
+
} finally {
|
|
126
|
+
owner = previous;
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
effectOf.set(this.computed, this);
|
|
130
|
+
watcher.watch(this.computed);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Re-validate: re-runs fn only if a dependency changed since the last run. */
|
|
134
|
+
run() {
|
|
135
|
+
// untrack: if this effect was created inside another effect, the parent must not end up
|
|
136
|
+
// depending on whatever the child reads.
|
|
137
|
+
untrack(() => this.computed.get());
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
runCleanup() {
|
|
141
|
+
const cleanup = this.cleanup;
|
|
142
|
+
this.cleanup = undefined;
|
|
143
|
+
if (cleanup) untrack(cleanup);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
dispose() {
|
|
147
|
+
if (this.disposed) return;
|
|
148
|
+
super.dispose();
|
|
149
|
+
unwatchLater(this.computed);
|
|
150
|
+
try { this.runCleanup(); } catch (error) { report(error); }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
pause() {
|
|
154
|
+
if (this.paused || this.disposed) return;
|
|
155
|
+
super.pause();
|
|
156
|
+
unwatchLater(this.computed);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
resume() {
|
|
160
|
+
if (!this.paused || this.disposed) return;
|
|
161
|
+
this.paused = false;
|
|
162
|
+
watchAgain(this.computed);
|
|
163
|
+
try { this.run(); } catch (error) { report(error); }
|
|
164
|
+
for (const child of [...this.children]) if (!child.disposed) child.resume();
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
class Scope extends Owner {
|
|
169
|
+
run(fn) {
|
|
170
|
+
const previous = owner;
|
|
171
|
+
owner = this;
|
|
172
|
+
try { return fn(); } finally { owner = previous; }
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Run `fn` now and again whenever a signal it read changes. Re-runs are batched: any number of
|
|
178
|
+
* writes in one task cause one re-run, in a microtask. `fn` may return a cleanup function, which
|
|
179
|
+
* runs before the next run and on dispose. Errors thrown by re-runs are reported, not thrown, so
|
|
180
|
+
* one failing effect doesn't stop the others; an error in the first run is thrown to the caller.
|
|
181
|
+
*/
|
|
182
|
+
export function effect(fn) {
|
|
183
|
+
const e = new Effect(fn, owner);
|
|
184
|
+
e.run(); // if this throws, the effect stays registered and re-runs when its dependencies change
|
|
185
|
+
return () => e.dispose();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Create an ownership root and run `fn` inside it. Returns the scope, whose `dispose()`,
|
|
190
|
+
* `pause()` and `resume()` apply to every effect created within (now or by later `run()` calls).
|
|
191
|
+
*/
|
|
192
|
+
export function scope(fn) {
|
|
193
|
+
const s = new Scope(owner);
|
|
194
|
+
if (fn) s.run(fn);
|
|
195
|
+
return s;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Like `scope`, but owned by nothing and untracked: for something whose life isn't the running
|
|
200
|
+
* effect's (an element, which lives as long as it's in the document, even when an effect's run
|
|
201
|
+
* inserted it).
|
|
202
|
+
*/
|
|
203
|
+
export function root(fn) {
|
|
204
|
+
const s = new Scope(null);
|
|
205
|
+
if (fn) untrack(() => s.run(fn));
|
|
206
|
+
return s;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Apply all pending re-runs synchronously, parents before children. */
|
|
210
|
+
export function flush() {
|
|
211
|
+
if (flushing) return;
|
|
212
|
+
flushing = true;
|
|
213
|
+
try {
|
|
214
|
+
for (let round = 0; ; round++) {
|
|
215
|
+
const pending = watcher.getPending();
|
|
216
|
+
watcher.watch(); // re-arm before running, so writes made by effects schedule another round
|
|
217
|
+
if (pending.length === 0) return;
|
|
218
|
+
if (round >= MAX_ROUNDS) {
|
|
219
|
+
report(new Error('zubra: effects keep re-triggering each other; stopped after 100 rounds'));
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
const effects = pending.map((computed) => effectOf.get(computed)).sort((a, b) => a.id - b.id);
|
|
223
|
+
for (const e of effects) {
|
|
224
|
+
if (e.disposed || e.paused) continue;
|
|
225
|
+
try { e.run(); } catch (error) { report(error); }
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
} finally {
|
|
229
|
+
flushing = false;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Resolves once pending re-runs have been applied. */
|
|
234
|
+
export const tick = () => scheduled ?? Promise.resolve();
|