@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.js
ADDED
|
@@ -0,0 +1,744 @@
|
|
|
1
|
+
// src/signals.ts
|
|
2
|
+
function debugEnabled() {
|
|
3
|
+
try {
|
|
4
|
+
return typeof localStorage !== "undefined" && localStorage.getItem("hyperfixi:debug") !== null;
|
|
5
|
+
} catch {
|
|
6
|
+
return false;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
function debugWarn(message, err) {
|
|
10
|
+
if (!debugEnabled()) return;
|
|
11
|
+
if (typeof console === "undefined") return;
|
|
12
|
+
console.warn(`[@hyperfixi/reactivity] ${message}`, err);
|
|
13
|
+
}
|
|
14
|
+
var globalDepKey = (name) => `global:${name}`;
|
|
15
|
+
var elementDepKey = (el, name) => `element:${reactiveIdFor(el)}:${name}`;
|
|
16
|
+
var domDepKey = (el, prop) => `dom:${reactiveIdFor(el)}:${prop}`;
|
|
17
|
+
var _reactiveIds = /* @__PURE__ */ new WeakMap();
|
|
18
|
+
var _idCounter = 0;
|
|
19
|
+
function reactiveIdFor(el) {
|
|
20
|
+
const existing = _reactiveIds.get(el);
|
|
21
|
+
if (existing !== void 0) return existing;
|
|
22
|
+
const id = ++_idCounter;
|
|
23
|
+
_reactiveIds.set(el, id);
|
|
24
|
+
return id;
|
|
25
|
+
}
|
|
26
|
+
var Effect = class {
|
|
27
|
+
constructor(r, expression, handler, element) {
|
|
28
|
+
this.r = r;
|
|
29
|
+
this.expression = expression;
|
|
30
|
+
this.handler = handler;
|
|
31
|
+
this.element = element;
|
|
32
|
+
}
|
|
33
|
+
dependencies = /* @__PURE__ */ new Map();
|
|
34
|
+
lastValue = void 0;
|
|
35
|
+
_stopped = false;
|
|
36
|
+
_consecutiveTriggers = 0;
|
|
37
|
+
get stopped() {
|
|
38
|
+
return this._stopped;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Run the effect for the first time: collect dependencies, subscribe, call
|
|
42
|
+
* handler with the initial value. Errors in expression evaluation are caught
|
|
43
|
+
* and silently swallowed (matches upstream's tolerance).
|
|
44
|
+
*/
|
|
45
|
+
async initialize() {
|
|
46
|
+
if (this._stopped) return;
|
|
47
|
+
try {
|
|
48
|
+
const value = await this.r._runWithEffect(this, this.expression);
|
|
49
|
+
this.lastValue = value;
|
|
50
|
+
if (value !== void 0 && value !== null) {
|
|
51
|
+
await this.handler(value);
|
|
52
|
+
}
|
|
53
|
+
} catch (err) {
|
|
54
|
+
debugWarn("effect.initialize failed", err);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Re-run the effect after a notify. Cycle-detects (halts at 101 consecutive
|
|
59
|
+
* triggers). If the new value is Object.is-equal to the last, the handler
|
|
60
|
+
* is skipped. If the owning element has disconnected, the effect stops
|
|
61
|
+
* itself and returns.
|
|
62
|
+
*/
|
|
63
|
+
async run() {
|
|
64
|
+
if (this._stopped) return;
|
|
65
|
+
if (this.element && !this.element.isConnected) {
|
|
66
|
+
this.stop();
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
this._consecutiveTriggers++;
|
|
70
|
+
if (this._consecutiveTriggers > 100) {
|
|
71
|
+
if (typeof console !== "undefined") {
|
|
72
|
+
console.error(
|
|
73
|
+
"[@hyperfixi/reactivity] Effect halted: > 100 consecutive triggers (cycle detected)."
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
this.stop();
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
const value = await this.r._runWithEffect(this, this.expression);
|
|
81
|
+
if (Object.is(value, this.lastValue)) return;
|
|
82
|
+
this.lastValue = value;
|
|
83
|
+
await this.handler(value);
|
|
84
|
+
} catch (err) {
|
|
85
|
+
debugWarn("effect.run failed", err);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
resetTriggerCount() {
|
|
89
|
+
this._consecutiveTriggers = 0;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Stop the effect and remove it from all dependency subscriptions. Safe to
|
|
93
|
+
* call multiple times.
|
|
94
|
+
*/
|
|
95
|
+
stop() {
|
|
96
|
+
if (this._stopped) return;
|
|
97
|
+
this._stopped = true;
|
|
98
|
+
this.r._unsubscribeEffect(this);
|
|
99
|
+
this.dependencies.clear();
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
var Reactive = class {
|
|
103
|
+
currentEffect = null;
|
|
104
|
+
pending = /* @__PURE__ */ new Set();
|
|
105
|
+
scheduled = false;
|
|
106
|
+
globalSubs = /* @__PURE__ */ new Map();
|
|
107
|
+
elementState = /* @__PURE__ */ new WeakMap();
|
|
108
|
+
getElementState(el) {
|
|
109
|
+
let s = this.elementState.get(el);
|
|
110
|
+
if (!s) {
|
|
111
|
+
s = {
|
|
112
|
+
symbolSubs: /* @__PURE__ */ new Map(),
|
|
113
|
+
caretVars: /* @__PURE__ */ new Map(),
|
|
114
|
+
domHandlers: /* @__PURE__ */ new Map(),
|
|
115
|
+
effects: /* @__PURE__ */ new Set()
|
|
116
|
+
};
|
|
117
|
+
this.elementState.set(el, s);
|
|
118
|
+
}
|
|
119
|
+
return s;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Internal helper invoked by Effect — installs `this` as the current effect,
|
|
123
|
+
* runs the expression, then restores the previous current-effect pointer.
|
|
124
|
+
* Track* methods (called from read-paths) consult `currentEffect` to know
|
|
125
|
+
* which effect to subscribe.
|
|
126
|
+
*/
|
|
127
|
+
async _runWithEffect(e, fn) {
|
|
128
|
+
const prev = this.currentEffect;
|
|
129
|
+
this.currentEffect = e;
|
|
130
|
+
this._unsubscribeEffect(e);
|
|
131
|
+
e.dependencies.clear();
|
|
132
|
+
try {
|
|
133
|
+
return await fn();
|
|
134
|
+
} finally {
|
|
135
|
+
this.currentEffect = prev;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
// Track (read-path) — invoked from interceptors / evaluators.
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
trackGlobal(name) {
|
|
142
|
+
const e = this.currentEffect;
|
|
143
|
+
if (!e) return;
|
|
144
|
+
const key = globalDepKey(name);
|
|
145
|
+
if (e.dependencies.has(key)) return;
|
|
146
|
+
e.dependencies.set(key, { key, kind: "global", name, element: null });
|
|
147
|
+
let subs = this.globalSubs.get(name);
|
|
148
|
+
if (!subs) {
|
|
149
|
+
subs = /* @__PURE__ */ new Set();
|
|
150
|
+
this.globalSubs.set(name, subs);
|
|
151
|
+
}
|
|
152
|
+
subs.add(e);
|
|
153
|
+
}
|
|
154
|
+
trackElement(el, name) {
|
|
155
|
+
const e = this.currentEffect;
|
|
156
|
+
if (!e) return;
|
|
157
|
+
const key = elementDepKey(el, name);
|
|
158
|
+
if (e.dependencies.has(key)) return;
|
|
159
|
+
e.dependencies.set(key, { key, kind: "element", name, element: el });
|
|
160
|
+
const state = this.getElementState(el);
|
|
161
|
+
let subs = state.symbolSubs.get(name);
|
|
162
|
+
if (!subs) {
|
|
163
|
+
subs = /* @__PURE__ */ new Set();
|
|
164
|
+
state.symbolSubs.set(name, subs);
|
|
165
|
+
}
|
|
166
|
+
subs.add(e);
|
|
167
|
+
state.effects.add(e);
|
|
168
|
+
}
|
|
169
|
+
trackDomProperty(el, prop) {
|
|
170
|
+
const e = this.currentEffect;
|
|
171
|
+
if (!e) return;
|
|
172
|
+
const key = domDepKey(el, prop);
|
|
173
|
+
if (e.dependencies.has(key)) return;
|
|
174
|
+
e.dependencies.set(key, { key, kind: "dom", name: prop, element: el });
|
|
175
|
+
const state = this.getElementState(el);
|
|
176
|
+
let handler = state.domHandlers.get(prop);
|
|
177
|
+
if (!handler) {
|
|
178
|
+
const subs = /* @__PURE__ */ new Set();
|
|
179
|
+
const listener = () => {
|
|
180
|
+
for (const effect of subs) this.schedule(effect);
|
|
181
|
+
};
|
|
182
|
+
el.addEventListener("input", listener);
|
|
183
|
+
el.addEventListener("change", listener);
|
|
184
|
+
handler = {
|
|
185
|
+
subs,
|
|
186
|
+
detach: () => {
|
|
187
|
+
el.removeEventListener("input", listener);
|
|
188
|
+
el.removeEventListener("change", listener);
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
state.domHandlers.set(prop, handler);
|
|
192
|
+
}
|
|
193
|
+
handler.subs.add(e);
|
|
194
|
+
state.effects.add(e);
|
|
195
|
+
}
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
// Notify (write-path) — schedules dependent effects for re-run.
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
notifyGlobal(name) {
|
|
200
|
+
const subs = this.globalSubs.get(name);
|
|
201
|
+
if (!subs) return;
|
|
202
|
+
for (const e of subs) this.schedule(e);
|
|
203
|
+
}
|
|
204
|
+
notifyElement(el, name) {
|
|
205
|
+
const state = this.elementState.get(el);
|
|
206
|
+
if (!state) return;
|
|
207
|
+
const subs = state.symbolSubs.get(name);
|
|
208
|
+
if (!subs) return;
|
|
209
|
+
for (const e of subs) this.schedule(e);
|
|
210
|
+
}
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
// Effect lifecycle.
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
/**
|
|
215
|
+
* Create + initialize an effect. Returns a disposer that stops the effect.
|
|
216
|
+
* Callers are expected to register the disposer with the core runtime's
|
|
217
|
+
* cleanup registry so it fires on element removal.
|
|
218
|
+
*/
|
|
219
|
+
createEffect(expression, handler, owner) {
|
|
220
|
+
const e = new Effect(this, expression, handler, owner);
|
|
221
|
+
if (owner) this.getElementState(owner).effects.add(e);
|
|
222
|
+
queueMicrotask(() => {
|
|
223
|
+
void e.initialize();
|
|
224
|
+
});
|
|
225
|
+
return () => e.stop();
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Stop all effects owned by an element. Called by the reactivity plugin's
|
|
229
|
+
* cleanup hook registered via `runtime.getCleanupRegistry()`.
|
|
230
|
+
*/
|
|
231
|
+
stopElementEffects(el) {
|
|
232
|
+
const state = this.elementState.get(el);
|
|
233
|
+
if (!state) return;
|
|
234
|
+
for (const e of Array.from(state.effects)) e.stop();
|
|
235
|
+
state.effects.clear();
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Internal: remove an effect's entries from all subscriber sets. Called by
|
|
239
|
+
* `Effect.stop()`.
|
|
240
|
+
*/
|
|
241
|
+
_unsubscribeEffect(e) {
|
|
242
|
+
for (const dep of e.dependencies.values()) {
|
|
243
|
+
if (dep.kind === "global") {
|
|
244
|
+
const subs = this.globalSubs.get(dep.name);
|
|
245
|
+
subs?.delete(e);
|
|
246
|
+
} else if (dep.kind === "element" && dep.element) {
|
|
247
|
+
const state = this.elementState.get(dep.element);
|
|
248
|
+
const subs = state?.symbolSubs.get(dep.name);
|
|
249
|
+
subs?.delete(e);
|
|
250
|
+
state?.effects.delete(e);
|
|
251
|
+
} else if (dep.kind === "dom" && dep.element) {
|
|
252
|
+
const state = this.elementState.get(dep.element);
|
|
253
|
+
const handler = state?.domHandlers.get(dep.name);
|
|
254
|
+
handler?.subs.delete(e);
|
|
255
|
+
if (handler && handler.subs.size === 0) {
|
|
256
|
+
handler.detach();
|
|
257
|
+
state?.domHandlers.delete(dep.name);
|
|
258
|
+
}
|
|
259
|
+
state?.effects.delete(e);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
// ---------------------------------------------------------------------------
|
|
264
|
+
// Caret-variable storage — `^name` reads/writes.
|
|
265
|
+
// ---------------------------------------------------------------------------
|
|
266
|
+
/**
|
|
267
|
+
* Whether `el` is a `dom-scope="isolated"` boundary. Walks of `^var`
|
|
268
|
+
* lookups stop at boundary elements that don't define the var, so nested
|
|
269
|
+
* components don't accidentally read or write each other's state.
|
|
270
|
+
*/
|
|
271
|
+
isIsolationBoundary(el) {
|
|
272
|
+
return typeof el.getAttribute === "function" && el.getAttribute("dom-scope") === "isolated";
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Walk up the DOM tree from `lookupRoot`, returning the first element whose
|
|
276
|
+
* state has `name` defined. Stops at any `dom-scope="isolated"` boundary
|
|
277
|
+
* that doesn't itself define the var. Returns `null` if no owner is found.
|
|
278
|
+
*/
|
|
279
|
+
findCaretOwner(lookupRoot, name) {
|
|
280
|
+
let el = lookupRoot;
|
|
281
|
+
while (el) {
|
|
282
|
+
const state = this.elementState.get(el);
|
|
283
|
+
if (state && state.caretVars.has(name)) return el;
|
|
284
|
+
if (this.isIsolationBoundary(el)) return null;
|
|
285
|
+
el = el.parentElement;
|
|
286
|
+
}
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Read a DOM-scoped variable. Walks up from `lookupRoot`, tracking each
|
|
291
|
+
* element visited as an `element` dep (so writes at any ancestor notify
|
|
292
|
+
* dependent effects). Stops at any `dom-scope="isolated"` boundary that
|
|
293
|
+
* doesn't itself define the var.
|
|
294
|
+
*/
|
|
295
|
+
readCaret(lookupRoot, name) {
|
|
296
|
+
let el = lookupRoot;
|
|
297
|
+
while (el) {
|
|
298
|
+
this.trackElement(el, name);
|
|
299
|
+
const state = this.elementState.get(el);
|
|
300
|
+
if (state && state.caretVars.has(name)) {
|
|
301
|
+
return state.caretVars.get(name);
|
|
302
|
+
}
|
|
303
|
+
if (this.isIsolationBoundary(el)) return void 0;
|
|
304
|
+
el = el.parentElement;
|
|
305
|
+
}
|
|
306
|
+
return void 0;
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Write a DOM-scoped variable. If `target` is provided, writes there
|
|
310
|
+
* directly; otherwise walks up from `lookupRoot` to find the existing owner,
|
|
311
|
+
* falling back to `lookupRoot` itself if no owner exists.
|
|
312
|
+
*/
|
|
313
|
+
writeCaret(lookupRoot, name, value, target) {
|
|
314
|
+
const owner = target ?? this.findCaretOwner(lookupRoot, name) ?? lookupRoot;
|
|
315
|
+
const state = this.getElementState(owner);
|
|
316
|
+
state.caretVars.set(name, value);
|
|
317
|
+
this.notifyElement(owner, name);
|
|
318
|
+
}
|
|
319
|
+
// ---------------------------------------------------------------------------
|
|
320
|
+
// Scheduler — microtask-batched flush.
|
|
321
|
+
// ---------------------------------------------------------------------------
|
|
322
|
+
schedule(e) {
|
|
323
|
+
if (e.stopped) return;
|
|
324
|
+
this.pending.add(e);
|
|
325
|
+
if (this.scheduled) return;
|
|
326
|
+
this.scheduled = true;
|
|
327
|
+
queueMicrotask(() => void this.flush());
|
|
328
|
+
}
|
|
329
|
+
async flush() {
|
|
330
|
+
try {
|
|
331
|
+
while (this.pending.size > 0) {
|
|
332
|
+
const batch = Array.from(this.pending);
|
|
333
|
+
this.pending.clear();
|
|
334
|
+
for (const e of batch) {
|
|
335
|
+
if (e.stopped) continue;
|
|
336
|
+
await e.run();
|
|
337
|
+
if (!this.pending.has(e)) e.resetTriggerCount();
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
} finally {
|
|
341
|
+
this.scheduled = false;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
};
|
|
345
|
+
var reactive = new Reactive();
|
|
346
|
+
|
|
347
|
+
// src/caret-var.ts
|
|
348
|
+
function coerceToElement(value) {
|
|
349
|
+
if (value instanceof Element) return value;
|
|
350
|
+
if (Array.isArray(value) && value[0] instanceof Element) return value[0];
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
function parseCaretPrefix(token, ctx) {
|
|
354
|
+
const pctx = ctx;
|
|
355
|
+
const ident = pctx.advance();
|
|
356
|
+
if (!ident || !ident.value) {
|
|
357
|
+
throw new Error("Expected identifier after '^'");
|
|
358
|
+
}
|
|
359
|
+
let onTarget = null;
|
|
360
|
+
const next = pctx.peek();
|
|
361
|
+
if (next && next.value === "on") {
|
|
362
|
+
pctx.advance();
|
|
363
|
+
onTarget = pctx.parseExpr(86);
|
|
364
|
+
}
|
|
365
|
+
const startTok = token;
|
|
366
|
+
return {
|
|
367
|
+
type: "caretVar",
|
|
368
|
+
name: ident.value,
|
|
369
|
+
onTarget,
|
|
370
|
+
start: startTok?.start ?? 0,
|
|
371
|
+
end: startTok?.end ?? 0,
|
|
372
|
+
line: startTok?.line,
|
|
373
|
+
column: startTok?.column
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
function makeEvaluateCaretVar(runtime) {
|
|
377
|
+
return async function evaluateCaretVar(node, ctx) {
|
|
378
|
+
const n = node;
|
|
379
|
+
const context = ctx;
|
|
380
|
+
let anchor = context.me ?? null;
|
|
381
|
+
if (n.onTarget) {
|
|
382
|
+
const resolved = await runtime.execute(n.onTarget, context);
|
|
383
|
+
const el = coerceToElement(resolved);
|
|
384
|
+
if (el) anchor = el;
|
|
385
|
+
}
|
|
386
|
+
if (!anchor) return void 0;
|
|
387
|
+
return reactive.readCaret(anchor, n.name);
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
function makeWriteCaretVar(runtime) {
|
|
391
|
+
return async function writeCaretVar(node, value, ctx) {
|
|
392
|
+
const n = node;
|
|
393
|
+
const context = ctx;
|
|
394
|
+
const anchor = context.me ?? null;
|
|
395
|
+
if (!anchor) return;
|
|
396
|
+
let target;
|
|
397
|
+
if (n.onTarget) {
|
|
398
|
+
const resolved = await runtime.execute(n.onTarget, context);
|
|
399
|
+
const el = coerceToElement(resolved);
|
|
400
|
+
if (el) target = el;
|
|
401
|
+
}
|
|
402
|
+
reactive.writeCaret(anchor, n.name, value, target);
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// src/live.ts
|
|
407
|
+
function parseLiveFeature(ctx, token) {
|
|
408
|
+
const pctx = ctx;
|
|
409
|
+
const body = pctx.parseCommandListUntilEnd();
|
|
410
|
+
if (!pctx.isAtEnd() && pctx.check("end")) pctx.match("end");
|
|
411
|
+
const tok = token;
|
|
412
|
+
return {
|
|
413
|
+
type: "liveFeature",
|
|
414
|
+
body,
|
|
415
|
+
start: tok?.start ?? 0,
|
|
416
|
+
end: pctx.getPosition().end,
|
|
417
|
+
line: tok?.line,
|
|
418
|
+
column: tok?.column
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
function makeEvaluateLiveFeature(runtime) {
|
|
422
|
+
return async function evaluateLiveFeature(node, ctx) {
|
|
423
|
+
const context = ctx;
|
|
424
|
+
const owner = context.me ?? document.body;
|
|
425
|
+
const n = node;
|
|
426
|
+
const stop = reactive.createEffect(
|
|
427
|
+
async () => {
|
|
428
|
+
for (const cmd of n.body) {
|
|
429
|
+
await runtime.execute(cmd, context);
|
|
430
|
+
}
|
|
431
|
+
},
|
|
432
|
+
() => {
|
|
433
|
+
},
|
|
434
|
+
owner
|
|
435
|
+
);
|
|
436
|
+
context.registerCleanup?.(owner, stop, "live-effect");
|
|
437
|
+
return void 0;
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// src/when.ts
|
|
442
|
+
function parseWhenFeature(ctx, token) {
|
|
443
|
+
const pctx = ctx;
|
|
444
|
+
const watched = [pctx.parseExpression()];
|
|
445
|
+
while (pctx.match("or")) {
|
|
446
|
+
watched.push(pctx.parseExpression());
|
|
447
|
+
}
|
|
448
|
+
pctx.consume("changes", "Expected 'changes' after when expression list");
|
|
449
|
+
const body = pctx.parseCommandListUntilEnd();
|
|
450
|
+
if (!pctx.isAtEnd() && pctx.check("end")) pctx.match("end");
|
|
451
|
+
const tok = token;
|
|
452
|
+
return {
|
|
453
|
+
type: "whenFeature",
|
|
454
|
+
watched,
|
|
455
|
+
body,
|
|
456
|
+
start: tok?.start ?? 0,
|
|
457
|
+
end: pctx.getPosition().end,
|
|
458
|
+
line: tok?.line,
|
|
459
|
+
column: tok?.column
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
function makeEvaluateWhenFeature(runtime) {
|
|
463
|
+
return async function evaluateWhenFeature(node, ctx) {
|
|
464
|
+
const context = ctx;
|
|
465
|
+
const owner = context.me ?? document.body;
|
|
466
|
+
const n = node;
|
|
467
|
+
for (const watchedExpr of n.watched) {
|
|
468
|
+
const stop = reactive.createEffect(
|
|
469
|
+
async () => {
|
|
470
|
+
return await runtime.execute(watchedExpr, context);
|
|
471
|
+
},
|
|
472
|
+
async (newValue) => {
|
|
473
|
+
const subCtx = { ...context, it: newValue, result: newValue };
|
|
474
|
+
for (const cmd of n.body) {
|
|
475
|
+
await runtime.execute(cmd, subCtx);
|
|
476
|
+
}
|
|
477
|
+
},
|
|
478
|
+
owner
|
|
479
|
+
);
|
|
480
|
+
context.registerCleanup?.(owner, stop, "when-effect");
|
|
481
|
+
}
|
|
482
|
+
return void 0;
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// src/bind.ts
|
|
487
|
+
function parseBindFeature(ctx, token) {
|
|
488
|
+
const pctx = ctx;
|
|
489
|
+
const left = pctx.parseExpression();
|
|
490
|
+
if (!(pctx.match("to") || pctx.match("and") || pctx.match("with"))) {
|
|
491
|
+
throw new Error("bind requires 'to', 'and', or 'with' between the two sides");
|
|
492
|
+
}
|
|
493
|
+
const right = pctx.parseExpression();
|
|
494
|
+
if (pctx.check("end")) pctx.match("end");
|
|
495
|
+
const tok = token;
|
|
496
|
+
return {
|
|
497
|
+
type: "bindFeature",
|
|
498
|
+
left,
|
|
499
|
+
right,
|
|
500
|
+
start: tok?.start ?? 0,
|
|
501
|
+
end: pctx.getPosition().end,
|
|
502
|
+
line: tok?.line,
|
|
503
|
+
column: tok?.column
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
function detectProperty(el) {
|
|
507
|
+
const tag = el.tagName;
|
|
508
|
+
if (tag === "INPUT") {
|
|
509
|
+
const type = el.type;
|
|
510
|
+
if (type === "checkbox" || type === "radio") return "checked";
|
|
511
|
+
if (type === "number" || type === "range") return "valueAsNumber";
|
|
512
|
+
return "value";
|
|
513
|
+
}
|
|
514
|
+
if (tag === "TEXTAREA" || tag === "SELECT") return "value";
|
|
515
|
+
const ce = el.contentEditable;
|
|
516
|
+
if (ce === "true") return "textContent";
|
|
517
|
+
if ("value" in el) return "value";
|
|
518
|
+
return null;
|
|
519
|
+
}
|
|
520
|
+
function unwrapExplicitProperty(node) {
|
|
521
|
+
if (!node || node.type !== "memberExpression" && node.type !== "possessiveExpression") {
|
|
522
|
+
return null;
|
|
523
|
+
}
|
|
524
|
+
if (node.type === "memberExpression" && node.computed === true) return null;
|
|
525
|
+
const property = node.property;
|
|
526
|
+
const object = node.object;
|
|
527
|
+
if (!property || !object || property.type !== "identifier") return null;
|
|
528
|
+
const name = property.name ?? "";
|
|
529
|
+
if (!name) return null;
|
|
530
|
+
return { element: object, propertyName: name };
|
|
531
|
+
}
|
|
532
|
+
function isChainedMember(node) {
|
|
533
|
+
if (!node) return false;
|
|
534
|
+
return node.type === "memberExpression" || node.type === "possessiveExpression";
|
|
535
|
+
}
|
|
536
|
+
function isFormLikeElement(el) {
|
|
537
|
+
const tag = el.tagName;
|
|
538
|
+
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true;
|
|
539
|
+
if (el.contentEditable === "true") return true;
|
|
540
|
+
return false;
|
|
541
|
+
}
|
|
542
|
+
function debugEnabled2() {
|
|
543
|
+
try {
|
|
544
|
+
return typeof localStorage !== "undefined" && localStorage.getItem("hyperfixi:debug") !== null;
|
|
545
|
+
} catch {
|
|
546
|
+
return false;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
function makeEvaluateBindFeature(runtime) {
|
|
550
|
+
return async function evaluateBindFeature(node, ctx) {
|
|
551
|
+
const context = ctx;
|
|
552
|
+
const owner = context.me ?? document.body;
|
|
553
|
+
const n = node;
|
|
554
|
+
const leftIsVar = isVarRef(n.left);
|
|
555
|
+
const rightIsVar = isVarRef(n.right);
|
|
556
|
+
let varSide = null;
|
|
557
|
+
let domSide = null;
|
|
558
|
+
if (leftIsVar && !rightIsVar) {
|
|
559
|
+
varSide = varRefInfo(n.left);
|
|
560
|
+
domSide = { exprNode: n.right };
|
|
561
|
+
} else if (rightIsVar && !leftIsVar) {
|
|
562
|
+
varSide = varRefInfo(n.right);
|
|
563
|
+
domSide = { exprNode: n.left };
|
|
564
|
+
} else if (leftIsVar && rightIsVar) {
|
|
565
|
+
throw new Error("bind: cannot bind two symbols together (need a DOM side)");
|
|
566
|
+
} else {
|
|
567
|
+
throw new Error("bind: could not identify a symbol side");
|
|
568
|
+
}
|
|
569
|
+
const explicit = unwrapExplicitProperty(domSide.exprNode);
|
|
570
|
+
if (explicit && isChainedMember(explicit.element)) {
|
|
571
|
+
throw new Error(
|
|
572
|
+
"bind: multi-level property access (e.g., `#el.a.b`) is not supported in v1 \u2014 restructure to a single property write or pass the element directly for auto-detection (e.g., `bind $x to #el`)."
|
|
573
|
+
);
|
|
574
|
+
}
|
|
575
|
+
const elementExpr = explicit ? explicit.element : domSide.exprNode;
|
|
576
|
+
const domValue = await runtime.execute(elementExpr, context);
|
|
577
|
+
if (!(domValue instanceof Element)) {
|
|
578
|
+
const valueType = domValue === null ? "null" : domValue === void 0 ? "undefined" : typeof domValue;
|
|
579
|
+
const snippet = domValue !== null && domValue !== void 0 ? ` "${String(domValue).slice(0, 40)}"` : "";
|
|
580
|
+
const suggestion = explicit ? "" : " If you meant to write to a property, use the explicit form: `<selector>'s <property>`.";
|
|
581
|
+
throw new Error(
|
|
582
|
+
`bind: right-hand side did not resolve to an element (got ${valueType}${snippet}).${suggestion}`
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
const el = domValue;
|
|
586
|
+
const prop = explicit ? explicit.propertyName : detectProperty(el);
|
|
587
|
+
if (!prop) {
|
|
588
|
+
throw new Error(
|
|
589
|
+
`bind: could not auto-detect property for <${el.tagName.toLowerCase()}> \u2014 use explicit \`<expr>'s <property>\` form`
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
const installDomToVar = !explicit || isFormLikeElement(el);
|
|
593
|
+
if (explicit && !installDomToVar && debugEnabled2() && typeof console !== "undefined") {
|
|
594
|
+
console.warn(
|
|
595
|
+
`[@hyperfixi/reactivity] bind: DOM\u2192var skipped for <${el.tagName.toLowerCase()}>.${prop} \u2014 no input/change event source.`
|
|
596
|
+
);
|
|
597
|
+
}
|
|
598
|
+
const readDom = () => {
|
|
599
|
+
if (prop === "valueAsNumber") return el.valueAsNumber;
|
|
600
|
+
if (prop === "checked") return el.checked;
|
|
601
|
+
if (prop === "textContent") return el.textContent ?? "";
|
|
602
|
+
return el[prop];
|
|
603
|
+
};
|
|
604
|
+
const writeDom = (value) => {
|
|
605
|
+
if (prop === "valueAsNumber") {
|
|
606
|
+
const n2 = Number(value);
|
|
607
|
+
el.valueAsNumber = Number.isNaN(n2) ? null : n2;
|
|
608
|
+
} else if (prop === "checked") {
|
|
609
|
+
el.checked = Boolean(value);
|
|
610
|
+
} else if (prop === "textContent") {
|
|
611
|
+
el.textContent = value == null ? "" : String(value);
|
|
612
|
+
} else {
|
|
613
|
+
el[prop] = value;
|
|
614
|
+
}
|
|
615
|
+
};
|
|
616
|
+
const readVar = () => {
|
|
617
|
+
if (varSide.isGlobal) return context.globals?.get(varSide.name);
|
|
618
|
+
return context.locals?.get(varSide.name);
|
|
619
|
+
};
|
|
620
|
+
const writeVar = (value) => {
|
|
621
|
+
if (varSide.isGlobal) {
|
|
622
|
+
context.globals?.set(varSide.name, value);
|
|
623
|
+
reactive.notifyGlobal(varSide.name);
|
|
624
|
+
} else {
|
|
625
|
+
context.locals?.set(varSide.name, value);
|
|
626
|
+
reactive.notifyElement(owner, varSide.name);
|
|
627
|
+
}
|
|
628
|
+
};
|
|
629
|
+
let stopDomToVar = null;
|
|
630
|
+
if (installDomToVar) {
|
|
631
|
+
stopDomToVar = reactive.createEffect(
|
|
632
|
+
() => {
|
|
633
|
+
reactive.trackDomProperty(el, prop);
|
|
634
|
+
return readDom();
|
|
635
|
+
},
|
|
636
|
+
(newValue) => {
|
|
637
|
+
writeVar(newValue);
|
|
638
|
+
},
|
|
639
|
+
owner
|
|
640
|
+
);
|
|
641
|
+
}
|
|
642
|
+
const stopVarToDom = reactive.createEffect(
|
|
643
|
+
() => {
|
|
644
|
+
if (varSide.isGlobal) reactive.trackGlobal(varSide.name);
|
|
645
|
+
else reactive.trackElement(owner, varSide.name);
|
|
646
|
+
return readVar();
|
|
647
|
+
},
|
|
648
|
+
(newValue) => {
|
|
649
|
+
if (newValue === void 0) return;
|
|
650
|
+
writeDom(newValue);
|
|
651
|
+
},
|
|
652
|
+
owner
|
|
653
|
+
);
|
|
654
|
+
if (stopDomToVar) context.registerCleanup?.(owner, stopDomToVar, "bind-dom-to-var");
|
|
655
|
+
context.registerCleanup?.(owner, stopVarToDom, "bind-var-to-dom");
|
|
656
|
+
return void 0;
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
function isVarRef(node) {
|
|
660
|
+
if (!node) return false;
|
|
661
|
+
if (node.type !== "identifier") return false;
|
|
662
|
+
const scope = node.scope;
|
|
663
|
+
if (scope === "local" || scope === "global") return true;
|
|
664
|
+
const name = node.name ?? "";
|
|
665
|
+
return name.startsWith("$") || name.startsWith(":");
|
|
666
|
+
}
|
|
667
|
+
function varRefInfo(node) {
|
|
668
|
+
const rawName = node.name ?? "";
|
|
669
|
+
const scope = node.scope;
|
|
670
|
+
let isGlobal;
|
|
671
|
+
let name;
|
|
672
|
+
if (scope === "global") {
|
|
673
|
+
isGlobal = true;
|
|
674
|
+
name = rawName;
|
|
675
|
+
} else if (scope === "local") {
|
|
676
|
+
isGlobal = false;
|
|
677
|
+
name = rawName;
|
|
678
|
+
} else if (rawName.startsWith("$")) {
|
|
679
|
+
isGlobal = true;
|
|
680
|
+
name = rawName.slice(1);
|
|
681
|
+
} else if (rawName.startsWith(":")) {
|
|
682
|
+
isGlobal = false;
|
|
683
|
+
name = rawName.slice(1);
|
|
684
|
+
} else {
|
|
685
|
+
isGlobal = false;
|
|
686
|
+
name = rawName;
|
|
687
|
+
}
|
|
688
|
+
if (!name) {
|
|
689
|
+
throw new Error("bind: variable reference has empty name");
|
|
690
|
+
}
|
|
691
|
+
return { name, isGlobal };
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// src/index.ts
|
|
695
|
+
var reactivityPlugin = {
|
|
696
|
+
name: "@hyperfixi/reactivity",
|
|
697
|
+
version: "2.3.1",
|
|
698
|
+
install(ctx) {
|
|
699
|
+
const { parserExtensions, runtime } = ctx;
|
|
700
|
+
if (parserExtensions.hasFeature("live")) return;
|
|
701
|
+
parserExtensions.registerFeature("live", parseLiveFeature);
|
|
702
|
+
parserExtensions.registerFeature("when", parseWhenFeature);
|
|
703
|
+
parserExtensions.registerFeature("bind", parseBindFeature);
|
|
704
|
+
parserExtensions.registerPrefixOperator("^", 85, parseCaretPrefix);
|
|
705
|
+
parserExtensions.registerNodeEvaluator(
|
|
706
|
+
"liveFeature",
|
|
707
|
+
makeEvaluateLiveFeature(runtime)
|
|
708
|
+
);
|
|
709
|
+
parserExtensions.registerNodeEvaluator(
|
|
710
|
+
"whenFeature",
|
|
711
|
+
makeEvaluateWhenFeature(runtime)
|
|
712
|
+
);
|
|
713
|
+
parserExtensions.registerNodeEvaluator(
|
|
714
|
+
"bindFeature",
|
|
715
|
+
makeEvaluateBindFeature(runtime)
|
|
716
|
+
);
|
|
717
|
+
parserExtensions.registerNodeEvaluator(
|
|
718
|
+
"caretVar",
|
|
719
|
+
makeEvaluateCaretVar(runtime)
|
|
720
|
+
);
|
|
721
|
+
parserExtensions.registerNodeWriter("caretVar", makeWriteCaretVar(runtime));
|
|
722
|
+
parserExtensions.registerGlobalWriteHook((name, _value, _context) => {
|
|
723
|
+
reactive.notifyGlobal(name);
|
|
724
|
+
});
|
|
725
|
+
parserExtensions.registerGlobalReadHook((name, _context) => {
|
|
726
|
+
reactive.trackGlobal(name);
|
|
727
|
+
});
|
|
728
|
+
parserExtensions.registerLocalWriteHook((name, _value, context) => {
|
|
729
|
+
const owner = context.me ?? null;
|
|
730
|
+
if (owner) reactive.notifyElement(owner, name);
|
|
731
|
+
});
|
|
732
|
+
parserExtensions.registerLocalReadHook((name, context) => {
|
|
733
|
+
const owner = context.me ?? null;
|
|
734
|
+
if (owner) reactive.trackElement(owner, name);
|
|
735
|
+
});
|
|
736
|
+
}
|
|
737
|
+
};
|
|
738
|
+
var index_default = reactivityPlugin;
|
|
739
|
+
export {
|
|
740
|
+
index_default as default,
|
|
741
|
+
reactive,
|
|
742
|
+
reactivityPlugin
|
|
743
|
+
};
|
|
744
|
+
//# sourceMappingURL=index.js.map
|