diff --git a/docs/reactivity-design.md b/docs/reactivity-design.md new file mode 100644 index 000000000..0f0e6f5f5 --- /dev/null +++ b/docs/reactivity-design.md @@ -0,0 +1,92 @@ +# Reactivity Design Notes + +Internal reference for the reactive features: `always`, `when`, and `bind`. + +## Why this works without explicit signals + +SolidJS (and most JS reactive frameworks) use explicit signal primitives: + +```js +const [count, setCount] = createSignal(0); +count() // read — registers a subscription +setCount(5) // write — notifies subscribers +``` + +This is necessary because JavaScript has no way to intercept plain variable +reads and writes. `myVar = 5` is invisible to framework code. So Solid must +wrap values in function calls to run custom logic on access. + +_hyperscript doesn't have this constraint. It is its own language with its own +execution engine. Every variable read goes through `resolveSymbol()` in the +runtime, and every write goes through `setSymbol()`. The runtime is already +the middleman for all variable access, so we hook tracking and notification +directly into these existing paths: + +- **Read** (`resolveSymbol`): if an effect is currently evaluating, record the + variable as a dependency. +- **Write** (`setSymbol`): notify all effects that depend on this variable. + +The user gets reactive behavior without any API ceremony. `$count` is just a +variable. Writing `when $count changes ...` is all it takes to make it watched. + +## The JS interop boundary + +This approach is airtight for pure _hyperscript code, but leaky at the JS +boundary: + +- **Writes from JS** (`window.$count = 99`) bypass `setSymbol`, so no + notification fires. +- **Reads inside `js()` blocks** (`js(x) return window.$price * window.$qty end`) + bypass `resolveSymbol`, so no dependencies are tracked. +- **`Object.assign` in the js feature** writes directly to `globalScope`, + also bypassing `setSymbol`. + +This is an accepted trade-off. For typical _hyperscript usage the reactivity +is invisible and correct. Users mixing JS interop with reactive expressions +should be aware that the tracking boundary is the _hyperscript runtime. + +## What creates reactivity + +Variables are not signals. `set $count to 0` just stores a value on +`globalScope` (i.e. `window`). Nothing reactive happens. + +Reactivity is created by `always`, `when`, and `bind`. All three use +`createEffect()` under the hood, which evaluates code with tracking +enabled. During that evaluation, `resolveSymbol` sees that an effect +is active and records dependencies. Future writes via `setSymbol` notify +all subscribed effects. + +The variable itself has no idea it's being watched. The reactivity lives +entirely in the effect and the subscription maps. + +## The three reactive features + +Each serves a distinct purpose: + +- **`always`** declares relationships. The block runs as one unit with + automatic dependency tracking and re-runs when any dependency changes. + Used for derived values, DOM updates, and conditional state. For + independent tracking, use separate `always` statements. + +- **`when`** reacts to changes. Watches a specific expression and runs a + command body when the value changes. `it` refers to the new value. Used + for side effects, async work, and events. + +- **`bind`** syncs two values bidirectionally. Includes same-value dedup to + prevent infinite loops. Used for form inputs and shared state. + +`always` runs its entire command block inside the tracking context (the +block IS the effect). `when` separates the tracked expression from the +handler commands. `bind` creates two `when`-style effects pointing at +each other. + +## Why styles and computed styles are not tracked + +`*opacity`, `*computed-width`, and other style references are not reactive. +There is no efficient DOM API for "notify me when a computed style changes." +`MutationObserver` only catches inline style attribute edits, not changes +from classes, media queries, CSS animations, or the cascade. No reactive +framework (SolidJS, Vue, Svelte) tracks computed styles either. + +To react to style-affecting changes, track the cause instead: the variable, +attribute, or class that drives the style. diff --git a/src/_hyperscript.js b/src/_hyperscript.js index 1772fde07..9b5a00f20 100644 --- a/src/_hyperscript.js +++ b/src/_hyperscript.js @@ -9,6 +9,7 @@ import {Runtime} from './core/runtime/runtime.js'; import {HyperscriptModule} from './core/runtime/collections.js'; import {config} from './core/config.js'; import {conversions} from './core/runtime/conversions.js'; +import {reactivity} from './core/runtime/reactivity.js'; // Import parse element modules import * as Expressions from './parsetree/expressions/expressions.js'; @@ -36,6 +37,9 @@ import * as WorkerFeatureModule from './parsetree/features/worker.js'; import * as BehaviorFeatureModule from './parsetree/features/behavior.js'; import * as InstallFeatureModule from './parsetree/features/install.js'; import * as JsFeatureModule from './parsetree/features/js.js'; +import * as WhenFeatureModule from './parsetree/features/when.js'; +import * as BindFeatureModule from './parsetree/features/bind.js'; +import * as AlwaysFeatureModule from './parsetree/features/always.js'; const globalScope = typeof self !== 'undefined' ? self : (typeof global !== 'undefined' ? global : this); @@ -76,6 +80,9 @@ kernel.registerModule(WorkerFeatureModule); kernel.registerModule(BehaviorFeatureModule); kernel.registerModule(InstallFeatureModule); kernel.registerModule(JsFeatureModule); +kernel.registerModule(WhenFeatureModule); +kernel.registerModule(BindFeatureModule); +kernel.registerModule(AlwaysFeatureModule); // ===== Public API ===== @@ -111,7 +118,7 @@ const _hyperscript = Object.assign( }, internals: { - tokenizer, runtime, + tokenizer, runtime, reactivity, createParser: (tokens) => new Parser(kernel, tokens), }, diff --git a/src/core/runtime/reactivity.js b/src/core/runtime/reactivity.js new file mode 100644 index 000000000..814401289 --- /dev/null +++ b/src/core/runtime/reactivity.js @@ -0,0 +1,490 @@ +// Reactivity - Automatic dependency tracking for _hyperscript +// +// When an effect is active, reads via resolveSymbol, resolveProperty, +// and resolveAttribute record dependencies. Writes via setSymbol +// notify subscribers. Property and attribute changes are detected +// via DOM events and MutationObserver. + +/** + * A reactive effect. Re-runs when its dependencies change. + * + * @typedef {Object} Effect + * @property {() => any} expression - The watched expression. e.g. () => $price * $qty + * @property {(v: any) => void} handler - Called when value changes. e.g. (newValue) => { put newValue into me } + * @property {Map} dependencies - What was read during expression(). e.g. { "symbol:global:$price" => {type:"symbol", ...} } + * @property {Element|null} element - Owner element; auto-stops when element disconnects + * @property {boolean} isStopped - True after stopEffect(); skips all further processing + * @property {any} _lastExpressionValue - Last result of expression(), used to skip unchanged + * @property {number} _consecutiveTriggers - Counts consecutive triggers; stops runaway loops at 100 + * + * A single tracked read, recording what was accessed during expression(). + * + * @typedef {Object} Dependency + * @property {string} type - "symbol" | "property" | "attribute" + * @property {string} name - e.g. "$price", "value", "data-title" + * @property {string} [scope] - "global" or "element" (symbol deps only) + * @property {Element} [element] - Target element (element-scoped/property/attribute deps) + */ + +/** Object.is semantics: treats NaN===NaN and distinguishes +0/-0 */ +function _sameValue(a, b) { + // eslint-disable-next-line no-self-compare + return a === b ? (a !== 0 || 1 / a === 1 / b) : (a !== a && b !== b); +} + +/** Per-element reactive state, keyed by DOM element */ +const elementState = new WeakMap(); + +/** + * Global symbol subscriptions: symbolName -> Set + * When a global variable is written, all effects in its set are scheduled. + * @type {Map>} + */ +const globalSubscriptions = new Map(); + +/** Next ID to assign to an element for dependency dedup keys */ +let nextId = 0; + +/** + * Get or create the reactive state object for a DOM element. + * Assigns a stable unique ID on first access. + * @param {Element} element + * @returns {{ id: string, subscriptions: Map|null, propertyHandler: Object|null }} + */ +function getElementState(element) { + var state = elementState.get(element); + if (!state) { + elementState.set(element, state = { + id: String(++nextId), + subscriptions: null, + propertyHandler: null, + attributeObservers: null, + }); + } + return state; +} + +export class Reactivity { + constructor() { + /** @type {Effect|null} The effect currently being evaluated */ + this._currentEffect = null; + + /** @type {Set} Effects waiting to run in the next microtask */ + this._pendingEffects = new Set(); + + /** @type {boolean} Whether a microtask is scheduled to run pending effects */ + this._isRunScheduled = false; + } + + /** + * Whether an effect is currently evaluating its expression(). + * When true, reads (symbol/property/attribute) are recorded as dependencies. + * @returns {boolean} + */ + get isTracking() { + return this._currentEffect !== null; + } + + /** + * Track a global variable read as a dependency. + * @param {string} name - Variable name + */ + trackGlobalSymbol(name) { + // e.g. deps.set("symbol:global:$count", { type: "symbol", name: "$count", scope: "global" }) + this._currentEffect.dependencies.set("symbol:global:" + name, + { type: "symbol", name: name, scope: "global" }); + } + + /** + * Track an element-scoped variable read as a dependency. + * @param {string} name - Variable name + * @param {Element} element - Owning element + */ + trackElementSymbol(name, element) { + if (!element) return; + var elementId = getElementState(element).id; + // e.g. deps.set("symbol:element::count:3", { type: "symbol", name: ":count", scope: "element", element:
}) + this._currentEffect.dependencies.set("symbol:element:" + name + ":" + elementId, + { type: "symbol", name: name, scope: "element", element: element }); + } + + /** + * Track a DOM property read as a dependency. + * @param {Element} element + * @param {string} name - Property name + */ + trackProperty(element, name) { + if (!(element instanceof Element)) return; + // e.g. deps.set("property:value:5", { type: "property", element: , name: "value" }) + this._currentEffect.dependencies.set("property:" + name + ":" + getElementState(element).id, + { type: "property", element: element, name: name }); + } + + /** + * Track a DOM attribute read as a dependency. + * @param {Element} element + * @param {string} name - Attribute name + */ + trackAttribute(element, name) { + if (!(element instanceof Element)) return; + // e.g. deps.set("attribute:data-title:2", { type: "attribute", element:
, name: "data-title" }) + this._currentEffect.dependencies.set("attribute:" + name + ":" + getElementState(element).id, + { type: "attribute", element: element, name: name }); + } + + /** + * Notify that a global variable was written. + * @param {string} name - Variable name + */ + notifyGlobalSymbol(name) { + var subs = globalSubscriptions.get(name); + if (subs) { + for (var effect of subs) { + this._scheduleEffect(effect); + } + } + } + + /** + * Notify that an element-scoped variable was written. + * @param {string} name - Variable name + * @param {Element} element - Owning element + */ + notifyElementSymbol(name, element) { + if (!element) return; + var state = getElementState(element); + if (state.subscriptions) { + var subs = state.subscriptions.get(name); + if (subs) { + for (var effect of subs) { + this._scheduleEffect(effect); + } + } + } + } + + /** + * Notify that a DOM element property was written programmatically. + * Schedules all effects watching properties on this element. + * @param {Element} element + */ + notifyProperty(element) { + if (!(element instanceof Element)) return; + var state = elementState.get(element); + if (state && state.propertyHandler) { + state.propertyHandler.queueAll(); + } + } + + /** + * Add an effect to the pending set. + * Schedules a microtask to run them if one isn't already scheduled. + * @param {Effect} effect + */ + _scheduleEffect(effect) { + if (effect.isStopped) return; + this._pendingEffects.add(effect); + if (!this._isRunScheduled) { + this._isRunScheduled = true; + var self = this; + queueMicrotask(function () { self._runPendingEffects(); }); + } + } + + /** + * Run all pending effects. Called once per microtask batch. + * Effects that re-trigger during this run are queued for the next batch. + */ + _runPendingEffects() { + this._isRunScheduled = false; + // Copy because effects may re-schedule themselves during this run + var effects = Array.from(this._pendingEffects); + this._pendingEffects.clear(); + for (var effect of effects) { + if (effect.isStopped) continue; + // Auto-stop if owning element is disconnected + if (effect.element && !effect.element.isConnected) { + this.stopEffect(effect); + continue; + } + // Circular dependency guard: count accumulates across microtask + // flushes so cross-microtask ping-pong (effect writes to own dep) + // is caught. Reset happens below when the cascade settles. + effect._consecutiveTriggers++; + if (effect._consecutiveTriggers > 100) { + console.error( + "Reactivity loop detected: an effect triggered 100 consecutive " + + "times without settling. This usually means an effect is modifying " + + "a variable it also depends on.", + effect.element || effect + ); + continue; + } + this._runEffect(effect); + } + // Reset trigger counts when the cascade settles (no more pending + // effects). Legitimate re-triggers on future user events start + // fresh, while infinite cross-microtask loops accumulate to 100. + if (this._pendingEffects.size === 0) { + for (var i = 0; i < effects.length; i++) { + if (!effects[i].isStopped) effects[i]._consecutiveTriggers = 0; + } + } + } + + /** @param {Effect} effect */ + _runEffect(effect) { + // Unsubscribe from current deps + this._unsubscribeEffect(effect); + + // Re-run expression with tracking + var oldDeps = effect.dependencies; + effect.dependencies = new Map(); + + var prev = this._currentEffect; + this._currentEffect = effect; + var newValue; + try { + newValue = effect.expression(); + } catch (e) { + console.error("Error in reactive expression:", e); + // Restore old dependencies on error + effect.dependencies = oldDeps; + this._currentEffect = prev; + this._subscribeEffect(effect); + return; + } + this._currentEffect = prev; + + // Subscribe to new deps + this._subscribeEffect(effect); + + // Compare and fire (Object.is semantics: NaN === NaN, +0 !== -0) + if (!_sameValue(newValue, effect._lastExpressionValue)) { + effect._lastExpressionValue = newValue; + try { + effect.handler(newValue); + } catch (e) { + console.error("Error in reactive handler:", e); + } + } + } + + /** + * Subscribe an effect to all its current deps. + * Symbols go into subscription maps, attributes get MutationObservers, + * properties use persistent per-element input/change listeners. + * @param {Effect} effect + */ + _subscribeEffect(effect) { + var reactivity = this; + + for (var [depKey, dep] of effect.dependencies) { + if (dep.type === "symbol" && dep.scope === "global") { + if (!globalSubscriptions.has(dep.name)) { + globalSubscriptions.set(dep.name, new Set()); + } + globalSubscriptions.get(dep.name).add(effect); + + } else if (dep.type === "symbol" && dep.scope === "element") { + var state = getElementState(dep.element); + if (!state.subscriptions) { + state.subscriptions = new Map(); + } + if (!state.subscriptions.has(dep.name)) { + state.subscriptions.set(dep.name, new Set()); + } + state.subscriptions.get(dep.name).add(effect); + + } else if (dep.type === "attribute") { + reactivity._subscribeAttributeDependency(dep.element, dep.name, effect); + + } else if (dep.type === "property") { + reactivity._subscribePropertyDependency(dep.element, dep.name, effect); + } + } + } + + /** + * Subscribe to a DOM attribute. Sets up a persistent MutationObserver + * per element+attribute, shared across effects and re-runs. + * @param {Element} element + * @param {string} attrName + * @param {Effect} effect + */ + _subscribeAttributeDependency(element, attrName, effect) { + var reactivity = this; + var state = getElementState(element); + + if (!state.attributeObservers) { + state.attributeObservers = {}; + } + + if (!state.attributeObservers[attrName]) { + var trackedEffects = new Set(); + var observer = new MutationObserver(function () { + for (var eff of trackedEffects) { + reactivity._scheduleEffect(eff); + } + }); + observer.observe(element, { + attributes: true, + attributeFilter: [attrName] + }); + state.attributeObservers[attrName] = { + effects: trackedEffects, + observer: observer + }; + } + state.attributeObservers[attrName].effects.add(effect); + } + + /** + * Subscribe to a DOM element property. Sets up persistent per-element + * event listeners. Extracted into its own method to create proper + * closure scope for each element/property. + * @param {Element} element + * @param {string} propName + * @param {Effect} effect + */ + _subscribePropertyDependency(element, propName, effect) { + var reactivity = this; + var state = getElementState(element); + + if (!state.propertyHandler) { + var trackedEffects = new Set(); + var queueAll = function () { + for (var eff of trackedEffects) { + reactivity._scheduleEffect(eff); + } + }; + + element.addEventListener("input", queueAll); + element.addEventListener("change", queueAll); + + state.propertyHandler = { + effects: trackedEffects, + queueAll: queueAll, + remove: function () { + element.removeEventListener("input", queueAll); + element.removeEventListener("change", queueAll); + } + }; + } + state.propertyHandler.effects.add(effect); + } + + /** @param {Effect} effect */ + _unsubscribeEffect(effect) { + for (var [depKey, dep] of effect.dependencies) { + if (dep.type === "symbol" && dep.scope === "global") { + var subs = globalSubscriptions.get(dep.name); + if (subs) { + subs.delete(effect); + if (subs.size === 0) { + globalSubscriptions.delete(dep.name); + } + } + } else if (dep.type === "symbol" && dep.scope === "element") { + var state = getElementState(dep.element); + if (state.subscriptions) { + var subs = state.subscriptions.get(dep.name); + if (subs) { + subs.delete(effect); + if (subs.size === 0) { + state.subscriptions.delete(dep.name); + } + } + } + } else if (dep.type === "attribute" && dep.element) { + var state = getElementState(dep.element); + if (state.attributeObservers && state.attributeObservers[dep.name]) { + state.attributeObservers[dep.name].effects.delete(effect); + } + } else if (dep.type === "property" && dep.element) { + var state = getElementState(dep.element); + if (state.propertyHandler) { + state.propertyHandler.effects.delete(effect); + } + } + } + } + + /** + * Create a reactive effect with automatic dependency tracking. + * @param {() => any} expression - The watched expression + * @param {(value: any) => void} handler - Called when the value changes + * @param {Object} [options] + * @param {Element} [options.element] - Auto-stop when element disconnects + * @returns {() => void} Stop function + */ + createEffect(expression, handler, options) { + var effect = { + expression: expression, + handler: handler, + dependencies: new Map(), + _lastExpressionValue: undefined, + element: (options && options.element) || null, + isStopped: false, + _consecutiveTriggers: 0, + }; + + // Initial tracked evaluation + var prev = this._currentEffect; + this._currentEffect = effect; + try { + effect._lastExpressionValue = expression(); + } catch (e) { + console.error("Error in reactive expression:", e); + } + this._currentEffect = prev; + + // Subscribe to tracked dependencies + this._subscribeEffect(effect); + + // Initial sync: if value already exists, call handler immediately. + // Both undefined and null are treated as "no value yet" to support + // left-side-wins initialization in bind. + if (effect._lastExpressionValue != null) { + try { + handler(effect._lastExpressionValue); + } catch (e) { + console.error("Error in reactive handler:", e); + } + } + + var reactivity = this; + return function stop() { + reactivity.stopEffect(effect); + }; + } + + /** @param {Effect} effect */ + stopEffect(effect) { + if (effect.isStopped) return; + effect.isStopped = true; + this._unsubscribeEffect(effect); + // Clean up per-element listeners and observers if no effects remain + for (var [depKey, dep] of effect.dependencies) { + if (dep.type === "attribute" && dep.element) { + var state = getElementState(dep.element); + if (state.attributeObservers && state.attributeObservers[dep.name]) { + var obs = state.attributeObservers[dep.name]; + if (obs.effects.size === 0) { + obs.observer.disconnect(); + delete state.attributeObservers[dep.name]; + } + } + } else if (dep.type === "property" && dep.element) { + var state = getElementState(dep.element); + if (state.propertyHandler && state.propertyHandler.effects.size === 0) { + state.propertyHandler.remove(); + state.propertyHandler = null; + } + } + } + this._pendingEffects.delete(effect); + } +} + +export const reactivity = new Reactivity(); diff --git a/src/core/runtime/runtime.js b/src/core/runtime/runtime.js index 4e3b49d12..85f2702f9 100644 --- a/src/core/runtime/runtime.js +++ b/src/core/runtime/runtime.js @@ -3,6 +3,7 @@ import { config } from '../config.js'; import { conversions } from './conversions.js'; import { CookieJar } from './cookies.js'; import { ElementCollection, SHOULD_AUTO_ITERATE_SYM } from './collections.js'; +import { reactivity } from './reactivity.js'; // cookie jar proxy for runtime let cookies = new CookieJar().proxy(); @@ -49,6 +50,7 @@ export class Runtime { this.#globalScope = globalScope; this.#kernel = kernel; this.#tokenizer = tokenizer; + } get globalScope() { @@ -247,8 +249,10 @@ export class Runtime { return context.you; } else { if (type === "global") { + if (reactivity.isTracking) reactivity.trackGlobalSymbol(str); return this.#globalScope[str]; } else if (type === "element") { + if (reactivity.isTracking) reactivity.trackElementSymbol(str, context.meta.owner); var elementScope = this.#getElementScope(context); return elementScope[str]; } else if (type === "local") { @@ -272,13 +276,19 @@ export class Runtime { var fromContext = context[str]; } if (typeof fromContext !== "undefined") { + // Found in locals/meta - don't track (ephemeral) return fromContext; } else { + // element scope var elementScope = this.#getElementScope(context); fromContext = elementScope[str]; if (typeof fromContext !== "undefined") { + if (reactivity.isTracking) reactivity.trackElementSymbol(str, context.meta.owner); return fromContext; } else { + // Global scope (or not found - track as global + // so we catch the first write) + if (reactivity.isTracking) reactivity.trackGlobalSymbol(str); return this.#globalScope[str]; } } @@ -289,23 +299,31 @@ export class Runtime { setSymbol(str, context, type, value) { if (type === "global") { this.#globalScope[str] = value; + reactivity.notifyGlobalSymbol(str); } else if (type === "element") { var elementScope = this.#getElementScope(context); elementScope[str] = value; + reactivity.notifyElementSymbol(str, context.meta.owner); } else if (type === "local") { context.locals[str] = value; + // Don't notify - local scope is ephemeral } else { if (this.#isHyperscriptContext(context) && !this.#isReservedWord(str) && typeof context.locals[str] !== "undefined") { + // local scope - don't notify context.locals[str] = value; } else { + // element scope var elementScope = this.#getElementScope(context); var fromContext = elementScope[str]; if (typeof fromContext !== "undefined") { elementScope[str] = value; + reactivity.notifyElementSymbol(str, context.meta.owner); } else { if (this.#isHyperscriptContext(context) && !this.#isReservedWord(str)) { + // local scope - don't notify context.locals[str] = value; } else { + // direct set on normal JS object or top-level of context context[str] = value; } } @@ -357,19 +375,32 @@ export class Runtime { } resolveProperty(root, property) { - return this.#flatGet(root, property, (root, property) => root[property] ) + if (reactivity.isTracking) reactivity.trackProperty(root, property); + return this.#flatGet(root, property, (root, property) => root[property]) + } + + /** + * Set a property on a DOM element and notify the reactivity system. + * @param {Element} element + * @param {string} property + * @param {any} value + */ + setProperty(element, property, value) { + element[property] = value; + reactivity.notifyProperty(element); } resolveAttribute(root, property) { - return this.#flatGet(root, property, (root, property) => root.getAttribute && root.getAttribute(property) ) + if (reactivity.isTracking) reactivity.trackAttribute(root, property); + return this.#flatGet(root, property, (root, property) => root.getAttribute && root.getAttribute(property)) } resolveStyle(root, property) { - return this.#flatGet(root, property, (root, property) => root.style && root.style[property] ) + return this.#flatGet(root, property, (root, property) => root.style && root.style[property]) } resolveComputedStyle(root, property) { - return this.#flatGet(root, property, (root, property) => getComputedStyle(root).getPropertyValue(property) ) + return this.#flatGet(root, property, (root, property) => getComputedStyle(root).getPropertyValue(property)) } assignToNamespace(elt, nameSpace, name, value) { diff --git a/src/parsetree/expressions/expressions.js b/src/parsetree/expressions/expressions.js index d78698ac8..bf5347f66 100644 --- a/src/parsetree/expressions/expressions.js +++ b/src/parsetree/expressions/expressions.js @@ -267,7 +267,14 @@ export class PropertyAccess extends Expression { set(ctx, lhs, value) { ctx.meta.runtime.nullCheck(lhs.root, this.root); - ctx.meta.runtime.implicitLoop(lhs.root, elt => { elt[this.prop.value] = value; }); + var runtime = ctx.meta.runtime; + runtime.implicitLoop(lhs.root, elt => { + if (elt instanceof Element) { + runtime.setProperty(elt, this.prop.value, value); + } else { + elt[this.prop.value] = value; + } + }); } } @@ -364,7 +371,14 @@ export class OfExpression extends Expression { } else if (urRoot.type === "styleRef") { ctx.meta.runtime.implicitLoop(lhs.root, elt => { elt.style[prop] = value; }); } else { - ctx.meta.runtime.implicitLoop(lhs.root, elt => { elt[prop] = value; }); + var runtime = ctx.meta.runtime; + runtime.implicitLoop(lhs.root, elt => { + if (elt instanceof Element) { + runtime.setProperty(elt, prop, value); + } else { + elt[prop] = value; + } + }); } } } @@ -446,7 +460,15 @@ export class PossessiveExpression extends Expression { }); } } else { - ctx.meta.runtime.implicitLoop(lhs.root, elt => { elt[this.prop.value] = value; }); + var runtime = ctx.meta.runtime; + var prop = this.prop.value; + runtime.implicitLoop(lhs.root, elt => { + if (elt instanceof Element) { + runtime.setProperty(elt, prop, value); + } else { + elt[prop] = value; + } + }); } } } diff --git a/src/parsetree/expressions/webliterals.js b/src/parsetree/expressions/webliterals.js index 1b3aa1855..dd8e882a1 100644 --- a/src/parsetree/expressions/webliterals.js +++ b/src/parsetree/expressions/webliterals.js @@ -197,7 +197,7 @@ export class AttributeRef extends Expression { resolve(context) { var target = context.you || context.me; if (target) { - return target.getAttribute(this.name); + return context.meta.runtime.resolveAttribute(target, this.name); } } diff --git a/src/parsetree/features/always.js b/src/parsetree/features/always.js new file mode 100644 index 000000000..cf2519600 --- /dev/null +++ b/src/parsetree/features/always.js @@ -0,0 +1,53 @@ +/** + * Always Feature - Reactive commands that re-run when dependencies change + * + * always set $total to ($price * $qty) + * + * always + * set $subtotal to ($price * $qty) + * set $total to ($subtotal + $tax) + * if $total > 100 add .expensive to me else remove .expensive from me end + * end + * + * Each command in the block becomes an independent tracked effect. + * Whatever a command reads during execution becomes its dependencies. + * When any dependency changes, that specific command re-runs. + */ + +import { Feature } from '../base.js'; +import { reactivity } from '../../core/runtime/reactivity.js'; + +export class AlwaysFeature extends Feature { + static keyword = "always"; + + constructor(commands) { + super(); + this.commands = commands; + this.displayName = "always"; + } + + static parse(parser) { + if (!parser.matchToken("always")) return; + + var start = parser.requireElement("commandList"); + var feature = new AlwaysFeature(start); + parser.ensureTerminated(start); + parser.setParent(start, feature); + return feature; + } + + install(target, source, args, runtime) { + var feature = this; + queueMicrotask(function () { + reactivity.createEffect( + function () { + feature.commands.execute( + runtime.makeContext(target, feature, target, null) + ); + }, + function () {}, + { element: target } + ); + }); + } +} diff --git a/src/parsetree/features/bind.js b/src/parsetree/features/bind.js new file mode 100644 index 000000000..359361d00 --- /dev/null +++ b/src/parsetree/features/bind.js @@ -0,0 +1,300 @@ +/** + * Bind Feature - Two-way reactive binding (sugar over `when ... changes`) + * + * bind and + * bind with + * Two-way. Both sides stay in sync. Equivalent to: + * when changes set to it end + * when changes set to it + * + * bind + * Shorthand on form elements. Auto-detects the bound property: + * input[type=checkbox/radio] -> checked + * input[type=number/range] -> valueAsNumber + * input, textarea, select -> value + */ + +import { Feature } from '../base.js'; +import { reactivity } from '../../core/runtime/reactivity.js'; + +export class BindFeature extends Feature { + static keyword = "bind"; + + /** + * Parse bind feature + * @param {Parser} parser + * @returns {BindFeature | undefined} + */ + static parse(parser) { + if (!parser.matchToken("bind")) return; + + parser.pushFollow("and"); + parser.pushFollow("with"); + parser.pushFollow("to"); + var left; + try { + left = parser.requireElement("expression"); + } finally { + parser.popFollow(); + parser.popFollow(); + parser.popFollow(); + } + + var right = null; + if (parser.matchToken("and") || parser.matchToken("with") || parser.matchToken("to")) { + right = parser.requireElement("expression"); + } + + return new BindFeature(left, right); + } + + constructor(left, right) { + super(); + this.left = left; + this.right = right; + this.displayName = right ? "bind ... and ..." : "bind (shorthand)"; + } + + install(target, source, args, runtime) { + var feature = this; + queueMicrotask(function () { + if (feature.right) { + _twoWayBind(feature.left, feature.right, target, feature, runtime); + } else { + _shorthandBind(feature.left, target, feature, runtime); + } + }); + } +} + +/** + * Two-way bind between two parsed expressions. Left side wins on init: + * Effect 1 (left→right) runs first, establishing the initial state. + */ +function _twoWayBind(left, right, target, feature, runtime) { + // Read the current value of a bind side. Class refs are read as + // booleans (does the element have this class?) rather than evaluated + // as expressions (which would return an ElementCollection). + // This mirrors how `add .dark` treats .dark as a class name, not + // as a query. + function read(expr) { + if (expr.type === "classRef") { + runtime.resolveAttribute(target, "class"); + return target.classList.contains(expr.className); + } + return expr.evaluate(runtime.makeContext(target, feature, target, null)); + } + + // Effect 1: left changes -> set right + reactivity.createEffect( + function () { return read(left); }, + function (newValue) { + var ctx = runtime.makeContext(target, feature, target, null); + _assignTo(runtime, right, ctx, newValue); + }, + { element: target } + ); + // Effect 2: right changes -> set left + reactivity.createEffect( + function () { return read(right); }, + function (newValue) { + var ctx = runtime.makeContext(target, feature, target, null); + _assignTo(runtime, left, ctx, newValue); + }, + { element: target } + ); +} + +/** + * Shorthand bind: auto-detect property from element type, + * read/write it directly without a synthetic expression object. + */ +function _shorthandBind(left, target, feature, runtime) { + var propName = _detectProperty(target); + + // Radio buttons work fundamentally differently from other inputs. + // The variable holds the value of the selected radio in the group, + // not a per-element property. See _radioBind for details. + if (propName === "radio") { + return _radioBind(left, target, feature, runtime); + } + + // Effect 1: variable changes -> write property to element + reactivity.createEffect( + function () { + return left.evaluate(runtime.makeContext(target, feature, target, null)); + }, + function (newValue) { + target[propName] = newValue; + }, + { element: target } + ); + + var isNumeric = propName === "valueAsNumber"; + + // Effect 2: element property changes -> write to variable + reactivity.createEffect( + function () { + var val = runtime.resolveProperty(target, propName); + return (isNumeric && val !== val) ? null : val; + }, + function (newValue) { + var ctx = runtime.makeContext(target, feature, target, null); + _assignTo(runtime, left, ctx, newValue); + }, + { element: target } + ); + + // form.reset() changes input values without firing input/change events. + // Listen for the reset event and re-sync after the browser resets values. + var form = target.closest("form"); + if (form) { + form.addEventListener("reset", function () { + setTimeout(function () { + if (!target.isConnected) return; + var val = target[propName]; + if (isNumeric && val !== val) val = null; + var ctx = runtime.makeContext(target, feature, target, null); + _assignTo(runtime, left, ctx, val); + }, 0); + }); + } +} + +/** + * Radio button bind. Unlike normal bind which syncs a variable with a + * single element's property, radio bind syncs a variable with a GROUP + * of radio buttons that share the same name attribute. + * + * The variable holds the value of the selected radio (e.g. "red"). + * - User clicks a radio: variable is set to that radio's value attribute + * - Variable changes: the radio whose value matches is checked, others unchecked + * + * Each radio in the group has its own bind. They all share the same variable. + */ +function _radioBind(left, target, feature, runtime) { + var radioValue = target.value; + var groupName = target.getAttribute("name"); + + // Effect 1: variable changes -> check/uncheck this radio + reactivity.createEffect( + function () { + return left.evaluate(runtime.makeContext(target, feature, target, null)); + }, + function (newValue) { + target.checked = (newValue === radioValue); + }, + { element: target } + ); + + // Effect 2: this radio is checked -> set variable to this radio's value + // Only fires when this specific radio is clicked (change event). + target.addEventListener("change", function () { + if (target.checked) { + var ctx = runtime.makeContext(target, feature, target, null); + _assignTo(runtime, left, ctx, radioValue); + } + }); +} + +/** + * Detect the default property for shorthand bind based on element type. + * @param {Element} element + * @returns {string} Property name ("value", "checked", "valueAsNumber", or "radio") + */ +function _detectProperty(element) { + var tag = element.tagName; + if (tag === "INPUT") { + var type = element.getAttribute("type") || "text"; + if (type === "radio") return "radio"; + if (type === "checkbox") return "checked"; + if (type === "number" || type === "range") return "valueAsNumber"; + return "value"; + } + if (tag === "TEXTAREA" || tag === "SELECT") return "value"; + throw new Error( + "bind shorthand is not supported on <" + tag.toLowerCase() + "> elements. " + + "Use 'bind $var and my value' explicitly." + ); +} + +/** Set an attribute, handling booleans via presence/absence (or "true"/"false" for aria-*) */ +function _setAttr(elt, name, value) { + if (typeof value === "boolean") { + if (name.startsWith("aria-")) { + elt.setAttribute(name, String(value)); + } else if (value) { + elt.setAttribute(name, ""); + } else { + elt.removeAttribute(name); + } + } else if (value == null) { + elt.removeAttribute(name); + } else { + elt.setAttribute(name, value); + } +} + +/** + * Assign a value to a parsed expression target, mirroring what + * the `set` command does for each target type. + * @param {Runtime} runtime + * @param {Object} target - The parsed expression to assign to + * @param {Context} ctx - Execution context + * @param {any} value - Value to assign + */ +function _assignTo(runtime, target, ctx, value) { + if (target.type === "symbol") { + runtime.setSymbol(target.name, ctx, target.scope, value); + } else if (target.type === "attributeRef") { + var elt = ctx.you || ctx.me; + if (elt) { + _setAttr(elt, target.name, value); + } + } else if (target.type === "propertyAccess" || target.type === "possessive") { + var root = target.root ? target.root.evaluate(ctx) : ctx.me; + var prop = target.prop ? target.prop.value : target.name; + if (root != null) { + runtime.implicitLoop(root, function (elt) { + if (elt instanceof Element) { + runtime.setProperty(elt, prop, value); + } else { + elt[prop] = value; + } + }); + } + } else if (target.type === "attributeRefAccess") { + var root = target.root ? target.root.evaluate(ctx) : ctx.me; + var attr = target.attribute ? target.attribute.name : target.name; + if (root != null) { + runtime.implicitLoop(root, function (elt) { + _setAttr(elt, attr, value); + }); + } + } else if (target.type === "classRef") { + var elt = ctx.you || ctx.me; + if (elt) { + if (value) { + elt.classList.add(target.className); + } else { + elt.classList.remove(target.className); + } + } + } else if (target.type === "styleRef") { + var elt = ctx.you || ctx.me; + if (elt) { + elt.style[target.name] = value; + } + } else if (target.set) { + // Fallback: use the expression's own set() method (same as the set command). + // This handles ofExpression and any other assignable expression type. + var lhs = {}; + if (target.lhs) { + for (var key in target.lhs) { + var expr = target.lhs[key]; + lhs[key] = expr && expr.evaluate ? expr.evaluate(ctx) : expr; + } + } + target.set(ctx, lhs, value); + } +} diff --git a/src/parsetree/features/when.js b/src/parsetree/features/when.js new file mode 100644 index 000000000..3fc827499 --- /dev/null +++ b/src/parsetree/features/when.js @@ -0,0 +1,96 @@ +/** + * When Feature - Reactive effect + * + * Parses: when [or ]* changes + * Executes: Re-runs commands when the watched expression's value changes. + * Dependencies are tracked automatically via createEffect. + */ + +import { Feature } from '../base.js'; +import { reactivity } from '../../core/runtime/reactivity.js'; + +export class WhenFeature extends Feature { + static keyword = "when"; + + /** + * Parse when feature + * @param {Parser} parser + * @returns {WhenFeature | undefined} + */ + static parse(parser) { + if (!parser.matchToken("when")) return; + + // Collect one or more watched expressions, separated by "or". + // pushFollow("or") tells the expression parser to stop before "or" + // instead of consuming it as a logical operator. + var exprs = []; + do { + parser.pushFollow("or"); + try { + exprs.push(parser.requireElement("expression")); + } finally { + parser.popFollow(); + } + } while (parser.matchToken("or")); + + // Check for bare local variables that can't be reactive + for (var i = 0; i < exprs.length; i++) { + var expr = exprs[i]; + if (expr.type === "symbol" && expr.scope === "default" + && !expr.name.startsWith("$") && !expr.name.startsWith(":")) { + parser.raiseParseError( + "Cannot watch local variable '" + expr.name + "'. " + + "Local variables are not reactive. Use '$" + expr.name + + "' (global) or ':" + expr.name + "' (element-scoped) instead." + ); + } + } + + parser.requireToken("changes"); + var start = parser.requireElement("commandList"); + parser.ensureTerminated(start); + var feature = new WhenFeature(exprs, start); + parser.setParent(start, feature); + return feature; + } + + constructor(exprs, start) { + super(); + this.exprs = exprs; + this.start = start; + this.displayName = "when ... changes"; + } + + install(target, source, args, runtime) { + var feature = this; + // Defer effect creation to a microtask so that ID references (e.g. + // #reactive-input) can be resolved after the element is appended to + // the DOM. + queueMicrotask(function () { + // Create one effect per watched expression. Each triggers the + // same command list, mirroring how `on` handles `or` for events. + for (var i = 0; i < feature.exprs.length; i++) { + (function (expr) { + reactivity.createEffect( + function () { + return expr.evaluate( + runtime.makeContext(target, feature, target, null) + ); + }, + function (newValue) { + var ctx = runtime.makeContext(target, feature, target, null); + ctx.result = newValue; + ctx.meta.reject = function (err) { + console.error(err.message ? err.message : err); + runtime.triggerEvent(target, "exception", { error: err }); + }; + ctx.meta.onHalt = function () {}; + feature.start.execute(ctx); + }, + { element: target } + ); + })(feature.exprs[i]); + } + }); + } +} diff --git a/test/features/always.js b/test/features/always.js new file mode 100644 index 000000000..c8f9a7e35 --- /dev/null +++ b/test/features/always.js @@ -0,0 +1,292 @@ +import {test, expect} from '../fixtures.js' + +test.describe('the always feature', () => { + + // ================================================================ + // Single statement + // ================================================================ + + test('derives a variable from a computed expression', async ({html, find, run, evaluate}) => { + await run("set $price to 10") + await run("set $qty to 3") + await html( + `
` + ) + await expect(find('div')).toHaveText('30') + + await run("set $price to 25") + await expect.poll(() => find('div').textContent()).toBe('75') + await evaluate(() => { delete window.$price; delete window.$qty; delete window.$total }) + }) + + test('updates DOM text reactively with put', async ({html, find, run, evaluate}) => { + await run("set $greeting to 'world'") + await html(`
`) + await expect.poll(() => find('div').textContent()).toBe('hello world') + + await run("set $greeting to 'there'") + await expect.poll(() => find('div').textContent()).toBe('hello there') + await evaluate(() => { delete window.$greeting }) + }) + + test('sets an attribute reactively', async ({html, find, run, evaluate}) => { + await run("set $theme to 'light'") + await html(`
`) + await expect(find('div')).toHaveAttribute('data-theme', 'light') + + await run("set $theme to 'dark'") + await expect.poll(() => find('div').getAttribute('data-theme')).toBe('dark') + await evaluate(() => { delete window.$theme }) + }) + + test('sets a style reactively', async ({html, find, run, evaluate}) => { + await run("set $opacity to 1") + await html(`
visible
`) + await new Promise(r => setTimeout(r, 100)) + + await run("set $opacity to 0.5") + await expect.poll(() => + evaluate(() => document.querySelector('#work-area div').style.opacity) + ).toBe('0.5') + await evaluate(() => { delete window.$opacity }) + }) + + test('puts a computed dollar amount into the DOM', async ({html, find, run, evaluate}) => { + await run("set $price to 10") + await run("set $qty to 2") + await html(`
`) + await expect.poll(() => find('div').textContent()).toBe('$20') + + await run("set $qty to 5") + await expect.poll(() => find('div').textContent()).toBe('$50') + await evaluate(() => { delete window.$price; delete window.$qty }) + }) + + // ================================================================ + // Block form + // ================================================================ + + test('block form re-runs all commands when any dependency changes', async ({html, find, run, evaluate}) => { + await run("set $width to 100") + await run("set $height to 200") + await html( + `` + + `` + + `
` + ) + await expect.poll(() => find('#w').textContent()).toBe('200') + await expect.poll(() => find('#h').textContent()).toBe('400') + + // Change $height — both commands re-run, but $doubleWidth stays the same (dedup) + await run("set $height to 300") + await expect.poll(() => find('#h').textContent()).toBe('600') + await expect(find('#w')).toHaveText('200') // unchanged because dedup + + await evaluate(() => { + delete window.$width; delete window.$height + delete window.$doubleWidth; delete window.$doubleHeight + }) + }) + + test('separate always statements create independent effects', async ({html, find, run, evaluate}) => { + await run("set $width to 100") + await run("set $height to 200") + await html( + `` + + `` + + `
` + ) + await expect.poll(() => find('#w').textContent()).toBe('200') + await expect.poll(() => find('#h').textContent()).toBe('400') + + // Change only $height — only $doubleHeight updates + await run("set $height to 300") + await expect.poll(() => find('#h').textContent()).toBe('600') + await expect(find('#w')).toHaveText('200') + + await evaluate(() => { + delete window.$width; delete window.$height + delete window.$doubleWidth; delete window.$doubleHeight + }) + }) + + test('block form cascades inter-dependent commands', async ({html, find, run, evaluate}) => { + await run("set $price to 10") + await run("set $qty to 3") + await run("set $tax to 5") + await html( + `
` + ) + await expect.poll(() => find('div').textContent()).toBe('35') + + await run("set $price to 20") + await expect.poll(() => find('div').textContent()).toBe('65') + + await run("set $tax to 10") + await expect.poll(() => find('div').textContent()).toBe('70') + await evaluate(() => { + delete window.$price; delete window.$qty; delete window.$tax + delete window.$subtotal; delete window.$total + }) + }) + + // ================================================================ + // if/else inside always + // ================================================================ + + test('toggles a class based on a boolean variable', async ({html, find, run, evaluate}) => { + await run("set $isActive to false") + await html( + `
test
` + ) + await new Promise(r => setTimeout(r, 100)) + await expect(find('div')).not.toHaveClass('active') + + await run("set $isActive to true") + await expect.poll(() => find('div').getAttribute('class')).toContain('active') + + await run("set $isActive to false") + await expect.poll(() => find('div').getAttribute('class') || '').not.toContain('active') + await evaluate(() => { delete window.$isActive }) + }) + + test('toggles display style based on a boolean variable', async ({html, find, run, evaluate}) => { + await run("set $isVisible to true") + await html( + `
content
` + ) + await expect.poll(() => + evaluate(() => document.querySelector('#work-area div').style.display) + ).toBe('block') + + await run("set $isVisible to false") + await expect.poll(() => + evaluate(() => document.querySelector('#work-area div').style.display) + ).toBe('none') + + await run("set $isVisible to true") + await expect.poll(() => + evaluate(() => document.querySelector('#work-area div').style.display) + ).toBe('block') + await evaluate(() => { delete window.$isVisible }) + }) + + // ================================================================ + // Cleanup + // ================================================================ + + test('effects stop when element is removed from DOM', async ({html, find, run, evaluate}) => { + await run("set $message to 'initial'") + await html(`
`) + await expect.poll(() => find('div').textContent()).toBe('initial') + + await evaluate(() => document.querySelector('#work-area div').remove()) + await run("set $message to 'after removal'") + await new Promise(r => setTimeout(r, 100)) + await evaluate(() => { delete window.$message }) + }) + + // ================================================================ + // Dynamic dependencies + // ================================================================ + + test('conditional branch only tracks the active dependency', async ({html, find, run, evaluate}) => { + await run("set $showFirst to true") + await run("set $firstName to 'Alice'") + await run("set $lastName to 'Smith'") + await html( + `
` + ) + await expect.poll(() => find('div').textContent()).toBe('Alice') + + // Active branch: $firstName triggers + await run("set $firstName to 'Bob'") + await expect.poll(() => find('div').textContent()).toBe('Bob') + + // Inactive branch: $lastName does NOT trigger + await run("set $lastName to 'Jones'") + await new Promise(r => setTimeout(r, 100)) + await expect(find('div')).toHaveText('Bob') + + // Flip condition + await run("set $showFirst to false") + await expect.poll(() => find('div').textContent()).toBe('Jones') + + // Now $firstName is inactive + await run("set $firstName to 'Charlie'") + await new Promise(r => setTimeout(r, 100)) + await expect(find('div')).toHaveText('Jones') + + await evaluate(() => { + delete window.$showFirst; delete window.$firstName; delete window.$lastName + }) + }) + + // ================================================================ + // Multiple features on same element + // ================================================================ + + test('multiple always on same element work independently', async ({html, find, run, evaluate}) => { + await run("set $firstName to 'Alice'") + await run("set $age to 30") + await html( + `
` + ) + await expect(find('div')).toHaveAttribute('data-name', 'Alice') + await expect(find('div')).toHaveAttribute('data-age', '30') + + await run("set $firstName to 'Bob'") + await expect.poll(() => find('div').getAttribute('data-name')).toBe('Bob') + await expect(find('div')).toHaveAttribute('data-age', '30') + await evaluate(() => { delete window.$firstName; delete window.$age }) + }) + + test('always and when on same element do not interfere', async ({html, find, run, evaluate}) => { + await run("set $status to 'online'") + await html( + `
` + ) + await expect(find('div')).toHaveAttribute('data-status', 'online') + await expect(find('div')).toHaveText('Status: online') + + await run("set $status to 'offline'") + await expect.poll(() => find('div').getAttribute('data-status')).toBe('offline') + await expect.poll(() => find('div').textContent()).toBe('Status: offline') + await evaluate(() => { delete window.$status }) + }) + + test('bind and always on same element do not interfere', async ({html, find, run, evaluate}) => { + await run("set $username to 'alice'") + await html( + `` + + `` + ) + await expect.poll(() => find('span').textContent()).toBe('alice') + await expect.poll(() => find('input').getAttribute('data-mirror')).toBe('alice') + + await find('input').fill('bob') + await expect.poll(() => find('span').textContent()).toBe('bob') + await expect.poll(() => find('input').getAttribute('data-mirror')).toBe('bob') + await evaluate(() => { delete window.$username }) + }) + +}) diff --git a/test/features/bind.js b/test/features/bind.js new file mode 100644 index 000000000..5a050af76 --- /dev/null +++ b/test/features/bind.js @@ -0,0 +1,503 @@ +import {test, expect} from '../fixtures.js' + +test.describe('the bind feature', () => { + + // ================================================================ + // Two-way binding + // ================================================================ + + test('syncs variable and input value in both directions', async ({html, find, run, evaluate}) => { + await html( + `` + + `` + ) + await expect(find('span')).toHaveText('Alice') + + // User types -> variable updates + await evaluate(() => { + var input = document.getElementById('name-input') + input.value = 'Bob' + input.dispatchEvent(new Event('input', { bubbles: true })) + }) + await expect(find('span')).toHaveText('Bob') + + // Variable changes -> input updates + await run("set $name to 'Charlie'") + await expect.poll(() => evaluate(() => document.getElementById('name-input').value)).toBe('Charlie') + await evaluate(() => { delete window.$name }) + }) + + test('syncs variable and attribute in both directions', async ({html, find, run, evaluate}) => { + await run("set $theme to 'light'") + await html(`
`) + await expect(find('div')).toHaveAttribute('data-theme', 'light') + + await run("set $theme to 'dark'") + await expect(find('div')).toHaveAttribute('data-theme', 'dark') + + await evaluate(() => document.querySelector('#work-area div').setAttribute('data-theme', 'auto')) + await expect.poll(() => evaluate(() => window.$theme), { timeout: 5000 }).toBe('auto') + await evaluate(() => { delete window.$theme }) + }) + + test('dedup prevents infinite loop in two-way bind', async ({html, find, run, evaluate}) => { + await run("set $color to 'red'") + await html(`
`) + await expect(find('div')).toHaveAttribute('data-color', 'red') + + await run("set $color to 'blue'") + await expect(find('div')).toHaveAttribute('data-color', 'blue') + await expect.poll(() => evaluate(() => window.$color)).toBe('blue') + await evaluate(() => { delete window.$color }) + }) + + test('"with" is a synonym for "and"', async ({html, find, run, evaluate}) => { + await html( + `` + + `` + ) + await expect(find('span')).toHaveText('Paris') + + await run("set $city to 'London'") + await expect.poll(() => evaluate(() => document.getElementById('city-input').value)).toBe('London') + await evaluate(() => { delete window.$city }) + }) + + // ================================================================ + // Shorthand: bind $var + // ================================================================ + + test('shorthand on text input binds to value', async ({html, find, run, evaluate}) => { + await html( + `` + + `` + ) + await expect.poll(() => find('span').textContent()).toBe('hello') + + await find('input').fill('goodbye') + await expect.poll(() => find('span').textContent()).toBe('goodbye') + + await run("set $greeting to 'hey'") + await expect.poll(() => evaluate(() => document.querySelector('#work-area input').value)).toBe('hey') + await evaluate(() => { delete window.$greeting }) + }) + + test('shorthand on checkbox binds to checked', async ({html, find, run, evaluate}) => { + await run("set $isDarkMode to false") + await html( + `` + + `` + ) + await expect.poll(() => find('span').textContent()).toBe('false') + + await find('input').check() + await expect.poll(() => find('span').textContent()).toBe('true') + + await run("set $isDarkMode to false") + await expect.poll(() => evaluate(() => document.querySelector('#work-area input').checked)).toBe(false) + await evaluate(() => { delete window.$isDarkMode }) + }) + + test('shorthand on textarea binds to value', async ({html, find, run, evaluate}) => { + await html( + `` + + `` + ) + await expect.poll(() => find('span').textContent()).toBe('Hello world') + + await find('textarea').fill('New bio') + await expect.poll(() => find('span').textContent()).toBe('New bio') + await evaluate(() => { delete window.$bio }) + }) + + test('shorthand on select binds to value', async ({html, find, run, evaluate}) => { + await html( + `` + + `` + ) + await expect.poll(() => find('span').textContent()).toBe('us') + + await find('select').selectOption('uk') + await expect.poll(() => find('span').textContent()).toBe('uk') + await evaluate(() => { delete window.$country }) + }) + + test('shorthand on unsupported element produces an error', async ({html, find, evaluate}) => { + const error = await evaluate(() => { + return new Promise(resolve => { + var origError = console.error + console.error = function(msg) { + if (typeof msg === 'string' && msg.includes('bind shorthand')) { + resolve(msg) + } + origError.apply(console, arguments) + } + var wa = document.getElementById('work-area') + wa.innerHTML = '
' + _hyperscript.processNode(wa) + setTimeout(() => { console.error = origError; resolve(null) }, 500) + }) + }) + expect(await evaluate(() => window.$nope)).toBeUndefined() + }) + + // ================================================================ + // Type coercion + // ================================================================ + + test('shorthand on type=number preserves number type', async ({html, find, run, evaluate}) => { + await run("set $price to 42") + await html( + `` + + `` + ) + await new Promise(r => setTimeout(r, 200)) + expect(await evaluate(() => typeof window.$price)).toBe('number') + await expect(find('span')).toHaveText('42') + await evaluate(() => { delete window.$price }) + }) + + test('boolean bind to attribute uses presence/absence', async ({html, find, run, evaluate}) => { + await run("set $isEnabled to true") + await html(`
`) + await new Promise(r => setTimeout(r, 100)) + await expect(find('div')).toHaveAttribute('data-active', '') + + await run("set $isEnabled to false") + await new Promise(r => setTimeout(r, 100)) + expect(await evaluate(() => + document.querySelector('#work-area div').hasAttribute('data-active') + )).toBe(false) + await evaluate(() => { delete window.$isEnabled }) + }) + + test('boolean bind to aria-* attribute uses "true"/"false" strings', async ({html, find, run, evaluate}) => { + await run("set $isHidden to true") + await html(`
`) + await new Promise(r => setTimeout(r, 100)) + await expect(find('div')).toHaveAttribute('aria-hidden', 'true') + + await run("set $isHidden to false") + await new Promise(r => setTimeout(r, 100)) + await expect(find('div')).toHaveAttribute('aria-hidden', 'false') + await evaluate(() => { delete window.$isHidden }) + }) + + test('style bind is one-way: variable drives style, not vice versa', async ({html, find, run, evaluate}) => { + await run("set $opacity to 1") + await html(`
visible
`) + await new Promise(r => setTimeout(r, 100)) + + await run("set $opacity to 0.3") + await expect.poll(() => + evaluate(() => document.querySelector('#work-area div').style.opacity) + ).toBe('0.3') + + // Changing style directly does NOT update the variable + await evaluate(() => document.querySelector('#work-area div').style.opacity = '0.9') + await new Promise(r => setTimeout(r, 200)) + expect(await evaluate(() => window.$opacity)).toBe(0.3) + await evaluate(() => { delete window.$opacity }) + }) + + // ================================================================ + // Edge cases + // ================================================================ + + test('same value does not re-set input (prevents cursor jump)', async ({html, find, evaluate}) => { + await html(``) + await new Promise(r => setTimeout(r, 100)) + + const setterWasCalled = await evaluate(() => { + var input = document.querySelector('#work-area input') + var called = false + var desc = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value') + Object.defineProperty(input, 'value', { + get: desc.get, + set: function(v) { called = true; desc.set.call(this, v) }, + configurable: true + }) + window.$message = 'hello' // same value + return new Promise(resolve => { + setTimeout(() => { delete input.value; resolve(called) }, 100) + }) + }) + expect(setterWasCalled).toBe(false) + await evaluate(() => { delete window.$message }) + }) + + test('external JS property write does not sync (known limitation)', async ({html, find, run, evaluate}) => { + await html( + `` + + `` + ) + await expect.poll(() => find('span').textContent()).toBe('original') + + await evaluate(() => { + document.querySelector('#work-area input').value = 'from-javascript' + }) + await new Promise(r => setTimeout(r, 200)) + expect(await evaluate(() => window.$searchTerm)).toBe('original') + await evaluate(() => { delete window.$searchTerm }) + }) + + test('form.reset() syncs variable back to default value', async ({html, find, run, evaluate}) => { + await html( + `
` + + ` ` + + `
` + + `` + ) + await expect.poll(() => find('span').textContent()).toBe('default') + + await find('input').fill('user typed this') + await expect.poll(() => find('span').textContent()).toBe('user typed this') + + await evaluate(() => document.getElementById('test-form').reset()) + await expect.poll(() => find('span').textContent()).toBe('default') + expect(await evaluate(() => window.$formField)).toBe('default') + await evaluate(() => { delete window.$formField }) + }) + + // ================================================================ + // Radio button groups + // ================================================================ + + test('clicking a radio sets the variable to its value', async ({html, find, run, evaluate}) => { + await run("set $color to 'red'") + await html( + `` + + `` + + `` + + `` + ) + await expect.poll(() => find('span').textContent()).toBe('red') + + await find('input[value="blue"]').click() + await expect.poll(() => find('span').textContent()).toBe('blue') + + await find('input[value="green"]').click() + await expect.poll(() => find('span').textContent()).toBe('green') + await evaluate(() => { delete window.$color }) + }) + + test('setting variable programmatically checks the matching radio', async ({html, find, run, evaluate}) => { + await run("set $size to 'small'") + await html( + `` + + `` + + `` + ) + await new Promise(r => setTimeout(r, 100)) + expect(await evaluate(() => document.querySelector('input[value="small"]').checked)).toBe(true) + expect(await evaluate(() => document.querySelector('input[value="medium"]').checked)).toBe(false) + + await run("set $size to 'large'") + await expect.poll(() => + evaluate(() => document.querySelector('input[value="large"]').checked) + ).toBe(true) + expect(await evaluate(() => document.querySelector('input[value="small"]').checked)).toBe(false) + expect(await evaluate(() => document.querySelector('input[value="medium"]').checked)).toBe(false) + await evaluate(() => { delete window.$size }) + }) + + test('initial value checks the correct radio on load', async ({html, find, run, evaluate}) => { + await run("set $fruit to 'banana'") + await html( + `` + + `` + + `` + ) + await new Promise(r => setTimeout(r, 100)) + expect(await evaluate(() => document.querySelector('input[value="apple"]').checked)).toBe(false) + expect(await evaluate(() => document.querySelector('input[value="banana"]').checked)).toBe(true) + expect(await evaluate(() => document.querySelector('input[value="cherry"]').checked)).toBe(false) + await evaluate(() => { delete window.$fruit }) + }) + + // ================================================================ + // Class binding + // ================================================================ + + test('variable drives class: setting variable adds/removes class', async ({html, find, run, evaluate}) => { + await run("set $darkMode to false") + await html(`
test
`) + await new Promise(r => setTimeout(r, 100)) + await expect(find('div')).not.toHaveClass('dark') + + await run("set $darkMode to true") + await expect.poll(() => find('div').getAttribute('class')).toContain('dark') + + await run("set $darkMode to false") + await expect.poll(() => find('div').getAttribute('class') || '').not.toContain('dark') + await evaluate(() => { delete window.$darkMode }) + }) + + test('external class change syncs back to variable', async ({html, find, run, evaluate}) => { + await run("set $darkMode to false") + await html(`
test
`) + await new Promise(r => setTimeout(r, 100)) + + await evaluate(() => document.querySelector('#work-area div').classList.add('dark')) + await expect.poll(() => evaluate(() => window.$darkMode)).toBe(true) + + await evaluate(() => document.querySelector('#work-area div').classList.remove('dark')) + await expect.poll(() => evaluate(() => window.$darkMode)).toBe(false) + await evaluate(() => { delete window.$darkMode }) + }) + + test('variable on left drives class on init', async ({html, find, run, evaluate}) => { + await run("set $highlighted to true") + await html(`
test
`) + await new Promise(r => setTimeout(r, 100)) + await expect(find('div')).toHaveClass('highlight') + await evaluate(() => { delete window.$highlighted }) + }) + + // ================================================================ + // Initialization: left side wins + // ================================================================ + + test('init: variable on left wins over input value on right', async ({html, find, run, evaluate}) => { + await run("set $name to 'Alice'") + await html(``) + await new Promise(r => setTimeout(r, 100)) + expect(await evaluate(() => document.querySelector('#work-area input').value)).toBe('Alice') + expect(await evaluate(() => window.$name)).toBe('Alice') + await evaluate(() => { delete window.$name }) + }) + + test('init: input value on left wins over variable on right', async ({html, find, run, evaluate}) => { + await run("set $name to 'Alice'") + await html(``) + await new Promise(r => setTimeout(r, 100)) + expect(await evaluate(() => document.querySelector('#work-area input').value)).toBe('Bob') + expect(await evaluate(() => window.$name)).toBe('Bob') + await evaluate(() => { delete window.$name }) + }) + + test('init: undefined left side loses to defined right side', async ({html, find, run, evaluate}) => { + await html(`
`) + await new Promise(r => setTimeout(r, 100)) + expect(await evaluate(() => window.$color)).toBe('red') + await evaluate(() => { delete window.$color }) + }) + + test('init: defined left side wins over null right side', async ({html, find, run, evaluate}) => { + await run("set $theme to 'dark'") + await html(`
`) + await new Promise(r => setTimeout(r, 100)) + await expect(find('div')).toHaveAttribute('data-theme', 'dark') + await evaluate(() => { delete window.$theme }) + }) + + test('init: present class on left wins over false variable on right', async ({html, find, run, evaluate}) => { + await run("set $isDark to false") + await html(`
`) + await new Promise(r => setTimeout(r, 100)) + expect(await evaluate(() => window.$isDark)).toBe(true) + await evaluate(() => { delete window.$isDark }) + }) + + test('init: true variable on left wins over absent class on right', async ({html, find, run, evaluate}) => { + await run("set $isDark to true") + await html(`
`) + await new Promise(r => setTimeout(r, 100)) + await expect.poll(() => find('div').getAttribute('class')).toContain('dark') + await evaluate(() => { delete window.$isDark }) + }) + + // ================================================================ + // Expression types: possessive, of-expression, attributeRefAccess + // ================================================================ + + test('possessive property: bind $var and my value', async ({html, find, run, evaluate}) => { + await run("set $myVal to 'hello'") + await html(``) + await new Promise(r => setTimeout(r, 100)) + expect(await evaluate(() => document.querySelector('#work-area input').value)).toBe('hello') + + await find('input').fill('world') + await expect.poll(() => evaluate(() => window.$myVal)).toBe('world') + await evaluate(() => { delete window.$myVal }) + }) + + test('possessive attribute: bind $var and my @data-label', async ({html, find, run, evaluate}) => { + await run("set $label to 'important'") + await html(`
`) + await new Promise(r => setTimeout(r, 100)) + await expect(find('div')).toHaveAttribute('data-label', 'important') + + await run("set $label to 'normal'") + await expect.poll(() => find('div').getAttribute('data-label')).toBe('normal') + await evaluate(() => { delete window.$label }) + }) + + test('of-expression: bind $var and value of #input', async ({html, find, run, evaluate}) => { + await run("set $search to 'initial'") + await html( + `` + + `
` + ) + await new Promise(r => setTimeout(r, 100)) + expect(await evaluate(() => document.getElementById('of-input').value)).toBe('initial') + await evaluate(() => { delete window.$search }) + }) + + // ================================================================ + // Cross-element binding + // ================================================================ + + test('class bound to another element checkbox', async ({html, find, evaluate}) => { + await html( + `` + + `
test
` + ) + await new Promise(r => setTimeout(r, 100)) + await expect(find('div')).not.toHaveClass('dark') + + await find('#dark-toggle').check() + await expect.poll(() => find('div').getAttribute('class')).toContain('dark') + + await find('#dark-toggle').uncheck() + await expect.poll(() => find('div').getAttribute('class') || '').not.toContain('dark') + }) + + test('attribute bound to another element input value', async ({html, find, evaluate}) => { + await html( + `` + + `

` + ) + await expect.poll(() => find('h1').getAttribute('data-title')).toBe('Hello') + + await find('#title-input').fill('World') + await expect.poll(() => find('h1').getAttribute('data-title')).toBe('World') + }) + + test('two inputs synced via bind', async ({html, find, evaluate}) => { + await html( + `` + + `` + ) + await new Promise(r => setTimeout(r, 100)) + expect(await evaluate(() => document.querySelector('#work-area input[type=number]').value)).toBe('50') + + await evaluate(() => { + var slider = document.getElementById('slider') + slider.value = '75' + slider.dispatchEvent(new Event('input', {bubbles: true})) + }) + await expect.poll(() => + evaluate(() => document.querySelector('#work-area input[type=number]').value) + ).toBe('75') + }) + +}) diff --git a/test/features/when.js b/test/features/when.js new file mode 100644 index 000000000..8bbb1b3b8 --- /dev/null +++ b/test/features/when.js @@ -0,0 +1,542 @@ +import {test, expect} from '../fixtures.js' + +test.describe('the when feature', () => { + + test('provides access to `it` and syncs initial value', async ({html, find, run, evaluate}) => { + await run("set $global to 'initial'") + await html(`
`) + await expect(find('div')).toHaveText('initial') + + await run("set $global to 'hello world'") + await expect(find('div')).toHaveText('hello world') + + await run("set $global to 42") + await expect(find('div')).toHaveText('42') + + await evaluate(() => { delete window.$global }) + }) + + test('detects changes from $global variable', async ({html, find, run, evaluate}) => { + await html(`
`) + await run("set $global to 'Changed!'") + await expect(find('div')).toHaveText('Changed!') + await evaluate(() => { delete window.$global }) + }) + + test('detects changes from :element variable', async ({html, find}) => { + await html( + `
0
` + ) + await expect(find('div')).toHaveText('0') + await find('div').click() + await expect(find('div')).toHaveText('1') + await find('div').click() + await expect(find('div')).toHaveText('2') + }) + + test('triggers multiple elements watching same variable', async ({html, find, run, evaluate}) => { + await html( + `
` + + `
` + ) + await run("set $shared to 'changed'") + await expect(find('#d1')).toHaveText('first') + await expect(find('#d2')).toHaveText('second') + await evaluate(() => { delete window.$shared }) + }) + + test('executes multiple commands', async ({html, find, run, evaluate}) => { + await html(`
`) + await run("set $multi to 'go'") + await expect(find('div')).toHaveText('first') + await expect(find('div')).toHaveClass(/executed/) + await evaluate(() => { delete window.$multi }) + }) + + test('does not execute when variable is undefined initially', async ({html, find}) => { + await html(`
original
`) + // Wait a bit and verify it didn't change + await new Promise(r => setTimeout(r, 50)) + await expect(find('div')).toHaveText('original') + }) + + test('only triggers when variable actually changes value', async ({html, find, run, evaluate}) => { + await html( + `
` + ) + await run("set $dedup to 'value1'") + await expect(find('div')).toHaveText('1') + + // Same value — should NOT re-trigger + await run("set $dedup to 'value1'") + await new Promise(r => setTimeout(r, 50)) + await expect(find('div')).toHaveText('1') + + await run("set $dedup to 'value2'") + await expect(find('div')).toHaveText('2') + await evaluate(() => { delete window.$dedup }) + }) + + test('auto-tracks compound expressions', async ({html, find, run, evaluate}) => { + await run("set $a to 1") + await run("set $b to 2") + await html(`
`) + await expect(find('div')).toHaveText('3') + + await run("set $a to 10") + await expect(find('div')).toHaveText('12') + + await run("set $b to 20") + await expect(find('div')).toHaveText('30') + await evaluate(() => { delete window.$a; delete window.$b }) + }) + + test('detects attribute changes', async ({html, find, evaluate}) => { + await html(`
`) + await expect(find('div')).toHaveText('original') + + await evaluate(() => document.querySelector('#work-area div').setAttribute('data-title', 'updated')) + // MutationObserver + effect pipeline is async — poll for the update + await expect.poll(() => find('div').textContent(), { timeout: 5000 }).toBe('updated') + }) + + test('detects form input value changes via user interaction', async ({html, find, evaluate}) => { + await html( + `` + + `` + ) + await expect(find('span')).toHaveText('start') + + await evaluate(() => { + const input = document.getElementById('reactive-input') + input.value = 'typed' + input.dispatchEvent(new Event('input', { bubbles: true })) + }) + await expect(find('span')).toHaveText('typed') + }) + + test('detects property change via hyperscript set', async ({html, find, run}) => { + await html( + `` + + `` + ) + await expect(find('span')).toHaveText('initial') + + await run("set #prog-input.value to 'updated'") + await expect.poll(() => find('span').textContent()).toBe('updated') + }) + + test('disposes effect when element is removed from DOM', async ({html, find, run, evaluate}) => { + await run("set $dispose to 'before'") + await html(`
`) + await expect(find('div')).toHaveText('before') + + const textBefore = await evaluate(() => { + const div = document.querySelector('#work-area div') + div.parentNode.removeChild(div) + return div.innerHTML + }) + expect(textBefore).toBe('before') + + await run("set $dispose to 'after'") + await new Promise(r => setTimeout(r, 50)) + + // Element was removed — should still show old value + const textAfter = await evaluate(() => { + // The div is detached, check it still has old content + return true + }) + await evaluate(() => { delete window.$dispose }) + }) + + test('batches multiple synchronous writes into one effect run', async ({html, find, run, evaluate}) => { + await run("set $batchA to 0") + await run("set $batchB to 0") + await html( + `
` + ) + await expect(find('div')).toHaveText('1') + + // Both writes in a single evaluate so they happen in the same microtask + await evaluate(() => { + _hyperscript("set $batchA to 5") + _hyperscript("set $batchB to 10") + }) + await expect(find('div')).toHaveText('2') + await evaluate(() => { delete window.$batchA; delete window.$batchB }) + }) + + test('handles chained reactivity across elements', async ({html, find, run, evaluate}) => { + await html( + `
` + + `
` + ) + await run("set $source to 5") + await expect(find('#output')).toHaveText('10') + + await run("set $source to 20") + await expect(find('#output')).toHaveText('40') + await evaluate(() => { delete window.$source; delete window.$derived }) + }) + + test('supports multiple when features on the same element', async ({html, find, run, evaluate}) => { + await run("set $left to 'L'") + await run("set $right to 'R'") + await html( + `
` + ) + await expect(find('div')).toHaveAttribute('data-left', 'L') + await expect(find('div')).toHaveAttribute('data-right', 'R') + + await run("set $left to 'newL'") + await expect(find('div')).toHaveAttribute('data-left', 'newL') + await expect(find('div')).toHaveAttribute('data-right', 'R') + await evaluate(() => { delete window.$left; delete window.$right }) + }) + + test('works with on handlers that modify the watched variable', async ({html, find}) => { + await html( + `
initial
` + ) + await expect(find('div')).toHaveText('initial') + await find('div').click() + await expect(find('div')).toHaveText('clicked') + }) + + test('does not cross-trigger on unrelated variable writes', async ({html, find, run, evaluate}) => { + await html( + `
` + ) + await run("set $trigger to 'go'") + await expect(find('div')).toHaveText('1') + await new Promise(r => setTimeout(r, 50)) + await expect(find('div')).toHaveText('1') + await evaluate(() => { delete window.$trigger; delete window.$other }) + }) + + test('handles rapid successive changes correctly', async ({html, find, run, evaluate}) => { + await html(`
`) + for (let i = 0; i < 10; i++) { + await run("set $rapid to " + i) + } + await expect(find('div')).toHaveText('9') + await evaluate(() => { delete window.$rapid }) + }) + + test('isolates element-scoped variables between elements', async ({html, find}) => { + await html( + `
A
` + + `
B
` + ) + await expect(find('#d1')).toHaveText('A') + await expect(find('#d2')).toHaveText('B') + + await find('#d1').click() + await expect(find('#d1')).toHaveText('A-clicked') + await expect(find('#d2')).toHaveText('B') + + await find('#d2').click() + await expect(find('#d2')).toHaveText('B-clicked') + await expect(find('#d1')).toHaveText('A-clicked') + }) + + test('handles NaN without infinite re-firing', async ({html, find, evaluate}) => { + await html( + `` + + `` + ) + await expect(find('span')).toHaveText('NaN') + + await evaluate(() => { + document.getElementById('nan-input').dispatchEvent(new Event('input', { bubbles: true })) + }) + await new Promise(r => setTimeout(r, 50)) + await expect(find('span')).toHaveText('NaN') + }) + + test('fires when either expression changes using or', async ({html, find, run, evaluate}) => { + await html(`
`) + await run("set $x to 'from-x'") + await expect(find('div')).toHaveText('from-x') + + await run("set $y to 'from-y'") + await expect(find('div')).toHaveText('from-y') + await evaluate(() => { delete window.$x; delete window.$y }) + }) + + test('supports three or more expressions with or', async ({html, find, run, evaluate}) => { + await html(`
`) + await run("set $r to 'red'") + await expect(find('div')).toHaveText('red') + await run("set $g to 'green'") + await expect(find('div')).toHaveText('green') + await run("set $b to 'blue'") + await expect(find('div')).toHaveText('blue') + await evaluate(() => { delete window.$r; delete window.$g; delete window.$b }) + }) + + // ---- Tracking coverage ---- + + test('#element.checked is tracked', async ({html, find}) => { + await html( + `` + + `` + ) + await expect(find('span')).toHaveText('false') + await find('#cb-input').check() + await expect.poll(() => find('span').textContent()).toBe('true') + }) + + test("my @attr is tracked", async ({html, find, evaluate}) => { + await html(`
`) + await expect.poll(() => find('div').textContent()).toBe('one') + await evaluate(() => document.querySelector('#work-area div').setAttribute('data-x', 'two')) + await expect.poll(() => find('div').textContent()).toBe('two') + }) + + test('value of #element is tracked', async ({html, find}) => { + await html( + `` + + `` + ) + await expect(find('span')).toHaveText('init') + await find('#of-input').fill('changed') + await expect.poll(() => find('span').textContent()).toBe('changed') + }) + + test('math on tracked symbols works', async ({html, find, run}) => { + await run("set $mA to 3") + await run("set $mB to 4") + await html(`
`) + await expect(find('div')).toHaveText('12') + await run("set $mA to 10") + await expect.poll(() => find('div').textContent()).toBe('40') + }) + + test('comparison on tracked symbol works', async ({html, find, run}) => { + await run("set $cmpVal to 3") + await html(`
`) + await expect(find('div')).toHaveText('false') + await run("set $cmpVal to 10") + await expect.poll(() => find('div').textContent()).toBe('true') + }) + + test('string template with tracked symbol works', async ({html, find, run}) => { + await run("set $tplName to 'world'") + await html('
') + await expect(find('div')).toHaveText('hello world') + await run("set $tplName to 'there'") + await expect.poll(() => find('div').textContent()).toBe('hello there') + }) + + test('function call on tracked value works (Math.round)', async ({html, find, run}) => { + await run("set $rawNum to 3.7") + await html(`
`) + await expect.poll(() => find('div').textContent()).toBe('4') + await run("set $rawNum to 9.2") + await expect.poll(() => find('div').textContent()).toBe('9') + }) + + test('inline style change via JS is NOT detected', async ({html, find, evaluate}) => { + await html(`
not fired
`) + await new Promise(r => setTimeout(r, 100)) + const initialText = await find('div').textContent() + await evaluate(() => document.getElementById('style-target').style.opacity = '0.5') + await new Promise(r => setTimeout(r, 200)) + expect(await find('div').textContent()).toBe(initialText) + }) + + test('reassigning whole array IS detected', async ({html, find, run}) => { + await run("set $arrWhole to [1, 2, 3]") + await html(`
`) + await expect.poll(() => find('div').textContent()).toBe('1,2,3') + await run("set $arrWhole to [4, 5, 6]") + await expect.poll(() => find('div').textContent()).toBe('4,5,6') + }) + + test('mutating array element in place is NOT detected', async ({html, find, run, evaluate}) => { + await run("set $arrMut to [1, 2, 3]") + await html(`
`) + await new Promise(r => setTimeout(r, 100)) + const initialText = await find('div').textContent() + await evaluate(() => { window.$arrMut[0] = 99 }) + await new Promise(r => setTimeout(r, 200)) + expect(await find('div').textContent()).toBe(initialText) + }) + + test('local variable in when expression produces a parse error', async ({evaluate}) => { + const error = await evaluate(() => { + var origError = console.error + var captured = null + console.error = function() { + for (var i = 0; i < arguments.length; i++) { + var arg = String(arguments[i]) + if (arg.includes('local variable')) captured = arg + } + origError.apply(console, arguments) + } + var wa = document.getElementById('work-area') + wa.innerHTML = '
' + _hyperscript.processNode(wa) + console.error = origError + return captured + }) + expect(error).not.toBeNull() + }) + + test('attribute observers are persistent (not recreated on re-run)', async ({html, find, evaluate}) => { + const observersCreated = await evaluate(async () => { + const OrigMO = window.MutationObserver + let count = 0 + window.MutationObserver = function(cb) { + count++ + return new OrigMO(cb) + } + window.MutationObserver.prototype = OrigMO.prototype + const wa = document.getElementById('work-area') + wa.innerHTML = '
' + _hyperscript.processNode(wa) + await new Promise(r => setTimeout(r, 50)) + const countAfterInit = count + for (let i = 2; i <= 6; i++) { + wa.querySelector('div').setAttribute('data-val', String(i)) + await new Promise(r => setTimeout(r, 30)) + } + window.MutationObserver = OrigMO + return count - countAfterInit + }) + expect(observersCreated).toBe(0) + }) + + // ---- Robustness ---- + + test('boolean short-circuit does not track unread branch', async ({html, find, run, evaluate}) => { + await run("set $x to false") + await run("set $y to 'hello'") + await html(`
`) + await new Promise(r => setTimeout(r, 100)) + await run("set $y to 'world'") + await new Promise(r => setTimeout(r, 100)) + expect(await find('div').textContent()).not.toBe('world') + await evaluate(() => { delete window.$x; delete window.$y }) + }) + + test('diamond: cascaded derived values produce correct final value', async ({html, find, run, evaluate}) => { + await run("set $a to 1") + await html( + `` + + `` + + `
` + ) + await new Promise(r => setTimeout(r, 100)) + await run("set $a to 10") + await new Promise(r => setTimeout(r, 200)) + expect(await find('div').textContent()).toContain('50') + await evaluate(() => { delete window.$a; delete window.$b; delete window.$c }) + }) + + test('error in one effect does not break other effects in the same batch', async ({html, find, run, evaluate}) => { + await run("set $trigger to 0") + await html( + `` + + `` + ) + await new Promise(r => setTimeout(r, 50)) + await run("set $trigger to 42") + await new Promise(r => setTimeout(r, 100)) + await expect(find('#err-b')).toHaveText('ok:42') + await evaluate(() => { delete window.$trigger }) + }) + + test('circular guard resets after cascade settles', async ({html, find, run, evaluate}) => { + await run("set $ping to 0") + await html( + `` + + `
` + ) + await run("set $ping to 1") + await new Promise(r => setTimeout(r, 500)) + await run("set $ping to 0") + await run("set $ping to 999") + await new Promise(r => setTimeout(r, 200)) + expect(Number(await find('div').textContent())).toBeGreaterThan(0) + await evaluate(() => { delete window.$ping }) + }) + + test('cross-microtask ping-pong is caught by circular guard', async ({html, find, run, evaluate}) => { + await html( + `` + + `` + + `
` + ) + + // This creates A->B->A->B... across microtask boundaries + await run("set $ping to 1") + await new Promise(r => setTimeout(r, 1000)) + + // The browser should not freeze. The guard should have stopped it. + // The value should be finite (not still incrementing). + const val = Number(await find('div').textContent()) + expect(val).toBeLessThan(200) + await evaluate(() => { delete window.$ping; delete window.$pong }) + }) + + test('element moved in DOM retains reactivity', async ({html, find, run, evaluate}) => { + await run("set $movable to 'start'") + await html( + `
` + + `
` + ) + await expect(find('span')).toHaveText('start') + + // Move the span from container-a to container-b + await evaluate(() => { + var span = document.querySelector('#work-area span') + document.getElementById('container-b').appendChild(span) + }) + + // Element is still connected, just in a different parent + await run("set $movable to 'moved'") + await expect.poll(() => find('span').textContent()).toBe('moved') + await evaluate(() => { delete window.$movable }) + }) + + test('rapid detach/reattach in same sync block does not kill effect', async ({html, find, run, evaluate}) => { + await run("set $thrash to 'before'") + await html( + `
` + ) + await evaluate(() => { + var parent = document.getElementById('thrash-parent') + parent.innerHTML = '' + _hyperscript.processNode(parent) + }) + await expect.poll(() => find('span').textContent()).toBe('before') + + // Detach and immediately reattach in the same synchronous block + await evaluate(() => { + var span = document.querySelector('#thrash-parent span') + var parent = span.parentNode + parent.removeChild(span) + parent.appendChild(span) + }) + + // Effect should still work + await run("set $thrash to 'after'") + await expect.poll(() => find('span').textContent()).toBe('after') + await evaluate(() => { delete window.$thrash }) + }) + +}) diff --git a/www/docs.md b/www/docs.md index 46d7c09a9..1b3fe3a39 100644 --- a/www/docs.md +++ b/www/docs.md @@ -1180,6 +1180,37 @@ If you have logic that you wish to run when an element is initialized, you can u The `init` keyword should be followed by a set of commands to execute when the element is loaded. +### Reactivity {#reactivity} + +For simple cases, [`on`](/features/on) is the right tool. But when a value can be +changed from multiple places, or when you don't want to list every source of change, +reactive features let you just declare what you want and it stays in sync. + +**[`always`](/features/always)** keeps the DOM in sync with values: + + ~~~ html + + + + ~~~ + +**[`when`](/features/when)** reacts to changes with side effects or chained logic: + + ~~~ html +
+ + ~~~ + +**[`bind`](/features/bind)** keeps two values in sync (two-way): + + ~~~ html + + + ~~~ + +See the [`always`](/features/always), [`when`](/features/when), and [`bind`](/features/bind) pages +for full details. + ### Functions {#functions} Functions in hyperscript are defined by using the [`def` keyword](/features/def). diff --git a/www/features/always.md b/www/features/always.md new file mode 100644 index 000000000..38fdd3747 --- /dev/null +++ b/www/features/always.md @@ -0,0 +1,126 @@ +--- +title: always - ///_hyperscript +--- + +## The `always` Feature + +The `always` feature declares reactive commands that re-run whenever their dependencies +change. + +For reacting to changes with side effects, see [`when`](/features/when). +For two-way sync, see [`bind`](/features/bind). + +### Syntax + +```ebnf +always + +always + {} +end +``` + +### Why Not Just Use `on`? + +For simple cases, `on` works great: + +```html + + +``` + +But what if `$count` can be changed by multiple buttons, a keyboard shortcut, a timer, +or an htmx response? With `on`, you have to list every source: + +```html + +``` + +With `always`, you just say what you want and it stays in sync no matter what changes +`$count`: + +```html + +``` + +Add a new source of change tomorrow and the `always` version just works. + +### Single Statement + +```html + + + + +``` + +Works with any command: + +```html + + + + + + + +
+ + +
+ + +
+``` + +### Block Form + +Group multiple reactive commands in a block. All commands run top to bottom +as one unit, just like any other block in _hyperscript: + +```html +
+``` + +When any dependency changes (`$price`, `$qty`, `$tax`), the entire block re-runs. + +### Independent Effects + +If you want each reactive command to track its own dependencies independently, +use separate `always` statements: + +```html +
+``` + +### How It Works + +The block runs with automatic dependency tracking. Whatever it reads +during execution (variables, properties, attributes) becomes its dependencies. +When any dependency changes, the block re-runs. + +### Cleanup + +When the element is removed from the DOM, all its `always` effects are automatically +stopped. + +### Choosing Between `always`, `when`, and `bind` + +| I want to... | Use | +|---|---| +| Keep the DOM in sync with values | `always` | +| React to a change with side effects or chained logic | `when` | +| Keep a variable and a form input in sync | `bind` | + +For simple cases where you know the exact source of a change, `on` is still the +right tool. diff --git a/www/features/bind.md b/www/features/bind.md new file mode 100644 index 000000000..01737a41e --- /dev/null +++ b/www/features/bind.md @@ -0,0 +1,109 @@ +--- +title: bind - ///_hyperscript +--- + +## The `bind` Feature + +The `bind` feature keeps two values in sync (two-way). Use it for form inputs and +shared state. + +For one-way derived values, see [`always`](/features/always). +For reacting to changes with side effects, see [`when`](/features/when). + +### Syntax + +```ebnf +bind and +bind with +bind to +bind +``` + +The keywords `and`, `with`, and `to` are interchangeable. + +### Two-Way Binding + +Keeps two values in sync. Changes to either side propagate to the other. + +Toggle a class based on a checkbox: + +```html + + +``` + +Keep an attribute in sync with a nearby input: + +```html + +

+``` + +Sync two inputs together: + +```html + + +``` + +#### Boolean Attributes + +When binding a boolean value to an attribute, standard attributes use +presence/absence. ARIA attributes use `"true"`/`"false"` strings: + +```html +
+
+``` + +### Initialization + +When both sides have values on init, the **left side wins**: + +```html + + +

+``` + +If either side is `undefined` or `null`, the other side wins regardless of position. + +### Shorthand: `bind $var` + +On form elements, you can omit the second argument. The bound property is detected +automatically from the element type: + +| Element | Binds to | +|---------|----------| +| `` | `my value` | +| `` | `my valueAsNumber` (preserves number type) | +| `` | `my checked` | +| `` | group (see below) | +| `