diff --git a/packages/compiler/src/codegen.ts b/packages/compiler/src/codegen.ts index e19d57dd..ba7657f4 100644 --- a/packages/compiler/src/codegen.ts +++ b/packages/compiler/src/codegen.ts @@ -751,16 +751,16 @@ function renderNestedComponentInvocation( const rendered = ` ${attr.name}="${compiledValue}"`; if ( - wholeExpression || !attr.value.includes("{") || - !exprRefsState(attr.value, ctx.stateNames) + (!exprRefsState(attr.value, ctx.stateNames) && + !exprRefsState(attr.value, ctx.propNames)) ) { return rendered; } 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(""); @@ -1673,6 +1673,8 @@ const ${name} = async (ctx: any) => {${api.body}};`); interface CompCtx { /** State names — text referencing any of them stays a reactive client mustache. */ stateNames: Set; + /** Props are signals too, allowing a parent to drive a mounted child. */ + propNames: Set; /** Component functions can read state and therefore make their callers reactive. */ functionNames: Set; /** Rewrite reserved-word prop/state identifiers to their safe const names. */ @@ -1848,7 +1850,11 @@ function exprRefsState(expr: string, stateNames: Set): 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 { @@ -1918,6 +1924,8 @@ function compileText(raw: string, ctx: CompCtx): string { // Loop variable (from data-for): leave a literal client mustache — the // list renderer fills it per item; it has no server-side value. out += escLit(`{${expr}}`); + } else if (expr === "content") { + out += `\${__wireRaw(${ctx.resolveExpr(expr)})}`; } else if (exprRefsComponentReactiveValue(expr, ctx)) { // 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 @@ -1926,8 +1934,6 @@ function compileText(raw: string, ctx: CompCtx): string { escLit(``) + `\${__wireHtml(${ctx.resolveExpr(expr)})}` + escLit(``); - } else if (expr === "content") { - out += `\${__wireRaw(${ctx.resolveExpr(expr)})}`; } else { out += `\${__wireHtml(${ctx.resolveExpr(expr)})}`; } @@ -2346,6 +2352,7 @@ function generateComponent(ast: PageAst): string { }; const ctx: CompCtx = { stateNames, + propNames: new Set(effectiveProps.map((entry) => entry.name)), functionNames: new Set( 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 styleTag = escLit(localStyleTag(ast, styles)); - // A component needs a reactive scope only when it has state or event handlers. - // Prop-driven text/attributes are baked server-side, so static components ship - // no JavaScript at all. + // Props remain server-rendered and also become signals so a parent can drive + // a mounted child after hydration. const behavior = componentBehavior(ast); const needsScope = ast.runtime !== "server" && - (browserStates.length > 0 || + (effectiveProps.length > 0 || + browserStates.length > 0 || ast.computed.length > 0 || viewHasEvents(ast.view) || behavior !== null); @@ -2628,10 +2635,12 @@ function __wireSpreadAttrs(value: any): string { lowerName === "style" || lowerName === "slot" || lowerName === "data-component" || - // Internal markers must not leak through a spread -- except the - // parent's output handlers, whose whole job is to ride from the mount - // onto the view root so the mounting scope can bind them there. - (lowerName.startsWith("data-wrn") && !lowerName.startsWith("data-wrn-out-")) + // Internal markers must not leak through a spread. Parent-owned output + // and prop bindings ride from the mount onto the + // rendered child root so the mounting scope can bind them there. + (lowerName.startsWith("data-wrn") && + !lowerName.startsWith("data-wrn-out-") && + !lowerName.startsWith("data-wrn-prop-bind-")) ) { continue; } diff --git a/packages/compiler/test/__snapshots__/resilience.test.ts.snap b/packages/compiler/test/__snapshots__/resilience.test.ts.snap index d2599c3e..ee5e1b9b 100644 --- a/packages/compiler/test/__snapshots__/resilience.test.ts.snap +++ b/packages/compiler/test/__snapshots__/resilience.test.ts.snap @@ -176,10 +176,12 @@ function __wireSpreadAttrs(value: any): string { lowerName === "style" || lowerName === "slot" || lowerName === "data-component" || - // Internal markers must not leak through a spread -- except the - // parent's output handlers, whose whole job is to ride from the mount - // onto the view root so the mounting scope can bind them there. - (lowerName.startsWith("data-wrn") && !lowerName.startsWith("data-wrn-out-")) + // Internal markers must not leak through a spread. Parent-owned output + // and prop bindings ride from the mount onto the + // rendered child root so the mounting scope can bind them there. + (lowerName.startsWith("data-wrn") && + !lowerName.startsWith("data-wrn-out-") && + !lowerName.startsWith("data-wrn-prop-bind-")) ) { continue; } @@ -269,7 +271,7 @@ export function render(props: CounterProps = {} as CounterProps): string { const __scope = __wrnexusScopeDecl(__scopeState); const __scopePayload = __WrnexusBuffer.from(JSON.stringify(__scopeState), "utf8").toString("base64"); return \`
-
\${__wireHtml(label)}: \${__wireHtml(count)}
+
\${__wireHtml(label)}: \${__wireHtml(count)}
\`; } diff --git a/packages/compiler/test/compiler.test.ts b/packages/compiler/test/compiler.test.ts index 45fcb76e..3ef0b8e4 100644 --- a/packages/compiler/test/compiler.test.ts +++ b/packages/compiler/test/compiler.test.ts @@ -200,9 +200,10 @@ test("typed props enforce required values and runtime-compatible input", async ( }`); const render = component.render as (props?: Record) => string; expect(() => render()).toThrow("TypedInput requires prop 'label' (string)"); - expect(render({ label: "Total", count: "4", enabled: "true" })).toContain( - "Total:4:true", - ); + const rendered = render({ label: "Total", count: "4", enabled: "true" }); + expect(rendered).toContain('Total'); + expect(rendered).toContain('4'); + expect(rendered).toContain('true'); 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; const enabled = render(); - expect(enabled).not.toContain(" disabled"); - expect(enabled).not.toContain(" checked"); - expect(enabled).not.toContain(" required"); + expect(enabled).not.toContain("'); + expect(html).toContain(' }\n}`, ); @@ -313,10 +316,11 @@ test("stateless component bakes props into server HTML (zero JS)", async () => { const out = render({ label: "Save ", variant: "primary", class: "mt-2" }); expect(out).toContain('class="wire-btn wire-btn--primary mt-2"'); expect(out).toContain("Save <b>"); // 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 { }\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: "Score", 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: 10'); + expect(out).toContain( + 'Score: 10', + ); 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 { } + }`), + ); + + expect(output).toContain("data-wrn-prop-bind-0"); + expect(output).toContain("["value","{count}"]"); + expect(output).toContain('lowerName.startsWith("data-wrn-prop-bind-")'); +}); test("component functions are available during server rendering", () => { const output = generate( parse(` diff --git a/packages/csr/src/reactive-runtime.ts b/packages/csr/src/reactive-runtime.ts index d92d4b12..b13ef6f4 100644 --- a/packages/csr/src/reactive-runtime.ts +++ b/packages/csr/src/reactive-runtime.ts @@ -2600,9 +2600,43 @@ export const REACTIVE_RUNTIME = String.raw` }); }); - if (behavior) { - installBehaviorFunctions(behavior.functions); - var publicScopeApi = { + // Prop expressions belong to the parent that mounted the component. The + // 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 = { get: peekScope, set: writeScope, call: function (name) { @@ -2610,11 +2644,14 @@ export const REACTIVE_RUNTIME = String.raw` if (typeof fn !== "function") return undefined; return fn.apply(null, Array.prototype.slice.call(arguments, 1)); }, - }; - el.__wrnexusScopeApi = publicScopeApi; - el.querySelectorAll("[data-wrn-select]").forEach(function (select) { - select.__wrnexusScopeApi = publicScopeApi; - }); + }; + el.__wrnexusScopeApi = publicScopeApi; + el.querySelectorAll("[data-wrn-select]").forEach(function (select) { + select.__wrnexusScopeApi = publicScopeApi; + }); + + if (behavior) { + installBehaviorFunctions(behavior.functions); (behavior.effects || []).forEach(function (source) { if (typeof source !== "string" || !source.trim()) return; diff --git a/packages/csr/test/reactive.test.ts b/packages/csr/test/reactive.test.ts index 81a4ed0d..cc32796b 100644 --- a/packages/csr/test/reactive.test.ts +++ b/packages/csr/test/reactive.test.ts @@ -362,6 +362,20 @@ test("reactive attribute bindings update input and accessibility attributes", () expect(button.getAttribute("aria-label")).toBe("Hide password"); }); +test("parent state updates a mounted child's reactive prop", async () => { + const win = mount( + `
` + + `` + + `
` + + `1` + + `
`, + ); + 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)", () => { const win = mount( `