Skip to content

Commit b7274fc

Browse files
committed
Add reactive always, when & bind features
1 parent 01fd042 commit b7274fc

File tree

18 files changed

+2834
-18
lines changed

18 files changed

+2834
-18
lines changed

docs/reactivity-design.md

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
# Reactivity Design Notes
2+
3+
Internal reference for the reactive features: `always`, `when`, and `bind`.
4+
5+
## Why this works without explicit signals
6+
7+
SolidJS (and most JS reactive frameworks) use explicit signal primitives:
8+
9+
```js
10+
const [count, setCount] = createSignal(0);
11+
count() // read — registers a subscription
12+
setCount(5) // write — notifies subscribers
13+
```
14+
15+
This is necessary because JavaScript has no way to intercept plain variable
16+
reads and writes. `myVar = 5` is invisible to framework code. So Solid must
17+
wrap values in function calls to run custom logic on access.
18+
19+
_hyperscript doesn't have this constraint. It is its own language with its own
20+
execution engine. Every variable read goes through `resolveSymbol()` in the
21+
runtime, and every write goes through `setSymbol()`. The runtime is already
22+
the middleman for all variable access, so we hook tracking and notification
23+
directly into these existing paths:
24+
25+
- **Read** (`resolveSymbol`): if an effect is currently evaluating, record the
26+
variable as a dependency.
27+
- **Write** (`setSymbol`): notify all effects that depend on this variable.
28+
29+
The user gets reactive behavior without any API ceremony. `$count` is just a
30+
variable. Writing `when $count changes ...` is all it takes to make it watched.
31+
32+
## The JS interop boundary
33+
34+
This approach is airtight for pure _hyperscript code, but leaky at the JS
35+
boundary:
36+
37+
- **Writes from JS** (`window.$count = 99`) bypass `setSymbol`, so no
38+
notification fires.
39+
- **Reads inside `js()` blocks** (`js(x) return window.$price * window.$qty end`)
40+
bypass `resolveSymbol`, so no dependencies are tracked.
41+
- **`Object.assign` in the js feature** writes directly to `globalScope`,
42+
also bypassing `setSymbol`.
43+
44+
This is an accepted trade-off. For typical _hyperscript usage the reactivity
45+
is invisible and correct. Users mixing JS interop with reactive expressions
46+
should be aware that the tracking boundary is the _hyperscript runtime.
47+
48+
## What creates reactivity
49+
50+
Variables are not signals. `set $count to 0` just stores a value on
51+
`globalScope` (i.e. `window`). Nothing reactive happens.
52+
53+
Reactivity is created by `always`, `when`, and `bind`. All three use
54+
`createEffect()` under the hood, which evaluates code with tracking
55+
enabled. During that evaluation, `resolveSymbol` sees that an effect
56+
is active and records dependencies. Future writes via `setSymbol` notify
57+
all subscribed effects.
58+
59+
The variable itself has no idea it's being watched. The reactivity lives
60+
entirely in the effect and the subscription maps.
61+
62+
## The three reactive features
63+
64+
Each serves a distinct purpose:
65+
66+
- **`always`** declares relationships. The block runs as one unit with
67+
automatic dependency tracking and re-runs when any dependency changes.
68+
Used for derived values, DOM updates, and conditional state. For
69+
independent tracking, use separate `always` statements.
70+
71+
- **`when`** reacts to changes. Watches a specific expression and runs a
72+
command body when the value changes. `it` refers to the new value. Used
73+
for side effects, async work, and events.
74+
75+
- **`bind`** syncs two values bidirectionally. Includes same-value dedup to
76+
prevent infinite loops. Used for form inputs and shared state.
77+
78+
`always` runs its entire command block inside the tracking context (the
79+
block IS the effect). `when` separates the tracked expression from the
80+
handler commands. `bind` creates two `when`-style effects pointing at
81+
each other.
82+
83+
## Why styles and computed styles are not tracked
84+
85+
`*opacity`, `*computed-width`, and other style references are not reactive.
86+
There is no efficient DOM API for "notify me when a computed style changes."
87+
`MutationObserver` only catches inline style attribute edits, not changes
88+
from classes, media queries, CSS animations, or the cascade. No reactive
89+
framework (SolidJS, Vue, Svelte) tracks computed styles either.
90+
91+
To react to style-affecting changes, track the cause instead: the variable,
92+
attribute, or class that drives the style.

src/_hyperscript.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {Runtime} from './core/runtime/runtime.js';
99
import {HyperscriptModule} from './core/runtime/collections.js';
1010
import {config} from './core/config.js';
1111
import {conversions} from './core/runtime/conversions.js';
12+
import {reactivity} from './core/runtime/reactivity.js';
1213

1314
// Import parse element modules
1415
import * as Expressions from './parsetree/expressions/expressions.js';
@@ -36,6 +37,9 @@ import * as WorkerFeatureModule from './parsetree/features/worker.js';
3637
import * as BehaviorFeatureModule from './parsetree/features/behavior.js';
3738
import * as InstallFeatureModule from './parsetree/features/install.js';
3839
import * as JsFeatureModule from './parsetree/features/js.js';
40+
import * as WhenFeatureModule from './parsetree/features/when.js';
41+
import * as BindFeatureModule from './parsetree/features/bind.js';
42+
import * as AlwaysFeatureModule from './parsetree/features/always.js';
3943

4044
const globalScope = typeof self !== 'undefined' ? self : (typeof global !== 'undefined' ? global : this);
4145

@@ -76,6 +80,9 @@ kernel.registerModule(WorkerFeatureModule);
7680
kernel.registerModule(BehaviorFeatureModule);
7781
kernel.registerModule(InstallFeatureModule);
7882
kernel.registerModule(JsFeatureModule);
83+
kernel.registerModule(WhenFeatureModule);
84+
kernel.registerModule(BindFeatureModule);
85+
kernel.registerModule(AlwaysFeatureModule);
7986

8087
// ===== Public API =====
8188

@@ -111,7 +118,7 @@ const _hyperscript = Object.assign(
111118
},
112119

113120
internals: {
114-
tokenizer, runtime,
121+
tokenizer, runtime, reactivity,
115122
createParser: (tokens) => new Parser(kernel, tokens),
116123
},
117124

0 commit comments

Comments
 (0)