Skip to content

Commit 065e235

Browse files
jrusso1020claude
andcommitted
fix(producer): scope per-instance variables for repeated sub-composition mounts
#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 + buildScopeSelector into the shared inliner, mirroring the preview bundler. Timelines already remap to the runtime id via the scoping proxy, so each instance's timeline lands under its own id too. 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>
1 parent 41ad5b4 commit 065e235

4 files changed

Lines changed: 70 additions & 3 deletions

File tree

packages/core/src/compiler/htmlBundler.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -296,7 +296,7 @@ function rewriteCssUrlsWithInlinedAssets(cssText: string, projectDir: string): s
296296
);
297297
}
298298

299-
function cssAttributeSelector(attr: string, value: string): string {
299+
export function cssAttributeSelector(attr: string, value: string): string {
300300
const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
301301
return `[${attr}="${escaped}"]`;
302302
}
@@ -305,7 +305,7 @@ function uniqueCompositionId(baseId: string, index: number): string {
305305
return `${baseId}__hf${index}`;
306306
}
307307

308-
type BundledHostCompositionIdentity = {
308+
export type BundledHostCompositionIdentity = {
309309
authoredCompositionId: string | null;
310310
runtimeCompositionId: string | null;
311311
};
@@ -351,7 +351,8 @@ function countBundledAuthoredCompositionIds(hosts: Element[]): Map<string, numbe
351351
return counts;
352352
}
353353

354-
function assignBundledRuntimeCompositionIds(
354+
// fallow-ignore-next-line complexity
355+
export function assignBundledRuntimeCompositionIds(
355356
hosts: Element[],
356357
counts: Map<string, number> = countBundledAuthoredCompositionIds(hosts),
357358
): Map<Element, BundledHostCompositionIdentity> {

packages/core/src/compiler/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,11 @@ export { compileHtml, type MediaDurationProber } from "./htmlCompiler";
2828

2929
// HTML bundler (Node.js — requires fs, linkedom, esbuild)
3030
export {
31+
assignBundledRuntimeCompositionIds,
32+
type BundledHostCompositionIdentity,
3133
bundleToSingleHtml,
3234
type BundleOptions,
35+
cssAttributeSelector,
3336
prepareFlattenedInnerRoot,
3437
FLATTENED_INNER_ROOT_STRIP_ATTRS,
3538
emitRootCompositionVariableStyles,

packages/producer/src/services/htmlCompiler.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1764,6 +1764,54 @@ describe("sub-composition variable injection (render path, #2064)", () => {
17641764
expect(compiled.html).toContain('"card-1":{"color":"#000000"}');
17651765
});
17661766

1767+
it("scopes per-instance values when one sub-comp is mounted multiple times (template reuse)", async () => {
1768+
// #2066 fixed the single-instance case but left a preview/render divergence:
1769+
// two mounts of the SAME sub-comp (same authored data-composition-id) with
1770+
// different data-variable-values collapsed to one __hfVariablesByComp key
1771+
// and one scope selector, so the last mount clobbered the earlier one and
1772+
// all-but-one instance rendered blank. The producer now assigns per-instance
1773+
// runtime ids (card__hf1, card__hf2), mirroring the preview bundler.
1774+
const projectDir = mkdtempSync(join(tmpdir(), "hf-subvar-multi-"));
1775+
mkdirSync(join(projectDir, "compositions"), { recursive: true });
1776+
writeFileSync(
1777+
join(projectDir, "compositions", "card.html"),
1778+
`<!DOCTYPE html>
1779+
<html data-composition-variables='[{"id":"label","type":"string","label":"Label","default":"DEFAULT"}]'>
1780+
<body>
1781+
<div data-composition-id="card" data-width="320" data-height="240">
1782+
<div class="lbl"></div>
1783+
<script>
1784+
document.querySelector('.lbl').textContent = __hyperframes.getVariables().label || "DEFAULT";
1785+
</script>
1786+
</div>
1787+
</body>
1788+
</html>`,
1789+
);
1790+
writeFileSync(
1791+
join(projectDir, "index.html"),
1792+
`<!DOCTYPE html>
1793+
<html>
1794+
<body>
1795+
<div id="root" class="composition" data-composition-id="host" data-start="0" data-duration="3" data-width="640" data-height="240">
1796+
<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>
1797+
<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>
1798+
</div>
1799+
</body>
1800+
</html>`,
1801+
);
1802+
const compiled = await compileForRender(projectDir, join(projectDir, "index.html"), projectDir);
1803+
const { document } = parseHTML(compiled.html);
1804+
const ids = Array.from(
1805+
document.querySelectorAll('[data-composition-file="compositions/card.html"]'),
1806+
).map((h) => h.getAttribute("data-composition-id"));
1807+
// Each instance gets a unique runtime id, in document order.
1808+
expect(ids).toContain("card__hf1");
1809+
expect(ids).toContain("card__hf2");
1810+
// And each carries its own per-instance values — no cross-instance clobber.
1811+
expect(compiled.html).toContain('"card__hf1":{"label":"CARD_A"}');
1812+
expect(compiled.html).toContain('"card__hf2":{"label":"CARD_B"}');
1813+
});
1814+
17671815
it("omits the writer when the sub-comp declares no variables at all", async () => {
17681816
const projectDir = mkdtempSync(join(tmpdir(), "hf-subvar-none-"));
17691817
mkdirSync(join(projectDir, "compositions"), { recursive: true });

packages/producer/src/services/htmlCompiler.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,9 @@ import {
2525
type UnresolvedElement,
2626
} from "@hyperframes/core";
2727
import {
28+
assignBundledRuntimeCompositionIds,
2829
buildVariablesByCompScript,
30+
cssAttributeSelector,
2931
inlineSubCompositions as inlineSubCompositionsShared,
3032
prepareFlattenedInnerRoot,
3133
emitRootCompositionVariableStyles,
@@ -785,10 +787,23 @@ function inlineSubCompositions(
785787
return emitted ? document.toString() : html;
786788
}
787789

790+
// Assign per-instance runtime composition ids BEFORE inlining, mirroring the
791+
// preview bundler. When the same sub-composition (same authored
792+
// data-composition-id) is mounted more than once — the reusable-template
793+
// pattern from issue #2064 — each host is rewritten to a unique runtime id
794+
// (`card__hf1`, `card__hf2`). Without this, every instance shares one
795+
// `__hfVariablesByComp` key and one scope selector: the last mount's
796+
// data-variable-values clobbers the earlier ones and all-but-one instance
797+
// renders blank. #2066 fixed the single-instance case but left this
798+
// divergence (snapshot/preview correct, render wrong).
799+
const hostIdentityByElement = assignBundledRuntimeCompositionIds(hosts as unknown as Element[]);
800+
788801
const result = inlineSubCompositionsShared(
789802
document as unknown as Document,
790803
hosts as unknown as Element[],
791804
{
805+
hostIdentityMap: hostIdentityByElement,
806+
buildScopeSelector: (compId: string) => cssAttributeSelector("data-composition-id", compId),
792807
readVariableDefaults: readDeclaredDefaults,
793808
parseHostVariables: parseHostVariableValues,
794809
resolveHtml: (srcPath: string) => {

0 commit comments

Comments
 (0)