diff --git a/packages/core/src/compiler/inlineSubCompositions.test.ts b/packages/core/src/compiler/inlineSubCompositions.test.ts
index 214894a673..5aeca1ffad 100644
--- a/packages/core/src/compiler/inlineSubCompositions.test.ts
+++ b/packages/core/src/compiler/inlineSubCompositions.test.ts
@@ -428,3 +428,128 @@ describe("inlineSubCompositions – variable defaults on a template sub-comp roo
expect(result.variablesByComp["card"]).toMatchObject({ headline: "Overridden" });
});
});
+
+describe("inlineSubCompositions – recursive host discovery", () => {
+ const MAX_NESTING_DEPTH = 20;
+
+ function compositionHtml(label: string, nestedSrcs: string[] = []) {
+ const nestedHosts = nestedSrcs
+ .map(
+ (src, index) =>
+ `
`,
+ )
+ .join("");
+ return `${label}${nestedHosts}
`;
+ }
+
+ function inlineFixture(rootHosts: string[], compositions: Record) {
+ const { document } = parseHTML(`
+ root
+ ${rootHosts
+ .map(
+ (src, index) =>
+ ``,
+ )
+ .join("")}
+ `);
+ const missing: Array<{ src: string; reason?: string }> = [];
+
+ inlineSubCompositions(
+ document,
+ Array.from(document.querySelectorAll("[data-composition-src]")),
+ {
+ resolveHtml: (src) => compositions[src] ?? null,
+ parseHtml: (html) => parseHTML(html).document,
+ onMissingComposition: (src, reason) => missing.push({ src, reason }),
+ },
+ );
+
+ return { document, missing };
+ }
+
+ it("inlines a three-level root -> A -> B chain", () => {
+ const { document, missing } = inlineFixture(["a.html"], {
+ "a.html": compositionHtml("A", ["b.html"]),
+ "b.html": compositionHtml("B"),
+ });
+
+ expect(document.querySelector('[data-level="root"]')?.textContent).toBe("root");
+ expect(document.querySelector('[data-level="A"]')?.textContent).toBe("A");
+ expect(document.querySelector('[data-level="B"]')?.textContent).toBe("B");
+ expect(missing).toEqual([]);
+ });
+
+ it("inlines a four-level root -> A -> B -> C chain", () => {
+ const { document, missing } = inlineFixture(["a.html"], {
+ "a.html": compositionHtml("A", ["b.html"]),
+ "b.html": compositionHtml("B", ["c.html"]),
+ "c.html": compositionHtml("C"),
+ });
+
+ expect(
+ Array.from(document.querySelectorAll("[data-level]")).map((element) => element.textContent),
+ ).toEqual(["root", "A", "B", "C"]);
+ expect(missing).toEqual([]);
+ });
+
+ it("reports and leaves a direct circular reference uninlined", () => {
+ const { document, missing } = inlineFixture(["a.html"], {
+ "a.html": compositionHtml("A", ["a.html"]),
+ });
+
+ expect(document.querySelectorAll('[data-level="A"]')).toHaveLength(1);
+ expect(document.querySelector('[data-composition-src="a.html"]')).not.toBeNull();
+ expect(missing).toEqual([{ src: "a.html", reason: "circular composition reference" }]);
+ });
+
+ it("reports and leaves an indirect circular reference uninlined", () => {
+ const { document, missing } = inlineFixture(["a.html"], {
+ "a.html": compositionHtml("A", ["b.html"]),
+ "b.html": compositionHtml("B", ["a.html"]),
+ });
+
+ expect(document.querySelectorAll('[data-level="A"]')).toHaveLength(1);
+ expect(document.querySelectorAll('[data-level="B"]')).toHaveLength(1);
+ expect(document.querySelector('[data-composition-src="a.html"]')).not.toBeNull();
+ expect(missing).toEqual([{ src: "a.html", reason: "circular composition reference" }]);
+ });
+
+ it("inlines the same sub-composition in sibling branches", () => {
+ const { document, missing } = inlineFixture(["a.html"], {
+ "a.html": compositionHtml("A", ["shared.html", "shared.html"]),
+ "shared.html": compositionHtml("shared"),
+ });
+
+ expect(document.querySelectorAll('[data-level="shared"]')).toHaveLength(2);
+ expect(missing).toEqual([]);
+ });
+
+ it("allows the depth ceiling and stops only the branch one level beyond it", () => {
+ const compositions: Record = {
+ "sibling.html": compositionHtml("sibling"),
+ };
+ for (const prefix of ["exact", "overflow"]) {
+ const length = prefix === "exact" ? MAX_NESTING_DEPTH : MAX_NESTING_DEPTH + 1;
+ for (let level = 1; level <= length; level += 1) {
+ const nextSrc = level < length ? [`${prefix}-${level + 1}.html`] : [];
+ compositions[`${prefix}-${level}.html`] = compositionHtml(`${prefix}-${level}`, nextSrc);
+ }
+ }
+
+ const { document, missing } = inlineFixture(
+ ["exact-1.html", "overflow-1.html", "sibling.html"],
+ compositions,
+ );
+
+ expect(document.querySelector(`[data-level="exact-${MAX_NESTING_DEPTH}"]`)).not.toBeNull();
+ expect(document.querySelector(`[data-level="overflow-${MAX_NESTING_DEPTH}"]`)).not.toBeNull();
+ expect(document.querySelector(`[data-level="overflow-${MAX_NESTING_DEPTH + 1}"]`)).toBeNull();
+ expect(document.querySelector('[data-level="sibling"]')).not.toBeNull();
+ expect(missing).toEqual([
+ {
+ src: `overflow-${MAX_NESTING_DEPTH + 1}.html`,
+ reason: "nesting depth exceeded",
+ },
+ ]);
+ });
+});
diff --git a/packages/core/src/compiler/inlineSubCompositions.ts b/packages/core/src/compiler/inlineSubCompositions.ts
index b1d650abb6..f9573e631f 100644
--- a/packages/core/src/compiler/inlineSubCompositions.ts
+++ b/packages/core/src/compiler/inlineSubCompositions.ts
@@ -130,6 +130,8 @@ function defaultBuildScopeSelector(compId: string): string {
return `[data-composition-id="${escaped}"]`;
}
+const MAX_SUB_COMPOSITION_DEPTH = 20;
+
// ---------------------------------------------------------------------------
// Core implementation
// ---------------------------------------------------------------------------
@@ -177,7 +179,9 @@ export function inlineSubCompositions(
const seenLinkHrefs = new Set();
const variablesByComp: Record> = {};
- for (const hostEl of hosts) {
+ const queue = hosts.map((element) => ({ element, ancestry: [] as string[] }));
+ for (let queueIndex = 0; queueIndex < queue.length; queueIndex += 1) {
+ const { element: hostEl, ancestry } = queue[queueIndex]!;
const src = hostEl.getAttribute("data-composition-src");
if (!src) continue;
@@ -415,6 +419,21 @@ export function inlineSubCompositions(
hostEl.setAttribute("data-composition-file", src);
hostEl.removeAttribute("data-composition-src");
+
+ const nestedAncestry = [...ancestry, src];
+ for (const nestedHost of [...hostEl.querySelectorAll("[data-composition-src]")]) {
+ const nestedSrc = nestedHost.getAttribute("data-composition-src");
+ if (!nestedSrc) continue;
+ if (nestedAncestry.includes(nestedSrc)) {
+ onMissingComposition?.(nestedSrc, "circular composition reference");
+ continue;
+ }
+ if (nestedAncestry.length >= MAX_SUB_COMPOSITION_DEPTH) {
+ onMissingComposition?.(nestedSrc, "nesting depth exceeded");
+ continue;
+ }
+ queue.push({ element: nestedHost, ancestry: nestedAncestry });
+ }
}
return { styles, scripts, externalScriptSrcs, scriptItems, externalLinks, variablesByComp };
diff --git a/packages/producer/src/services/htmlCompiler.test.ts b/packages/producer/src/services/htmlCompiler.test.ts
index 7253af9ffe..fef35fc867 100644
--- a/packages/producer/src/services/htmlCompiler.test.ts
+++ b/packages/producer/src/services/htmlCompiler.test.ts
@@ -1980,6 +1980,71 @@ describe("sub-composition variable injection (render path, #2064)", () => {
expect(compiled.html).toContain('"card__hf4":{"label":"CARD_D"}');
});
+ it("assigns unique runtime ids to repeated sub-compositions discovered during inlining", async () => {
+ const projectDir = mkdtempSync(join(tmpdir(), "hf-subvar-nested-multi-"));
+ mkdirSync(join(projectDir, "compositions"), { recursive: true });
+ writeFileSync(
+ join(projectDir, "compositions", "c.html"),
+ `
+
+
+
+
+`,
+ );
+ writeFileSync(
+ join(projectDir, "compositions", "b.html"),
+ `
+
+
+
+
+`,
+ );
+ writeFileSync(
+ join(projectDir, "compositions", "a.html"),
+ `
+
+
+
+
+`,
+ );
+ writeFileSync(
+ join(projectDir, "index.html"),
+ `
+
+
+
+
+`,
+ );
+
+ const compiled = await compileForRender(projectDir, join(projectDir, "index.html"), projectDir);
+ const { document } = parseHTML(compiled.html);
+ const idsByFile = (file: string) =>
+ Array.from(document.querySelectorAll(`[data-composition-file="${file}"]`)).map((host) =>
+ host.getAttribute("data-composition-id"),
+ );
+
+ expect(idsByFile("compositions/b.html")).toEqual(["b__hf1", "b__hf2"]);
+ expect(idsByFile("compositions/c.html")).toEqual(["c__hf1", "c__hf2"]);
+ expect(compiled.html).toContain('"b__hf1":{"label":"B_LEFT"}');
+ expect(compiled.html).toContain('"b__hf2":{"label":"B_RIGHT"}');
+ expect(compiled.html).toContain('"c__hf1":{"label":"C_CHILD"}');
+ expect(compiled.html).toContain('"c__hf2":{"label":"C_CHILD"}');
+ });
+
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.
diff --git a/packages/producer/src/services/htmlCompiler.ts b/packages/producer/src/services/htmlCompiler.ts
index d6be2d6f38..4c1ea6e733 100644
--- a/packages/producer/src/services/htmlCompiler.ts
+++ b/packages/producer/src/services/htmlCompiler.ts
@@ -26,6 +26,7 @@ import {
} from "@hyperframes/core";
import {
assignBundledRuntimeCompositionIds,
+ type BundledHostCompositionIdentity,
buildVariablesByCompScript,
inlineSubCompositions as inlineSubCompositionsShared,
prepareFlattenedInnerRoot,
@@ -753,6 +754,55 @@ function promoteCssImportsToLinkTags(html: string): string {
return document.toString();
}
+class ProducerHostIdentityMap extends Map {
+ readonly #document: Document;
+ readonly #lateInstanceByCompositionId = new Map();
+
+ constructor(document: Document, initialHosts: Element[]) {
+ super(assignBundledRuntimeCompositionIds(initialHosts));
+ this.#document = document;
+ }
+
+ override get(host: Element): BundledHostCompositionIdentity | undefined {
+ const existing = super.get(host);
+ if (existing) return existing;
+
+ // The shared inliner discovers nested hosts after the producer's initial
+ // DOM scan. Assign those late hosts on first use so their scope and
+ // variables key are fixed before any content is processed.
+ const authoredCompositionId =
+ (
+ host.getAttribute("data-hf-original-composition-id") ||
+ host.getAttribute("data-composition-id") ||
+ ""
+ ).trim() || null;
+ if (!authoredCompositionId) {
+ const identity = { authoredCompositionId: null, runtimeCompositionId: null };
+ this.set(host, identity);
+ return identity;
+ }
+
+ let instanceIndex = this.#lateInstanceByCompositionId.get(authoredCompositionId) || 0;
+ let runtimeCompositionId: string;
+ do {
+ instanceIndex += 1;
+ runtimeCompositionId = `${authoredCompositionId}__hf${instanceIndex}`;
+ } while (
+ Array.from(this.#document.querySelectorAll("[data-composition-id]")).some(
+ (element) =>
+ element !== host && element.getAttribute("data-composition-id") === runtimeCompositionId,
+ )
+ );
+ this.#lateInstanceByCompositionId.set(authoredCompositionId, instanceIndex);
+
+ host.setAttribute("data-hf-original-composition-id", authoredCompositionId);
+ host.setAttribute("data-composition-id", runtimeCompositionId);
+ const identity = { authoredCompositionId, runtimeCompositionId };
+ this.set(host, identity);
+ return identity;
+ }
+}
+
/**
* Merge all `` `
+
+
+
+
+
+