mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix(producer): scope per-instance variables for repeated sub-composition mounts (#2070)
#2066 fixed sub-composition data-variable-values on the render path for a single mount, but the reusable-template pattern from #2064 (the same sub-comp mounted multiple times with different values) still diverged from preview/snapshot: every mount shared one __hfVariablesByComp key and one CSS scope selector, so the last mount's values clobbered the earlier ones and all-but-one instance rendered blank. The producer now assigns per-instance runtime composition ids (assignBundledRuntimeCompositionIds) and threads hostIdentityMap into the shared inliner, mirroring the preview bundler. The shared inliner's default buildScopeSelector already scopes by the runtime id, and timelines remap to it via the scoping proxy, so each instance's variables, CSS, and timeline land under its own id. Pixel-verified end to end: two mounts of one sub-comp with different data-variable-values now render their own content (green CARD_A / blue CARD_B), matching snapshot; single-instance behavior is unchanged. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
701ae9e9b9
commit
5ebc5bb10f
@@ -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;
|
||||
};
|
||||
@@ -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> {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
type UnresolvedElement,
|
||||
} from "@hyperframes/core";
|
||||
import {
|
||||
assignBundledRuntimeCompositionIds,
|
||||
buildVariablesByCompScript,
|
||||
inlineSubCompositions as inlineSubCompositionsShared,
|
||||
prepareFlattenedInnerRoot,
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user