diff --git a/packages/core/src/compiler/htmlBundler.ts b/packages/core/src/compiler/htmlBundler.ts index 8ceb5f1f4..4af4154fe 100644 --- a/packages/core/src/compiler/htmlBundler.ts +++ b/packages/core/src/compiler/htmlBundler.ts @@ -305,7 +305,7 @@ function uniqueCompositionId(baseId: string, index: number): string { return `${baseId}__hf${index}`; } -type BundledHostCompositionIdentity = { +export type BundledHostCompositionIdentity = { authoredCompositionId: string | null; runtimeCompositionId: string | null; }; @@ -351,7 +351,8 @@ function countBundledAuthoredCompositionIds(hosts: Element[]): Map = countBundledAuthoredCompositionIds(hosts), ): Map { diff --git a/packages/core/src/compiler/index.ts b/packages/core/src/compiler/index.ts index 3ab4ec516..0474d19d5 100644 --- a/packages/core/src/compiler/index.ts +++ b/packages/core/src/compiler/index.ts @@ -28,6 +28,8 @@ export { compileHtml, type MediaDurationProber } from "./htmlCompiler"; // HTML bundler (Node.js — requires fs, linkedom, esbuild) export { + assignBundledRuntimeCompositionIds, + type BundledHostCompositionIdentity, bundleToSingleHtml, type BundleOptions, prepareFlattenedInnerRoot, diff --git a/packages/producer/src/services/htmlCompiler.test.ts b/packages/producer/src/services/htmlCompiler.test.ts index 45fcedd37..5b41203a3 100644 --- a/packages/producer/src/services/htmlCompiler.test.ts +++ b/packages/producer/src/services/htmlCompiler.test.ts @@ -1764,6 +1764,145 @@ describe("sub-composition variable injection (render path, #2064)", () => { expect(compiled.html).toContain('"card-1":{"color":"#000000"}'); }); + it("scopes per-instance values when one sub-comp is mounted multiple times (template reuse)", async () => { + // #2066 fixed the single-instance case but left a preview/render divergence: + // two mounts of the SAME sub-comp (same authored data-composition-id) with + // different data-variable-values collapsed to one __hfVariablesByComp key + // and one scope selector, so the last mount clobbered the earlier one and + // all-but-one instance rendered blank. The producer now assigns per-instance + // runtime ids (card__hf1, card__hf2), mirroring the preview bundler. + const projectDir = mkdtempSync(join(tmpdir(), "hf-subvar-multi-")); + mkdirSync(join(projectDir, "compositions"), { recursive: true }); + writeFileSync( + join(projectDir, "compositions", "card.html"), + ` + + +
+
+ +
+ +`, + ); + writeFileSync( + join(projectDir, "index.html"), + ` + + +
+
+
+
+ +`, + ); + const compiled = await compileForRender(projectDir, join(projectDir, "index.html"), projectDir); + const { document } = parseHTML(compiled.html); + const ids = Array.from( + document.querySelectorAll('[data-composition-file="compositions/card.html"]'), + ).map((h) => h.getAttribute("data-composition-id")); + // Each instance gets a unique runtime id, in document order. + expect(ids).toContain("card__hf1"); + expect(ids).toContain("card__hf2"); + // And each carries its own per-instance values — no cross-instance clobber. + expect(compiled.html).toContain('"card__hf1":{"label":"CARD_A"}'); + expect(compiled.html).toContain('"card__hf2":{"label":"CARD_B"}'); + }); + + it("assigns a distinct runtime id to every mount when the same sub-comp appears 3+ times", async () => { + // Pins the uniqueCompositionId(baseId, index) progression beyond two: the + // third and fourth mounts must land as card__hf3 / card__hf4, each with its + // own values. + const projectDir = mkdtempSync(join(tmpdir(), "hf-subvar-multi3-")); + mkdirSync(join(projectDir, "compositions"), { recursive: true }); + writeFileSync( + join(projectDir, "compositions", "card.html"), + ` + + +
+
+ +
+ +`, + ); + const mounts = ["A", "B", "C", "D"] + .map( + (label, i) => + `
`, + ) + .join("\n "); + writeFileSync( + join(projectDir, "index.html"), + ` + + +
+ ${mounts} +
+ +`, + ); + const compiled = await compileForRender(projectDir, join(projectDir, "index.html"), projectDir); + const ids = Array.from( + parseHTML(compiled.html).document.querySelectorAll( + '[data-composition-file="compositions/card.html"]', + ), + ).map((h) => h.getAttribute("data-composition-id")); + expect(ids).toEqual(["card__hf1", "card__hf2", "card__hf3", "card__hf4"]); + expect(compiled.html).toContain('"card__hf1":{"label":"CARD_A"}'); + expect(compiled.html).toContain('"card__hf2":{"label":"CARD_B"}'); + expect(compiled.html).toContain('"card__hf3":{"label":"CARD_C"}'); + expect(compiled.html).toContain('"card__hf4":{"label":"CARD_D"}'); + }); + + it("leaves a single-mount sub-comp's authored id untouched while renaming a duplicated one", async () => { + // Pins the "single instances are untouched" claim: a solo mount keeps its + // authored data-composition-id; only the duplicated sub-comp is renamed. + const projectDir = mkdtempSync(join(tmpdir(), "hf-subvar-mixed-")); + mkdirSync(join(projectDir, "compositions"), { recursive: true }); + const declare = (id: string) => + ` + + +
+
+ +
+ +`; + writeFileSync(join(projectDir, "compositions", "solo.html"), declare("solo")); + writeFileSync(join(projectDir, "compositions", "card.html"), declare("card")); + writeFileSync( + join(projectDir, "index.html"), + ` + + +
+
+
+
+
+ +`, + ); + const compiled = await compileForRender(projectDir, join(projectDir, "index.html"), projectDir); + // Solo mount keeps its authored id (not renamed to solo__hf1). + expect(compiled.html).toContain('"solo":{"label":"SOLO"}'); + expect(compiled.html).not.toContain("solo__hf"); + // Duplicated card mounts are renamed per-instance. + expect(compiled.html).toContain('"card__hf1":{"label":"CARD_A"}'); + expect(compiled.html).toContain('"card__hf2":{"label":"CARD_B"}'); + }); + it("omits the writer when the sub-comp declares no variables at all", async () => { const projectDir = mkdtempSync(join(tmpdir(), "hf-subvar-none-")); mkdirSync(join(projectDir, "compositions"), { recursive: true }); diff --git a/packages/producer/src/services/htmlCompiler.ts b/packages/producer/src/services/htmlCompiler.ts index 40a1107ca..fdffd8aaa 100644 --- a/packages/producer/src/services/htmlCompiler.ts +++ b/packages/producer/src/services/htmlCompiler.ts @@ -25,6 +25,7 @@ import { type UnresolvedElement, } from "@hyperframes/core"; import { + assignBundledRuntimeCompositionIds, buildVariablesByCompScript, inlineSubCompositions as inlineSubCompositionsShared, prepareFlattenedInnerRoot, @@ -785,10 +786,25 @@ function inlineSubCompositions( return emitted ? document.toString() : html; } + // Assign per-instance runtime composition ids BEFORE inlining, mirroring the + // preview bundler. When the same sub-composition (same authored + // data-composition-id) is mounted more than once — the reusable-template + // pattern from issue #2064 — each host is rewritten to a unique runtime id + // (`card__hf1`, `card__hf2`). Without this, every instance shares one + // `__hfVariablesByComp` key and one scope selector: the last mount's + // data-variable-values clobbers the earlier ones and all-but-one instance + // renders blank. #2066 fixed the single-instance case but left this + // divergence (snapshot/preview correct, render wrong). + const hostIdentityByElement = assignBundledRuntimeCompositionIds(hosts as unknown as Element[]); + const result = inlineSubCompositionsShared( document as unknown as Document, hosts as unknown as Element[], { + // hostIdentityMap gives each repeated mount a unique runtime id; the + // shared inliner's default buildScopeSelector already scopes by + // `[data-composition-id=""]`, matching the preview bundler. + hostIdentityMap: hostIdentityByElement, readVariableDefaults: readDeclaredDefaults, parseHostVariables: parseHostVariableValues, resolveHtml: (srcPath: string) => {