feat: make state-based attributes reactive

This commit is contained in:
2026-07-14 00:43:07 +05:30
parent 4ce087b238
commit 420706ca3b
61 changed files with 221 additions and 99 deletions
+12 -1
View File
@@ -138,14 +138,25 @@ Bun.serve({
Browser side — server-rendered HTML that the reactive runtime hydrates:
```html
<div data-scope="count: 0">
<div data-scope="count: 0, showPassword: false">
<button data-on-click="count++">+1</button>
<span data-text="count"></span>
<p>Total: {{count}}</p>
<input type="{showPassword ? 'text' : 'password'}" />
<button
data-on-click="showPassword = !showPassword"
aria-label="{showPassword ? 'Hide password' : 'Show password'}"
>
Toggle password
</button>
</div>
<script src="/__wrnexus/reactive.js"></script>
```
State interpolation in ordinary attributes is reactive. The compiler keeps the
initial SSR value and emits an internal binding so attributes such as `type`,
`aria-label`, `aria-pressed`, `class`, and `href` update after state changes.
A realtime chat, fully declarative:
```html
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/csr",
"version": "0.2.24",
"version": "0.2.25",
"type": "module",
"main": "src/index.ts",
"exports": {
+32
View File
@@ -199,6 +199,38 @@ export const REACTIVE_RUNTIME = String.raw`
});
});
// Reactive ordinary attributes emitted by the compiler. Each marker stores
// [attributeName, originalTemplate], preserving an SSR value while allowing
// state changes to update type, aria-*, class, href, and other attributes.
var bindNodes = [el].concat(Array.prototype.slice.call(el.querySelectorAll("*")));
bindNodes.forEach(function (node) {
if (!owns(node)) return;
Array.prototype.slice.call(node.attributes).forEach(function (marker) {
if (marker.name.indexOf("data-wrn-bind-") !== 0) return;
var binding;
try { binding = JSON.parse(marker.value); } catch (e) { return; }
if (!binding || binding.length !== 2) return;
var name = binding[0];
var template = binding[1];
reactive(function () {
var exact = /^\{([^{}]+)\}$/.exec(template);
var raw;
if (exact) {
try { raw = evalExpr(exact[1].trim()); } catch (e) { return; }
} else {
raw = template.replace(/\{([^{}]+)\}/g, function (_, expr) {
try {
var value = evalExpr(expr.trim());
return value == null ? "" : String(value);
} catch (e) { return ""; }
});
}
if (raw === false || raw == null) node.removeAttribute(name);
else node.setAttribute(name, raw === true ? "" : String(raw));
});
});
});
// {{expr}} / {expr} interpolation in text nodes
var walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null);
var textNode;
+17
View File
@@ -128,6 +128,23 @@ test("data-show toggles visibility on a reactive expression (tabs pattern)", ()
expect(disp("b")).toBe("");
});
test("reactive attribute bindings update input and accessibility attributes", () => {
const win = mount(
`<div data-scope="show: false">
<input id="password" type="password" data-wrn-bind-0='["type","{show ? &#39;text&#39; : &#39;password&#39;}"]'>
<button data-on-click="show = !show" aria-label="Show password"
data-wrn-bind-0='["aria-label","{show ? &#39;Hide password&#39; : &#39;Show password&#39;}"]'>Toggle</button>
</div>`,
);
const input = win.document.getElementById("password") as unknown as HTMLInputElement;
const button = win.document.querySelector("button") as unknown as HTMLElement;
expect(input.type).toBe("password");
expect(button.getAttribute("aria-label")).toBe("Show password");
button.click();
expect(input.type).toBe("text");
expect(button.getAttribute("aria-label")).toBe("Hide password");
});
test("independent signals in one scope update correctly (dependency tracking)", () => {
const win = mount(
`<div data-scope="a: 0, b: 100">