diff --git a/bun.lock b/bun.lock index 090127a4..e677decd 100644 --- a/bun.lock +++ b/bun.lock @@ -320,7 +320,7 @@ }, "packages/compiler": { "name": "@wrnexus/compiler", - "version": "0.8.23", + "version": "0.8.24", "dependencies": { "@wrnexus/csr": "workspace:*", "@wrnexus/store": "workspace:*", diff --git a/packages/compiler/package.json b/packages/compiler/package.json index 521be118..1160ad97 100644 --- a/packages/compiler/package.json +++ b/packages/compiler/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/compiler", - "version": "0.8.23", + "version": "0.8.24", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/compiler/src/codegen.ts b/packages/compiler/src/codegen.ts index 28248ad9..a26268ef 100644 --- a/packages/compiler/src/codegen.ts +++ b/packages/compiler/src/codegen.ts @@ -417,15 +417,17 @@ function substituteReactiveText( dynamicExpressions?: string[], ): string { const text = substituteTMarkers(raw); - if (!reactive || reactive.stateNames.size === 0) return text; + if (!reactive) return text; return text.replace(/\{([^{}]+)\}/g, (whole, inner: string) => { const expr = inner.trim(); - if (expr.startsWith("t:") || !exprRefsState(expr, reactive.stateNames)) return whole; + const clientReactive = exprRefsState(expr, reactive.stateNames); + if (expr.startsWith("t:")) return whole; if (exprRefsState(expr, reactive.runtimeStateNames) && dynamicExpressions) { dynamicExpressions.push(`\${__wrnexusEscapeHtml(${expr})}`); const sentinel = `\x00WRNEACH${dynamicExpressions.length - 1}\x00`; - return `${sentinel}`; + return clientReactive ? `${sentinel}` : sentinel; } + if (!clientReactive) return whole; let value: unknown; try { value = new Function("with(this){return (" + expr + ");}").call(reactive.scope); @@ -660,6 +662,19 @@ function renderNode( // templateEscape, swapped for its real `${…}` code after escaping. if (node.type === "each" || node.type === "if") { loops.push(node.type === "each" ? compileEachExpr(node) : compileIfExpr(node)); + const conditions = + node.type === "each" + ? [node.list] + : node.branches.flatMap((branch) => (branch.cond ? [branch.cond] : [])); + // Server-only loader values are not serialized into browser state. Their + // controls must not be re-evaluated by a surrounding component's hydration. + if ( + reactive && + conditions.some((condition) => exprRefsState(condition, reactive.runtimeStateNames)) && + !conditions.some((condition) => exprRefsState(condition, reactive.stateNames)) + ) { + return `\x00WRNEACH${loops.length - 1}\x00`; + } const definition = node.type === "each" ? { diff --git a/packages/compiler/test/server-load-attributes.test.ts b/packages/compiler/test/server-load-attributes.test.ts index 09830218..46f54797 100644 --- a/packages/compiler/test/server-load-attributes.test.ts +++ b/packages/compiler/test/server-load-attributes.test.ts @@ -6,6 +6,9 @@ test("server-only loader data renders native input attributes without client sta parse(`page Profile { load server account { return {} } view { +
Read only
{:else}Editable
{/if} + {#each account.items as item}{item}{/each} @@ -17,7 +20,13 @@ test("server-only loader data renders native input attributes without client sta `data:text/javascript;base64,${Buffer.from(javascript).toString("base64")}` ); const html = await page.default({ - account: { name: 'A "Editable
"); + expect(html).toContain("Example record"); + expect(html).not.toContain("data-wrn-if"); + expect(html).not.toContain("data-wrn-each"); + expect(html).not.toContain("data-text"); });