Skip to content

Commit b00abc6

Browse files
committed
Add always feature, refactor bind, fix reactivity footguns
Reactive features: - Add `always` feature for declarative reactive commands - Refactor `bind` to two-way only (and/with/to), remove one-way - Add bind shorthand: `bind $var` auto-detects property from element type - Add radio button group support in bind shorthand Reactivity fixes: - Remove defineProperty interception (anti-pattern), use direct notification via runtime.setProperty instead - Make MutationObservers persistent per element (no churn on re-run) - Boolean attributes use presence/absence, aria-* uses "true"/"false" - Number inputs use valueAsNumber to preserve type - form.reset() properly syncs bound variables - Local variables in `when` produce parse-time error - Improve circular guard error message Tests: 74 reactivity tests across when.js, bind.js, always.js Docs: added always.md, updated bind.md and when.md with guidance on when to use each feature
1 parent d397919 commit b00abc6

File tree

17 files changed

+1519
-711
lines changed

17 files changed

+1519
-711
lines changed

docs/reactivity-design.md

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
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. Each command runs with automatic
67+
dependency tracking and re-runs when dependencies change. Used for derived
68+
values, DOM updates, and conditional state.
69+
70+
- **`when`** reacts to changes. Watches a specific expression and runs a
71+
command body when the value changes. `it` refers to the new value. Used
72+
for side effects, async work, and events.
73+
74+
- **`bind`** syncs two values bidirectionally. Includes same-value dedup to
75+
prevent infinite loops. Used for form inputs and shared state.
76+
77+
`always` runs commands directly inside the tracking context (the command
78+
IS the effect). `when` separates the tracked expression from the handler
79+
commands. `bind` creates two `when`-style effects pointing at each other.
80+
81+
## Why styles and computed styles are not tracked
82+
83+
`*opacity`, `*computed-width`, and other style references are not reactive.
84+
There is no efficient DOM API for "notify me when a computed style changes."
85+
`MutationObserver` only catches inline style attribute edits, not changes
86+
from classes, media queries, CSS animations, or the cascade. No reactive
87+
framework (SolidJS, Vue, Svelte) tracks computed styles either.
88+
89+
To react to style-affecting changes, track the cause instead: the variable,
90+
attribute, or class that drives the style.

src/_hyperscript.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import * as InstallFeatureModule from './parsetree/features/install.js';
3939
import * as JsFeatureModule from './parsetree/features/js.js';
4040
import * as WhenFeatureModule from './parsetree/features/when.js';
4141
import * as BindFeatureModule from './parsetree/features/bind.js';
42+
import * as AlwaysFeatureModule from './parsetree/features/always.js';
4243

4344
const globalScope = typeof self !== 'undefined' ? self : (typeof global !== 'undefined' ? global : this);
4445

@@ -81,6 +82,7 @@ kernel.registerModule(InstallFeatureModule);
8182
kernel.registerModule(JsFeatureModule);
8283
kernel.registerModule(WhenFeatureModule);
8384
kernel.registerModule(BindFeatureModule);
85+
kernel.registerModule(AlwaysFeatureModule);
8486

8587
// ===== Public API =====
8688

0 commit comments

Comments
 (0)