fix(compiler): preserve server-only loader text and control blocks

This commit is contained in:
Clintchiz committed 2026-08-26 12:25:26 +05:30
1 parent aec1dc6056
commit bfa72d5679
4 files changed
+36 -6

No files matched your search

+1 -1
View File
@@ -320,7 +320,7 @@
}, },
"packages/compiler": { "packages/compiler": {
"name": "@wrnexus/compiler", "name": "@wrnexus/compiler",
"version": "0.8.23", "version": "0.8.24",
"dependencies": { "dependencies": {
"@wrnexus/csr": "workspace:*", "@wrnexus/csr": "workspace:*",
"@wrnexus/store": "workspace:*", "@wrnexus/store": "workspace:*",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/compiler", "name": "@wrnexus/compiler",
"version": "0.8.23", "version": "0.8.24",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"exports": { "exports": {
+18 -3
View File
@@ -417,15 +417,17 @@ function substituteReactiveText(
dynamicExpressions?: string[], dynamicExpressions?: string[],
): string { ): string {
const text = substituteTMarkers(raw); const text = substituteTMarkers(raw);
if (!reactive || reactive.stateNames.size === 0) return text; if (!reactive) return text;
return text.replace(/\{([^{}]+)\}/g, (whole, inner: string) => { return text.replace(/\{([^{}]+)\}/g, (whole, inner: string) => {
const expr = inner.trim(); 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) { if (exprRefsState(expr, reactive.runtimeStateNames) && dynamicExpressions) {
dynamicExpressions.push(`\${__wrnexusEscapeHtml(${expr})}`); dynamicExpressions.push(`\${__wrnexusEscapeHtml(${expr})}`);
const sentinel = `\x00WRNEACH${dynamicExpressions.length - 1}\x00`; const sentinel = `\x00WRNEACH${dynamicExpressions.length - 1}\x00`;
return `<span data-text="${attrEscape(expr)}">${sentinel}</span>`; return clientReactive ? `<span data-text="${attrEscape(expr)}">${sentinel}</span>` : sentinel;
} }
if (!clientReactive) return whole;
let value: unknown; let value: unknown;
try { try {
value = new Function("with(this){return (" + expr + ");}").call(reactive.scope); value = new Function("with(this){return (" + expr + ");}").call(reactive.scope);
@@ -660,6 +662,19 @@ function renderNode(
// templateEscape, swapped for its real `${…}` code after escaping. // templateEscape, swapped for its real `${…}` code after escaping.
if (node.type === "each" || node.type === "if") { if (node.type === "each" || node.type === "if") {
loops.push(node.type === "each" ? compileEachExpr(node) : compileIfExpr(node)); 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 = const definition =
node.type === "each" node.type === "each"
? { ? {
@@ -6,6 +6,9 @@ test("server-only loader data renders native input attributes without client sta
parse(`page Profile { parse(`page Profile {
load server account { return {} } load server account { return {} }
view { view {
<h1>{account.name}</h1>
{#if account.readonly}<p>Read only</p>{:else}<p>Editable</p>{/if}
{#each account.items as item}<strong>{item}</strong>{/each}
<input value="{account.name}" readonly="{account.readonly}" /> <input value="{account.name}" readonly="{account.readonly}" />
<select><option value="light" selected="{account.theme === 'light'}">Light</option><option value="dark" selected="{account.theme === 'dark'}">Dark</option></select> <select><option value="light" selected="{account.theme === 'light'}">Light</option><option value="dark" selected="{account.theme === 'dark'}">Dark</option></select>
<div data-component="ProfileShell" identity="{account.identity}"></div> <div data-component="ProfileShell" identity="{account.identity}"></div>
@@ -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")}` `data:text/javascript;base64,${Buffer.from(javascript).toString("base64")}`
); );
const html = await page.default({ const html = await page.default({
account: { name: 'A "<User>"', readonly: false, theme: "dark", identity: { name: "Alice" } }, account: {
items: ["Example record"],
name: 'A "<User>"',
readonly: false,
theme: "dark",
identity: { name: "Alice" },
},
}); });
expect(html).toContain('value="A &quot;&lt;User&gt;&quot;"'); expect(html).toContain('value="A &quot;&lt;User&gt;&quot;"');
expect(html).not.toContain(" readonly"); expect(html).not.toContain(" readonly");
@@ -26,4 +35,10 @@ test("server-only loader data renders native input attributes without client sta
expect(html).toContain('identity="{&quot;name&quot;:&quot;Alice&quot;}"'); expect(html).toContain('identity="{&quot;name&quot;:&quot;Alice&quot;}"');
expect(html).not.toContain("data-wrn-bind"); expect(html).not.toContain("data-wrn-bind");
expect(html).not.toContain("{account."); expect(html).not.toContain("{account.");
expect(html).toContain("<h1>A &quot;&lt;User&gt;&quot;</h1>");
expect(html).toContain("<p>Editable</p>");
expect(html).toContain("<strong>Example record</strong>");
expect(html).not.toContain("data-wrn-if");
expect(html).not.toContain("data-wrn-each");
expect(html).not.toContain("data-text");
}); });