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"]);
+});