feat(framework): make component props reactive

This commit is contained in:
2026-08-09 13:17:36 +05:30
parent 7c584c1d2e
commit a1f671ed5d
5 changed files with 123 additions and 43 deletions
+23 -14
View File
@@ -751,16 +751,16 @@ function renderNestedComponentInvocation(
const rendered = ` ${attr.name}="${compiledValue}"`; const rendered = ` ${attr.name}="${compiledValue}"`;
if ( if (
wholeExpression ||
!attr.value.includes("{") || !attr.value.includes("{") ||
!exprRefsState(attr.value, ctx.stateNames) (!exprRefsState(attr.value, ctx.stateNames) &&
!exprRefsState(attr.value, ctx.propNames))
) { ) {
return rendered; return rendered;
} }
const marker = attrEscape(JSON.stringify([attr.name, attr.value])); const marker = attrEscape(JSON.stringify([attr.name, attr.value]));
return rendered + ` data-wrn-bind-${bindIndex++}="${escLit(marker)}"`; return rendered + ` data-wrn-prop-bind-${bindIndex++}="${escLit(marker)}"`;
}) })
.join(""); .join("");
@@ -1673,6 +1673,8 @@ const ${name} = async (ctx: any) => {${api.body}};`);
interface CompCtx { interface CompCtx {
/** State names — text referencing any of them stays a reactive client mustache. */ /** State names — text referencing any of them stays a reactive client mustache. */
stateNames: Set<string>; stateNames: Set<string>;
/** Props are signals too, allowing a parent to drive a mounted child. */
propNames: Set<string>;
/** Component functions can read state and therefore make their callers reactive. */ /** Component functions can read state and therefore make their callers reactive. */
functionNames: Set<string>; functionNames: Set<string>;
/** Rewrite reserved-word prop/state identifiers to their safe const names. */ /** Rewrite reserved-word prop/state identifiers to their safe const names. */
@@ -1848,7 +1850,11 @@ function exprRefsState(expr: string, stateNames: Set<string>): boolean {
} }
function exprRefsComponentReactiveValue(expr: string, ctx: CompCtx): boolean { function exprRefsComponentReactiveValue(expr: string, ctx: CompCtx): boolean {
return exprRefsState(expr, ctx.stateNames) || exprRefsState(expr, ctx.functionNames); return (
exprRefsState(expr, ctx.stateNames) ||
exprRefsState(expr, ctx.propNames) ||
exprRefsState(expr, ctx.functionNames)
);
} }
function viewHasEvents(nodes: ViewNode[]): boolean { function viewHasEvents(nodes: ViewNode[]): boolean {
@@ -1918,6 +1924,8 @@ function compileText(raw: string, ctx: CompCtx): string {
// Loop variable (from data-for): leave a literal client mustache — the // Loop variable (from data-for): leave a literal client mustache — the
// list renderer fills it per item; it has no server-side value. // list renderer fills it per item; it has no server-side value.
out += escLit(`{${expr}}`); out += escLit(`{${expr}}`);
} else if (expr === "content") {
out += `\${__wireRaw(${ctx.resolveExpr(expr)})}`;
} else if (exprRefsComponentReactiveValue(expr, ctx)) { } else if (exprRefsComponentReactiveValue(expr, ctx)) {
// State interpolation: bake the initial value AND keep it reactive via a // State interpolation: bake the initial value AND keep it reactive via a
// data-text span, so no-JS clients see the real value and hydration // data-text span, so no-JS clients see the real value and hydration
@@ -1926,8 +1934,6 @@ function compileText(raw: string, ctx: CompCtx): string {
escLit(`<span data-text="${attrEscape(expr)}">`) + escLit(`<span data-text="${attrEscape(expr)}">`) +
`\${__wireHtml(${ctx.resolveExpr(expr)})}` + `\${__wireHtml(${ctx.resolveExpr(expr)})}` +
escLit(`</span>`); escLit(`</span>`);
} else if (expr === "content") {
out += `\${__wireRaw(${ctx.resolveExpr(expr)})}`;
} else { } else {
out += `\${__wireHtml(${ctx.resolveExpr(expr)})}`; out += `\${__wireHtml(${ctx.resolveExpr(expr)})}`;
} }
@@ -2346,6 +2352,7 @@ function generateComponent(ast: PageAst): string {
}; };
const ctx: CompCtx = { const ctx: CompCtx = {
stateNames, stateNames,
propNames: new Set(effectiveProps.map((entry) => entry.name)),
functionNames: new Set( functionNames: new Set(
ast.runtimeFunctions.filter((fn) => fn.runtime !== "server").map((fn) => fn.name), ast.runtimeFunctions.filter((fn) => fn.runtime !== "server").map((fn) => fn.name),
), ),
@@ -2374,14 +2381,14 @@ function generateComponent(ast: PageAst): string {
const styles = ast.styles.map((body) => body.trim()).filter(Boolean); const styles = ast.styles.map((body) => body.trim()).filter(Boolean);
const styleTag = escLit(localStyleTag(ast, styles)); const styleTag = escLit(localStyleTag(ast, styles));
// A component needs a reactive scope only when it has state or event handlers. // Props remain server-rendered and also become signals so a parent can drive
// Prop-driven text/attributes are baked server-side, so static components ship // a mounted child after hydration.
// no JavaScript at all.
const behavior = componentBehavior(ast); const behavior = componentBehavior(ast);
const needsScope = const needsScope =
ast.runtime !== "server" && ast.runtime !== "server" &&
(browserStates.length > 0 || (effectiveProps.length > 0 ||
browserStates.length > 0 ||
ast.computed.length > 0 || ast.computed.length > 0 ||
viewHasEvents(ast.view) || viewHasEvents(ast.view) ||
behavior !== null); behavior !== null);
@@ -2628,10 +2635,12 @@ function __wireSpreadAttrs(value: any): string {
lowerName === "style" || lowerName === "style" ||
lowerName === "slot" || lowerName === "slot" ||
lowerName === "data-component" || lowerName === "data-component" ||
// Internal markers must not leak through a spread -- except the // Internal markers must not leak through a spread. Parent-owned output
// parent's output handlers, whose whole job is to ride from the mount // and prop bindings ride from the mount onto the
// onto the view root so the mounting scope can bind them there. // rendered child root so the mounting scope can bind them there.
(lowerName.startsWith("data-wrn") && !lowerName.startsWith("data-wrn-out-")) (lowerName.startsWith("data-wrn") &&
!lowerName.startsWith("data-wrn-out-") &&
!lowerName.startsWith("data-wrn-prop-bind-"))
) { ) {
continue; continue;
} }
@@ -176,10 +176,12 @@ function __wireSpreadAttrs(value: any): string {
lowerName === "style" || lowerName === "style" ||
lowerName === "slot" || lowerName === "slot" ||
lowerName === "data-component" || lowerName === "data-component" ||
// Internal markers must not leak through a spread -- except the // Internal markers must not leak through a spread. Parent-owned output
// parent's output handlers, whose whole job is to ride from the mount // and prop bindings ride from the mount onto the
// onto the view root so the mounting scope can bind them there. // rendered child root so the mounting scope can bind them there.
(lowerName.startsWith("data-wrn") && !lowerName.startsWith("data-wrn-out-")) (lowerName.startsWith("data-wrn") &&
!lowerName.startsWith("data-wrn-out-") &&
!lowerName.startsWith("data-wrn-prop-bind-"))
) { ) {
continue; continue;
} }
@@ -269,7 +271,7 @@ export function render(props: CounterProps = {} as CounterProps): string {
const __scope = __wrnexusScopeDecl(__scopeState); const __scope = __wrnexusScopeDecl(__scopeState);
const __scopePayload = __WrnexusBuffer.from(JSON.stringify(__scopeState), "utf8").toString("base64"); const __scopePayload = __WrnexusBuffer.from(JSON.stringify(__scopeState), "utf8").toString("base64");
return \`<div data-scope="\${__scope}" data-wrn-scope="\${__scopePayload}" data-wrn-behavior="eyJmdW5jdGlvbnMiOiJmdW5jdGlvbiBpbmNyZW1lbnQoKXtcbiAgICAgIGNvdW50ID0gY291bnQgKyAxXG4gICAgICBvdXRwdXQuY2hhbmdlKGNvdW50KVxuICAgIH0iLCJvdXRwdXRzIjpbeyJuYW1lIjoiY2hhbmdlIiwicGF5bG9hZCI6eyJuYW1lIjoidmFsdWUiLCJ2YWx1ZVR5cGUiOiJudW1iZXIiLCJvcHRpb25hbCI6ZmFsc2V9fV0sImNvbXB1dGVkIjpbXSwiZWZmZWN0cyI6W10sImxpZmVjeWNsZSI6e30sIndhdGNoZXMiOltdfQ==" data-wrn-hydration="Counter:1skggk6" data-wrn-hydrate="load" data-wrn-runtime="universal" data-wrn-client-module="__WRNEXUS_CLIENT_MODULE__"> return \`<div data-scope="\${__scope}" data-wrn-scope="\${__scopePayload}" data-wrn-behavior="eyJmdW5jdGlvbnMiOiJmdW5jdGlvbiBpbmNyZW1lbnQoKXtcbiAgICAgIGNvdW50ID0gY291bnQgKyAxXG4gICAgICBvdXRwdXQuY2hhbmdlKGNvdW50KVxuICAgIH0iLCJvdXRwdXRzIjpbeyJuYW1lIjoiY2hhbmdlIiwicGF5bG9hZCI6eyJuYW1lIjoidmFsdWUiLCJ2YWx1ZVR5cGUiOiJudW1iZXIiLCJvcHRpb25hbCI6ZmFsc2V9fV0sImNvbXB1dGVkIjpbXSwiZWZmZWN0cyI6W10sImxpZmVjeWNsZSI6e30sIndhdGNoZXMiOltdfQ==" data-wrn-hydration="Counter:1skggk6" data-wrn-hydrate="load" data-wrn-runtime="universal" data-wrn-client-module="__WRNEXUS_CLIENT_MODULE__">
<div data-component="Button"\${__wireSpreadAttrs(__attrs)} on:click="\${__wireProp(increment)}">\${__wireHtml(label)}: <span data-text="count">\${__wireHtml(count)}</span></div> <div data-component="Button"\${__wireSpreadAttrs(__attrs)} on:click="\${__wireProp(increment)}"><span data-text="label">\${__wireHtml(label)}</span>: <span data-text="count">\${__wireHtml(count)}</span></div>
</div>\`; </div>\`;
} }
+34 -16
View File
@@ -200,9 +200,10 @@ test("typed props enforce required values and runtime-compatible input", async (
}`); }`);
const render = component.render as (props?: Record<string, unknown>) => string; const render = component.render as (props?: Record<string, unknown>) => string;
expect(() => render()).toThrow("TypedInput requires prop 'label' (string)"); expect(() => render()).toThrow("TypedInput requires prop 'label' (string)");
expect(render({ label: "Total", count: "4", enabled: "true" })).toContain( const rendered = render({ label: "Total", count: "4", enabled: "true" });
"<span>Total:4:true</span>", expect(rendered).toContain('<span data-text="label">Total</span>');
); expect(rendered).toContain('<span data-text="count">4</span>');
expect(rendered).toContain('<span data-text="enabled">true</span>');
expect(() => render({ label: "Total", count: "many" })).toThrow("Expected a finite number prop"); expect(() => render({ label: "Total", count: "many" })).toThrow("Expected a finite number prop");
expect(() => render({ label: "Total", enabled: "sometimes" })).toThrow("Expected a boolean prop"); expect(() => render({ label: "Total", enabled: "sometimes" })).toThrow("Expected a boolean prop");
}); });
@@ -238,13 +239,14 @@ test("dynamic HTML boolean attributes are omitted when false", async () => {
const render = mod.render as (props?: Record<string, unknown>) => string; const render = mod.render as (props?: Record<string, unknown>) => string;
const enabled = render(); const enabled = render();
expect(enabled).not.toContain(" disabled"); expect(enabled).not.toContain("<button disabled");
expect(enabled).not.toContain(" checked"); expect(enabled).not.toContain("<input checked");
expect(enabled).not.toContain(" required"); expect(enabled).not.toContain("<input required");
const disabled = render({ disabled: "true", checked: "true", required: "true" }); const disabled = render({ disabled: "true", checked: "true", required: "true" });
expect(disabled).toContain("<button disabled>"); expect(disabled).toContain("<button disabled");
expect(disabled).toContain("<input checked required>"); expect(disabled).toContain("<input checked");
expect(disabled).toContain(" required");
}); });
test("components safely forward undeclared HTML attributes to their root", async () => { test("components safely forward undeclared HTML attributes to their root", async () => {
@@ -282,7 +284,7 @@ test("components safely forward undeclared HTML attributes to their root", async
expect(html).not.toContain("variant="); expect(html).not.toContain("variant=");
expect(html).not.toContain("onclick="); expect(html).not.toContain("onclick=");
expect(html).not.toContain("style="); expect(html).not.toContain("style=");
expect(html).not.toContain("data-wrn-bind"); expect(html).not.toContain('data-wrn-bind-0="unsafe"');
}); });
test("an explicit attrs spread overrides automatic root forwarding", async () => { test("an explicit attrs spread overrides automatic root forwarding", async () => {
@@ -300,12 +302,13 @@ test("an explicit attrs spread overrides automatic root forwarding", async () =>
const render = mod.render as (props?: Record<string, unknown>) => string; const render = mod.render as (props?: Record<string, unknown>) => string;
const html = render({ label: "Save", class: "wrapper", formaction: "/save" }); const html = render({ label: "Save", class: "wrapper", formaction: "/save" });
expect(html).toContain('<span class="wrapper">'); expect(html).toContain('<span class="wrapper"');
expect(html).not.toContain('<span formaction="/save"'); expect(html).not.toContain('<span formaction="/save"');
expect(html).toContain('<button formaction="/save">Save</button>'); expect(html).toContain('<button formaction="/save">');
expect(html).toContain('<span data-text="label">Save</span>');
}); });
test("stateless component bakes props into server HTML (zero JS)", async () => { test("prop-driven component renders SSR content and exposes reactive prop signals", async () => {
const mod = await compileAndImport( const mod = await compileAndImport(
`component Button {\n props {\n label = "Button"\n variant = "default"\n class = ""\n }\n view { <button class="wire-btn wire-btn--{variant} {class}">{label}</button> }\n}`, `component Button {\n props {\n label = "Button"\n variant = "default"\n class = ""\n }\n view { <button class="wire-btn wire-btn--{variant} {class}">{label}</button> }\n}`,
); );
@@ -313,10 +316,11 @@ test("stateless component bakes props into server HTML (zero JS)", async () => {
const out = render({ label: "Save <b>", variant: "primary", class: "mt-2" }); const out = render({ label: "Save <b>", variant: "primary", class: "mt-2" });
expect(out).toContain('class="wire-btn wire-btn--primary mt-2"'); expect(out).toContain('class="wire-btn wire-btn--primary mt-2"');
expect(out).toContain("Save &lt;b&gt;"); // html-escaped expect(out).toContain("Save &lt;b&gt;"); // html-escaped
expect(out).not.toContain("data-scope"); // no reactivity → no scope expect(out).toContain("data-scope");
expect(out).toContain('data-text="label"');
}); });
test("stateful component: state text baked into a reactive data-text span, prop text baked", async () => { test("stateful component keeps both prop and state text reactive", async () => {
const mod = await compileAndImport( const mod = await compileAndImport(
`component Counter {\n props {\n start = 0\n label = "Count"\n }\n state count = start\n view { <button @click="count++">{label}: {count}</button> }\n}`, `component Counter {\n props {\n start = 0\n label = "Count"\n }\n state count = start\n view { <button @click="count++">{label}: {count}</button> }\n}`,
); );
@@ -324,8 +328,9 @@ test("stateful component: state text baked into a reactive data-text span, prop
const out = render({ start: "10", label: "Score" }); const out = render({ start: "10", label: "Score" });
expect(out).toContain('data-scope="start: 10, label: &quot;Score&quot;, count: 10"'); expect(out).toContain('data-scope="start: 10, label: &quot;Score&quot;, count: 10"');
expect(out).toContain('data-on-click="count++"'); expect(out).toContain('data-on-click="count++"');
// label baked as static text; count baked as its initial value AND kept live. expect(out).toContain(
expect(out).toContain('Score: <span data-text="count">10</span>'); '<span data-text="label">Score</span>: <span data-text="count">10</span>',
);
expect(out).toContain('data-scope="'); expect(out).toContain('data-scope="');
@@ -1083,6 +1088,19 @@ page Home {
expect(output).toContain("__wrnexusPropAttr(["); expect(output).toContain("__wrnexusPropAttr([");
}); });
test("nested component props retain parent-owned reactive bindings", () => {
const output = generate(
parse(`component Parent {
props { value = 0 }
state count = value
view { <Child value={count} /> }
}`),
);
expect(output).toContain("data-wrn-prop-bind-0");
expect(output).toContain("[&quot;value&quot;,&quot;{count}&quot;]");
expect(output).toContain('lowerName.startsWith("data-wrn-prop-bind-")');
});
test("component functions are available during server rendering", () => { test("component functions are available during server rendering", () => {
const output = generate( const output = generate(
parse(` parse(`
+39 -2
View File
@@ -2600,8 +2600,42 @@ export const REACTIVE_RUNTIME = String.raw`
}); });
}); });
if (behavior) { // Prop expressions belong to the parent that mounted the component. The
installBehaviorFunctions(behavior.functions); // server forwards these markers onto the rendered child root; evaluate
// them here and write changes into the child's prop signals.
Array.prototype.slice
.call(el.querySelectorAll("[data-scope], [data-wrn-scope]"))
.forEach(function (node) {
if (!node.parentNode || ownerScope(node.parentNode) !== el) return;
Array.prototype.slice.call(node.attributes).forEach(function (attr) {
if (attr.name.indexOf("data-wrn-prop-bind-") !== 0) return;
var binding;
try { binding = JSON.parse(attr.value); } catch (_) { return; }
if (!binding || binding.length !== 2) return;
var propName = binding[0];
var template = binding[1];
reactive(function () {
var exact = /^\{([^{}]+)\}$/.exec(template);
var value;
try {
value = exact
? evalExpr(exact[1].trim(), decodeLoopLocals(node))
: template.replace(/\{([^{}]+)\}/g, function (_, expression) {
var part = evalExpr(expression.trim(), decodeLoopLocals(node));
return part == null ? "" : String(part);
});
} catch (_) { return; }
var apply = function () {
if (node.__wrnexusScopeApi) node.__wrnexusScopeApi.set(propName, value);
};
if (node.__wrnexusScopeApi) apply();
else queueMicrotask(apply);
});
});
});
// Every hydrated scope exposes state writes. Prop-only components need the
// same API even when they have no behavior block.
var publicScopeApi = { var publicScopeApi = {
get: peekScope, get: peekScope,
set: writeScope, set: writeScope,
@@ -2616,6 +2650,9 @@ export const REACTIVE_RUNTIME = String.raw`
select.__wrnexusScopeApi = publicScopeApi; select.__wrnexusScopeApi = publicScopeApi;
}); });
if (behavior) {
installBehaviorFunctions(behavior.functions);
(behavior.effects || []).forEach(function (source) { (behavior.effects || []).forEach(function (source) {
if (typeof source !== "string" || !source.trim()) return; if (typeof source !== "string" || !source.trim()) return;
reactive(function () { reactive(function () {
+14
View File
@@ -362,6 +362,20 @@ test("reactive attribute bindings update input and accessibility attributes", ()
expect(button.getAttribute("aria-label")).toBe("Hide password"); expect(button.getAttribute("aria-label")).toBe("Hide password");
}); });
test("parent state updates a mounted child's reactive prop", async () => {
const win = mount(
`<div data-scope="n: 1">` +
`<button data-on-click="n++">increment</button>` +
`<div data-scope="value: 1" data-wrn-prop-bind-0='["value","{n}"]'>` +
`<span id="child-value" data-text="value">1</span>` +
`</div></div>`,
);
await Promise.resolve();
expect(win.document.querySelector("#child-value")?.textContent).toBe("1");
(win.document.querySelector("button") as unknown as HTMLElement).click();
expect(win.document.querySelector("#child-value")?.textContent).toBe("2");
});
test("independent signals in one scope update correctly (dependency tracking)", () => { test("independent signals in one scope update correctly (dependency tracking)", () => {
const win = mount( const win = mount(
`<div data-scope="a: 0, b: 100"> `<div data-scope="a: 0, b: 100">