From 53b9947ef9f9dc1aea4f38e09022929abfbad6cc Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Sun, 23 Aug 2026 19:42:39 +0530 Subject: [PATCH] fix: synchronize mounted component props --- bun.lock | 6 +-- packages/compiler/package.json | 2 +- packages/compiler/src/codegen.ts | 16 ++++-- packages/compiler/test/compiler.test.ts | 10 ++++ packages/dev-server/package.json | 2 +- packages/dev-server/src/runtime.ts | 12 ++++- .../component-composition-runtime.test.ts | 26 ++++++++++ packages/test/package.json | 2 +- packages/test/src/index.ts | 6 +++ packages/test/src/mutation.ts | 52 +++++++++++++++++++ packages/test/test/mutation.test.ts | 35 +++++++++++++ 11 files changed, 159 insertions(+), 10 deletions(-) create mode 100644 packages/test/src/mutation.ts create mode 100644 packages/test/test/mutation.test.ts diff --git a/bun.lock b/bun.lock index bbbb024a..7d61343f 100644 --- a/bun.lock +++ b/bun.lock @@ -320,7 +320,7 @@ }, "packages/compiler": { "name": "@wrnexus/compiler", - "version": "0.8.19", + "version": "0.8.20", "dependencies": { "@wrnexus/csr": "workspace:*", "@wrnexus/store": "workspace:*", @@ -357,7 +357,7 @@ }, "packages/dev-server": { "name": "@wrnexus/dev-server", - "version": "0.8.50", + "version": "0.8.51", "dependencies": { "@wrnexus/authz": "workspace:*", "@wrnexus/cache": "workspace:*", @@ -662,7 +662,7 @@ }, "packages/test": { "name": "@wrnexus/test", - "version": "0.8.11", + "version": "0.8.12", }, "packages/tracking": { "name": "@wrnexus/tracking", diff --git a/packages/compiler/package.json b/packages/compiler/package.json index d9db7966..850dd516 100644 --- a/packages/compiler/package.json +++ b/packages/compiler/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/compiler", - "version": "0.8.19", + "version": "0.8.20", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/compiler/src/codegen.ts b/packages/compiler/src/codegen.ts index cbdc1a53..8f4e3fbd 100644 --- a/packages/compiler/src/codegen.ts +++ b/packages/compiler/src/codegen.ts @@ -834,7 +834,7 @@ function renderPageComponentInvocation( const attrs = expandBindings(node.attrs) .filter((attr) => attr.name !== "data-component") - .map((attr) => renderPageComponentAttr(attr, loops)) + .map((attr) => renderPageComponentAttr(attr, loops, reactive)) .join(""); const inner = node.children @@ -3170,7 +3170,11 @@ function __wrnProp(v: unknown): string { return __wrnAttr(value); } -function renderPageComponentAttr(attr: Attr, dynamicExpressions: string[]): string { +function renderPageComponentAttr( + attr: Attr, + dynamicExpressions: string[], + reactive: PageReactive | null, +): string { if (attr.event) { return ` ${componentEventAttribute(attr.name)}="${attrEscape(attr.value)}"`; } @@ -3188,6 +3192,12 @@ function renderPageComponentAttr(attr: Attr, dynamicExpressions: string[]): stri dynamicExpressions.push(`\${__wrnexusPropAttr(${expression})}`); const marker = `\x00WRNEACH${dynamicExpressions.length - 1}\x00`; + // The mount disappears during SSR. Preserve every dynamic component prop + // as a parent-owned binding; server-only expressions simply fail closed in + // the browser, while page state can continue driving the mounted child. + const binding = ` data-wrn-prop-bind-${dynamicExpressions.length - 1}="${attrEscape( + JSON.stringify([attr.name, attr.value]), + )}"`; - return ` ${attr.name}="${marker}"`; + return ` ${attr.name}="${marker}"${binding}`; } diff --git a/packages/compiler/test/compiler.test.ts b/packages/compiler/test/compiler.test.ts index 4a95fec9..0ffbea5c 100644 --- a/packages/compiler/test/compiler.test.ts +++ b/packages/compiler/test/compiler.test.ts @@ -1151,6 +1151,16 @@ page Home { expect(output).toContain("__wrnexusPropAttr(["); }); +test("page state passed to a component retains a parent-owned prop binding", () => { + const output = generate( + parse(`page Home { + state count = 1 + view { } + }`), + ); + expect(output).toContain("data-wrn-prop-bind-"); + expect(output).toContain("{count}"); +}); test("nested component props retain parent-owned reactive bindings", () => { const output = generate( parse(`component Parent { diff --git a/packages/dev-server/package.json b/packages/dev-server/package.json index 2b0cb7a7..39f391b6 100644 --- a/packages/dev-server/package.json +++ b/packages/dev-server/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/dev-server", - "version": "0.8.50", + "version": "0.8.51", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/dev-server/src/runtime.ts b/packages/dev-server/src/runtime.ts index 5995a77b..ba2a2c16 100644 --- a/packages/dev-server/src/runtime.ts +++ b/packages/dev-server/src/runtime.ts @@ -1769,7 +1769,17 @@ export function createHandlers(deps: RuntimeDeps): Handlers { }, ) : renderComponent(); - result += await renderComponents(rendered, translate, language, depth + 1); + // Prop bindings are authored by the parent mount but consumed by the + // rendered child's scope. The mount itself is replaced during SSR, so + // carry those reserved attributes onto the child's scope root. + const propBindings = Array.from( + attrStr!.matchAll(/\s(data-wrn-prop-bind-[A-Za-z0-9_-]+(?:="[^"]*")?)/g), + (match) => ` ${match[1]}`, + ).join(""); + const bridged = propBindings + ? rendered.replace(/(<[A-Za-z][A-Za-z0-9-]*\b[^>]*\bdata-scope="[^"]*")/, `$1${propBindings}`) + : rendered; + result += await renderComponents(bridged, translate, language, depth + 1); } catch (err) { console.error(`[wrnexus] component '${name}' failed to render`, err); deps.devToolbar?.collector.add( diff --git a/packages/dev-server/test/component-composition-runtime.test.ts b/packages/dev-server/test/component-composition-runtime.test.ts index 7d5b34fc..1f86cdbf 100644 --- a/packages/dev-server/test/component-composition-runtime.test.ts +++ b/packages/dev-server/test/component-composition-runtime.test.ts @@ -90,6 +90,32 @@ test("a component in an initially hidden client branch is server-prepared for in expect(branches[0].body).not.toContain("data-component="); }); +test("SSR carries a parent prop binding onto the rendered child scope", async () => { + const root = mkdtempSync(join(tmpdir(), "wrnexus-component-prop-")); + roots.push(root); + const app = join(root, "app"); + mkdirSync(join(app, "pages"), { recursive: true }); + mkdirSync(join(app, "components"), { recursive: true }); + writeFileSync(join(app, "pages/index.ts"), "export default () => '';\n"); + writeFileSync(join(app, "components/Child.wrn"), "component Child { props { value = 0 } view { {value} } }\n"); + const marker = "["value","{count}"]"; + const handlers = createHandlers({ + mode: "development", + hmr: false, + router: buildRouter(app), + loadModule: async (file) => + basename(file) === "Child.wrn" + ? { render: () => '
1
' } + : { default: () => `
` }, + getMiddleware: async () => [], + assets: { serve: async () => null }, + } satisfies RuntimeDeps); + const response = await handlers.fetch(new Request("https://example.test/"), { upgrade: () => false }); + const html = await response!.text(); + expect(html).toContain(`data-wrn-prop-bind-0="${marker}"`); + expect(html).not.toContain('data-component="Child"'); +}); + test("translated text is present in the raw server response", async () => { const root = mkdtempSync(join(tmpdir(), "wrnexus-ssr-i18n-")); roots.push(root); diff --git a/packages/test/package.json b/packages/test/package.json index eb7ef94b..e606602d 100644 --- a/packages/test/package.json +++ b/packages/test/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/test", - "version": "0.8.11", + "version": "0.8.12", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/test/src/index.ts b/packages/test/src/index.ts index 3d3c1c5b..39bccda8 100644 --- a/packages/test/src/index.ts +++ b/packages/test/src/index.ts @@ -205,3 +205,9 @@ export type { } from "./advanced.ts"; export { withDatabaseRollback, createFactory, captureBrowserArtifacts } from "./platform.ts"; export type { TransactionalDatabase, BrowserArtifactPage } from "./platform.ts"; +export { detectMutations } from "./mutation.ts"; +export type { + MutationCase, + MutationReport, + DetectMutationsOptions, +} from "./mutation.ts"; diff --git a/packages/test/src/mutation.ts b/packages/test/src/mutation.ts new file mode 100644 index 00000000..a280f665 --- /dev/null +++ b/packages/test/src/mutation.ts @@ -0,0 +1,52 @@ +export interface MutationCase { + name: string; + value: T; +} + +export interface MutationReport { + baseline: O; + killed: string[]; + survived: string[]; +} + +export interface DetectMutationsOptions { + baseline: T; + mutations: Array>; + exercise(value: T): O | Promise; + equivalent?(left: O, right: O): boolean; +} + +/** + * Run the same behavioural probe against a baseline and explicit mutations. + * A mutation survives when the observable result is unchanged. The helper + * throws with every survivor by default, making it suitable for an ordinary + * Bun test and CI without a second runner or source-rewriting process. + */ +export async function detectMutations( + options: DetectMutationsOptions, +): Promise> { + if (!options.mutations.length) { + throw new Error("WRN-MUTATION-EMPTY: declare at least one meaningful mutation"); + } + const equivalent = options.equivalent ?? ((left, right) => Object.is(left, right)); + const baseline = await options.exercise(options.baseline); + const killed: string[] = []; + const survived: string[] = []; + for (const mutation of options.mutations) { + if (!mutation.name.trim()) throw new Error("WRN-MUTATION-NAME: every mutation needs a name"); + let same = false; + try { + same = equivalent(baseline, await options.exercise(mutation.value)); + } catch { + // A behavioural failure is a killed mutation. + } + (same ? survived : killed).push(mutation.name); + } + if (survived.length) { + throw Object.assign( + new Error(`WRN-MUTATION-SURVIVED: ${survived.join(", ")}`), + { report: { baseline, killed, survived } }, + ); + } + return { baseline, killed, survived }; +} diff --git a/packages/test/test/mutation.test.ts b/packages/test/test/mutation.test.ts new file mode 100644 index 00000000..64e0d127 --- /dev/null +++ b/packages/test/test/mutation.test.ts @@ -0,0 +1,35 @@ +import { expect, test } from "bun:test"; +import { detectMutations } from "../src/mutation.ts"; + +test("detectMutations reports mutations whose behaviour changes", async () => { + const report = await detectMutations({ + baseline: (amount: number) => amount, + mutations: [ + { name: "invert sign", value: (amount: number) => -amount }, + { name: "drop amount", value: () => 0 }, + ], + exercise: (adjust) => adjust(7), + }); + expect(report.killed).toEqual(["invert sign", "drop amount"]); + expect(report.survived).toEqual([]); +}); + +test("detectMutations fails with the names of surviving mutations", async () => { + await expect( + detectMutations({ + baseline: (amount: number) => amount, + mutations: [{ name: "wrong implementation", value: (amount: number) => amount }], + exercise: (adjust) => adjust(7), + }), + ).rejects.toThrow(/WRN-MUTATION-SURVIVED: wrong implementation/); +}); + +test("detectMutations accepts structured observations through an equivalence function", async () => { + const report = await detectMutations({ + baseline: { status: 200 }, + mutations: [{ name: "deny request", value: { status: 403 } }], + exercise: async (response) => ({ ...response, volatile: crypto.randomUUID() }), + equivalent: (left, right) => left.status === right.status, + }); + expect(report.killed).toEqual(["deny request"]); +});