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) => {