@hyperfixi/reactivity 2.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,282 @@
1
+ import { HyperfixiPlugin } from '@hyperfixi/core';
2
+
3
+ /**
4
+ * Signal/effect reactive core.
5
+ *
6
+ * Models three dependency kinds for v1:
7
+ * - `global` — `$name` variables in `context.globals`
8
+ * - `element` — `^name` DOM-scoped variables (per-element storage)
9
+ * - `dom` — DOM properties read via `input`/`change` listeners
10
+ *
11
+ * Inspired by upstream _hyperscript 0.9.91's `src/core/runtime/reactivity.js`
12
+ * but reimplemented in TypeScript against hyperfixi's types. Batched via
13
+ * `queueMicrotask` so synchronous writes coalesce into a single flush. Cycle
14
+ * detection halts at >100 consecutive triggers (matching upstream).
15
+ *
16
+ * External packages never see `Effect` or `Reactive` directly — they hold
17
+ * the disposer returned by `createEffect()` and call it when the owning
18
+ * element is cleaned up.
19
+ */
20
+ type DepKind = 'global' | 'element' | 'dom';
21
+ interface Dep {
22
+ readonly key: string;
23
+ readonly kind: DepKind;
24
+ readonly name: string;
25
+ readonly element: Element | null;
26
+ }
27
+ declare class Effect {
28
+ private readonly r;
29
+ readonly expression: () => unknown | Promise<unknown>;
30
+ readonly handler: (value: unknown) => void | Promise<void>;
31
+ readonly element: Element | null;
32
+ readonly dependencies: Map<string, Dep>;
33
+ private lastValue;
34
+ private _stopped;
35
+ private _consecutiveTriggers;
36
+ constructor(r: Reactive, expression: () => unknown | Promise<unknown>, handler: (value: unknown) => void | Promise<void>, element: Element | null);
37
+ get stopped(): boolean;
38
+ /**
39
+ * Run the effect for the first time: collect dependencies, subscribe, call
40
+ * handler with the initial value. Errors in expression evaluation are caught
41
+ * and silently swallowed (matches upstream's tolerance).
42
+ */
43
+ initialize(): Promise<void>;
44
+ /**
45
+ * Re-run the effect after a notify. Cycle-detects (halts at 101 consecutive
46
+ * triggers). If the new value is Object.is-equal to the last, the handler
47
+ * is skipped. If the owning element has disconnected, the effect stops
48
+ * itself and returns.
49
+ */
50
+ run(): Promise<void>;
51
+ resetTriggerCount(): void;
52
+ /**
53
+ * Stop the effect and remove it from all dependency subscriptions. Safe to
54
+ * call multiple times.
55
+ */
56
+ stop(): void;
57
+ }
58
+ declare class Reactive {
59
+ private currentEffect;
60
+ private pending;
61
+ private scheduled;
62
+ private globalSubs;
63
+ private elementState;
64
+ private getElementState;
65
+ /**
66
+ * Internal helper invoked by Effect — installs `this` as the current effect,
67
+ * runs the expression, then restores the previous current-effect pointer.
68
+ * Track* methods (called from read-paths) consult `currentEffect` to know
69
+ * which effect to subscribe.
70
+ */
71
+ _runWithEffect(e: Effect, fn: () => unknown | Promise<unknown>): Promise<unknown>;
72
+ trackGlobal(name: string): void;
73
+ trackElement(el: Element, name: string): void;
74
+ trackDomProperty(el: Element, prop: string): void;
75
+ notifyGlobal(name: string): void;
76
+ notifyElement(el: Element, name: string): void;
77
+ /**
78
+ * Create + initialize an effect. Returns a disposer that stops the effect.
79
+ * Callers are expected to register the disposer with the core runtime's
80
+ * cleanup registry so it fires on element removal.
81
+ */
82
+ createEffect(expression: () => unknown | Promise<unknown>, handler: (value: unknown) => void | Promise<void>, owner: Element | null): () => void;
83
+ /**
84
+ * Stop all effects owned by an element. Called by the reactivity plugin's
85
+ * cleanup hook registered via `runtime.getCleanupRegistry()`.
86
+ */
87
+ stopElementEffects(el: Element): void;
88
+ /**
89
+ * Internal: remove an effect's entries from all subscriber sets. Called by
90
+ * `Effect.stop()`.
91
+ */
92
+ _unsubscribeEffect(e: Effect): void;
93
+ /**
94
+ * Whether `el` is a `dom-scope="isolated"` boundary. Walks of `^var`
95
+ * lookups stop at boundary elements that don't define the var, so nested
96
+ * components don't accidentally read or write each other's state.
97
+ */
98
+ private isIsolationBoundary;
99
+ /**
100
+ * Walk up the DOM tree from `lookupRoot`, returning the first element whose
101
+ * state has `name` defined. Stops at any `dom-scope="isolated"` boundary
102
+ * that doesn't itself define the var. Returns `null` if no owner is found.
103
+ */
104
+ private findCaretOwner;
105
+ /**
106
+ * Read a DOM-scoped variable. Walks up from `lookupRoot`, tracking each
107
+ * element visited as an `element` dep (so writes at any ancestor notify
108
+ * dependent effects). Stops at any `dom-scope="isolated"` boundary that
109
+ * doesn't itself define the var.
110
+ */
111
+ readCaret(lookupRoot: Element, name: string): unknown;
112
+ /**
113
+ * Write a DOM-scoped variable. If `target` is provided, writes there
114
+ * directly; otherwise walks up from `lookupRoot` to find the existing owner,
115
+ * falling back to `lookupRoot` itself if no owner exists.
116
+ */
117
+ writeCaret(lookupRoot: Element, name: string, value: unknown, target?: Element): void;
118
+ private schedule;
119
+ private flush;
120
+ }
121
+ declare const reactive: Reactive;
122
+
123
+ /**
124
+ * Lightweight local type stubs — mirror the speech package pattern of
125
+ * avoiding tight coupling to `@hyperfixi/core` internals. Commands and
126
+ * evaluators consume raw shapes via these structural types.
127
+ */
128
+ interface ASTNode {
129
+ type: string;
130
+ name?: string;
131
+ value?: unknown;
132
+ start?: number;
133
+ end?: number;
134
+ line?: number;
135
+ column?: number;
136
+ [k: string]: unknown;
137
+ }
138
+
139
+ /**
140
+ * `^name` — DOM-scoped inherited variable.
141
+ *
142
+ * Upstream syntax:
143
+ * ^counter → read from nearest ancestor that has `counter` set
144
+ * ^counter on #target → read from (or near) #target
145
+ * set ^counter to 42 → write to owner (or lookupRoot if not yet defined)
146
+ *
147
+ * Parse side: register `^` as a Pratt prefix operator. The handler consumes
148
+ * the identifier token and an optional `on <target>` clause, emitting a
149
+ * `caretVar` AST node.
150
+ *
151
+ * Eval side: `caretVar` node evaluator calls `reactive.readCaret(anchor,
152
+ * name)` which walks the DOM tree for the owner and records deps as it goes.
153
+ */
154
+
155
+ interface CaretVarNode extends ASTNode {
156
+ type: 'caretVar';
157
+ name: string;
158
+ onTarget: ASTNode | null;
159
+ }
160
+
161
+ /**
162
+ * `live ... end` — reactive block. Body re-runs whenever any dependency read
163
+ * during its execution changes.
164
+ *
165
+ * Upstream syntax:
166
+ * live [commandList] end
167
+ *
168
+ * Each command in the body is re-executed as a single effect. The entire
169
+ * body shares one effect instance; its dependency set is the union of every
170
+ * read performed during body execution.
171
+ */
172
+
173
+ interface LiveFeatureNode extends ASTNode {
174
+ type: 'liveFeature';
175
+ body: ASTNode[];
176
+ }
177
+
178
+ /**
179
+ * `when <expr> [or <expr>]* changes [commandList] end` — observer feature.
180
+ *
181
+ * Runs the body when any watched expression's value changes (Object.is
182
+ * semantics). One effect is created per watched expression so writes to a
183
+ * given dep only re-run that watcher.
184
+ */
185
+
186
+ interface WhenFeatureNode extends ASTNode {
187
+ type: 'whenFeature';
188
+ watched: ASTNode[];
189
+ body: ASTNode[];
190
+ }
191
+
192
+ /**
193
+ * `bind X [to|and|with] Y` — two-way binding.
194
+ *
195
+ * Creates two effects (registration order is load-bearing — both initialize
196
+ * via `queueMicrotask` and run in registration order):
197
+ * 1. DOM → var: read DOM, write var. DOM "wins" on init.
198
+ * 2. var → DOM: read var, write DOM. Fires on programmatic var writes
199
+ * after init. On its own initial run, var === DOM (Effect 1 just synced
200
+ * them), so the write is a no-op.
201
+ *
202
+ * Two binding forms:
203
+ *
204
+ * 1. Auto-detected (DOM side is a bare element expression):
205
+ *
206
+ * bind $name to #input -- detects `value`
207
+ * bind $checked to me -- detects `checked` on a checkbox
208
+ *
209
+ * Auto-detected property by element type:
210
+ * - INPUT[type=checkbox|radio] → `checked`
211
+ * - INPUT[type=number|range] → `valueAsNumber`
212
+ * - INPUT|TEXTAREA|SELECT → `value`
213
+ * - contenteditable="true" → `textContent`
214
+ * - Custom elements with own `value` → `value`
215
+ *
216
+ * 2. Explicit property (DOM side is a member or possessive expression):
217
+ *
218
+ * bind $color to #picker's value -- possessive (preferred — reads in any language)
219
+ * bind $color to #picker.value -- dot (JS-style alternative)
220
+ * bind $text to #div's textContent -- non-form properties: var→DOM only
221
+ *
222
+ * For form-like elements, both directions work. For non-form elements
223
+ * (e.g., binding a div's `textContent`), only var→DOM fires — there are
224
+ * no input/change events to drive DOM→var, so user mutations of the
225
+ * property via devtools won't propagate back.
226
+ */
227
+
228
+ interface BindFeatureNode extends ASTNode {
229
+ type: 'bindFeature';
230
+ left: ASTNode;
231
+ right: ASTNode;
232
+ }
233
+
234
+ /**
235
+ * @hyperfixi/reactivity — signal-based reactive features for hyperfixi.
236
+ *
237
+ * Adds four constructs from upstream _hyperscript 0.9.90:
238
+ *
239
+ * live [commandList] end reactive block — body re-runs
240
+ * when tracked reads change.
241
+ *
242
+ * when <expr> [or <expr>]* changes observer — body runs when any
243
+ * [commandList] end watched expression changes.
244
+ *
245
+ * bind <var> to <element> two-way DOM ⇄ var binding.
246
+ *
247
+ * ^name [on <target>] DOM-scoped inherited variable
248
+ * (read + write with `^`).
249
+ *
250
+ * Install:
251
+ *
252
+ * ```ts
253
+ * import { createRuntime, installPlugin } from '@hyperfixi/core';
254
+ * import { reactivityPlugin } from '@hyperfixi/reactivity';
255
+ *
256
+ * const runtime = createRuntime();
257
+ * installPlugin(runtime, reactivityPlugin);
258
+ * ```
259
+ */
260
+
261
+ /**
262
+ * The plugin object. Install once at app startup; re-installing is idempotent
263
+ * (guarded via a `parserExtensions.hasFeature('live')` check).
264
+ *
265
+ * Registers:
266
+ * - Features: `live`, `when`, `bind` (top-level features with `end` bodies)
267
+ * - Prefix op: `^` (primary expression for DOM-scoped vars)
268
+ * - Node evaluators: `liveFeature`, `whenFeature`, `bindFeature`, `caretVar`
269
+ * - A node writer for `caretVar` so `set ^X to Y` flows through `reactive.writeCaret`
270
+ * - Global read/write hooks so `$name` reads track and writes notify
271
+ *
272
+ * Effect cleanup: each effect-creating evaluator calls
273
+ * `context.registerCleanup(owner, stop, ...)` so the core runtime tears effects
274
+ * down when their owning element is cleaned up. There is no separate plugin-level
275
+ * cleanup hook; `reactive.stopElementEffects(el)` is exposed for explicit teardown
276
+ * by tests and consumers that manage element lifecycle outside the runtime.
277
+ */
278
+ declare const reactivityPlugin: HyperfixiPlugin & {
279
+ version: string;
280
+ };
281
+
282
+ export { type BindFeatureNode, type CaretVarNode, type LiveFeatureNode, type WhenFeatureNode, reactivityPlugin as default, reactive, reactivityPlugin };