@zoijs/core 1.3.1 → 1.4.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,56 @@
1
+ // Type surface for `@zoijs/core/devtools` — the read-only reactive-graph
2
+ // inspection hook (RFC 0005). This is dev-tooling, NOT part of the stable
3
+ // nine-function API; it lives behind its own subpath so the learnable surface
4
+ // stays frozen. See `src/reactivity/devtools.js`.
5
+
6
+ /** What a node is. */
7
+ export type NodeKind = "state" | "computed" | "effect";
8
+
9
+ /**
10
+ * A reactive graph node. Treat it as an opaque identity with a few read-only
11
+ * fields — never mutate it. `sources` / `observers` let an inspector walk the
12
+ * graph on demand (so reads stay un-instrumented). `fn` is present on
13
+ * computeds/effects, absent on states.
14
+ */
15
+ export interface ReactiveNode {
16
+ readonly value?: unknown;
17
+ readonly fn?: Function | null;
18
+ readonly sources?: ReadonlySet<ReactiveNode>;
19
+ readonly observers: ReadonlySet<ReactiveNode>;
20
+ readonly disposed?: boolean;
21
+ }
22
+
23
+ /**
24
+ * A tag the renderer attaches to a binding node, naming the DOM it updates:
25
+ * a text slot, an attribute, or a keyed list. Lets an inspector show *which DOM
26
+ * node each signal updates*.
27
+ */
28
+ export interface NodeLabel {
29
+ kind: "text" | "attr" | "list";
30
+ el: Node;
31
+ name?: string;
32
+ }
33
+
34
+ /**
35
+ * Implement this to observe the reactive graph. Every callback is read-only and
36
+ * fires only in dev mode while attached. `onCreate` is called once per node;
37
+ * `onRun` each time a computed/effect recomputes; `onWrite` each time a state
38
+ * actually changes; `onDispose` when a computed/effect leaves the graph.
39
+ */
40
+ export interface Inspector {
41
+ onAttach?(): void;
42
+ onCreate(node: ReactiveNode, kind: NodeKind, label?: NodeLabel): void;
43
+ onRun(node: ReactiveNode): void;
44
+ onWrite(node: ReactiveNode): void;
45
+ onDispose(node: ReactiveNode): void;
46
+ }
47
+
48
+ /**
49
+ * Attach a read-only inspector; returns a detach function. A no-op (returns a
50
+ * no-op) under `configure({ dev: false })`, so an inspector can never run
51
+ * against an app shipped in production mode.
52
+ */
53
+ export function attachInspector(inspector: Inspector): () => void;
54
+
55
+ /** True while an inspector is attached. */
56
+ export function inspecting(): boolean;
package/src/index.d.ts CHANGED
@@ -1,185 +1,185 @@
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
- * A callback ref. Place it on an element as `ref=${fn}`; it receives the real DOM
45
- * element once the surrounding render has been inserted (deferred one microtask,
46
- * so `focus()` / `scrollIntoView()` / `getBoundingClientRect()` work). It may
47
- * return a cleanup function, which runs on unmount or list-item removal. It runs
48
- * once and is not reactive.
49
- *
50
- * ```ts
51
- * html`<input ref=${(el: HTMLInputElement) => el.focus()} />`
52
- * html`<div ref=${(el) => { const c = chart(el); return () => c.destroy(); }}></div>`
53
- * ```
54
- */
55
- export type Ref<E extends Element = Element> = (element: E) => void | (() => void);
56
-
57
- /**
58
- * Tagged-template function — write your markup as HTML.
59
- *
60
- * ```js
61
- * html`<button onclick=${() => count.set(count.get() + 1)}>${() => count.get()}</button>`
62
- * ```
63
- *
64
- * To reach the rendered DOM element, add a callback `ref` (see {@link Ref}):
65
- * `html\`<input ref=${(el) => el.focus()} />\``.
66
- */
67
- export function html(strings: TemplateStringsArray, ...values: unknown[]): TemplateResult;
68
-
69
- /**
70
- * Render a component (or a template) into a DOM element or CSS selector.
71
- * Returns an `unmount()` that detaches the DOM and disposes all reactivity.
72
- */
73
- export function mount(component: Component | TemplateResult, target: Element | string): () => void;
74
-
75
- /**
76
- * Create a reactive value.
77
- *
78
- * ```ts
79
- * const count = createState(0); // State<number>
80
- * const name = createState<string>(""); // explicit
81
- * ```
82
- */
83
- export function createState<T>(initial: T, equals?: (a: T, b: T) => boolean): State<T>;
84
-
85
- /**
86
- * Create a lazy, cached derived value.
87
- *
88
- * ```ts
89
- * const fullName = computed(() => `${first.get()} ${last.get()}`); // Computed<string>
90
- * ```
91
- */
92
- export function computed<T>(fn: () => T, equals?: (a: T, b: T) => boolean): Computed<T>;
93
-
94
- /** A disposable handle returned by {@link effect}. */
95
- export interface EffectHandle {
96
- /** Dispose the effect now. It also auto-disposes with its owner (component/list item). */
97
- dispose(): void;
98
- }
99
-
100
- /**
101
- * Run a side effect that re-runs whenever a reactive value it reads changes.
102
- * Runs once immediately, then again (batched on a microtask) on any change.
103
- * Dependencies are tracked automatically — there is no dependency array.
104
- *
105
- * The function may return a cleanup function (same convention as a `ref`); it
106
- * runs **before the next run** and **on dispose**. Use the return value for
107
- * per-run teardown — `onCleanup` is component-scoped (fires on unmount), not
108
- * per-run.
109
- *
110
- * Created inside a component or list item, it auto-disposes with that scope;
111
- * created at module top level, it lives until you call `dispose()`.
112
- *
113
- * ```ts
114
- * // persist on change
115
- * effect(() => localStorage.setItem("theme", theme.get()));
116
- *
117
- * // per-run cleanup
118
- * effect(() => {
119
- * const id = setInterval(() => poll(query.get()), 1000);
120
- * return () => clearInterval(id);
121
- * });
122
- * ```
123
- *
124
- * For reactive *content on screen*, use a binding (`${() => …}`), not an effect.
125
- */
126
- export function effect(fn: () => void | (() => void)): EffectHandle;
127
-
128
- /**
129
- * Keyed list rendering. `items` may be a reactive function or a plain array;
130
- * `keyFn` returns a stable unique key; `renderFn` returns the template for one item.
131
- *
132
- * ```ts
133
- * each(() => todos.get(), (t) => t.id, (t) => html`<li>${() => t.text}</li>`)
134
- * ```
135
- */
136
- export function each<T>(
137
- items: () => readonly T[],
138
- keyFn: (item: T) => unknown,
139
- renderFn: (item: T) => TemplateResult
140
- ): EachResult;
141
- export function each<T>(
142
- items: readonly T[],
143
- keyFn: (item: T) => unknown,
144
- renderFn: (item: T) => TemplateResult
145
- ): EachResult;
146
-
147
- /**
148
- * Render `child`; if it **throws synchronously while building its markup** (a
149
- * setup/render error that would otherwise break the whole `mount`), tear down the
150
- * partial work and render `fallback` instead. Place the call in a template slot.
151
- *
152
- * Catches synchronous setup/render throws only. Errors in reactive *updates* are
153
- * already contained per binding by the core; *async* errors belong to
154
- * `@zoijs/resource` / `@zoijs/action`'s `error()` state. It renders once (no reset
155
- * — re-mount the subtree to retry), logs in dev, and is silent in production.
156
- *
157
- * ```ts
158
- * html`<section>
159
- * ${boundary(() => RiskyWidget(), (err) => html`<p>Couldn't load this.</p>`)}
160
- * </section>`
161
- * ```
162
- */
163
- export function boundary<C, F>(
164
- child: (() => C) | C,
165
- fallback: ((error: unknown) => F) | F
166
- ): C | F;
167
-
168
- /** Toggle development warnings (default: `dev` is `true`). */
169
- export function configure(options: { dev?: boolean }): void;
170
-
171
- /**
172
- * Register a teardown function for the current component or list item. It runs
173
- * when that component is unmounted or that list item is removed. Use it for
174
- * timers, subscriptions, or third-party widgets created during setup.
175
- *
176
- * ```ts
177
- * function Clock() {
178
- * const now = createState(Date.now());
179
- * const id = setInterval(() => now.set(Date.now()), 1000);
180
- * onCleanup(() => clearInterval(id));
181
- * return html`<time>${() => now.get()}</time>`;
182
- * }
183
- * ```
184
- */
185
- export function onCleanup(fn: () => void): void;
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
+ * A callback ref. Place it on an element as `ref=${fn}`; it receives the real DOM
45
+ * element once the surrounding render has been inserted (deferred one microtask,
46
+ * so `focus()` / `scrollIntoView()` / `getBoundingClientRect()` work). It may
47
+ * return a cleanup function, which runs on unmount or list-item removal. It runs
48
+ * once and is not reactive.
49
+ *
50
+ * ```ts
51
+ * html`<input ref=${(el: HTMLInputElement) => el.focus()} />`
52
+ * html`<div ref=${(el) => { const c = chart(el); return () => c.destroy(); }}></div>`
53
+ * ```
54
+ */
55
+ export type Ref<E extends Element = Element> = (element: E) => void | (() => void);
56
+
57
+ /**
58
+ * Tagged-template function — write your markup as HTML.
59
+ *
60
+ * ```js
61
+ * html`<button onclick=${() => count.set(count.get() + 1)}>${() => count.get()}</button>`
62
+ * ```
63
+ *
64
+ * To reach the rendered DOM element, add a callback `ref` (see {@link Ref}):
65
+ * `html\`<input ref=${(el) => el.focus()} />\``.
66
+ */
67
+ export function html(strings: TemplateStringsArray, ...values: unknown[]): TemplateResult;
68
+
69
+ /**
70
+ * Render a component (or a template) into a DOM element or CSS selector.
71
+ * Returns an `unmount()` that detaches the DOM and disposes all reactivity.
72
+ */
73
+ export function mount(component: Component | TemplateResult, target: Element | string): () => void;
74
+
75
+ /**
76
+ * Create a reactive value.
77
+ *
78
+ * ```ts
79
+ * const count = createState(0); // State<number>
80
+ * const name = createState<string>(""); // explicit
81
+ * ```
82
+ */
83
+ export function createState<T>(initial: T, equals?: (a: T, b: T) => boolean): State<T>;
84
+
85
+ /**
86
+ * Create a lazy, cached derived value.
87
+ *
88
+ * ```ts
89
+ * const fullName = computed(() => `${first.get()} ${last.get()}`); // Computed<string>
90
+ * ```
91
+ */
92
+ export function computed<T>(fn: () => T, equals?: (a: T, b: T) => boolean): Computed<T>;
93
+
94
+ /** A disposable handle returned by {@link effect}. */
95
+ export interface EffectHandle {
96
+ /** Dispose the effect now. It also auto-disposes with its owner (component/list item). */
97
+ dispose(): void;
98
+ }
99
+
100
+ /**
101
+ * Run a side effect that re-runs whenever a reactive value it reads changes.
102
+ * Runs once immediately, then again (batched on a microtask) on any change.
103
+ * Dependencies are tracked automatically — there is no dependency array.
104
+ *
105
+ * The function may return a cleanup function (same convention as a `ref`); it
106
+ * runs **before the next run** and **on dispose**. Use the return value for
107
+ * per-run teardown — `onCleanup` is component-scoped (fires on unmount), not
108
+ * per-run.
109
+ *
110
+ * Created inside a component or list item, it auto-disposes with that scope;
111
+ * created at module top level, it lives until you call `dispose()`.
112
+ *
113
+ * ```ts
114
+ * // persist on change
115
+ * effect(() => localStorage.setItem("theme", theme.get()));
116
+ *
117
+ * // per-run cleanup
118
+ * effect(() => {
119
+ * const id = setInterval(() => poll(query.get()), 1000);
120
+ * return () => clearInterval(id);
121
+ * });
122
+ * ```
123
+ *
124
+ * For reactive *content on screen*, use a binding (`${() => …}`), not an effect.
125
+ */
126
+ export function effect(fn: () => void | (() => void)): EffectHandle;
127
+
128
+ /**
129
+ * Keyed list rendering. `items` may be a reactive function or a plain array;
130
+ * `keyFn` returns a stable unique key; `renderFn` returns the template for one item.
131
+ *
132
+ * ```ts
133
+ * each(() => todos.get(), (t) => t.id, (t) => html`<li>${() => t.text}</li>`)
134
+ * ```
135
+ */
136
+ export function each<T>(
137
+ items: () => readonly T[],
138
+ keyFn: (item: T) => unknown,
139
+ renderFn: (item: T) => TemplateResult
140
+ ): EachResult;
141
+ export function each<T>(
142
+ items: readonly T[],
143
+ keyFn: (item: T) => unknown,
144
+ renderFn: (item: T) => TemplateResult
145
+ ): EachResult;
146
+
147
+ /**
148
+ * Render `child`; if it **throws synchronously while building its markup** (a
149
+ * setup/render error that would otherwise break the whole `mount`), tear down the
150
+ * partial work and render `fallback` instead. Place the call in a template slot.
151
+ *
152
+ * Catches synchronous setup/render throws only. Errors in reactive *updates* are
153
+ * already contained per binding by the core; *async* errors belong to
154
+ * `@zoijs/resource` / `@zoijs/action`'s `error()` state. It renders once (no reset
155
+ * — re-mount the subtree to retry), logs in dev, and is silent in production.
156
+ *
157
+ * ```ts
158
+ * html`<section>
159
+ * ${boundary(() => RiskyWidget(), (err) => html`<p>Couldn't load this.</p>`)}
160
+ * </section>`
161
+ * ```
162
+ */
163
+ export function boundary<C, F>(
164
+ child: (() => C) | C,
165
+ fallback: ((error: unknown) => F) | F
166
+ ): C | F;
167
+
168
+ /** Toggle development warnings (default: `dev` is `true`). */
169
+ export function configure(options: { dev?: boolean }): void;
170
+
171
+ /**
172
+ * Register a teardown function for the current component or list item. It runs
173
+ * when that component is unmounted or that list item is removed. Use it for
174
+ * timers, subscriptions, or third-party widgets created during setup.
175
+ *
176
+ * ```ts
177
+ * function Clock() {
178
+ * const now = createState(Date.now());
179
+ * const id = setInterval(() => now.set(Date.now()), 1000);
180
+ * onCleanup(() => clearInterval(id));
181
+ * return html`<time>${() => now.get()}</time>`;
182
+ * }
183
+ * ```
184
+ */
185
+ export function onCleanup(fn: () => void): void;
@@ -16,6 +16,7 @@
16
16
 
17
17
  import { onCleanup } from "./owner.js";
18
18
  import { isDev } from "./env.js";
19
+ import { reportCreate, reportRun, reportWrite, reportDispose } from "./devtools.js";
19
20
 
20
21
  const CLEAN = 0;
21
22
  const CHECK = 1;
@@ -70,6 +71,7 @@ function readNode(node) {
70
71
  function writeNode(node, next) {
71
72
  if (node.equals(node.value, next)) return; // equality-gated
72
73
  node.value = next;
74
+ reportWrite(node); // devtools: only fires on an actual change (dev + attached)
73
75
  for (const observer of [...node.observers]) markStale(observer, DIRTY);
74
76
  }
75
77
 
@@ -120,6 +122,7 @@ function runComputation(node) {
120
122
  } finally {
121
123
  currentObserver = previousObserver;
122
124
  }
125
+ reportRun(node); // devtools: this node actually recomputed (dev + attached)
123
126
  if (node.isEffect) {
124
127
  // An effect may return a cleanup function (same convention as a ref): it runs
125
128
  // before the next run and on dispose. Anything else is ignored.
@@ -156,6 +159,7 @@ function disposeNode(node) {
156
159
  node.disposed = true;
157
160
  cleanupSources(node);
158
161
  if (node.isEffect) runEffectCleanup(node); // final cleanup on dispose
162
+ reportDispose(node); // devtools: node left the graph (dev + attached)
159
163
  }
160
164
 
161
165
  const warned = new WeakSet();
@@ -169,6 +173,7 @@ function warnOnce(node, message) {
169
173
 
170
174
  export function createState(initial, equals = Object.is) {
171
175
  const node = { value: initial, observers: new Set(), equals, fn: null };
176
+ reportCreate(node, "state");
172
177
  return {
173
178
  get: () => readNode(node),
174
179
  set: (next) => writeNode(node, next),
@@ -188,6 +193,7 @@ export function computed(fn, equals = Object.is) {
188
193
  equals,
189
194
  };
190
195
  onCleanup(() => disposeNode(node)); // disposed with its owner scope
196
+ reportCreate(node, "computed");
191
197
  return {
192
198
  get: () => readNode(node),
193
199
  peek: () => {
@@ -209,6 +215,7 @@ export function effect(fn) {
209
215
  cleanup: null,
210
216
  };
211
217
  onCleanup(() => disposeNode(node)); // disposed with its owner scope
218
+ reportCreate(node, "effect"); // before its first run, so the run is attributed
212
219
  runComputation(node);
213
220
  node.state = CLEAN;
214
221
  return { dispose: () => disposeNode(node) };
@@ -0,0 +1,77 @@
1
+ // devtools.js — read-only inspection hook for the reactive graph (RFC 0005).
2
+ //
3
+ // This is the ONE seam between the engine and an external inspector
4
+ // (@zoijs/devtools, or a browser extension). It is, by construction:
5
+ //
6
+ // • Off by default. Until something attaches, every report below is a single
7
+ // `if (!inspector) return` — so an un-inspected app (every production app)
8
+ // pays no measurable cost and exposes nothing.
9
+ // • Read-light. The HOT path — readNode, run on every `.get()` — is NOT
10
+ // instrumented. Only lifecycle points are (create / run / write / dispose).
11
+ // The inspector reads a node's `sources` / `observers` on demand for the
12
+ // graph view, so even an *attached* inspector adds no per-read cost.
13
+ // • Dev-only. attachInspector() is a no-op under configure({ dev:false }), so
14
+ // an inspector can never run against an app shipped in production mode.
15
+ // • Read-only. The engine hands the inspector raw nodes to OBSERVE; it never
16
+ // reads a value back from the inspector or lets it mutate the graph.
17
+ //
18
+ // Not part of the learnable nine-function surface — reached through the dedicated
19
+ // subpath `@zoijs/core/devtools`, by tooling, never by application code.
20
+
21
+ import { isDev } from "./env.js";
22
+
23
+ let inspector = null;
24
+
25
+ // A small stack lets the renderer tag the node(s) created while it wires a DOM
26
+ // binding with the DOM they update (e.g. { kind:"text", el }); see labelNext.
27
+ const labels = [];
28
+
29
+ /**
30
+ * Attach a read-only inspector and start receiving reports. Returns a detach
31
+ * function. No-op (returns a no-op) in production mode or with a falsy inspector.
32
+ */
33
+ export function attachInspector(next) {
34
+ if (!isDev() || !next) return () => {};
35
+ inspector = next;
36
+ if (inspector.onAttach) inspector.onAttach();
37
+ return () => {
38
+ if (inspector === next) inspector = null;
39
+ };
40
+ }
41
+
42
+ /** True while an inspector is attached — a cheap guard for optional extra work. */
43
+ export function inspecting() {
44
+ return inspector !== null;
45
+ }
46
+
47
+ // ---- engine-side reports — each a no-op unless an inspector is attached -------
48
+
49
+ export function reportCreate(node, kind) {
50
+ if (!inspector) return;
51
+ inspector.onCreate(node, kind, labels.length ? labels[labels.length - 1] : undefined);
52
+ }
53
+ export function reportRun(node) {
54
+ if (inspector) inspector.onRun(node);
55
+ }
56
+ export function reportWrite(node) {
57
+ if (inspector) inspector.onWrite(node);
58
+ }
59
+ export function reportDispose(node) {
60
+ if (inspector) inspector.onDispose(node);
61
+ }
62
+
63
+ /**
64
+ * Tag the node(s) created while `run` executes with `label` — used by the
65
+ * renderer to attribute a binding effect to the DOM node it updates. The label is
66
+ * only ever read inside reportCreate, so this costs one null-check when no
67
+ * inspector is attached.
68
+ */
69
+ export function labelNext(label, run) {
70
+ if (!inspector) return run();
71
+ labels.push(label);
72
+ try {
73
+ return run();
74
+ } finally {
75
+ labels.pop();
76
+ }
77
+ }