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 ``; + } + + 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 `` ` + + +
+
+
+ + + diff --git a/packages/producer/tests/nested-subcomp-depth-3/src/level-2.html b/packages/producer/tests/nested-subcomp-depth-3/src/level-2.html new file mode 100644 index 0000000000..007f6ac0cf --- /dev/null +++ b/packages/producer/tests/nested-subcomp-depth-3/src/level-2.html @@ -0,0 +1,42 @@ + diff --git a/packages/producer/tests/nested-subcomp-depth-3/src/level-3.html b/packages/producer/tests/nested-subcomp-depth-3/src/level-3.html new file mode 100644 index 0000000000..9ce5338d01 --- /dev/null +++ b/packages/producer/tests/nested-subcomp-depth-3/src/level-3.html @@ -0,0 +1,40 @@ +