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
5 changes: 3 additions & 2 deletions packages/core/src/compiler/htmlBundler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand Down Expand Up @@ -351,7 +351,8 @@ function countBundledAuthoredCompositionIds(hosts: Element[]): Map<string, numbe
return counts;
}

function assignBundledRuntimeCompositionIds(
// fallow-ignore-next-line complexity
export function assignBundledRuntimeCompositionIds(
hosts: Element[],
counts: Map<string, number> = countBundledAuthoredCompositionIds(hosts),
): Map<Element, BundledHostCompositionIdentity> {
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/compiler/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
139 changes: 139 additions & 0 deletions packages/producer/src/services/htmlCompiler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
`<!DOCTYPE html>
<html data-composition-variables='[{"id":"label","type":"string","label":"Label","default":"DEFAULT"}]'>
<body>
<div data-composition-id="card" data-width="320" data-height="240">
<div class="lbl"></div>
<script>
document.querySelector('.lbl').textContent = __hyperframes.getVariables().label || "DEFAULT";
</script>
</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="card" data-composition-src="compositions/card.html" data-variable-values='{"label":"CARD_A"}' data-start="0" data-duration="3" data-track-index="1"></div>
<div data-composition-id="card" data-composition-src="compositions/card.html" data-variable-values='{"label":"CARD_B"}' data-start="0" data-duration="3" data-track-index="2"></div>
</div>
</body>
</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"),
`<!DOCTYPE html>
<html data-composition-variables='[{"id":"label","type":"string","label":"Label","default":"DEFAULT"}]'>
<body>
<div data-composition-id="card" data-width="320" data-height="240">
<div class="lbl"></div>
<script>
document.querySelector('.lbl').textContent = __hyperframes.getVariables().label || "DEFAULT";
</script>
</div>
</body>
</html>`,
);
const mounts = ["A", "B", "C", "D"]
.map(
(label, i) =>
`<div data-composition-id="card" data-composition-src="compositions/card.html" data-variable-values='{"label":"CARD_${label}"}' data-start="0" data-duration="3" data-track-index="${i + 1}"></div>`,
)
.join("\n ");
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">
${mounts}
</div>
</body>
</html>`,
);
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) =>
`<!DOCTYPE html>
<html data-composition-variables='[{"id":"label","type":"string","label":"Label","default":"DEFAULT"}]'>
<body>
<div data-composition-id="${id}" data-width="320" data-height="240">
<div class="lbl"></div>
<script>
document.querySelector('.lbl').textContent = __hyperframes.getVariables().label || "DEFAULT";
</script>
</div>
</body>
</html>`;
writeFileSync(join(projectDir, "compositions", "solo.html"), declare("solo"));
writeFileSync(join(projectDir, "compositions", "card.html"), declare("card"));
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="solo" data-composition-src="compositions/solo.html" data-variable-values='{"label":"SOLO"}' data-start="0" data-duration="3" data-track-index="1"></div>
<div data-composition-id="card" data-composition-src="compositions/card.html" data-variable-values='{"label":"CARD_A"}' data-start="0" data-duration="3" data-track-index="2"></div>
<div data-composition-id="card" data-composition-src="compositions/card.html" data-variable-values='{"label":"CARD_B"}' data-start="0" data-duration="3" data-track-index="3"></div>
</div>
</body>
</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 });
Expand Down
16 changes: 16 additions & 0 deletions packages/producer/src/services/htmlCompiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
type UnresolvedElement,
} from "@hyperframes/core";
import {
assignBundledRuntimeCompositionIds,
buildVariablesByCompScript,
inlineSubCompositions as inlineSubCompositionsShared,
prepareFlattenedInnerRoot,
Expand Down Expand Up @@ -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="<runtime id>"]`, matching the preview bundler.
hostIdentityMap: hostIdentityByElement,
readVariableDefaults: readDeclaredDefaults,
parseHostVariables: parseHostVariableValues,
resolveHtml: (srcPath: string) => {
Expand Down
Loading