@zoijs/core 1.0.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.
@@ -0,0 +1,364 @@
1
+ // renderer.js — fine-grained rendering engine.
2
+ //
3
+ // Turns an html() result into live DOM and wires each dynamic slot to a live
4
+ // binding. No Virtual DOM, no component re-execution.
5
+ //
6
+ // - A FUNCTION value is reactive (runs in an effect): text updates a Text node
7
+ // in place; attributes update in place.
8
+ // - An each() marker becomes a keyed LIST binding (reuse / move / remove).
9
+ // - A non-function value is static (set once, no effect).
10
+ // - An EVENT slot's value is the handler (addEventListener; never a string).
11
+ //
12
+ // Cleanup is owned: render() creates an owner scope; every effect, listener, and
13
+ // nested render registers into it, so disposing the owner tears everything down.
14
+
15
+ import { effect, untrack } from "../reactivity/effect.js";
16
+ import { createState } from "../reactivity/state.js";
17
+ import { createOwner, runWithOwner, disposeOwner, onCleanup } from "../reactivity/owner.js";
18
+ import { isDev } from "../reactivity/env.js";
19
+ import { toText, isSafeUrl, isSafeAttributeName } from "../utils/security.js";
20
+
21
+ const XLINK_NS = "http://www.w3.org/1999/xlink";
22
+ const URL_ATTRS = new Set(["href", "src", "action", "formaction", "poster", "ping", "xlink:href"]);
23
+ const noop = () => {};
24
+
25
+ /**
26
+ * @param {{ template: HTMLTemplateElement, parts: object[], values: any[] }} result
27
+ * @returns {{ node: DocumentFragment, dispose: Function }}
28
+ */
29
+ export function render(result) {
30
+ const owner = createOwner(); // nested under the active owner
31
+ const fragment = result.template.content.cloneNode(true);
32
+ const { parts, values } = result;
33
+
34
+ // Collect every part's target node on the PRISTINE clone first (document
35
+ // order), so a binding that inserts children can't shadow a later part.
36
+ const nodes = collectNodes(fragment, parts, result.hasElements);
37
+
38
+ runWithOwner(owner, () => {
39
+ for (let i = 0; i < parts.length; i++) {
40
+ const part = parts[i];
41
+ const node = nodes[i];
42
+ if (!node) continue;
43
+
44
+ if (part.type === "child") {
45
+ bindChild(node, values[part.hole]);
46
+ } else {
47
+ node.removeAttribute("data-zoijs-bind");
48
+ for (const attr of part.attrs) bindAttribute(node, attr, values);
49
+ }
50
+ }
51
+ });
52
+
53
+ return { node: fragment, dispose: () => disposeOwner(owner) };
54
+ }
55
+
56
+ // Match parts to nodes by document order: a child part ↔ the next marker comment,
57
+ // an element part ↔ the next element carrying data-zoijs-bind. Unique markers make
58
+ // this collision-proof across nested templates and list items.
59
+ function collectNodes(fragment, parts, hasElements) {
60
+ const nodes = new Array(parts.length);
61
+ if (!parts.length) return nodes;
62
+ // Child-only templates (common for list items) can walk comments only.
63
+ const filter = hasElements ? NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_COMMENT : NodeFilter.SHOW_COMMENT;
64
+ const walker = document.createTreeWalker(fragment, filter);
65
+ let p = 0;
66
+ let node;
67
+ while (p < parts.length && (node = walker.nextNode())) {
68
+ const isChildMarker = node.nodeType === 8 && node.data === "zoijs";
69
+ const isElementMarker = node.nodeType === 1 && node.hasAttribute("data-zoijs-bind");
70
+ if (isChildMarker || isElementMarker) {
71
+ nodes[p] = node;
72
+ p++;
73
+ }
74
+ }
75
+ return nodes;
76
+ }
77
+
78
+ function bindChild(anchor, value) {
79
+ if (isEach(value)) setupKeyedList(anchor, value);
80
+ else if (typeof value === "function") bindReactiveContent(anchor, value);
81
+ else insertStaticContent(anchor, value);
82
+ }
83
+
84
+ function bindAttribute(el, attr, values) {
85
+ if (attr.event) {
86
+ const handler = values[attr.holes[0]];
87
+ // Only real functions are accepted as handlers — a string/object is ignored,
88
+ // so an inline-handler string can never be wired up or executed.
89
+ if (typeof handler !== "function") {
90
+ if (isDev()) console.warn(`Zoijs: ignoring non-function event handler for "${attr.name}"`);
91
+ return;
92
+ }
93
+ const eventName = attr.name.slice(2).toLowerCase();
94
+ el.addEventListener(eventName, handler);
95
+ onCleanup(() => el.removeEventListener(eventName, handler));
96
+ return;
97
+ }
98
+
99
+ if (attr.whole) {
100
+ // A single ${} as the whole value → pass the raw value (preserves booleans,
101
+ // numbers, property types for value/checked).
102
+ const raw = values[attr.holes[0]];
103
+ if (typeof raw === "function") effect(() => applyAttribute(el, attr.name, raw()));
104
+ else applyAttribute(el, attr.name, raw);
105
+ return;
106
+ }
107
+
108
+ // Multi-part value (static text + one or more holes) → always a joined string.
109
+ const compute = () => {
110
+ let result = attr.strings[0];
111
+ for (let i = 0; i < attr.holes.length; i++) {
112
+ const hv = values[attr.holes[i]];
113
+ result += (typeof hv === "function" ? hv() : hv) + attr.strings[i + 1];
114
+ }
115
+ return result;
116
+ };
117
+ const reactive = attr.holes.some((h) => typeof values[h] === "function");
118
+ if (reactive) effect(() => applyAttribute(el, attr.name, compute()));
119
+ else applyAttribute(el, attr.name, compute());
120
+ }
121
+
122
+ // ---- text / content bindings -------------------------------------------------
123
+
124
+ function bindReactiveContent(anchor, getValue) {
125
+ let mode = null; // "text" | "nodes"
126
+ let textNode = null;
127
+ let items = []; // [{ nodes, dispose }]
128
+
129
+ const clearNodes = () => {
130
+ for (const it of items) {
131
+ it.dispose();
132
+ for (const n of it.nodes) n.remove();
133
+ }
134
+ items = [];
135
+ };
136
+ const clearText = () => {
137
+ if (textNode) {
138
+ textNode.remove();
139
+ textNode = null;
140
+ }
141
+ };
142
+
143
+ effect(() => {
144
+ const value = getValue();
145
+ const t = typeof value;
146
+ // null/undefined/booleans render NOTHING (matches the `cond && html\`...\``
147
+ // idiom); numbers/strings render as text; everything else is node content.
148
+ const asText = value == null || t === "boolean" || t === "number" || t === "string" || t === "bigint";
149
+ if (asText) {
150
+ if (mode !== "text") {
151
+ clearNodes();
152
+ textNode = document.createTextNode("");
153
+ anchor.parentNode.insertBefore(textNode, anchor);
154
+ mode = "text";
155
+ }
156
+ textNode.data = value == null || t === "boolean" ? "" : toText(value); // in-place update
157
+ } else {
158
+ if (mode === "text") clearText();
159
+ else clearNodes();
160
+ mode = "nodes";
161
+ insertItems(anchor, value, items);
162
+ }
163
+ });
164
+
165
+ onCleanup(() => {
166
+ clearNodes();
167
+ clearText();
168
+ });
169
+ }
170
+
171
+ function insertStaticContent(anchor, value) {
172
+ if (value == null) return;
173
+ const items = [];
174
+ insertItems(anchor, value, items);
175
+ onCleanup(() => {
176
+ for (const it of items) {
177
+ it.dispose();
178
+ for (const n of it.nodes) n.remove();
179
+ }
180
+ });
181
+ }
182
+
183
+ function insertItems(anchor, value, items) {
184
+ const parent = anchor.parentNode;
185
+ const list = Array.isArray(value) ? value : [value];
186
+ for (const v of list) {
187
+ const item = renderChild(v);
188
+ for (const n of item.nodes) parent.insertBefore(n, anchor);
189
+ items.push(item);
190
+ }
191
+ }
192
+
193
+ function renderChild(value) {
194
+ if (value == null || value === false || value === true) return { nodes: [], dispose: noop };
195
+ if (value instanceof Node) return { nodes: [value], dispose: noop };
196
+ if (isHtmlResult(value)) {
197
+ const r = render(value);
198
+ return { nodes: [...r.node.childNodes], dispose: r.dispose };
199
+ }
200
+ return { nodes: [document.createTextNode(toText(value))], dispose: noop };
201
+ }
202
+
203
+ // ---- keyed list binding ------------------------------------------------------
204
+
205
+ function isEach(v) {
206
+ return v != null && typeof v === "object" && v.__easyEach === true;
207
+ }
208
+
209
+ function setupKeyedList(anchor, marker) {
210
+ const { items, keyFn, renderFn } = marker;
211
+ const listOwner = createOwner(); // item subtrees nest here
212
+ let records = new Map();
213
+ let currentList = [];
214
+
215
+ const readItems = () => {
216
+ const raw = typeof items === "function" ? items() : items;
217
+ return raw == null ? [] : raw;
218
+ };
219
+
220
+ // Build one item. The item is a reactive proxy over a state cell, so reusing a
221
+ // node later (itemCell.set) refreshes only its own bindings. render() opens a
222
+ // child owner (nested in listOwner) so the whole item subtree disposes together.
223
+ const createRecord = (key, item) => {
224
+ const isObj = item !== null && typeof item === "object";
225
+ const itemCell = isObj ? createState(item) : null;
226
+ const arg = isObj ? makeItemProxy(itemCell) : item;
227
+ // Run renderFn inside this item's own owner scope, so any onCleanup() it
228
+ // registers fires when the item is removed (not only on full unmount).
229
+ const itemOwner = createOwner();
230
+ let nodes;
231
+ untrack(() =>
232
+ runWithOwner(itemOwner, () => {
233
+ const r = render(renderFn(arg));
234
+ nodes = [...r.node.childNodes];
235
+ })
236
+ );
237
+ return { key, item, itemCell, nodes, dispose: () => disposeOwner(itemOwner) };
238
+ };
239
+
240
+ const reconcile = (newItems) => {
241
+ const parent = anchor.parentNode;
242
+ const oldRecords = records;
243
+ const newRecords = new Map();
244
+ const ordered = [];
245
+ const seen = isDev() ? new Set() : null;
246
+
247
+ for (let i = 0; i < newItems.length; i++) {
248
+ const item = newItems[i];
249
+ const key = keyFn(item);
250
+ if (isDev()) {
251
+ if (seen.has(key)) {
252
+ console.warn(`Zoijs each(): duplicate key ${stringifyKey(key)} — keys must be unique; DOM for duplicates may be unstable.`);
253
+ }
254
+ seen.add(key);
255
+ }
256
+ let rec = oldRecords.get(key);
257
+ if (rec) {
258
+ oldRecords.delete(key);
259
+ if (rec.itemCell) rec.itemCell.set(item); // refresh this item's bindings only
260
+ rec.item = item;
261
+ } else {
262
+ rec = createRecord(key, item);
263
+ }
264
+ newRecords.set(key, rec);
265
+ ordered.push(rec);
266
+ }
267
+
268
+ // Removed keys: dispose their owner scope (effects/listeners) + remove nodes.
269
+ for (const rec of oldRecords.values()) {
270
+ rec.dispose();
271
+ for (const n of rec.nodes) n.remove();
272
+ }
273
+
274
+ // Place nodes in order before the anchor, moving only those out of position.
275
+ let cursor = anchor;
276
+ for (let i = ordered.length - 1; i >= 0; i--) {
277
+ const rec = ordered[i];
278
+ let ref = cursor;
279
+ for (let j = rec.nodes.length - 1; j >= 0; j--) {
280
+ const node = rec.nodes[j];
281
+ if (node.nextSibling !== ref) parent.insertBefore(node, ref);
282
+ ref = node;
283
+ }
284
+ cursor = rec.nodes[0] || cursor;
285
+ }
286
+
287
+ records = newRecords;
288
+ currentList = ordered;
289
+ };
290
+
291
+ effect(() => {
292
+ const newItems = readItems(); // tracked: subscribe to the list state
293
+ runWithOwner(listOwner, () => reconcile(newItems));
294
+ });
295
+
296
+ onCleanup(() => {
297
+ for (const rec of currentList) {
298
+ rec.dispose();
299
+ for (const n of rec.nodes) n.remove();
300
+ }
301
+ });
302
+ }
303
+
304
+ function makeItemProxy(itemCell) {
305
+ return new Proxy(
306
+ {},
307
+ {
308
+ get(_, prop) {
309
+ const item = itemCell.get();
310
+ if (item == null) return undefined;
311
+ const v = item[prop];
312
+ return typeof v === "function" ? v.bind(item) : v;
313
+ },
314
+ has(_, prop) {
315
+ const item = itemCell.get();
316
+ return item != null && prop in Object(item);
317
+ },
318
+ }
319
+ );
320
+ }
321
+
322
+ function stringifyKey(key) {
323
+ try {
324
+ return JSON.stringify(key);
325
+ } catch {
326
+ return String(key);
327
+ }
328
+ }
329
+
330
+ // ---- attribute binding -------------------------------------------------------
331
+
332
+ function applyAttribute(el, name, value) {
333
+ if (!isSafeAttributeName(name)) {
334
+ if (isDev()) console.warn(`Zoijs: refusing to bind unsafe attribute "${name}"`);
335
+ return;
336
+ }
337
+ if (URL_ATTRS.has(name) && !isSafeUrl(toText(value))) {
338
+ if (isDev()) console.warn(`Zoijs: refusing unsafe URL in "${name}": ${value}`);
339
+ return;
340
+ }
341
+ if (name === "value" || name === "checked") {
342
+ el[name] = value; // form-control state lives on the property
343
+ return;
344
+ }
345
+ if (name.startsWith("xlink:")) {
346
+ // SVG namespaced attribute (e.g. xlink:href).
347
+ if (value === false || value == null) el.removeAttributeNS(XLINK_NS, name.slice(6));
348
+ else el.setAttributeNS(XLINK_NS, name, toText(value));
349
+ return;
350
+ }
351
+ if (value === false || value == null) {
352
+ el.removeAttribute(name);
353
+ } else if (value === true) {
354
+ el.setAttribute(name, "");
355
+ } else {
356
+ el.setAttribute(name, toText(value));
357
+ }
358
+ }
359
+
360
+ // ---- helpers -----------------------------------------------------------------
361
+
362
+ function isHtmlResult(v) {
363
+ return v && typeof v === "object" && v.template && Array.isArray(v.parts);
364
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,113 @@
1
+ // Type definitions for Zoijs.
2
+ //
3
+ // Zoijs is authored in plain JavaScript; these declarations add editor
4
+ // autocomplete and optional type-checking WITHOUT converting the project to
5
+ // TypeScript. Using them is entirely opt-in — JS users are unaffected.
6
+
7
+ /** A reactive value created by {@link createState}. */
8
+ export interface State<T> {
9
+ /** Read the current value. Inside a binding/effect this subscribes to it. */
10
+ get(): T;
11
+ /** Write a new value. Dependents update only if the value actually changed. */
12
+ set(next: T): void;
13
+ /** Read the current value WITHOUT subscribing. */
14
+ peek(): T;
15
+ }
16
+
17
+ /** A lazy, cached, value-gated derived value created by {@link computed}. */
18
+ export interface Computed<T> {
19
+ /** Read the current value. Recomputes only if a dependency changed. */
20
+ get(): T;
21
+ /** Read without subscribing. */
22
+ peek(): T;
23
+ }
24
+
25
+ declare const templateBrand: unique symbol;
26
+ /**
27
+ * The result of an `html` template. Pass it to {@link mount}, return it from a
28
+ * component, or place it in another template. Treat it as opaque.
29
+ */
30
+ export interface TemplateResult {
31
+ readonly [templateBrand]: true;
32
+ }
33
+
34
+ declare const eachBrand: unique symbol;
35
+ /** The result of {@link each}. Place it in a template's child position. */
36
+ export interface EachResult {
37
+ readonly [eachBrand]: true;
38
+ }
39
+
40
+ /** A component is a function that returns an `html` template. */
41
+ export type Component = () => TemplateResult;
42
+
43
+ /**
44
+ * Tagged-template function — write your markup as HTML.
45
+ *
46
+ * ```js
47
+ * html`<button onclick=${() => count.set(count.get() + 1)}>${() => count.get()}</button>`
48
+ * ```
49
+ */
50
+ export function html(strings: TemplateStringsArray, ...values: unknown[]): TemplateResult;
51
+
52
+ /**
53
+ * Render a component (or a template) into a DOM element or CSS selector.
54
+ * Returns an `unmount()` that detaches the DOM and disposes all reactivity.
55
+ */
56
+ export function mount(component: Component | TemplateResult, target: Element | string): () => void;
57
+
58
+ /**
59
+ * Create a reactive value.
60
+ *
61
+ * ```ts
62
+ * const count = createState(0); // State<number>
63
+ * const name = createState<string>(""); // explicit
64
+ * ```
65
+ */
66
+ export function createState<T>(initial: T, equals?: (a: T, b: T) => boolean): State<T>;
67
+
68
+ /**
69
+ * Create a lazy, cached derived value.
70
+ *
71
+ * ```ts
72
+ * const fullName = computed(() => `${first.get()} ${last.get()}`); // Computed<string>
73
+ * ```
74
+ */
75
+ export function computed<T>(fn: () => T, equals?: (a: T, b: T) => boolean): Computed<T>;
76
+
77
+ /**
78
+ * Keyed list rendering. `items` may be a reactive function or a plain array;
79
+ * `keyFn` returns a stable unique key; `renderFn` returns the template for one item.
80
+ *
81
+ * ```ts
82
+ * each(() => todos.get(), (t) => t.id, (t) => html`<li>${() => t.text}</li>`)
83
+ * ```
84
+ */
85
+ export function each<T>(
86
+ items: () => readonly T[],
87
+ keyFn: (item: T) => unknown,
88
+ renderFn: (item: T) => TemplateResult
89
+ ): EachResult;
90
+ export function each<T>(
91
+ items: readonly T[],
92
+ keyFn: (item: T) => unknown,
93
+ renderFn: (item: T) => TemplateResult
94
+ ): EachResult;
95
+
96
+ /** Toggle development warnings (default: `dev` is `true`). */
97
+ export function configure(options: { dev?: boolean }): void;
98
+
99
+ /**
100
+ * Register a teardown function for the current component or list item. It runs
101
+ * when that component is unmounted or that list item is removed. Use it for
102
+ * timers, subscriptions, or third-party widgets created during setup.
103
+ *
104
+ * ```ts
105
+ * function Clock() {
106
+ * const now = createState(Date.now());
107
+ * const id = setInterval(() => now.set(Date.now()), 1000);
108
+ * onCleanup(() => clearInterval(id));
109
+ * return html`<time>${() => now.get()}</time>`;
110
+ * }
111
+ * ```
112
+ */
113
+ export function onCleanup(fn: () => void): void;
package/src/index.js ADDED
@@ -0,0 +1,14 @@
1
+ // Zoijs — public entry point.
2
+ //
3
+ // Re-exports the entire public API surface for the MVP. Keep this list small:
4
+ // the whole framework is meant to be learnable in one sitting.
5
+ //
6
+ // import { html, mount, createState } from "@zoijs/core";
7
+
8
+ export { html } from "./core/html.js";
9
+ export { mount } from "./core/mount.js";
10
+ export { each } from "./core/each.js";
11
+ export { createState } from "./reactivity/state.js";
12
+ export { computed } from "./reactivity/computed.js";
13
+ export { configure } from "./reactivity/env.js";
14
+ export { onCleanup } from "./reactivity/owner.js";
@@ -0,0 +1,10 @@
1
+ // computed() — lazy, cached, value-gated derived state.
2
+ //
3
+ // const fullName = computed(() => `${first.get()} ${last.get()}`);
4
+ //
5
+ // Recomputes only when a dependency changed AND only on read. Crucially, if the
6
+ // recomputed value is unchanged, subscribers are NOT woken (value gating).
7
+ //
8
+ // Implemented by the shared reactive core (see core.js).
9
+
10
+ export { computed } from "./core.js";
@@ -0,0 +1,205 @@
1
+ // core.js — the reactive graph: states, computeds, effects.
2
+ //
3
+ // One unified push-pull algorithm (CLEAN / CHECK / DIRTY) gives value-gated,
4
+ // lazy, cached updates:
5
+ //
6
+ // - A write marks observers DIRTY and their observers CHECK ("maybe dirty"),
7
+ // and enqueues affected effects (batched on a microtask).
8
+ // - On run, an effect/computed PULLS its sources: it only recomputes if a
9
+ // source is actually DIRTY. A computed that recomputes to the SAME value
10
+ // does not wake its observers — that is the value gating (Task 1).
11
+ //
12
+ // Nodes:
13
+ // state { value, observers, equals, fn:null }
14
+ // computed { value, observers, sources, state, equals, fn, isEffect:false }
15
+ // effect { observers, sources, state, fn, isEffect:true }
16
+
17
+ import { onCleanup } from "./owner.js";
18
+ import { isDev } from "./env.js";
19
+
20
+ const CLEAN = 0;
21
+ const CHECK = 1;
22
+ const DIRTY = 2;
23
+
24
+ let currentObserver = null;
25
+
26
+ // ---- batching scheduler -----------------------------------------------------
27
+
28
+ const queue = new Set();
29
+ let scheduled = false;
30
+ const RUNAWAY_LIMIT = 100;
31
+
32
+ function enqueue(node) {
33
+ queue.add(node);
34
+ if (!scheduled) {
35
+ scheduled = true;
36
+ queueMicrotask(flush);
37
+ }
38
+ }
39
+
40
+ /** Run all queued effects (and anything they schedule) to completion, in order. */
41
+ export function flush() {
42
+ scheduled = false;
43
+ const runCounts = new Map();
44
+ while (queue.size) {
45
+ const nodes = [...queue];
46
+ queue.clear();
47
+ for (const node of nodes) {
48
+ const count = (runCounts.get(node) || 0) + 1;
49
+ runCounts.set(node, count);
50
+ if (count === RUNAWAY_LIMIT && isDev()) {
51
+ console.warn(`Zoijs: an effect re-ran ${RUNAWAY_LIMIT}× in one flush — stopping it (possible infinite loop).`, node.fn);
52
+ }
53
+ if (count >= RUNAWAY_LIMIT) continue;
54
+ updateIfNecessary(node);
55
+ }
56
+ }
57
+ }
58
+
59
+ // ---- graph internals --------------------------------------------------------
60
+
61
+ function readNode(node) {
62
+ if (currentObserver) {
63
+ node.observers.add(currentObserver);
64
+ currentObserver.sources.add(node);
65
+ }
66
+ if (node.fn) updateIfNecessary(node); // computed: make sure it's fresh
67
+ return node.value;
68
+ }
69
+
70
+ function writeNode(node, next) {
71
+ if (node.equals(node.value, next)) return; // equality-gated
72
+ node.value = next;
73
+ for (const observer of [...node.observers]) markStale(observer, DIRTY);
74
+ }
75
+
76
+ function markStale(node, newState) {
77
+ if (node === currentObserver && isDev()) {
78
+ warnOnce(node, "Zoijs: an effect/computed updated state it depends on (self-triggering). Derive with computed() or guard the write.");
79
+ }
80
+ if (node.state < newState) {
81
+ const previous = node.state;
82
+ node.state = newState;
83
+ if (previous === CLEAN) {
84
+ for (const observer of node.observers) markStale(observer, CHECK);
85
+ if (node.isEffect) enqueue(node);
86
+ }
87
+ }
88
+ }
89
+
90
+ function updateIfNecessary(node) {
91
+ if (node.disposed) return;
92
+ if (node.state === CHECK) {
93
+ // "maybe dirty": resolve sources; recompute only if one really changed.
94
+ for (const source of node.sources) {
95
+ if (source.fn) updateIfNecessary(source);
96
+ if (node.state === DIRTY) break;
97
+ }
98
+ }
99
+ if (node.state === DIRTY) runComputation(node);
100
+ node.state = CLEAN;
101
+ }
102
+
103
+ function runComputation(node) {
104
+ if (node.disposed) {
105
+ node.state = CLEAN;
106
+ return;
107
+ }
108
+ cleanupSources(node);
109
+ const previousObserver = currentObserver;
110
+ currentObserver = node;
111
+ let result;
112
+ let threw = false;
113
+ try {
114
+ result = node.fn();
115
+ } catch (err) {
116
+ threw = true;
117
+ // Task 3/5: contain the failure so other bindings keep working.
118
+ console.error("Zoijs: a reactive binding threw (other bindings keep working):", err);
119
+ } finally {
120
+ currentObserver = previousObserver;
121
+ }
122
+ if (threw || node.isEffect) return;
123
+ if (!node.equals(node.value, result)) {
124
+ node.value = result;
125
+ // Value changed → promote observers (currently CHECK) to DIRTY so they update.
126
+ for (const observer of node.observers) observer.state = DIRTY;
127
+ }
128
+ }
129
+
130
+ function cleanupSources(node) {
131
+ for (const source of node.sources) source.observers.delete(node);
132
+ node.sources.clear();
133
+ }
134
+
135
+ function disposeNode(node) {
136
+ if (node.disposed) return;
137
+ node.disposed = true;
138
+ cleanupSources(node);
139
+ }
140
+
141
+ const warned = new WeakSet();
142
+ function warnOnce(node, message) {
143
+ if (warned.has(node)) return;
144
+ warned.add(node);
145
+ console.warn(message, node.fn);
146
+ }
147
+
148
+ // ---- public primitives ------------------------------------------------------
149
+
150
+ export function createState(initial, equals = Object.is) {
151
+ const node = { value: initial, observers: new Set(), equals, fn: null };
152
+ return {
153
+ get: () => readNode(node),
154
+ set: (next) => writeNode(node, next),
155
+ peek: () => node.value,
156
+ };
157
+ }
158
+
159
+ export function computed(fn, equals = Object.is) {
160
+ const node = {
161
+ fn,
162
+ value: undefined,
163
+ observers: new Set(),
164
+ sources: new Set(),
165
+ state: DIRTY,
166
+ isEffect: false,
167
+ disposed: false,
168
+ equals,
169
+ };
170
+ onCleanup(() => disposeNode(node)); // disposed with its owner scope
171
+ return {
172
+ get: () => readNode(node),
173
+ peek: () => {
174
+ updateIfNecessary(node);
175
+ return node.value;
176
+ },
177
+ };
178
+ }
179
+
180
+ export function effect(fn) {
181
+ const node = {
182
+ fn,
183
+ observers: new Set(),
184
+ sources: new Set(),
185
+ state: DIRTY,
186
+ isEffect: true,
187
+ disposed: false,
188
+ equals: Object.is,
189
+ };
190
+ onCleanup(() => disposeNode(node)); // disposed with its owner scope
191
+ runComputation(node);
192
+ node.state = CLEAN;
193
+ return { dispose: () => disposeNode(node) };
194
+ }
195
+
196
+ /** Run `fn` without subscribing the current observer to what it reads. */
197
+ export function untrack(fn) {
198
+ const previous = currentObserver;
199
+ currentObserver = null;
200
+ try {
201
+ return fn();
202
+ } finally {
203
+ currentObserver = previous;
204
+ }
205
+ }