feat: make state-based attributes reactive
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/ai",
|
||||
"version": "0.2.24",
|
||||
"version": "0.2.25",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Zero-dependency Claude (Anthropic) client for WrNexus apps.",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/authz",
|
||||
"version": "0.2.24",
|
||||
"version": "0.2.25",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/cli",
|
||||
"version": "0.2.24",
|
||||
"version": "0.2.25",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -296,6 +296,14 @@ const MIGRATIONS: Migration[] = [
|
||||
// Explicit no-op: this release updates package and portal documentation only.
|
||||
},
|
||||
},
|
||||
{
|
||||
version: "0.2.25",
|
||||
id: "reactive-state-attributes",
|
||||
description: "No project-file changes; state expressions in attributes are reactive at runtime",
|
||||
apply() {
|
||||
// Explicit no-op: updating @wrnexus/compiler and @wrnexus/csr is sufficient.
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/** Release tooling uses this to require an explicit migration entry per version. */
|
||||
|
||||
@@ -149,7 +149,7 @@ A file opens with `page <Name>` or `component <Name>` followed by a `{ ... }` bo
|
||||
- `layout = "<name>"` — selects `app/layouts/<name>.wrn` (pages only).
|
||||
- `props { name = <default> ... }` — component props; each default's type drives coercion.
|
||||
- `state <ident> = <expr>` — reactive state seeded from a raw JS expression.
|
||||
- `view { <html> }` — plain HTML with `{expr}` interpolation, hyphenated attributes, boolean attributes, `@event="..."` client bindings, and `<!-- comments -->`.
|
||||
- `view { <html> }` — plain HTML with `{expr}` interpolation in text and attributes, hyphenated attributes, boolean attributes, `@event="..."` client bindings, and `<!-- comments -->`. Attribute expressions that reference `state` keep an SSR value and update reactively in the browser.
|
||||
- `seo { key = "value" ... }` — metadata merged into the generated `meta`.
|
||||
- `style { <raw css> }` — inlined page/component stylesheet (repeatable).
|
||||
- `functions { <raw js> }` — shared server-side helpers (repeatable).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/compiler",
|
||||
"version": "0.2.24",
|
||||
"version": "0.2.25",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -79,8 +79,35 @@ function eventAttribute(name: string): string {
|
||||
return `data-on-${name}`;
|
||||
}
|
||||
|
||||
function renderAttrs(attrs: Attr[], csrId?: string): string {
|
||||
const rendered = attrs.map(renderAttr).join("");
|
||||
function reactiveAttrValue(raw: string, reactive: PageReactive): string | null {
|
||||
let found = false;
|
||||
const value = raw.replace(/\{([^{}]+)\}/g, (whole, inner: string) => {
|
||||
const expr = inner.trim();
|
||||
if (!exprRefsState(expr, reactive.stateNames)) return whole;
|
||||
found = true;
|
||||
try {
|
||||
const result = new Function("with(this){return (" + expr + ");}").call(reactive.scope);
|
||||
return result == null ? "" : String(result);
|
||||
} catch {
|
||||
return whole;
|
||||
}
|
||||
});
|
||||
return found ? value : null;
|
||||
}
|
||||
|
||||
function renderAttrs(attrs: Attr[], csrId?: string, reactive: PageReactive | null = null): string {
|
||||
let bindIndex = 0;
|
||||
const rendered = attrs
|
||||
.map((attr) => {
|
||||
const base = renderAttr(attr);
|
||||
if (!reactive || attr.event || attr.boolean || !base || !attr.value.includes("{"))
|
||||
return base;
|
||||
const initial = reactiveAttrValue(attr.value, reactive);
|
||||
if (initial === null) return base;
|
||||
const marker = JSON.stringify([attr.name, attr.value]);
|
||||
return ` ${attr.name}="${attrEscape(initial)}" data-wrn-bind-${bindIndex++}="${attrEscape(marker)}"`;
|
||||
})
|
||||
.join("");
|
||||
return csrId ? `${rendered} data-wrnexus-csr="${attrEscape(csrId)}"` : rendered;
|
||||
}
|
||||
|
||||
@@ -312,7 +339,7 @@ function renderNode(
|
||||
|
||||
// Void elements (<br>, <img>, …) have no closing tag and no children.
|
||||
if (VOID_ELEMENTS.has(node.tag.toLowerCase())) {
|
||||
return `<${node.tag}${renderAttrs(node.attrs, csrId)}>`;
|
||||
return `<${node.tag}${renderAttrs(node.attrs, csrId, reactive)}>`;
|
||||
}
|
||||
|
||||
const inner =
|
||||
@@ -331,7 +358,7 @@ function renderNode(
|
||||
)
|
||||
.join("");
|
||||
|
||||
return `<${node.tag}${renderAttrs(node.attrs, csrId)}>${inner}</${node.tag}>`;
|
||||
return `<${node.tag}${renderAttrs(node.attrs, csrId, reactive)}>${inner}</${node.tag}>`;
|
||||
}
|
||||
|
||||
function ssrMarker(bindings: SsrBinding[], binding: RenderBinding): string {
|
||||
@@ -745,14 +772,16 @@ function renderComponentNode(node: ViewNode, ctx: CompCtx): string {
|
||||
);
|
||||
}
|
||||
|
||||
let bindIndex = 0;
|
||||
const attrs = node.attrs
|
||||
.map((a) =>
|
||||
a.event
|
||||
? ` ${eventAttribute(a.name)}="${compileAttrValue(a.value, ctx)}"`
|
||||
: a.boolean
|
||||
? ` ${a.name}`
|
||||
: ` ${a.name}="${compileAttrValue(a.value, ctx)}"`,
|
||||
)
|
||||
.map((a) => {
|
||||
if (a.event) return ` ${eventAttribute(a.name)}="${compileAttrValue(a.value, ctx)}"`;
|
||||
if (a.boolean) return ` ${a.name}`;
|
||||
const rendered = ` ${a.name}="${compileAttrValue(a.value, ctx)}"`;
|
||||
if (!a.value.includes("{") || !exprRefsState(a.value, ctx.stateNames)) return rendered;
|
||||
const marker = attrEscape(JSON.stringify([a.name, a.value]));
|
||||
return `${rendered} data-wrn-bind-${bindIndex++}="${escLit(marker)}"`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
// A `data-for` element introduces loop variables for its subtree.
|
||||
|
||||
@@ -116,6 +116,20 @@ test("page state text bakes its initial value into a reactive data-text span", (
|
||||
expect(page).toContain("data-scope"); // state still forces a reactive scope
|
||||
});
|
||||
|
||||
test("page state attributes bake their initial value and retain a reactive binding", () => {
|
||||
const page = compileWireFile(`page Password {
|
||||
state show = false
|
||||
view {
|
||||
<input type="{show ? 'text' : 'password'}" aria-label="{show ? 'Hide' : 'Show'}">
|
||||
<button @click="show = !show">Toggle</button>
|
||||
}
|
||||
}`);
|
||||
expect(page).toContain('type="password"');
|
||||
expect(page).toContain('aria-label="Show"');
|
||||
expect(page.match(/data-wrn-bind-/g)).toHaveLength(2);
|
||||
expect(page).toContain("show ? 'text' : 'password'");
|
||||
});
|
||||
|
||||
test("data-for: loop-variable mustaches stay literal (not baked server-side)", () => {
|
||||
const comp = compileWireFile(
|
||||
`component TodoList {\n state todos = []\n view { <ul><li data-for="t in todos">{t.text}</li></ul> }\n}`,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/core",
|
||||
"version": "0.2.24",
|
||||
"version": "0.2.25",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
+12
-1
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/csr",
|
||||
"version": "0.2.24",
|
||||
"version": "0.2.25",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 ? 'text' : 'password'}"]'>
|
||||
<button data-on-click="show = !show" aria-label="Show password"
|
||||
data-wrn-bind-0='["aria-label","{show ? 'Hide password' : 'Show password'}"]'>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">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/db",
|
||||
"version": "0.2.24",
|
||||
"version": "0.2.25",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/dev-server",
|
||||
"version": "0.2.24",
|
||||
"version": "0.2.25",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/encryption",
|
||||
"version": "0.2.24",
|
||||
"version": "0.2.25",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/helpers",
|
||||
"version": "0.2.24",
|
||||
"version": "0.2.25",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Safe convenience helpers for WrNexus request contexts and common application flows.",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/i18n",
|
||||
"version": "0.2.24",
|
||||
"version": "0.2.25",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/jwt",
|
||||
"version": "0.2.24",
|
||||
"version": "0.2.25",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/mobile",
|
||||
"version": "0.2.24",
|
||||
"version": "0.2.25",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/native",
|
||||
"version": "0.2.24",
|
||||
"version": "0.2.25",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/oauth",
|
||||
"version": "0.2.24",
|
||||
"version": "0.2.25",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/pubsub",
|
||||
"version": "0.2.24",
|
||||
"version": "0.2.25",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/queue",
|
||||
"version": "0.2.24",
|
||||
"version": "0.2.25",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/reactive",
|
||||
"version": "0.2.24",
|
||||
"version": "0.2.25",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/router",
|
||||
"version": "0.2.24",
|
||||
"version": "0.2.25",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/ssr",
|
||||
"version": "0.2.24",
|
||||
"version": "0.2.25",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/styles",
|
||||
"version": "0.2.24",
|
||||
"version": "0.2.25",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/test",
|
||||
"version": "0.2.24",
|
||||
"version": "0.2.25",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/tracking",
|
||||
"version": "0.2.24",
|
||||
"version": "0.2.25",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/ui",
|
||||
"version": "0.2.24",
|
||||
"version": "0.2.25",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/uploader",
|
||||
"version": "0.2.24",
|
||||
"version": "0.2.25",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/validation",
|
||||
"version": "0.2.24",
|
||||
"version": "0.2.25",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
Reference in New Issue
Block a user