@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/src/signals.ts
ADDED
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signal/effect reactive core.
|
|
3
|
+
*
|
|
4
|
+
* Models three dependency kinds for v1:
|
|
5
|
+
* - `global` — `$name` variables in `context.globals`
|
|
6
|
+
* - `element` — `^name` DOM-scoped variables (per-element storage)
|
|
7
|
+
* - `dom` — DOM properties read via `input`/`change` listeners
|
|
8
|
+
*
|
|
9
|
+
* Inspired by upstream _hyperscript 0.9.91's `src/core/runtime/reactivity.js`
|
|
10
|
+
* but reimplemented in TypeScript against hyperfixi's types. Batched via
|
|
11
|
+
* `queueMicrotask` so synchronous writes coalesce into a single flush. Cycle
|
|
12
|
+
* detection halts at >100 consecutive triggers (matching upstream).
|
|
13
|
+
*
|
|
14
|
+
* External packages never see `Effect` or `Reactive` directly — they hold
|
|
15
|
+
* the disposer returned by `createEffect()` and call it when the owning
|
|
16
|
+
* element is cleaned up.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export type DepKind = 'global' | 'element' | 'dom';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Whether reactive debug logging is enabled. Mirrors the core convention:
|
|
23
|
+
* `localStorage.setItem('hyperfixi:debug', '*')` enables, anything else (or no
|
|
24
|
+
* `localStorage`) disables. Cheap call — no caching, since users toggle this
|
|
25
|
+
* interactively in the console.
|
|
26
|
+
*/
|
|
27
|
+
function debugEnabled(): boolean {
|
|
28
|
+
try {
|
|
29
|
+
return typeof localStorage !== 'undefined' && localStorage.getItem('hyperfixi:debug') !== null;
|
|
30
|
+
} catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function debugWarn(message: string, err: unknown): void {
|
|
36
|
+
if (!debugEnabled()) return;
|
|
37
|
+
if (typeof console === 'undefined') return;
|
|
38
|
+
console.warn(`[@hyperfixi/reactivity] ${message}`, err);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface Dep {
|
|
42
|
+
readonly key: string;
|
|
43
|
+
readonly kind: DepKind;
|
|
44
|
+
readonly name: string;
|
|
45
|
+
readonly element: Element | null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const globalDepKey = (name: string): string => `global:${name}`;
|
|
49
|
+
const elementDepKey = (el: Element, name: string): string => `element:${reactiveIdFor(el)}:${name}`;
|
|
50
|
+
const domDepKey = (el: Element, prop: string): string => `dom:${reactiveIdFor(el)}:${prop}`;
|
|
51
|
+
|
|
52
|
+
// WeakMap-based id assignment so we don't monkey-patch DOM nodes. Ids are
|
|
53
|
+
// per-process and only used to construct stable string keys for an effect's
|
|
54
|
+
// per-effect dependency dedup map.
|
|
55
|
+
const _reactiveIds = new WeakMap<Element, number>();
|
|
56
|
+
let _idCounter = 0;
|
|
57
|
+
function reactiveIdFor(el: Element): number {
|
|
58
|
+
const existing = _reactiveIds.get(el);
|
|
59
|
+
if (existing !== undefined) return existing;
|
|
60
|
+
const id = ++_idCounter;
|
|
61
|
+
_reactiveIds.set(el, id);
|
|
62
|
+
return id;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export class Effect {
|
|
66
|
+
readonly dependencies = new Map<string, Dep>();
|
|
67
|
+
private lastValue: unknown = undefined;
|
|
68
|
+
private _stopped = false;
|
|
69
|
+
private _consecutiveTriggers = 0;
|
|
70
|
+
|
|
71
|
+
constructor(
|
|
72
|
+
private readonly r: Reactive,
|
|
73
|
+
readonly expression: () => unknown | Promise<unknown>,
|
|
74
|
+
readonly handler: (value: unknown) => void | Promise<void>,
|
|
75
|
+
readonly element: Element | null
|
|
76
|
+
) {}
|
|
77
|
+
|
|
78
|
+
get stopped(): boolean {
|
|
79
|
+
return this._stopped;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Run the effect for the first time: collect dependencies, subscribe, call
|
|
84
|
+
* handler with the initial value. Errors in expression evaluation are caught
|
|
85
|
+
* and silently swallowed (matches upstream's tolerance).
|
|
86
|
+
*/
|
|
87
|
+
async initialize(): Promise<void> {
|
|
88
|
+
if (this._stopped) return;
|
|
89
|
+
try {
|
|
90
|
+
const value = await this.r._runWithEffect(this, this.expression);
|
|
91
|
+
this.lastValue = value;
|
|
92
|
+
if (value !== undefined && value !== null) {
|
|
93
|
+
await this.handler(value);
|
|
94
|
+
}
|
|
95
|
+
} catch (err) {
|
|
96
|
+
// Swallow — expression evaluation failed during initial read. Surface
|
|
97
|
+
// via debug log so users diagnosing "my effect never fires" can see why.
|
|
98
|
+
debugWarn('effect.initialize failed', err);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Re-run the effect after a notify. Cycle-detects (halts at 101 consecutive
|
|
104
|
+
* triggers). If the new value is Object.is-equal to the last, the handler
|
|
105
|
+
* is skipped. If the owning element has disconnected, the effect stops
|
|
106
|
+
* itself and returns.
|
|
107
|
+
*/
|
|
108
|
+
async run(): Promise<void> {
|
|
109
|
+
if (this._stopped) return;
|
|
110
|
+
if (this.element && !this.element.isConnected) {
|
|
111
|
+
this.stop();
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
this._consecutiveTriggers++;
|
|
115
|
+
if (this._consecutiveTriggers > 100) {
|
|
116
|
+
if (typeof console !== 'undefined') {
|
|
117
|
+
console.error(
|
|
118
|
+
'[@hyperfixi/reactivity] Effect halted: > 100 consecutive triggers (cycle detected).'
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
this.stop();
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
try {
|
|
125
|
+
const value = await this.r._runWithEffect(this, this.expression);
|
|
126
|
+
if (Object.is(value, this.lastValue)) return;
|
|
127
|
+
this.lastValue = value;
|
|
128
|
+
await this.handler(value);
|
|
129
|
+
} catch (err) {
|
|
130
|
+
// Swallow — expression/handler errors don't break the microtask flush.
|
|
131
|
+
debugWarn('effect.run failed', err);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
resetTriggerCount(): void {
|
|
136
|
+
this._consecutiveTriggers = 0;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Stop the effect and remove it from all dependency subscriptions. Safe to
|
|
141
|
+
* call multiple times.
|
|
142
|
+
*/
|
|
143
|
+
stop(): void {
|
|
144
|
+
if (this._stopped) return;
|
|
145
|
+
this._stopped = true;
|
|
146
|
+
this.r._unsubscribeEffect(this);
|
|
147
|
+
this.dependencies.clear();
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
interface ElementState {
|
|
152
|
+
symbolSubs: Map<string, Set<Effect>>;
|
|
153
|
+
caretVars: Map<string, unknown>;
|
|
154
|
+
domHandlers: Map<string, DomHandler>;
|
|
155
|
+
effects: Set<Effect>;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
interface DomHandler {
|
|
159
|
+
subs: Set<Effect>;
|
|
160
|
+
detach: () => void;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export class Reactive {
|
|
164
|
+
private currentEffect: Effect | null = null;
|
|
165
|
+
private pending = new Set<Effect>();
|
|
166
|
+
private scheduled = false;
|
|
167
|
+
|
|
168
|
+
private globalSubs = new Map<string, Set<Effect>>();
|
|
169
|
+
private elementState = new WeakMap<Element, ElementState>();
|
|
170
|
+
|
|
171
|
+
private getElementState(el: Element): ElementState {
|
|
172
|
+
let s = this.elementState.get(el);
|
|
173
|
+
if (!s) {
|
|
174
|
+
s = {
|
|
175
|
+
symbolSubs: new Map(),
|
|
176
|
+
caretVars: new Map(),
|
|
177
|
+
domHandlers: new Map(),
|
|
178
|
+
effects: new Set(),
|
|
179
|
+
};
|
|
180
|
+
this.elementState.set(el, s);
|
|
181
|
+
}
|
|
182
|
+
return s;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Internal helper invoked by Effect — installs `this` as the current effect,
|
|
187
|
+
* runs the expression, then restores the previous current-effect pointer.
|
|
188
|
+
* Track* methods (called from read-paths) consult `currentEffect` to know
|
|
189
|
+
* which effect to subscribe.
|
|
190
|
+
*/
|
|
191
|
+
async _runWithEffect(e: Effect, fn: () => unknown | Promise<unknown>): Promise<unknown> {
|
|
192
|
+
const prev = this.currentEffect;
|
|
193
|
+
this.currentEffect = e;
|
|
194
|
+
// Unsubscribe from previous deps before re-running. The expression will
|
|
195
|
+
// re-record whatever it actually reads this time, avoiding stale subs.
|
|
196
|
+
this._unsubscribeEffect(e);
|
|
197
|
+
e.dependencies.clear();
|
|
198
|
+
try {
|
|
199
|
+
return await fn();
|
|
200
|
+
} finally {
|
|
201
|
+
this.currentEffect = prev;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
// Track (read-path) — invoked from interceptors / evaluators.
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
trackGlobal(name: string): void {
|
|
210
|
+
const e = this.currentEffect;
|
|
211
|
+
if (!e) return;
|
|
212
|
+
const key = globalDepKey(name);
|
|
213
|
+
if (e.dependencies.has(key)) return;
|
|
214
|
+
e.dependencies.set(key, { key, kind: 'global', name, element: null });
|
|
215
|
+
let subs = this.globalSubs.get(name);
|
|
216
|
+
if (!subs) {
|
|
217
|
+
subs = new Set();
|
|
218
|
+
this.globalSubs.set(name, subs);
|
|
219
|
+
}
|
|
220
|
+
subs.add(e);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
trackElement(el: Element, name: string): void {
|
|
224
|
+
const e = this.currentEffect;
|
|
225
|
+
if (!e) return;
|
|
226
|
+
const key = elementDepKey(el, name);
|
|
227
|
+
if (e.dependencies.has(key)) return;
|
|
228
|
+
e.dependencies.set(key, { key, kind: 'element', name, element: el });
|
|
229
|
+
const state = this.getElementState(el);
|
|
230
|
+
let subs = state.symbolSubs.get(name);
|
|
231
|
+
if (!subs) {
|
|
232
|
+
subs = new Set();
|
|
233
|
+
state.symbolSubs.set(name, subs);
|
|
234
|
+
}
|
|
235
|
+
subs.add(e);
|
|
236
|
+
state.effects.add(e);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
trackDomProperty(el: Element, prop: string): void {
|
|
240
|
+
const e = this.currentEffect;
|
|
241
|
+
if (!e) return;
|
|
242
|
+
const key = domDepKey(el, prop);
|
|
243
|
+
if (e.dependencies.has(key)) return;
|
|
244
|
+
e.dependencies.set(key, { key, kind: 'dom', name: prop, element: el });
|
|
245
|
+
const state = this.getElementState(el);
|
|
246
|
+
let handler = state.domHandlers.get(prop);
|
|
247
|
+
if (!handler) {
|
|
248
|
+
const subs = new Set<Effect>();
|
|
249
|
+
const listener = (): void => {
|
|
250
|
+
for (const effect of subs) this.schedule(effect);
|
|
251
|
+
};
|
|
252
|
+
// Use both input and change to cover radios/selects which don't fire input.
|
|
253
|
+
el.addEventListener('input', listener);
|
|
254
|
+
el.addEventListener('change', listener);
|
|
255
|
+
handler = {
|
|
256
|
+
subs,
|
|
257
|
+
detach: () => {
|
|
258
|
+
el.removeEventListener('input', listener);
|
|
259
|
+
el.removeEventListener('change', listener);
|
|
260
|
+
},
|
|
261
|
+
};
|
|
262
|
+
state.domHandlers.set(prop, handler);
|
|
263
|
+
}
|
|
264
|
+
handler.subs.add(e);
|
|
265
|
+
state.effects.add(e);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ---------------------------------------------------------------------------
|
|
269
|
+
// Notify (write-path) — schedules dependent effects for re-run.
|
|
270
|
+
// ---------------------------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
notifyGlobal(name: string): void {
|
|
273
|
+
const subs = this.globalSubs.get(name);
|
|
274
|
+
if (!subs) return;
|
|
275
|
+
for (const e of subs) this.schedule(e);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
notifyElement(el: Element, name: string): void {
|
|
279
|
+
const state = this.elementState.get(el);
|
|
280
|
+
if (!state) return;
|
|
281
|
+
const subs = state.symbolSubs.get(name);
|
|
282
|
+
if (!subs) return;
|
|
283
|
+
for (const e of subs) this.schedule(e);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ---------------------------------------------------------------------------
|
|
287
|
+
// Effect lifecycle.
|
|
288
|
+
// ---------------------------------------------------------------------------
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Create + initialize an effect. Returns a disposer that stops the effect.
|
|
292
|
+
* Callers are expected to register the disposer with the core runtime's
|
|
293
|
+
* cleanup registry so it fires on element removal.
|
|
294
|
+
*/
|
|
295
|
+
createEffect(
|
|
296
|
+
expression: () => unknown | Promise<unknown>,
|
|
297
|
+
handler: (value: unknown) => void | Promise<void>,
|
|
298
|
+
owner: Element | null
|
|
299
|
+
): () => void {
|
|
300
|
+
const e = new Effect(this, expression, handler, owner);
|
|
301
|
+
if (owner) this.getElementState(owner).effects.add(e);
|
|
302
|
+
// Fire initialize asynchronously so the caller can set up cleanup registration
|
|
303
|
+
// before the first run touches the DOM.
|
|
304
|
+
queueMicrotask(() => {
|
|
305
|
+
void e.initialize();
|
|
306
|
+
});
|
|
307
|
+
return () => e.stop();
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Stop all effects owned by an element. Called by the reactivity plugin's
|
|
312
|
+
* cleanup hook registered via `runtime.getCleanupRegistry()`.
|
|
313
|
+
*/
|
|
314
|
+
stopElementEffects(el: Element): void {
|
|
315
|
+
const state = this.elementState.get(el);
|
|
316
|
+
if (!state) return;
|
|
317
|
+
for (const e of Array.from(state.effects)) e.stop();
|
|
318
|
+
state.effects.clear();
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Internal: remove an effect's entries from all subscriber sets. Called by
|
|
323
|
+
* `Effect.stop()`.
|
|
324
|
+
*/
|
|
325
|
+
_unsubscribeEffect(e: Effect): void {
|
|
326
|
+
for (const dep of e.dependencies.values()) {
|
|
327
|
+
if (dep.kind === 'global') {
|
|
328
|
+
const subs = this.globalSubs.get(dep.name);
|
|
329
|
+
subs?.delete(e);
|
|
330
|
+
} else if (dep.kind === 'element' && dep.element) {
|
|
331
|
+
const state = this.elementState.get(dep.element);
|
|
332
|
+
const subs = state?.symbolSubs.get(dep.name);
|
|
333
|
+
subs?.delete(e);
|
|
334
|
+
state?.effects.delete(e);
|
|
335
|
+
} else if (dep.kind === 'dom' && dep.element) {
|
|
336
|
+
const state = this.elementState.get(dep.element);
|
|
337
|
+
const handler = state?.domHandlers.get(dep.name);
|
|
338
|
+
handler?.subs.delete(e);
|
|
339
|
+
if (handler && handler.subs.size === 0) {
|
|
340
|
+
handler.detach();
|
|
341
|
+
state?.domHandlers.delete(dep.name);
|
|
342
|
+
}
|
|
343
|
+
state?.effects.delete(e);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// ---------------------------------------------------------------------------
|
|
349
|
+
// Caret-variable storage — `^name` reads/writes.
|
|
350
|
+
// ---------------------------------------------------------------------------
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Whether `el` is a `dom-scope="isolated"` boundary. Walks of `^var`
|
|
354
|
+
* lookups stop at boundary elements that don't define the var, so nested
|
|
355
|
+
* components don't accidentally read or write each other's state.
|
|
356
|
+
*/
|
|
357
|
+
private isIsolationBoundary(el: Element): boolean {
|
|
358
|
+
return typeof el.getAttribute === 'function' && el.getAttribute('dom-scope') === 'isolated';
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Walk up the DOM tree from `lookupRoot`, returning the first element whose
|
|
363
|
+
* state has `name` defined. Stops at any `dom-scope="isolated"` boundary
|
|
364
|
+
* that doesn't itself define the var. Returns `null` if no owner is found.
|
|
365
|
+
*/
|
|
366
|
+
private findCaretOwner(lookupRoot: Element, name: string): Element | null {
|
|
367
|
+
let el: Element | null = lookupRoot;
|
|
368
|
+
while (el) {
|
|
369
|
+
const state = this.elementState.get(el);
|
|
370
|
+
if (state && state.caretVars.has(name)) return el;
|
|
371
|
+
if (this.isIsolationBoundary(el)) return null;
|
|
372
|
+
el = el.parentElement;
|
|
373
|
+
}
|
|
374
|
+
return null;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Read a DOM-scoped variable. Walks up from `lookupRoot`, tracking each
|
|
379
|
+
* element visited as an `element` dep (so writes at any ancestor notify
|
|
380
|
+
* dependent effects). Stops at any `dom-scope="isolated"` boundary that
|
|
381
|
+
* doesn't itself define the var.
|
|
382
|
+
*/
|
|
383
|
+
readCaret(lookupRoot: Element, name: string): unknown {
|
|
384
|
+
let el: Element | null = lookupRoot;
|
|
385
|
+
while (el) {
|
|
386
|
+
this.trackElement(el, name);
|
|
387
|
+
const state = this.elementState.get(el);
|
|
388
|
+
if (state && state.caretVars.has(name)) {
|
|
389
|
+
return state.caretVars.get(name);
|
|
390
|
+
}
|
|
391
|
+
if (this.isIsolationBoundary(el)) return undefined;
|
|
392
|
+
el = el.parentElement;
|
|
393
|
+
}
|
|
394
|
+
return undefined;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Write a DOM-scoped variable. If `target` is provided, writes there
|
|
399
|
+
* directly; otherwise walks up from `lookupRoot` to find the existing owner,
|
|
400
|
+
* falling back to `lookupRoot` itself if no owner exists.
|
|
401
|
+
*/
|
|
402
|
+
writeCaret(lookupRoot: Element, name: string, value: unknown, target?: Element): void {
|
|
403
|
+
const owner = target ?? this.findCaretOwner(lookupRoot, name) ?? lookupRoot;
|
|
404
|
+
const state = this.getElementState(owner);
|
|
405
|
+
state.caretVars.set(name, value);
|
|
406
|
+
this.notifyElement(owner, name);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// ---------------------------------------------------------------------------
|
|
410
|
+
// Scheduler — microtask-batched flush.
|
|
411
|
+
// ---------------------------------------------------------------------------
|
|
412
|
+
|
|
413
|
+
private schedule(e: Effect): void {
|
|
414
|
+
if (e.stopped) return;
|
|
415
|
+
this.pending.add(e);
|
|
416
|
+
if (this.scheduled) return;
|
|
417
|
+
this.scheduled = true;
|
|
418
|
+
queueMicrotask(() => void this.flush());
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
private async flush(): Promise<void> {
|
|
422
|
+
try {
|
|
423
|
+
while (this.pending.size > 0) {
|
|
424
|
+
const batch = Array.from(this.pending);
|
|
425
|
+
this.pending.clear();
|
|
426
|
+
for (const e of batch) {
|
|
427
|
+
if (e.stopped) continue;
|
|
428
|
+
await e.run();
|
|
429
|
+
// If the effect's run did not synchronously re-schedule itself, its
|
|
430
|
+
// trigger count is reset. Self-rescheduling (a real cycle) keeps the
|
|
431
|
+
// counter climbing toward the 100 cap. Long-lived effects that fire
|
|
432
|
+
// sporadically (e.g. user-input bindings) stay alive forever.
|
|
433
|
+
if (!this.pending.has(e)) e.resetTriggerCount();
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
} finally {
|
|
437
|
+
this.scheduled = false;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Module singleton. One reactive graph per process; mirrors upstream's
|
|
443
|
+
// `runtime.reactivity` pattern.
|
|
444
|
+
export const reactive = new Reactive();
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight local type stubs — mirror the speech package pattern of
|
|
3
|
+
* avoiding tight coupling to `@hyperfixi/core` internals. Commands and
|
|
4
|
+
* evaluators consume raw shapes via these structural types.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export interface ASTNode {
|
|
8
|
+
type: string;
|
|
9
|
+
name?: string;
|
|
10
|
+
value?: unknown;
|
|
11
|
+
start?: number;
|
|
12
|
+
end?: number;
|
|
13
|
+
line?: number;
|
|
14
|
+
column?: number;
|
|
15
|
+
[k: string]: unknown;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ExpressionEvaluator {
|
|
19
|
+
evaluate(node: ASTNode, context: unknown): Promise<unknown>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ExecutionContext {
|
|
23
|
+
me?: Element | null;
|
|
24
|
+
result?: unknown;
|
|
25
|
+
it?: unknown;
|
|
26
|
+
globals?: Map<string, unknown>;
|
|
27
|
+
locals?: Map<string, unknown>;
|
|
28
|
+
registerCleanup?: (element: Element, cleanup: () => void, description?: string) => void;
|
|
29
|
+
[k: string]: unknown;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Structural shape of the parser context surfaced to feature parse functions
|
|
34
|
+
* (`registerFeature(...)`). The hyperfixi parser passes its own `ParserContext`
|
|
35
|
+
* — we declare just the methods our feature parsers actually call, so we don't
|
|
36
|
+
* couple to the full core interface.
|
|
37
|
+
*/
|
|
38
|
+
export interface FeatureParserCtx {
|
|
39
|
+
match(expected: string | string[]): boolean;
|
|
40
|
+
check(expected: string | string[]): boolean;
|
|
41
|
+
consume(expected: string, message: string): unknown;
|
|
42
|
+
isAtEnd(): boolean;
|
|
43
|
+
parseExpression(): ASTNode;
|
|
44
|
+
parseCommandListUntilEnd(): ASTNode[];
|
|
45
|
+
getPosition(): { start: number; end: number; line?: number; column?: number };
|
|
46
|
+
}
|
package/src/when.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `when <expr> [or <expr>]* changes [commandList] end` — observer feature.
|
|
3
|
+
*
|
|
4
|
+
* Runs the body when any watched expression's value changes (Object.is
|
|
5
|
+
* semantics). One effect is created per watched expression so writes to a
|
|
6
|
+
* given dep only re-run that watcher.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { ASTNode, ExecutionContext, FeatureParserCtx } from './types';
|
|
10
|
+
import { reactive } from './signals';
|
|
11
|
+
|
|
12
|
+
export interface WhenFeatureNode extends ASTNode {
|
|
13
|
+
type: 'whenFeature';
|
|
14
|
+
watched: ASTNode[];
|
|
15
|
+
body: ASTNode[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function parseWhenFeature(ctx: unknown, token: unknown): ASTNode {
|
|
19
|
+
const pctx = ctx as FeatureParserCtx;
|
|
20
|
+
const watched: ASTNode[] = [pctx.parseExpression()];
|
|
21
|
+
while (pctx.match('or')) {
|
|
22
|
+
watched.push(pctx.parseExpression());
|
|
23
|
+
}
|
|
24
|
+
pctx.consume('changes', "Expected 'changes' after when expression list");
|
|
25
|
+
const body = pctx.parseCommandListUntilEnd();
|
|
26
|
+
if (!pctx.isAtEnd() && pctx.check('end')) pctx.match('end');
|
|
27
|
+
const tok = token as { start?: number; end?: number; line?: number; column?: number };
|
|
28
|
+
return {
|
|
29
|
+
type: 'whenFeature',
|
|
30
|
+
watched,
|
|
31
|
+
body,
|
|
32
|
+
start: tok?.start ?? 0,
|
|
33
|
+
end: pctx.getPosition().end,
|
|
34
|
+
line: tok?.line,
|
|
35
|
+
column: tok?.column,
|
|
36
|
+
} as WhenFeatureNode;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function makeEvaluateWhenFeature(runtime: {
|
|
40
|
+
execute(node: ASTNode, ctx: ExecutionContext): Promise<unknown>;
|
|
41
|
+
evaluateExpressionWithResult?: (
|
|
42
|
+
node: ASTNode,
|
|
43
|
+
ctx: ExecutionContext
|
|
44
|
+
) => Promise<{ value: unknown }>;
|
|
45
|
+
}): (node: ASTNode, ctx: unknown) => unknown | Promise<unknown> {
|
|
46
|
+
return async function evaluateWhenFeature(node, ctx) {
|
|
47
|
+
const context = ctx as ExecutionContext;
|
|
48
|
+
const owner = (context.me as Element) ?? document.body;
|
|
49
|
+
const n = node as WhenFeatureNode;
|
|
50
|
+
|
|
51
|
+
for (const watchedExpr of n.watched) {
|
|
52
|
+
const stop = reactive.createEffect(
|
|
53
|
+
async () => {
|
|
54
|
+
// Evaluate the watched expression as a regular expression through the
|
|
55
|
+
// runtime so that trackGlobal/trackElement fire via the global-write
|
|
56
|
+
// hook and caret-var reads.
|
|
57
|
+
return await runtime.execute(watchedExpr, context);
|
|
58
|
+
},
|
|
59
|
+
async newValue => {
|
|
60
|
+
// Fire the body with `it` / `result` bound to the new value.
|
|
61
|
+
const subCtx: ExecutionContext = { ...context, it: newValue, result: newValue };
|
|
62
|
+
for (const cmd of n.body) {
|
|
63
|
+
await runtime.execute(cmd, subCtx);
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
owner
|
|
67
|
+
);
|
|
68
|
+
context.registerCleanup?.(owner, stop, 'when-effect');
|
|
69
|
+
}
|
|
70
|
+
return undefined;
|
|
71
|
+
};
|
|
72
|
+
}
|