Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 125 additions & 0 deletions packages/core/src/compiler/inlineSubCompositions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
`<div data-composition-id="${label}-child-${index}" data-composition-src="${src}"></div>`,
)
.join("");
return `<template><div data-composition-id="${label}"><span data-level="${label}">${label}</span>${nestedHosts}</div></template>`;
}

function inlineFixture(rootHosts: string[], compositions: Record<string, string>) {
const { document } = parseHTML(`<!DOCTYPE html><html><body>
<main data-level="root">root</main>
${rootHosts
.map(
(src, index) =>
`<div data-composition-id="root-host-${index}" data-composition-src="${src}"></div>`,
)
.join("")}
</body></html>`);
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<string, string> = {
"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",
},
]);
});
});
21 changes: 20 additions & 1 deletion packages/core/src/compiler/inlineSubCompositions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,8 @@ function defaultBuildScopeSelector(compId: string): string {
return `[data-composition-id="${escaped}"]`;
}

const MAX_SUB_COMPOSITION_DEPTH = 20;

// ---------------------------------------------------------------------------
// Core implementation
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -177,7 +179,9 @@ export function inlineSubCompositions(
const seenLinkHrefs = new Set<string>();
const variablesByComp: Record<string, Record<string, unknown>> = {};

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;

Expand Down Expand Up @@ -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 };
Expand Down
65 changes: 65 additions & 0 deletions packages/producer/src/services/htmlCompiler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
`<!DOCTYPE html>
<html data-composition-variables='[{"id":"label","type":"string","label":"Label","default":"C_DEFAULT"}]'>
<body>
<div data-composition-id="c" data-width="160" data-height="120">
<div class="c-label"></div>
</div>
</body>
</html>`,
);
writeFileSync(
join(projectDir, "compositions", "b.html"),
`<!DOCTYPE html>
<html data-composition-variables='[{"id":"label","type":"string","label":"Label","default":"B_DEFAULT"}]'>
<body>
<div data-composition-id="b" data-width="320" data-height="240">
<div class="b-label"></div>
<div data-composition-id="c" data-composition-src="compositions/c.html" data-variable-values='{"label":"C_CHILD"}' data-start="0" data-duration="3" data-track-index="1"></div>
</div>
</body>
</html>`,
);
writeFileSync(
join(projectDir, "compositions", "a.html"),
`<!DOCTYPE html>
<html>
<body>
<div data-composition-id="a" data-width="640" data-height="240">
<div data-composition-id="b" data-composition-src="compositions/b.html" data-variable-values='{"label":"B_LEFT"}' data-start="0" data-duration="3" data-track-index="1"></div>
<div data-composition-id="b" data-composition-src="compositions/b.html" data-variable-values='{"label":"B_RIGHT"}' data-start="0" data-duration="3" data-track-index="2"></div>
</div>
</body>
</html>`,
);
writeFileSync(
join(projectDir, "index.html"),
`<!DOCTYPE html>
<html>
<body>
<div id="root" class="composition" data-composition-id="host" data-start="0" data-duration="3" data-width="640" data-height="240">
<div data-composition-id="a" data-composition-src="compositions/a.html" data-start="0" data-duration="3" data-track-index="1"></div>
</div>
</body>
</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.
Expand Down
61 changes: 58 additions & 3 deletions packages/producer/src/services/htmlCompiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
} from "@hyperframes/core";
import {
assignBundledRuntimeCompositionIds,
type BundledHostCompositionIdentity,
buildVariablesByCompScript,
inlineSubCompositions as inlineSubCompositionsShared,
prepareFlattenedInnerRoot,
Expand Down Expand Up @@ -753,6 +754,55 @@ function promoteCssImportsToLinkTags(html: string): string {
return document.toString();
}

class ProducerHostIdentityMap extends Map<Element, BundledHostCompositionIdentity> {
readonly #document: Document;
readonly #lateInstanceByCompositionId = new Map<string, number>();

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 `<head>` `<style>` blocks into a single tag with `@import` rules
* at the top, and merge all inline `<body>` `<script>` blocks into one at the
Expand Down Expand Up @@ -860,16 +910,21 @@ 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
// Assign per-instance runtime composition ids before each host is inlined,
// mirroring the preview bundler. Initial hosts are assigned as one pre-pass;
// hosts discovered by the shared inliner's queue are assigned lazily by the
// map above. 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 hostIdentityByElement = new ProducerHostIdentityMap(
document as unknown as Document,
hosts as unknown as Element[],
);

const result = inlineSubCompositionsShared(
document as unknown as Document,
Expand Down
12 changes: 12 additions & 0 deletions packages/producer/tests/nested-subcomp-depth-3/meta.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"name": "Nested sub-composition depth-3 inlining",
"description": "Regression test for the fix in 693768d21 (core: inlineSubCompositions now discovers [data-composition-src] hosts to a fixed point instead of a single flat pass) and fa4a7f012 (producer: hostIdentityMap now lazily assigns runtime composition ids to hosts discovered mid-inline). Before these fixes, a sub-composition hosting another sub-composition (root -> level-2 -> level-3) silently dropped level-3's content: two-level nesting always worked because the second-level host already existed in the root document before inlining began, but a third-level host only appears inside content the single pass had already finished walking, so it was never discovered or inlined. This fixture's level-3 composition contributes a large, uniquely colored and positioned marker element covering more than half the frame; if the single-pass bug is reintroduced, level-3 is dropped entirely and the rendered PSNR against the golden baseline falls well below threshold.",
"tags": ["sub-composition", "regression", "nested"],
"minPsnr": 20,
"maxFrameFailures": 10,
"minAudioCorrelation": 0.0,
"maxAudioLagWindows": 120,
"renderConfig": {
"fps": 24
}
}
Git LFS file not shown
Git LFS file not shown
Loading
Loading