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}"`;
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<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. */
functionNames: Set<string>;
/** 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 {
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(`<span data-text="${attrEscape(expr)}">`) +
`\${__wireHtml(${ctx.resolveExpr(expr)})}` +
escLit(`</span>`);
} 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;
}