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
+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;
expect(() => render()).toThrow("TypedInput requires prop 'label' (string)");
expect(render({ label: "Total", count: "4", enabled: "true" })).toContain(
"<span>Total:4:true</span>",
);
const rendered = render({ label: "Total", count: "4", enabled: "true" });
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", 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 enabled = render();
expect(enabled).not.toContain(" disabled");
expect(enabled).not.toContain(" checked");
expect(enabled).not.toContain(" required");
expect(enabled).not.toContain("<button disabled");
expect(enabled).not.toContain("<input checked");
expect(enabled).not.toContain("<input required");
const disabled = render({ disabled: "true", checked: "true", required: "true" });
expect(disabled).toContain("<button disabled>");
expect(disabled).toContain("<input checked required>");
expect(disabled).toContain("<button disabled");
expect(disabled).toContain("<input checked");
expect(disabled).toContain(" required");
});
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("onclick=");
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 () => {
@@ -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 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).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(
`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" });
expect(out).toContain('class="wire-btn wire-btn--primary mt-2"');
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(
`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" });
expect(out).toContain('data-scope="start: 10, label: &quot;Score&quot;, count: 10"');
expect(out).toContain('data-on-click="count++"');
// label baked as static text; count baked as its initial value AND kept live.
expect(out).toContain('Score: <span data-text="count">10</span>');
expect(out).toContain(
'<span data-text="label">Score</span>: <span data-text="count">10</span>',
);
expect(out).toContain('data-scope="');
@@ -1083,6 +1088,19 @@ page Home {
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", () => {
const output = generate(
parse(`