@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.
- package/LICENSE +20 -0
- package/README.md +137 -0
- package/dist/index.cjs +772 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +282 -0
- package/dist/index.d.ts +282 -0
- package/dist/index.js +744 -0
- package/dist/index.js.map +1 -0
- package/package.json +62 -0
- package/src/bind.ts +355 -0
- package/src/caret-var.test.ts +137 -0
- package/src/caret-var.ts +125 -0
- package/src/index.ts +132 -0
- package/src/integration.test.ts +585 -0
- package/src/live.ts +68 -0
- package/src/signals.test.ts +369 -0
- package/src/signals.ts +444 -0
- package/src/types.ts +46 -0
- package/src/when.ts +72 -0
package/dist/index.d.ts
ADDED
|
@@ -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 };
|