@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,137 @@
1
+ /**
2
+ * caret-var unit tests — `^name` read/write and inherited scope walking.
3
+ * The parse layer is exercised via the integration test; here we test the
4
+ * storage semantics in isolation via the Reactive API.
5
+ */
6
+
7
+ import { describe, it, expect, beforeEach } from 'vitest';
8
+ import { Reactive } from './signals';
9
+
10
+ describe('caret-var storage', () => {
11
+ let r: Reactive;
12
+
13
+ beforeEach(() => {
14
+ r = new Reactive();
15
+ });
16
+
17
+ it('read returns undefined for an untouched name', () => {
18
+ const el = document.createElement('div');
19
+ expect(r.readCaret(el, 'any')).toBeUndefined();
20
+ });
21
+
22
+ it('write then read on the same element', () => {
23
+ const el = document.createElement('div');
24
+ r.writeCaret(el, 'counter', 1);
25
+ expect(r.readCaret(el, 'counter')).toBe(1);
26
+ });
27
+
28
+ it('inherited scope: child reads parent var', () => {
29
+ const grand = document.createElement('section');
30
+ const parent = document.createElement('article');
31
+ const child = document.createElement('p');
32
+ grand.appendChild(parent);
33
+ parent.appendChild(child);
34
+ r.writeCaret(grand, 'theme', 'dark');
35
+ expect(r.readCaret(child, 'theme')).toBe('dark');
36
+ expect(r.readCaret(parent, 'theme')).toBe('dark');
37
+ expect(r.readCaret(grand, 'theme')).toBe('dark');
38
+ });
39
+
40
+ it('write without explicit target updates the inherited owner', () => {
41
+ const outer = document.createElement('div');
42
+ const inner = document.createElement('span');
43
+ outer.appendChild(inner);
44
+ r.writeCaret(outer, 'x', 'a');
45
+ // From inner, no explicit target → walks up, finds outer as owner, updates it.
46
+ r.writeCaret(inner, 'x', 'b');
47
+ expect(r.readCaret(outer, 'x')).toBe('b');
48
+ expect(r.readCaret(inner, 'x')).toBe('b'); // inherited
49
+ });
50
+
51
+ it('shadowing requires explicit target to create a local owner', () => {
52
+ const outer = document.createElement('div');
53
+ const inner = document.createElement('span');
54
+ outer.appendChild(inner);
55
+ r.writeCaret(outer, 'x', 'outer-val');
56
+ // Explicit target = inner → creates a new owner on inner, shadowing outer.
57
+ r.writeCaret(inner, 'x', 'inner-val', inner);
58
+ expect(r.readCaret(inner, 'x')).toBe('inner-val');
59
+ expect(r.readCaret(outer, 'x')).toBe('outer-val');
60
+ });
61
+
62
+ it('write with explicit target bypasses inheritance walk', () => {
63
+ const parent = document.createElement('div');
64
+ const child = document.createElement('span');
65
+ parent.appendChild(child);
66
+ // Write from child but target parent explicitly.
67
+ r.writeCaret(child, 'pinned', 'yes', parent);
68
+ expect(r.readCaret(parent, 'pinned')).toBe('yes');
69
+ expect(r.readCaret(child, 'pinned')).toBe('yes'); // inherits
70
+ });
71
+
72
+ describe('dom-scope="isolated" boundary', () => {
73
+ it('read stops at an isolation boundary that does not define the var', () => {
74
+ const outer = document.createElement('div');
75
+ const boundary = document.createElement('article');
76
+ boundary.setAttribute('dom-scope', 'isolated');
77
+ const inner = document.createElement('span');
78
+ outer.appendChild(boundary);
79
+ boundary.appendChild(inner);
80
+
81
+ r.writeCaret(outer, 'theme', 'dark');
82
+ // Inner can't see outer's theme — boundary blocks the walk.
83
+ expect(r.readCaret(inner, 'theme')).toBeUndefined();
84
+ expect(r.readCaret(boundary, 'theme')).toBeUndefined();
85
+ // Outer itself is unaffected.
86
+ expect(r.readCaret(outer, 'theme')).toBe('dark');
87
+ });
88
+
89
+ it('boundary itself can define the var (read returns boundary value)', () => {
90
+ const outer = document.createElement('div');
91
+ const boundary = document.createElement('article');
92
+ boundary.setAttribute('dom-scope', 'isolated');
93
+ const inner = document.createElement('span');
94
+ outer.appendChild(boundary);
95
+ boundary.appendChild(inner);
96
+
97
+ r.writeCaret(outer, 'theme', 'dark');
98
+ r.writeCaret(boundary, 'theme', 'light', boundary);
99
+ // Inner sees boundary's local theme; outer's is shadowed.
100
+ expect(r.readCaret(inner, 'theme')).toBe('light');
101
+ expect(r.readCaret(boundary, 'theme')).toBe('light');
102
+ expect(r.readCaret(outer, 'theme')).toBe('dark');
103
+ });
104
+
105
+ it('write from inside boundary falls back to lookupRoot when no owner is reachable', () => {
106
+ const outer = document.createElement('div');
107
+ const boundary = document.createElement('article');
108
+ boundary.setAttribute('dom-scope', 'isolated');
109
+ const inner = document.createElement('span');
110
+ outer.appendChild(boundary);
111
+ boundary.appendChild(inner);
112
+
113
+ // Outer has count, but inner is inside the boundary — write from inner
114
+ // should NOT reach outer; it should land on inner (lookupRoot fallback).
115
+ r.writeCaret(outer, 'count', 99);
116
+ r.writeCaret(inner, 'count', 1);
117
+ expect(r.readCaret(inner, 'count')).toBe(1);
118
+ // Inner's value is on inner itself; outer's stays.
119
+ expect(r.readCaret(outer, 'count')).toBe(99);
120
+ });
121
+
122
+ it('two sibling boundaries hold independent ^var state', () => {
123
+ const root = document.createElement('div');
124
+ const a = document.createElement('article');
125
+ a.setAttribute('dom-scope', 'isolated');
126
+ const b = document.createElement('article');
127
+ b.setAttribute('dom-scope', 'isolated');
128
+ root.appendChild(a);
129
+ root.appendChild(b);
130
+
131
+ r.writeCaret(a, 'count', 1);
132
+ r.writeCaret(b, 'count', 2);
133
+ expect(r.readCaret(a, 'count')).toBe(1);
134
+ expect(r.readCaret(b, 'count')).toBe(2);
135
+ });
136
+ });
137
+ });
@@ -0,0 +1,125 @@
1
+ /**
2
+ * `^name` — DOM-scoped inherited variable.
3
+ *
4
+ * Upstream syntax:
5
+ * ^counter → read from nearest ancestor that has `counter` set
6
+ * ^counter on #target → read from (or near) #target
7
+ * set ^counter to 42 → write to owner (or lookupRoot if not yet defined)
8
+ *
9
+ * Parse side: register `^` as a Pratt prefix operator. The handler consumes
10
+ * the identifier token and an optional `on <target>` clause, emitting a
11
+ * `caretVar` AST node.
12
+ *
13
+ * Eval side: `caretVar` node evaluator calls `reactive.readCaret(anchor,
14
+ * name)` which walks the DOM tree for the owner and records deps as it goes.
15
+ */
16
+
17
+ import type { ASTNode, ExecutionContext } from './types';
18
+ import { reactive } from './signals';
19
+
20
+ export interface CaretVarNode extends ASTNode {
21
+ type: 'caretVar';
22
+ name: string;
23
+ onTarget: ASTNode | null;
24
+ }
25
+
26
+ interface CaretVarRuntime {
27
+ execute(node: ASTNode, ctx: ExecutionContext): Promise<unknown>;
28
+ }
29
+
30
+ /**
31
+ * Coerce a runtime-evaluated `on <target>` result to a single Element.
32
+ * Selector expressions resolve to an array of matched elements; bare
33
+ * `me`/`it`/`you`/identifier references resolve to single Elements. Take the
34
+ * first element of an array, or the value itself if it's already an Element.
35
+ */
36
+ function coerceToElement(value: unknown): Element | null {
37
+ if (value instanceof Element) return value;
38
+ if (Array.isArray(value) && value[0] instanceof Element) return value[0];
39
+ return null;
40
+ }
41
+
42
+ /**
43
+ * Pratt prefix handler for `^`. Consumes the following identifier token and
44
+ * an optional `on <expr>` clause.
45
+ */
46
+ export function parseCaretPrefix(token: unknown, ctx: unknown): ASTNode {
47
+ // `ctx` is hyperfixi's PrattContext: { peek, advance, parseExpr, isStopToken, atEnd }.
48
+ const pctx = ctx as {
49
+ peek(): { value?: string; kind?: string } | undefined;
50
+ advance(): { value: string; kind?: string };
51
+ parseExpr(minBp: number): ASTNode;
52
+ };
53
+ const ident = pctx.advance();
54
+ if (!ident || !ident.value) {
55
+ throw new Error("Expected identifier after '^'");
56
+ }
57
+ let onTarget: ASTNode | null = null;
58
+ const next = pctx.peek();
59
+ if (next && next.value === 'on') {
60
+ pctx.advance(); // consume 'on'
61
+ // Binding power 86 — higher than standard comparisons so `^x on me` doesn't
62
+ // over-consume into surrounding expressions.
63
+ onTarget = pctx.parseExpr(86);
64
+ }
65
+ const startTok = token as { start?: number; end?: number; line?: number; column?: number };
66
+ return {
67
+ type: 'caretVar',
68
+ name: ident.value,
69
+ onTarget,
70
+ start: startTok?.start ?? 0,
71
+ end: startTok?.end ?? 0,
72
+ line: startTok?.line,
73
+ column: startTok?.column,
74
+ } as CaretVarNode;
75
+ }
76
+
77
+ /**
78
+ * Build a node evaluator for `caretVar` bound to a specific runtime. The
79
+ * evaluator walks up from the resolved anchor element, tracking every element
80
+ * visited so writes at any ancestor notify dependent effects.
81
+ *
82
+ * Capturing `runtime` via a factory closure (matching live/when/bind) keeps
83
+ * the evaluator independent of any module-scope state.
84
+ */
85
+ export function makeEvaluateCaretVar(
86
+ runtime: CaretVarRuntime
87
+ ): (node: ASTNode, ctx: unknown) => Promise<unknown> {
88
+ return async function evaluateCaretVar(node, ctx) {
89
+ const n = node as CaretVarNode;
90
+ const context = ctx as ExecutionContext;
91
+ let anchor: Element | null = (context.me as Element | null) ?? null;
92
+
93
+ if (n.onTarget) {
94
+ const resolved = await runtime.execute(n.onTarget, context);
95
+ const el = coerceToElement(resolved);
96
+ if (el) anchor = el;
97
+ }
98
+
99
+ if (!anchor) return undefined;
100
+ return reactive.readCaret(anchor, n.name);
101
+ };
102
+ }
103
+
104
+ /**
105
+ * Build a node writer for `caretVar` bound to a specific runtime. Used by the
106
+ * core `set` command via `parserExtensions.registerNodeWriter`.
107
+ */
108
+ export function makeWriteCaretVar(
109
+ runtime: CaretVarRuntime
110
+ ): (node: ASTNode, value: unknown, ctx: unknown) => Promise<void> {
111
+ return async function writeCaretVar(node, value, ctx) {
112
+ const n = node as CaretVarNode;
113
+ const context = ctx as ExecutionContext;
114
+ const anchor: Element | null = (context.me as Element | null) ?? null;
115
+ if (!anchor) return;
116
+
117
+ let target: Element | undefined;
118
+ if (n.onTarget) {
119
+ const resolved = await runtime.execute(n.onTarget, context);
120
+ const el = coerceToElement(resolved);
121
+ if (el) target = el;
122
+ }
123
+ reactive.writeCaret(anchor, n.name, value, target);
124
+ };
125
+ }
package/src/index.ts ADDED
@@ -0,0 +1,132 @@
1
+ /**
2
+ * @hyperfixi/reactivity — signal-based reactive features for hyperfixi.
3
+ *
4
+ * Adds four constructs from upstream _hyperscript 0.9.90:
5
+ *
6
+ * live [commandList] end reactive block — body re-runs
7
+ * when tracked reads change.
8
+ *
9
+ * when <expr> [or <expr>]* changes observer — body runs when any
10
+ * [commandList] end watched expression changes.
11
+ *
12
+ * bind <var> to <element> two-way DOM ⇄ var binding.
13
+ *
14
+ * ^name [on <target>] DOM-scoped inherited variable
15
+ * (read + write with `^`).
16
+ *
17
+ * Install:
18
+ *
19
+ * ```ts
20
+ * import { createRuntime, installPlugin } from '@hyperfixi/core';
21
+ * import { reactivityPlugin } from '@hyperfixi/reactivity';
22
+ *
23
+ * const runtime = createRuntime();
24
+ * installPlugin(runtime, reactivityPlugin);
25
+ * ```
26
+ */
27
+
28
+ import type { HyperfixiPlugin, HyperfixiPluginContext } from '@hyperfixi/core';
29
+ import { reactive } from './signals';
30
+ import { parseCaretPrefix, makeEvaluateCaretVar, makeWriteCaretVar } from './caret-var';
31
+ import { parseLiveFeature, makeEvaluateLiveFeature } from './live';
32
+ import { parseWhenFeature, makeEvaluateWhenFeature } from './when';
33
+ import { parseBindFeature, makeEvaluateBindFeature } from './bind';
34
+
35
+ export { reactive } from './signals';
36
+ export type { CaretVarNode } from './caret-var';
37
+ export type { LiveFeatureNode } from './live';
38
+ export type { WhenFeatureNode } from './when';
39
+ export type { BindFeatureNode } from './bind';
40
+
41
+ /**
42
+ * The plugin object. Install once at app startup; re-installing is idempotent
43
+ * (guarded via a `parserExtensions.hasFeature('live')` check).
44
+ *
45
+ * Registers:
46
+ * - Features: `live`, `when`, `bind` (top-level features with `end` bodies)
47
+ * - Prefix op: `^` (primary expression for DOM-scoped vars)
48
+ * - Node evaluators: `liveFeature`, `whenFeature`, `bindFeature`, `caretVar`
49
+ * - A node writer for `caretVar` so `set ^X to Y` flows through `reactive.writeCaret`
50
+ * - Global read/write hooks so `$name` reads track and writes notify
51
+ *
52
+ * Effect cleanup: each effect-creating evaluator calls
53
+ * `context.registerCleanup(owner, stop, ...)` so the core runtime tears effects
54
+ * down when their owning element is cleaned up. There is no separate plugin-level
55
+ * cleanup hook; `reactive.stopElementEffects(el)` is exposed for explicit teardown
56
+ * by tests and consumers that manage element lifecycle outside the runtime.
57
+ */
58
+ export const reactivityPlugin: HyperfixiPlugin & { version: string } = {
59
+ name: '@hyperfixi/reactivity',
60
+ version: '2.3.1',
61
+ install(ctx: HyperfixiPluginContext) {
62
+ const { parserExtensions, runtime } = ctx;
63
+
64
+ // Idempotency: the parser-extension registry is process-singleton, so
65
+ // re-installing into a fresh runtime would otherwise stack additional
66
+ // global read/write hooks on every call. `snapshot()`/`restore()` clears
67
+ // the feature registry — when tests roll back the registry, this guard
68
+ // re-enables a fresh install.
69
+ if (parserExtensions.hasFeature('live')) return;
70
+
71
+ // Parser hooks — three block features plus a primary-expression caret.
72
+ parserExtensions.registerFeature('live', parseLiveFeature as never);
73
+ parserExtensions.registerFeature('when', parseWhenFeature as never);
74
+ parserExtensions.registerFeature('bind', parseBindFeature as never);
75
+ parserExtensions.registerPrefixOperator('^', 85, parseCaretPrefix as never);
76
+
77
+ // Runtime evaluators. Features capture `runtime` so effect re-runs can
78
+ // dispatch body commands without going through module-scope state.
79
+ parserExtensions.registerNodeEvaluator(
80
+ 'liveFeature',
81
+ makeEvaluateLiveFeature(runtime as never) as never
82
+ );
83
+ parserExtensions.registerNodeEvaluator(
84
+ 'whenFeature',
85
+ makeEvaluateWhenFeature(runtime as never) as never
86
+ );
87
+ parserExtensions.registerNodeEvaluator(
88
+ 'bindFeature',
89
+ makeEvaluateBindFeature(runtime as never) as never
90
+ );
91
+ parserExtensions.registerNodeEvaluator(
92
+ 'caretVar',
93
+ makeEvaluateCaretVar(runtime as never) as never
94
+ );
95
+
96
+ // Caret-var write: lets the core `set` command dispatch `set ^X to Y`
97
+ // through `reactive.writeCaret`. Resolves `on <target>` via the captured
98
+ // runtime — no global-scope indirection.
99
+ parserExtensions.registerNodeWriter('caretVar', makeWriteCaretVar(runtime as never) as never);
100
+
101
+ // Global-write hook: notify the reactive graph whenever `$name` is set.
102
+ // The returned disposer is intentionally discarded — the install-time
103
+ // idempotency guard above is the sole gate against double-registration.
104
+ parserExtensions.registerGlobalWriteHook((name: string, _value: unknown, _context: unknown) => {
105
+ reactive.notifyGlobal(name);
106
+ });
107
+
108
+ // Global-read hook: track the read against the current effect (if any)
109
+ // so effects re-run when the global changes.
110
+ parserExtensions.registerGlobalReadHook((name: string, _context: unknown) => {
111
+ reactive.trackGlobal(name);
112
+ });
113
+
114
+ // Local hooks — analogous to globals, but keyed by `context.me` so each
115
+ // element holds independent state. A `set :foo to ...` running in a
116
+ // handler with `me=button1` notifies effects subscribed to (button1, 'foo');
117
+ // a `set :foo` in a different `me` won't reach them. This matches how
118
+ // locals already work — they're never cross-context — and lets two
119
+ // components hold independent `:foo` state without interference.
120
+ parserExtensions.registerLocalWriteHook((name: string, _value: unknown, context) => {
121
+ const owner = (context as { me?: Element | null }).me ?? null;
122
+ if (owner) reactive.notifyElement(owner, name);
123
+ });
124
+
125
+ parserExtensions.registerLocalReadHook((name: string, context) => {
126
+ const owner = (context as { me?: Element | null }).me ?? null;
127
+ if (owner) reactive.trackElement(owner, name);
128
+ });
129
+ },
130
+ };
131
+
132
+ export default reactivityPlugin;