From 693768d21bf74e983547cf3df04f73baa167b8a3 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 20 Jul 2026 05:17:39 +0200 Subject: [PATCH 1/3] fix(core): discover sub-composition hosts to a fixed point during inlining inlineSubCompositions collected [data-composition-src] hosts from the document once, before inlining began, then processed that fixed list in a single flat loop. A host's inlined content could introduce new hosts of its own (a sub-composition nesting another sub-composition), and those were never discovered: two-level nesting worked because the second level's host already existed in the root document, but any third level's host only appeared inside content the single pass had already finished walking, so its content silently never rendered. Host discovery now runs as a work queue: after a host is inlined, the newly inserted subtree is re-scanned for further hosts, which are enqueued with their ancestry chain. A host whose source already appears in its own ancestry is a circular reference and is reported through the existing onMissingComposition channel instead of being inlined; a depth ceiling backstops any gap in that check. Existing single- and two-level fixtures are unaffected. --- .../compiler/inlineSubCompositions.test.ts | 125 ++++++++++++++++++ .../src/compiler/inlineSubCompositions.ts | 21 ++- 2 files changed, 145 insertions(+), 1 deletion(-) 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 }; From fa4a7f01214cc4d5dc4b0628d32ae12a7935b7d7 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 20 Jul 2026 05:18:31 +0200 Subject: [PATCH 2/3] fix(producer): assign runtime composition ids to hosts discovered mid-inline The producer's per-instance runtime id assignment (assignBundledRuntimeCompositionIds) ran once over the root document's initial hosts, before inlining. With host discovery now iterating to a fixed point (previous commit), hosts revealed inside an already-inlined sub-composition were never in that pre-pass, so the identity map returned undefined for them: two sibling instances of the same sub-composition, discovered mid-traversal, both fell back to their shared authored id and clobbered each other's variablesByComp entry. hostIdentityMap is now a lazy map: a pre-pass cache hit returns unchanged, a miss reads the host's authored data-composition-id, allocates a collision-checked runtime id by scanning the live document, writes it back, and caches it. No change to the shared inliner's call signature. Existing single-instance and root-level reusable-template cases (#2064) are unaffected. --- .../src/services/htmlCompiler.test.ts | 65 +++++++++++++++++++ .../producer/src/services/htmlCompiler.ts | 61 ++++++++++++++++- 2 files changed, 123 insertions(+), 3 deletions(-) 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 @@ +