fix: synchronize mounted component props
This commit is contained in:
@@ -320,7 +320,7 @@
|
|||||||
},
|
},
|
||||||
"packages/compiler": {
|
"packages/compiler": {
|
||||||
"name": "@wrnexus/compiler",
|
"name": "@wrnexus/compiler",
|
||||||
"version": "0.8.19",
|
"version": "0.8.20",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/csr": "workspace:*",
|
"@wrnexus/csr": "workspace:*",
|
||||||
"@wrnexus/store": "workspace:*",
|
"@wrnexus/store": "workspace:*",
|
||||||
@@ -357,7 +357,7 @@
|
|||||||
},
|
},
|
||||||
"packages/dev-server": {
|
"packages/dev-server": {
|
||||||
"name": "@wrnexus/dev-server",
|
"name": "@wrnexus/dev-server",
|
||||||
"version": "0.8.50",
|
"version": "0.8.51",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/authz": "workspace:*",
|
"@wrnexus/authz": "workspace:*",
|
||||||
"@wrnexus/cache": "workspace:*",
|
"@wrnexus/cache": "workspace:*",
|
||||||
@@ -662,7 +662,7 @@
|
|||||||
},
|
},
|
||||||
"packages/test": {
|
"packages/test": {
|
||||||
"name": "@wrnexus/test",
|
"name": "@wrnexus/test",
|
||||||
"version": "0.8.11",
|
"version": "0.8.12",
|
||||||
},
|
},
|
||||||
"packages/tracking": {
|
"packages/tracking": {
|
||||||
"name": "@wrnexus/tracking",
|
"name": "@wrnexus/tracking",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/compiler",
|
"name": "@wrnexus/compiler",
|
||||||
"version": "0.8.19",
|
"version": "0.8.20",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -834,7 +834,7 @@ function renderPageComponentInvocation(
|
|||||||
|
|
||||||
const attrs = expandBindings(node.attrs)
|
const attrs = expandBindings(node.attrs)
|
||||||
.filter((attr) => attr.name !== "data-component")
|
.filter((attr) => attr.name !== "data-component")
|
||||||
.map((attr) => renderPageComponentAttr(attr, loops))
|
.map((attr) => renderPageComponentAttr(attr, loops, reactive))
|
||||||
.join("");
|
.join("");
|
||||||
|
|
||||||
const inner = node.children
|
const inner = node.children
|
||||||
@@ -3170,7 +3170,11 @@ function __wrnProp(v: unknown): string {
|
|||||||
|
|
||||||
return __wrnAttr(value);
|
return __wrnAttr(value);
|
||||||
}
|
}
|
||||||
function renderPageComponentAttr(attr: Attr, dynamicExpressions: string[]): string {
|
function renderPageComponentAttr(
|
||||||
|
attr: Attr,
|
||||||
|
dynamicExpressions: string[],
|
||||||
|
reactive: PageReactive | null,
|
||||||
|
): string {
|
||||||
if (attr.event) {
|
if (attr.event) {
|
||||||
return ` ${componentEventAttribute(attr.name)}="${attrEscape(attr.value)}"`;
|
return ` ${componentEventAttribute(attr.name)}="${attrEscape(attr.value)}"`;
|
||||||
}
|
}
|
||||||
@@ -3188,6 +3192,12 @@ function renderPageComponentAttr(attr: Attr, dynamicExpressions: string[]): stri
|
|||||||
dynamicExpressions.push(`\${__wrnexusPropAttr(${expression})}`);
|
dynamicExpressions.push(`\${__wrnexusPropAttr(${expression})}`);
|
||||||
|
|
||||||
const marker = `\x00WRNEACH${dynamicExpressions.length - 1}\x00`;
|
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}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1151,6 +1151,16 @@ page Home {
|
|||||||
|
|
||||||
expect(output).toContain("__wrnexusPropAttr([");
|
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 { <Child value={count} /> }
|
||||||
|
}`),
|
||||||
|
);
|
||||||
|
expect(output).toContain("data-wrn-prop-bind-");
|
||||||
|
expect(output).toContain("{count}");
|
||||||
|
});
|
||||||
test("nested component props retain parent-owned reactive bindings", () => {
|
test("nested component props retain parent-owned reactive bindings", () => {
|
||||||
const output = generate(
|
const output = generate(
|
||||||
parse(`component Parent {
|
parse(`component Parent {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/dev-server",
|
"name": "@wrnexus/dev-server",
|
||||||
"version": "0.8.50",
|
"version": "0.8.51",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -1769,7 +1769,17 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
: renderComponent();
|
: 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) {
|
} catch (err) {
|
||||||
console.error(`[wrnexus] component '${name}' failed to render`, err);
|
console.error(`[wrnexus] component '${name}' failed to render`, err);
|
||||||
deps.devToolbar?.collector.add(
|
deps.devToolbar?.collector.add(
|
||||||
|
|||||||
@@ -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=");
|
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 { <b>{value}</b> } }\n");
|
||||||
|
const marker = "["value","{count}"]";
|
||||||
|
const handlers = createHandlers({
|
||||||
|
mode: "development",
|
||||||
|
hmr: false,
|
||||||
|
router: buildRouter(app),
|
||||||
|
loadModule: async (file) =>
|
||||||
|
basename(file) === "Child.wrn"
|
||||||
|
? { render: () => '<div data-scope="value: 1" data-wrn-scope="e30="><b>1</b></div>' }
|
||||||
|
: { default: () => `<div data-component="Child" value="1" data-wrn-prop-bind-0="${marker}"></div>` },
|
||||||
|
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 () => {
|
test("translated text is present in the raw server response", async () => {
|
||||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-ssr-i18n-"));
|
const root = mkdtempSync(join(tmpdir(), "wrnexus-ssr-i18n-"));
|
||||||
roots.push(root);
|
roots.push(root);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/test",
|
"name": "@wrnexus/test",
|
||||||
"version": "0.8.11",
|
"version": "0.8.12",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
|
|||||||
@@ -205,3 +205,9 @@ export type {
|
|||||||
} from "./advanced.ts";
|
} from "./advanced.ts";
|
||||||
export { withDatabaseRollback, createFactory, captureBrowserArtifacts } from "./platform.ts";
|
export { withDatabaseRollback, createFactory, captureBrowserArtifacts } from "./platform.ts";
|
||||||
export type { TransactionalDatabase, BrowserArtifactPage } from "./platform.ts";
|
export type { TransactionalDatabase, BrowserArtifactPage } from "./platform.ts";
|
||||||
|
export { detectMutations } from "./mutation.ts";
|
||||||
|
export type {
|
||||||
|
MutationCase,
|
||||||
|
MutationReport,
|
||||||
|
DetectMutationsOptions,
|
||||||
|
} from "./mutation.ts";
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
export interface MutationCase<T> {
|
||||||
|
name: string;
|
||||||
|
value: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MutationReport<O> {
|
||||||
|
baseline: O;
|
||||||
|
killed: string[];
|
||||||
|
survived: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DetectMutationsOptions<T, O> {
|
||||||
|
baseline: T;
|
||||||
|
mutations: Array<MutationCase<T>>;
|
||||||
|
exercise(value: T): O | Promise<O>;
|
||||||
|
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<T, O>(
|
||||||
|
options: DetectMutationsOptions<T, O>,
|
||||||
|
): Promise<MutationReport<O>> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -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"]);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user