diff --git a/packages/dev-server/package.json b/packages/dev-server/package.json index bc554268..65c184d9 100644 --- a/packages/dev-server/package.json +++ b/packages/dev-server/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/dev-server", - "version": "0.8.52", + "version": "0.8.53", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/dev-server/src/runtime.ts b/packages/dev-server/src/runtime.ts index 2f646495..cdf712ec 100644 --- a/packages/dev-server/src/runtime.ts +++ b/packages/dev-server/src/runtime.ts @@ -1739,6 +1739,15 @@ export function createHandlers(deps: RuntimeDeps): Handlers { // Props may carry `{t:key}` i18n markers — resolve them for the active // language before handing them to the component. const props = resolveTProps(parseComponentProps(attrStr!), translate); + // Client control templates retain reactive expressions (for example + // `metrics="{metrics}"`) because their parent state is not available + // while we pre-render the inert branch metadata. Do not feed those + // expression strings into typed component coercion. Rendering with the + // component defaults creates the real child boundary; the bridged prop + // binding synchronizes the live value when the branch is instantiated. + for (const [key, value] of Object.entries(props)) { + if (/^\{[\s\S]*\}$/.test(value) && !/^\{t:/.test(value)) delete props[key]; + } if (normalizedName === "languageswitcher" && deps.i18n) { props.locales ??= JSON.stringify( deps.i18n.langs.map((locale) => ({ @@ -2682,7 +2691,12 @@ export function parseComponentProps(attrStr: string): Record { const props: Record = {}; for (const m of attrStr.matchAll(/([A-Za-z_][\w-]*)(?:="([^"]*)")?/g)) { const key = m[1]!; - if (key === "data-component") continue; + if ( + key === "data-component" || + key.startsWith("data-wrn-bind-") || + key.startsWith("data-wrn-prop-bind-") + ) + continue; props[key] = decodeHtmlEntities(m[2] ?? ""); } return props; diff --git a/packages/dev-server/test/component-composition-runtime.test.ts b/packages/dev-server/test/component-composition-runtime.test.ts index 041f736f..fa969764 100644 --- a/packages/dev-server/test/component-composition-runtime.test.ts +++ b/packages/dev-server/test/component-composition-runtime.test.ts @@ -90,6 +90,58 @@ test("a component in an initially hidden client branch is server-prepared for in expect(branches[0].body).not.toContain("data-component="); }); +test("typed components in client branches use defaults until reactive props are available", async () => { + const root = mkdtempSync(join(tmpdir(), "wrnexus-component-reactive-branch-")); + 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/Panel.wrn"), "component Panel { view {
} }\n"); + const marker = "["items","{items}"]"; + const definition = Buffer.from( + JSON.stringify([ + { + cond: "open", + body: `
`, + }, + ]), + "utf8", + ).toString("base64"); + let observed: Record | undefined; + + const handlers = createHandlers({ + mode: "development", + hmr: false, + router: buildRouter(app), + loadModule: async (file) => + basename(file) === "Panel.wrn" + ? { + render: (props: Record) => { + observed = props; + return '
'; + }, + } + : { + 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(observed).toEqual({}); + const encoded = /data-wrn-if="([A-Za-z0-9+/=]+)"/.exec(html)?.[1]; + const branches = JSON.parse(Buffer.from(encoded!, "base64").toString("utf8")); + expect(branches[0].body).toContain(" { const root = mkdtempSync(join(tmpdir(), "wrnexus-component-prop-")); roots.push(root);