@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
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Generated by scripts/build-production.js from src/zag.js: the runtime without hot reloading. Do not edit.
|
|
2
|
+
// @zubra/runtime/zag: Zag.js state machines driving zubra components.
|
|
3
|
+
//
|
|
4
|
+
// Zag (https://zagjs.com) is a framework-agnostic set of accessible widget behaviours (combobox,
|
|
5
|
+
// date picker, colour picker, tree view, …) as state machines, each with a `connect()` that
|
|
6
|
+
// returns prop getters. In a zubra script:
|
|
7
|
+
//
|
|
8
|
+
// import * as combobox from '@zag-js/combobox'
|
|
9
|
+
// import { id, computed } from '@zubra/runtime'
|
|
10
|
+
// import { useMachine, normalizeProps } from '@zubra/runtime/zag'
|
|
11
|
+
//
|
|
12
|
+
// const uid = id() // once: Zag's ids must be stable
|
|
13
|
+
// const service = useMachine(combobox.machine, () => ({ id: uid, collection, onValueChange }))
|
|
14
|
+
// const api = computed(() => combobox.connect(service.current, normalizeProps))
|
|
15
|
+
//
|
|
16
|
+
// <input {...api.getInputProps()}>
|
|
17
|
+
//
|
|
18
|
+
// `useMachine` runs Zag's own vanilla machine and turns its notifications into a signal, so the
|
|
19
|
+
// `api` computed re-derives when the machine changes, and also when the props function reads zubra
|
|
20
|
+
// state or props that change. A Zag machine can't be restarted once stopped (stopping drops its
|
|
21
|
+
// subscriptions), so each connection of the element gets a fresh one: an element that is moved,
|
|
22
|
+
// or removed and put back, keeps working. (`plan/evidence/2026-11-07-zag-spike.md`.)
|
|
23
|
+
//
|
|
24
|
+
// Zag is an optional peer dependency: install `@zag-js/vanilla` and the machines you use.
|
|
25
|
+
import { VanillaMachine } from '@zag-js/vanilla';
|
|
26
|
+
import { state, onMount } from './element.js';
|
|
27
|
+
|
|
28
|
+
export { normalizeProps } from '@zag-js/vanilla';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Run a Zag machine for this element: started when it connects, stopped when it disconnects.
|
|
32
|
+
* `props` is Zag's props, or a function returning them (read again whenever Zag asks, so it can
|
|
33
|
+
* read zubra state and props). Returns `{ current }`: the machine's service, read reactively.
|
|
34
|
+
*/
|
|
35
|
+
export function useMachine(machine, props = {}) {
|
|
36
|
+
const make = () => new VanillaMachine(machine, props);
|
|
37
|
+
// Before the element connects, a machine that hasn't started: connect() can read it, so the
|
|
38
|
+
// first render has the right attributes.
|
|
39
|
+
const current = state(make(), { equals: () => false });
|
|
40
|
+
const version = state(0);
|
|
41
|
+
let first = true;
|
|
42
|
+
onMount(() => {
|
|
43
|
+
const running = first ? current.get() : make();
|
|
44
|
+
first = false;
|
|
45
|
+
const unsubscribe = running.subscribe(() => version.set(version.get() + 1));
|
|
46
|
+
running.start();
|
|
47
|
+
current.set(running);
|
|
48
|
+
return () => {
|
|
49
|
+
unsubscribe();
|
|
50
|
+
running.stop();
|
|
51
|
+
};
|
|
52
|
+
});
|
|
53
|
+
return {
|
|
54
|
+
get current() {
|
|
55
|
+
version.get();
|
|
56
|
+
return current.get().service;
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// Angular adapter: zubra elements in Angular forms.
|
|
2
|
+
//
|
|
3
|
+
// import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
|
|
4
|
+
// import { FormsModule } from '@angular/forms';
|
|
5
|
+
// import { ZubraModel } from '@zubra/runtime/angular';
|
|
6
|
+
// import './rating.zb';
|
|
7
|
+
//
|
|
8
|
+
// @Component({
|
|
9
|
+
// imports: [FormsModule, ZubraModel],
|
|
10
|
+
// schemas: [CUSTOM_ELEMENTS_SCHEMA],
|
|
11
|
+
// template: `<zb-rating zubraModel [(ngModel)]="stars" (change)="saved()"></zb-rating>`,
|
|
12
|
+
// })
|
|
13
|
+
//
|
|
14
|
+
// Angular needs nothing else for a zubra element: `[heading]="x"` sets the property,
|
|
15
|
+
// `(valuechange)="…"` listens for the event, and children are the element's slot content.
|
|
16
|
+
// What it can't do on its own is two-way binding, because `[(value)]` listens for
|
|
17
|
+
// `valueChange` and the element fires `valuechange`. `zubraModel` makes the element a form control
|
|
18
|
+
// instead (ngModel, formControl, formControlName), using the `<prop>` + `<prop>change` pair:
|
|
19
|
+
// `zubraModel` alone picks the `value` pair (or the only pair), `zubraModel="checked"` names one.
|
|
20
|
+
//
|
|
21
|
+
// The class is published the way Angular libraries are (partial declarations, in a `fesm2022/`
|
|
22
|
+
// file as the Angular Package Format lays out), so the linker of the Angular CLI and of
|
|
23
|
+
// Vite-based builds (Analog) compiles it in AOT builds, and JIT compiles it at runtime.
|
|
24
|
+
import * as i0 from '@angular/core';
|
|
25
|
+
import { ChangeDetectorRef, DestroyRef, ElementRef, forwardRef, inject } from '@angular/core';
|
|
26
|
+
import { NG_VALUE_ACCESSOR } from '@angular/forms';
|
|
27
|
+
import { models } from '../src/vue.js';
|
|
28
|
+
|
|
29
|
+
const clone = (value) => (value && typeof value === 'object' ? structuredClone(value) : value);
|
|
30
|
+
|
|
31
|
+
function metaOf(element) {
|
|
32
|
+
const meta = element.constructor?.zubra ?? customElements.get(element.localName)?.zubra;
|
|
33
|
+
if (!meta) throw new TypeError(`zubra: zubraModel is on <${element.localName}>, which isn't a defined zubra element (import its .zb module first)`);
|
|
34
|
+
return meta;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class ZubraModel {
|
|
38
|
+
/** The prop to bind; empty for the `value` pair, or the only pair. */
|
|
39
|
+
zubraModel = '';
|
|
40
|
+
#element = inject(ElementRef).nativeElement;
|
|
41
|
+
#changes = inject(ChangeDetectorRef);
|
|
42
|
+
#pair = null;
|
|
43
|
+
#onChange = () => {};
|
|
44
|
+
#onTouched = () => {};
|
|
45
|
+
|
|
46
|
+
constructor() {
|
|
47
|
+
const listener = (event) => {
|
|
48
|
+
if (event.target !== this.#element) return;
|
|
49
|
+
const pair = this.#resolve();
|
|
50
|
+
if (event.type !== pair.event) return;
|
|
51
|
+
this.#onChange(event.detail);
|
|
52
|
+
this.#changes.markForCheck();
|
|
53
|
+
};
|
|
54
|
+
const touched = () => this.#onTouched();
|
|
55
|
+
// Every `…change` event is caught here, so the pair can be settled lazily (the element may be
|
|
56
|
+
// defined after the directive is created).
|
|
57
|
+
const types = new Set();
|
|
58
|
+
const listen = () => {
|
|
59
|
+
for (const { event } of models(metaOf(this.#element))) {
|
|
60
|
+
if (types.has(event)) continue;
|
|
61
|
+
types.add(event);
|
|
62
|
+
this.#element.addEventListener(event, listener);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
customElements.whenDefined(this.#element.localName).then(listen);
|
|
66
|
+
this.#element.addEventListener('focusout', touched);
|
|
67
|
+
inject(DestroyRef).onDestroy(() => {
|
|
68
|
+
for (const type of types) this.#element.removeEventListener(type, listener);
|
|
69
|
+
this.#element.removeEventListener('focusout', touched);
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
#resolve() {
|
|
74
|
+
if (this.#pair) return this.#pair;
|
|
75
|
+
const meta = metaOf(this.#element);
|
|
76
|
+
const pairs = models(meta);
|
|
77
|
+
const pair = this.zubraModel
|
|
78
|
+
? pairs.find((candidate) => candidate.prop === this.zubraModel)
|
|
79
|
+
: pairs.find((candidate) => candidate.prop === 'value') ?? (pairs.length === 1 ? pairs[0] : undefined);
|
|
80
|
+
if (!pair) {
|
|
81
|
+
const named = pairs.map((candidate) => `\`${candidate.prop}\``).join(', ') || 'none';
|
|
82
|
+
throw new TypeError(this.zubraModel
|
|
83
|
+
? `zubra: <${meta.tag}> has no \`${this.zubraModel}\` + \`${this.zubraModel.toLowerCase()}change\` pair for zubraModel (pairs: ${named})`
|
|
84
|
+
: `zubra: zubraModel on <${meta.tag}> needs a name, e.g. zubraModel="${pairs[0]?.prop ?? 'value'}" (pairs: ${named})`);
|
|
85
|
+
}
|
|
86
|
+
return (this.#pair = pair);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
writeValue(value) {
|
|
90
|
+
const set = () => {
|
|
91
|
+
const { prop } = this.#resolve();
|
|
92
|
+
// Forms reset to null: that's the component's default.
|
|
93
|
+
this.#element[prop] = value ?? clone(metaOf(this.#element).props[prop].default);
|
|
94
|
+
};
|
|
95
|
+
if (customElements.get(this.#element.localName)) set();
|
|
96
|
+
else customElements.whenDefined(this.#element.localName).then(set);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
registerOnChange(fn) { this.#onChange = fn; }
|
|
100
|
+
registerOnTouched(fn) { this.#onTouched = fn; }
|
|
101
|
+
|
|
102
|
+
setDisabledState(disabled) {
|
|
103
|
+
const { props } = metaOf(this.#element);
|
|
104
|
+
if ('disabled' in props) this.#element.disabled = disabled;
|
|
105
|
+
else this.#element.toggleAttribute('disabled', disabled);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
static ɵfac = i0.ɵɵngDeclareFactory({
|
|
109
|
+
minVersion: '12.0.0', version: '17.0.0', ngImport: i0, type: ZubraModel, deps: [], target: i0.ɵɵFactoryTarget.Directive,
|
|
110
|
+
});
|
|
111
|
+
static ɵdir = i0.ɵɵngDeclareDirective({
|
|
112
|
+
minVersion: '14.0.0', version: '17.0.0', type: ZubraModel, isStandalone: true, selector: '[zubraModel]',
|
|
113
|
+
inputs: { zubraModel: 'zubraModel' },
|
|
114
|
+
providers: [{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => ZubraModel), multi: true }],
|
|
115
|
+
exportAs: ['zubraModel'],
|
|
116
|
+
ngImport: i0,
|
|
117
|
+
});
|
|
118
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,128 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zubra/runtime",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "5.0.3",
|
|
4
|
+
"description": "Browser runtime for Zubra compiled components, built on the TC39 Signals API",
|
|
5
|
+
"license": "ISC",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/journey-to-code-org/zubra.git",
|
|
9
|
+
"directory": "runtime"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./types/index.d.ts",
|
|
15
|
+
"production": "./dist/production/index.js",
|
|
16
|
+
"default": "./src/index.js"
|
|
17
|
+
},
|
|
18
|
+
"./signal": {
|
|
19
|
+
"types": "./types/signal.d.ts",
|
|
20
|
+
"production": "./dist/production/signal.js",
|
|
21
|
+
"default": "./src/signal.js"
|
|
22
|
+
},
|
|
23
|
+
"./effect": {
|
|
24
|
+
"types": "./types/effect.d.ts",
|
|
25
|
+
"production": "./dist/production/effect.js",
|
|
26
|
+
"default": "./src/effect.js"
|
|
27
|
+
},
|
|
28
|
+
"./spread": {
|
|
29
|
+
"types": "./types/spread.d.ts",
|
|
30
|
+
"production": "./dist/production/spread.js",
|
|
31
|
+
"default": "./src/spread.js"
|
|
32
|
+
},
|
|
33
|
+
"./zag": {
|
|
34
|
+
"types": "./types/zag.d.ts",
|
|
35
|
+
"production": "./dist/production/zag.js",
|
|
36
|
+
"default": "./src/zag.js"
|
|
37
|
+
},
|
|
38
|
+
"./primitives": {
|
|
39
|
+
"types": "./types/primitives.d.ts",
|
|
40
|
+
"production": "./dist/production/primitives.js",
|
|
41
|
+
"default": "./src/primitives.js"
|
|
42
|
+
},
|
|
43
|
+
"./react": {
|
|
44
|
+
"types": "./types/react.d.ts",
|
|
45
|
+
"production": "./dist/production/react.js",
|
|
46
|
+
"default": "./src/react.js"
|
|
47
|
+
},
|
|
48
|
+
"./vue": {
|
|
49
|
+
"types": "./types/vue.d.ts",
|
|
50
|
+
"production": "./dist/production/vue.js",
|
|
51
|
+
"default": "./src/vue.js"
|
|
52
|
+
},
|
|
53
|
+
"./angular": {
|
|
54
|
+
"types": "./types/angular.d.ts",
|
|
55
|
+
"default": "./fesm2022/zubra-runtime-angular.mjs"
|
|
56
|
+
},
|
|
57
|
+
"./server": {
|
|
58
|
+
"types": "./types/server.d.ts",
|
|
59
|
+
"production": "./dist/production/server.js",
|
|
60
|
+
"default": "./src/server.js"
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
"scripts": {
|
|
64
|
+
"test": "node --test \"test/*.test.js\"",
|
|
65
|
+
"test:browser": "node --test \"test/browser/*.test.js\"",
|
|
66
|
+
"test:tools": "node --test \"test/tools/*.test.js\"",
|
|
67
|
+
"build:production": "node scripts/build-production.js"
|
|
68
|
+
},
|
|
69
|
+
"engines": {
|
|
70
|
+
"node": ">=20"
|
|
71
|
+
},
|
|
72
|
+
"peerDependencies": {
|
|
73
|
+
"@angular/core": ">=16",
|
|
74
|
+
"@angular/forms": ">=16",
|
|
75
|
+
"@zag-js/vanilla": ">=1.47",
|
|
76
|
+
"react": ">=18",
|
|
77
|
+
"vue": "^3.3"
|
|
78
|
+
},
|
|
79
|
+
"peerDependenciesMeta": {
|
|
80
|
+
"@angular/core": {
|
|
81
|
+
"optional": true
|
|
82
|
+
},
|
|
83
|
+
"@angular/forms": {
|
|
84
|
+
"optional": true
|
|
85
|
+
},
|
|
86
|
+
"@zag-js/vanilla": {
|
|
87
|
+
"optional": true
|
|
88
|
+
},
|
|
89
|
+
"react": {
|
|
90
|
+
"optional": true
|
|
91
|
+
},
|
|
92
|
+
"vue": {
|
|
93
|
+
"optional": true
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
"devDependencies": {
|
|
97
|
+
"@angular/common": "22.2.5",
|
|
98
|
+
"@angular/compiler": "22.2.5",
|
|
99
|
+
"@angular/compiler-cli": "22.2.5",
|
|
100
|
+
"@angular/core": "22.2.5",
|
|
101
|
+
"@angular/forms": "22.2.5",
|
|
102
|
+
"@angular/platform-browser": "22.2.5",
|
|
103
|
+
"@babel/core": "8.1.0",
|
|
104
|
+
"@types/react": "19.3.0",
|
|
105
|
+
"@zag-js/combobox": "1.47.0",
|
|
106
|
+
"@zag-js/vanilla": "1.47.0",
|
|
107
|
+
"ajv": "^8.20.0",
|
|
108
|
+
"custom-elements-manifest": "^2.1.0",
|
|
109
|
+
"esbuild": "0.28.2",
|
|
110
|
+
"playwright": "1.63.0",
|
|
111
|
+
"react": "19.3.1",
|
|
112
|
+
"react-dom": "19.3.1",
|
|
113
|
+
"react-dom18": "npm:react-dom@18.3.1",
|
|
114
|
+
"react18": "npm:react@18.3.1",
|
|
115
|
+
"rxjs": "7.8.2",
|
|
116
|
+
"signal-polyfill": "0.2.2",
|
|
117
|
+
"typescript": "^7.0.2",
|
|
118
|
+
"vscode-html-languageservice": "5.6.2",
|
|
119
|
+
"vscode-languageserver-textdocument": "1.0.16",
|
|
120
|
+
"vue": "3.5.45"
|
|
121
|
+
},
|
|
122
|
+
"files": [
|
|
123
|
+
"src",
|
|
124
|
+
"dist",
|
|
125
|
+
"fesm2022",
|
|
126
|
+
"types"
|
|
127
|
+
]
|
|
128
|
+
}
|
package/src/deep.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Deep reactivity for state: `let items = []` followed by `items.push(x)` updates the DOM.
|
|
2
|
+
//
|
|
3
|
+
// A `state()` holding an array or a plain object holds a proxy of it instead. Each property the
|
|
4
|
+
// template (or a computed) reads through the proxy is tracked by its own `Signal.State`, created
|
|
5
|
+
// on first read, and each write through the proxy bumps that property's signal, so only what
|
|
6
|
+
// read the property re-runs. Nested arrays and objects are wrapped lazily, on read. Nothing here
|
|
7
|
+
// is a new runtime concept: it's `Signal.State` per property, over the standard API.
|
|
8
|
+
//
|
|
9
|
+
// What's wrapped: arrays and plain objects (prototype `Object.prototype` or `null`). Class
|
|
10
|
+
// instances, `Map`/`Set`, dates, DOM nodes, functions and promises are left as they are, since a
|
|
11
|
+
// proxy would break their internals. `raw(value)` gives the original object back.
|
|
12
|
+
// (`plan/decisions/deep-reactivity.md`.)
|
|
13
|
+
|
|
14
|
+
import { Signal } from './signal.js';
|
|
15
|
+
|
|
16
|
+
const RAW = Symbol('zubra.raw');
|
|
17
|
+
const ITERATE = Symbol('zubra.iterate'); // "the set of keys" (ownKeys, `in`, array length)
|
|
18
|
+
const proxies = new WeakMap(); // raw → proxy
|
|
19
|
+
const signals = new WeakMap(); // raw → Map<key, Signal.State<number>>
|
|
20
|
+
|
|
21
|
+
const isPlain = (value) => {
|
|
22
|
+
if (value === null || typeof value !== 'object') return false;
|
|
23
|
+
if (Array.isArray(value)) return true;
|
|
24
|
+
const proto = Object.getPrototypeOf(value);
|
|
25
|
+
return proto === Object.prototype || proto === null;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** The object behind a reactive proxy, or the value itself. */
|
|
29
|
+
export const raw = (value) => (value && typeof value === 'object' && value[RAW]) || value;
|
|
30
|
+
|
|
31
|
+
function track(target, key) {
|
|
32
|
+
let map = signals.get(target);
|
|
33
|
+
if (!map) signals.set(target, (map = new Map()));
|
|
34
|
+
let signal = map.get(key);
|
|
35
|
+
if (!signal) map.set(key, (signal = new Signal.State(0)));
|
|
36
|
+
signal.get();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function trigger(target, key) {
|
|
40
|
+
const signal = signals.get(target)?.get(key);
|
|
41
|
+
if (signal) signal.set(signal.get() + 1);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const handler = {
|
|
45
|
+
get(target, key, receiver) {
|
|
46
|
+
if (key === RAW) return target;
|
|
47
|
+
if (typeof key !== 'symbol') track(target, key);
|
|
48
|
+
return wrap(Reflect.get(target, key, receiver));
|
|
49
|
+
},
|
|
50
|
+
has(target, key) {
|
|
51
|
+
if (typeof key !== 'symbol') track(target, key);
|
|
52
|
+
return Reflect.has(target, key);
|
|
53
|
+
},
|
|
54
|
+
ownKeys(target) {
|
|
55
|
+
track(target, ITERATE);
|
|
56
|
+
return Reflect.ownKeys(target);
|
|
57
|
+
},
|
|
58
|
+
set(target, key, value, receiver) {
|
|
59
|
+
value = raw(value);
|
|
60
|
+
const had = Object.prototype.hasOwnProperty.call(target, key);
|
|
61
|
+
const before = target[key];
|
|
62
|
+
const length = Array.isArray(target) ? target.length : -1;
|
|
63
|
+
const ok = Reflect.set(target, key, value, receiver);
|
|
64
|
+
if (!had) trigger(target, ITERATE);
|
|
65
|
+
if (!had || !Object.is(before, value)) trigger(target, key);
|
|
66
|
+
// Setting an index past the end grows the array without a `length` write.
|
|
67
|
+
if (length >= 0 && target.length !== length && key !== 'length') trigger(target, 'length');
|
|
68
|
+
return ok;
|
|
69
|
+
},
|
|
70
|
+
deleteProperty(target, key) {
|
|
71
|
+
const had = Object.prototype.hasOwnProperty.call(target, key);
|
|
72
|
+
const ok = Reflect.deleteProperty(target, key);
|
|
73
|
+
if (had) {
|
|
74
|
+
trigger(target, ITERATE);
|
|
75
|
+
trigger(target, key);
|
|
76
|
+
}
|
|
77
|
+
return ok;
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/** `value` as a reactive proxy, if it's an array or a plain object; otherwise `value`. */
|
|
82
|
+
export function wrap(value) {
|
|
83
|
+
if (!isPlain(value) || value[RAW]) return value;
|
|
84
|
+
let proxy = proxies.get(value);
|
|
85
|
+
if (!proxy) proxies.set(value, (proxy = new Proxy(value, handler)));
|
|
86
|
+
return proxy;
|
|
87
|
+
}
|
package/src/effect.js
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// Effects and ownership, built only on the Signals API.
|
|
2
|
+
//
|
|
3
|
+
// The Signals proposal intentionally leaves effects and scheduling to libraries; this is ours.
|
|
4
|
+
// It follows the pattern from the proposal's README (one Watcher, flushed in a microtask) and
|
|
5
|
+
// adds what components need:
|
|
6
|
+
//
|
|
7
|
+
// effect(fn) run fn now, re-run it (batched) when what it read changes; returns dispose
|
|
8
|
+
// scope(fn) an ownership root: effects created inside belong to it
|
|
9
|
+
// flush() / tick() apply pending re-runs now / await them
|
|
10
|
+
//
|
|
11
|
+
// Ownership: an effect created while another effect (or a scope) is running belongs to it. When
|
|
12
|
+
// the owner re-runs or is disposed, everything it owns is disposed first, so nothing outlives
|
|
13
|
+
// the code that created it.
|
|
14
|
+
//
|
|
15
|
+
// Pausing: a scope can be paused (an element left the DOM) and resumed (it came back). Paused
|
|
16
|
+
// effects are unwatched, so long-lived signals hold no reference to them, but each keeps its
|
|
17
|
+
// cached result and the versions it last saw. On resume it is re-validated and re-runs only if
|
|
18
|
+
// something it depends on changed while it was away — the version check in Signal.Computed does
|
|
19
|
+
// exactly that, so no extra bookkeeping is needed here.
|
|
20
|
+
import { Signal } from './signal.js';
|
|
21
|
+
|
|
22
|
+
const { untrack } = Signal.subtle;
|
|
23
|
+
const MAX_ROUNDS = 100;
|
|
24
|
+
|
|
25
|
+
let owner = null; // the Effect or Scope currently running
|
|
26
|
+
let nextId = 0;
|
|
27
|
+
let scheduled = null; // promise for the pending microtask flush
|
|
28
|
+
let flushing = false;
|
|
29
|
+
const effectOf = new WeakMap();
|
|
30
|
+
|
|
31
|
+
const watcher = new Signal.subtle.Watcher(() => {
|
|
32
|
+
scheduled ??= new Promise((resolve) => queueMicrotask(() => {
|
|
33
|
+
try { flush(); } finally { scheduled = null; resolve(); }
|
|
34
|
+
}));
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// Unwatching is batched: disposing or pausing a subtree (removing a list of 1,000 rows), or
|
|
38
|
+
// anything inside `batch()`, unwatches everything in one pass over the watcher's signals,
|
|
39
|
+
// instead of one pass per effect. Outside a batch it happens at once.
|
|
40
|
+
const unwatching = new Set();
|
|
41
|
+
let batching = 0;
|
|
42
|
+
|
|
43
|
+
function unwatchLater(computed) {
|
|
44
|
+
if (batching) unwatching.add(computed);
|
|
45
|
+
else watcher.unwatch(computed);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Run `fn`, unwatching what it disposes or pauses in one pass at the end. */
|
|
49
|
+
export function batch(fn) {
|
|
50
|
+
batching++;
|
|
51
|
+
try {
|
|
52
|
+
return fn();
|
|
53
|
+
} finally {
|
|
54
|
+
if (--batching === 0 && unwatching.size) {
|
|
55
|
+
const all = [...unwatching];
|
|
56
|
+
unwatching.clear();
|
|
57
|
+
for (let i = 0; i < all.length; i += 10000) watcher.unwatch(...all.slice(i, i + 10000));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Watch again (a resumed effect): if its unwatch is still pending, just cancel that. */
|
|
63
|
+
function watchAgain(computed) {
|
|
64
|
+
if (!unwatching.delete(computed)) watcher.watch(computed);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function report(error) {
|
|
68
|
+
if (typeof globalThis.reportError === 'function') globalThis.reportError(error);
|
|
69
|
+
else console.error(error);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
class Owner {
|
|
73
|
+
constructor(parent) {
|
|
74
|
+
this.parent = parent;
|
|
75
|
+
this.children = new Set();
|
|
76
|
+
this.disposed = false;
|
|
77
|
+
this.paused = false;
|
|
78
|
+
parent?.children.add(this);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
disposeChildren() {
|
|
82
|
+
batch(() => {
|
|
83
|
+
for (const child of [...this.children]) child.dispose();
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
dispose() {
|
|
88
|
+
if (this.disposed) return;
|
|
89
|
+
this.disposed = true;
|
|
90
|
+
this.disposeChildren();
|
|
91
|
+
this.parent?.children.delete(this);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
pause() {
|
|
95
|
+
if (this.paused || this.disposed) return;
|
|
96
|
+
this.paused = true;
|
|
97
|
+
batch(() => {
|
|
98
|
+
for (const child of [...this.children]) child.pause();
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
resume() {
|
|
103
|
+
if (!this.paused || this.disposed) return;
|
|
104
|
+
this.paused = false;
|
|
105
|
+
// Children may be replaced while their parent resumes, so iterate a snapshot and skip
|
|
106
|
+
// anything that was disposed along the way.
|
|
107
|
+
for (const child of [...this.children]) if (!child.disposed) child.resume();
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
class Effect extends Owner {
|
|
112
|
+
constructor(fn, parent) {
|
|
113
|
+
super(parent);
|
|
114
|
+
this.id = ++nextId;
|
|
115
|
+
this.cleanup = undefined;
|
|
116
|
+
this.computed = new Signal.Computed(() => {
|
|
117
|
+
this.disposeChildren();
|
|
118
|
+
this.runCleanup();
|
|
119
|
+
const previous = owner;
|
|
120
|
+
owner = this;
|
|
121
|
+
try {
|
|
122
|
+
const result = fn();
|
|
123
|
+
this.cleanup = typeof result === 'function' ? result : undefined;
|
|
124
|
+
} finally {
|
|
125
|
+
owner = previous;
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
effectOf.set(this.computed, this);
|
|
129
|
+
watcher.watch(this.computed);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Re-validate: re-runs fn only if a dependency changed since the last run. */
|
|
133
|
+
run() {
|
|
134
|
+
// untrack: if this effect was created inside another effect, the parent must not end up
|
|
135
|
+
// depending on whatever the child reads.
|
|
136
|
+
untrack(() => this.computed.get());
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
runCleanup() {
|
|
140
|
+
const cleanup = this.cleanup;
|
|
141
|
+
this.cleanup = undefined;
|
|
142
|
+
if (cleanup) untrack(cleanup);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
dispose() {
|
|
146
|
+
if (this.disposed) return;
|
|
147
|
+
super.dispose();
|
|
148
|
+
unwatchLater(this.computed);
|
|
149
|
+
try { this.runCleanup(); } catch (error) { report(error); }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
pause() {
|
|
153
|
+
if (this.paused || this.disposed) return;
|
|
154
|
+
super.pause();
|
|
155
|
+
unwatchLater(this.computed);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
resume() {
|
|
159
|
+
if (!this.paused || this.disposed) return;
|
|
160
|
+
this.paused = false;
|
|
161
|
+
watchAgain(this.computed);
|
|
162
|
+
try { this.run(); } catch (error) { report(error); }
|
|
163
|
+
for (const child of [...this.children]) if (!child.disposed) child.resume();
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
class Scope extends Owner {
|
|
168
|
+
run(fn) {
|
|
169
|
+
const previous = owner;
|
|
170
|
+
owner = this;
|
|
171
|
+
try { return fn(); } finally { owner = previous; }
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Run `fn` now and again whenever a signal it read changes. Re-runs are batched: any number of
|
|
177
|
+
* writes in one task cause one re-run, in a microtask. `fn` may return a cleanup function, which
|
|
178
|
+
* runs before the next run and on dispose. Errors thrown by re-runs are reported, not thrown, so
|
|
179
|
+
* one failing effect doesn't stop the others; an error in the first run is thrown to the caller.
|
|
180
|
+
*/
|
|
181
|
+
export function effect(fn) {
|
|
182
|
+
const e = new Effect(fn, owner);
|
|
183
|
+
e.run(); // if this throws, the effect stays registered and re-runs when its dependencies change
|
|
184
|
+
return () => e.dispose();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Create an ownership root and run `fn` inside it. Returns the scope, whose `dispose()`,
|
|
189
|
+
* `pause()` and `resume()` apply to every effect created within (now or by later `run()` calls).
|
|
190
|
+
*/
|
|
191
|
+
export function scope(fn) {
|
|
192
|
+
const s = new Scope(owner);
|
|
193
|
+
if (fn) s.run(fn);
|
|
194
|
+
return s;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Like `scope`, but owned by nothing and untracked: for something whose life isn't the running
|
|
199
|
+
* effect's (an element, which lives as long as it's in the document, even when an effect's run
|
|
200
|
+
* inserted it).
|
|
201
|
+
*/
|
|
202
|
+
export function root(fn) {
|
|
203
|
+
const s = new Scope(null);
|
|
204
|
+
if (fn) untrack(() => s.run(fn));
|
|
205
|
+
return s;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Apply all pending re-runs synchronously, parents before children. */
|
|
209
|
+
export function flush() {
|
|
210
|
+
if (flushing) return;
|
|
211
|
+
flushing = true;
|
|
212
|
+
try {
|
|
213
|
+
for (let round = 0; ; round++) {
|
|
214
|
+
const pending = watcher.getPending();
|
|
215
|
+
watcher.watch(); // re-arm before running, so writes made by effects schedule another round
|
|
216
|
+
if (pending.length === 0) return;
|
|
217
|
+
if (round >= MAX_ROUNDS) {
|
|
218
|
+
report(new Error('zubra: effects keep re-triggering each other; stopped after 100 rounds'));
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
const effects = pending.map((computed) => effectOf.get(computed)).sort((a, b) => a.id - b.id);
|
|
222
|
+
for (const e of effects) {
|
|
223
|
+
if (e.disposed || e.paused) continue;
|
|
224
|
+
try { e.run(); } catch (error) { report(error); }
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
} finally {
|
|
228
|
+
flushing = false;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Resolves once pending re-runs have been applied. */
|
|
233
|
+
export const tick = () => scheduled ?? Promise.resolve();
|