fix(engine): inject sub-composition variables on the render path (#2066)

render left window.__hyperframes.getVariables() empty inside every
sub-composition mounted via data-composition-src, so each instance rendered
its declared JS defaults instead of the per-instance data-variable-values.
preview/snapshot injected them correctly, so the composition looked right in
every authoring/QA surface and then rendered wrong content silently (exit 0).
Any template-library workflow (reusable sub-comp scenes parametrized per
video) shipped placeholder/default text in the final MP4.

The plumbing already existed on main: htmlCompiler passes
readVariableDefaults/parseHostVariables and populates result.variablesByComp,
and the CSS-custom-property path (emitRootCompositionVariableStyles) reaches
the render. But the render compiler emitted only the CSS vars and never the
JS table window.__hfVariablesByComp that the scoped getVariables reads, while
the preview bundler (htmlBundler) did -- so getVariables() returned {} only
during render.

Fix, so the paths cannot drift again: buildVariablesByCompScript, colocated
with the reader in compositionScoping.ts and shared by both compile paths.
htmlBundler now calls it instead of an inline string; htmlCompiler injects it
before the inlined sub-comp scripts, using the already-populated
result.variablesByComp.

Verified end-to-end: a sub-comp painting its background from a color variable
now renders the injected value under render, matching snapshot; previously it
rendered the default. 3 new producer tests; 89 htmlCompiler + core-compiler
tests pass.

Closes #2064.
This commit is contained in:
Miguel Ángel
2026-07-08 14:23:40 -04:00
committed by GitHub
parent 17b852784b
commit 41ad5b4690
5 changed files with 112 additions and 8 deletions
@@ -523,3 +523,24 @@ ${source.replace(/<\/(script)/gi, "<\\/$1")}
export function wrapInlineScriptWithErrorBoundary(source: string, errorLabel: string): string { export function wrapInlineScriptWithErrorBoundary(source: string, errorLabel: string): string {
return `(function(){ try { Function(${JSON.stringify(source)}).call(window); } catch (_err) { console.error(${JSON.stringify(errorLabel)}, _err); } })();`; return `(function(){ try { Function(${JSON.stringify(source)}).call(window); } catch (_err) { console.error(${JSON.stringify(errorLabel)}, _err); } })();`;
} }
/**
* Build the statement that populates `window.__hfVariablesByComp` — the table
* the scoped `getVariables` above reads. Returns `null` when there are no
* per-instance values.
*
* The WRITER lives next to the READER (the scoped `getVariables` in
* `wrapScopedCompositionScript`) on purpose: every compile path that wraps the
* reader MUST also emit this writer before the sub-comp scripts run. The
* render compiler (`htmlCompiler`) inlined the reader scripts but never emitted
* the writer while the preview bundler (`htmlBundler`) did, so
* `getVariables()` returned `{}` only during render — parametrized sub-comps
* silently shipped blank/default text in the final MP4 while snapshot QA passed
* (issue #2064). Both callers now share this one builder so they can't drift.
*/
export function buildVariablesByCompScript(
variablesByComp: Record<string, Record<string, unknown>>,
): string | null {
if (!variablesByComp || Object.keys(variablesByComp).length === 0) return null;
return `window.__hfVariablesByComp = Object.assign({}, window.__hfVariablesByComp || {}, ${JSON.stringify(variablesByComp)});`;
}
+4 -4
View File
@@ -14,6 +14,7 @@ import {
} from "./htmlDocument"; } from "./htmlDocument";
// rewriteSubCompPaths functions are used by inlineSubCompositions (shared module) // rewriteSubCompPaths functions are used by inlineSubCompositions (shared module)
import { import {
buildVariablesByCompScript,
scopeCssToComposition, scopeCssToComposition,
wrapInlineScriptWithErrorBoundary, wrapInlineScriptWithErrorBoundary,
wrapScopedCompositionScript, wrapScopedCompositionScript,
@@ -939,10 +940,9 @@ export async function bundleToSingleHtml(
style.textContent = compStyleChunks.join("\n\n"); style.textContent = compStyleChunks.join("\n\n");
document.head.appendChild(style); document.head.appendChild(style);
} }
if (Object.keys(compVariablesByComp).length > 0) { const variablesByCompScript = buildVariablesByCompScript(compVariablesByComp);
compScriptChunks.unshift( if (variablesByCompScript) {
`window.__hfVariablesByComp = Object.assign({}, window.__hfVariablesByComp || {}, ${JSON.stringify(compVariablesByComp)});`, compScriptChunks.unshift(variablesByCompScript);
);
} }
if (compScriptChunks.length) { if (compScriptChunks.length) {
const compScript = document.createElement("script"); const compScript = document.createElement("script");
+5 -1
View File
@@ -52,7 +52,11 @@ export {
} from "./staticGuard"; } from "./staticGuard";
// Composition isolation helpers // Composition isolation helpers
export { scopeCssToComposition, wrapScopedCompositionScript } from "./compositionScoping"; export {
buildVariablesByCompScript,
scopeCssToComposition,
wrapScopedCompositionScript,
} from "./compositionScoping";
// Sub-composition inlining (shared between bundler and producer) // Sub-composition inlining (shared between bundler and producer)
export { export {
@@ -1712,3 +1712,70 @@ describe("discoverAudioVolumeAutomationFromTimeline", () => {
} }
}); });
}); });
describe("sub-composition variable injection (render path, #2064)", () => {
function writeSubCompVarProject(hostVars: string): string {
const projectDir = mkdtempSync(join(tmpdir(), "hf-subvar-"));
mkdirSync(join(projectDir, "compositions"), { recursive: true });
writeFileSync(
join(projectDir, "compositions", "card.html"),
`<!DOCTYPE html>
<html data-composition-variables='[{"id":"color","type":"color","label":"Color","default":"#000000"}]'>
<body>
<div data-composition-id="card" data-width="320" data-height="240">
<div class="card-bg"></div>
<script>
var color = __hyperframes.getVariables().color || "#000000";
document.querySelector('[data-composition-id="card"] .card-bg').style.background = color;
</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="320" data-height="240">
<div data-composition-id="card-1" data-composition-src="compositions/card.html" data-start="0" data-duration="3" data-track-index="1" ${hostVars}></div>
</div>
</body>
</html>`,
);
return projectDir;
}
it("injects the __hfVariablesByComp writer so JS getVariables() sees per-instance values", async () => {
// Regression for #2064: render inlined the sub-comp reader scripts but never
// emitted the writer, so window.__hyperframes.getVariables() returned {} and
// parametrized sub-comps shipped blank/default text in the final MP4 while
// snapshot QA passed.
const projectDir = writeSubCompVarProject(`data-variable-values='{"color":"#00ff00"}'`);
const compiled = await compileForRender(projectDir, join(projectDir, "index.html"), projectDir);
expect(compiled.html).toMatch(/window\.__hfVariablesByComp\s*=\s*Object\.assign/);
expect(compiled.html).toContain("#00ff00");
});
it("still injects the declared default even with no per-instance override", async () => {
const projectDir = writeSubCompVarProject("");
const compiled = await compileForRender(projectDir, join(projectDir, "index.html"), projectDir);
expect(compiled.html).toMatch(/window\.__hfVariablesByComp\s*=\s*Object\.assign/);
expect(compiled.html).toContain('"card-1":{"color":"#000000"}');
});
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 });
writeFileSync(
join(projectDir, "compositions", "plain.html"),
`<!DOCTYPE html><html><body><div data-composition-id="plain" data-width="320" data-height="240"><span>hi</span></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="320" data-height="240"><div data-composition-id="p-1" data-composition-src="compositions/plain.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);
expect(compiled.html).not.toMatch(/window\.__hfVariablesByComp\s*=\s*Object\.assign/);
});
});
+15 -3
View File
@@ -25,6 +25,7 @@ import {
type UnresolvedElement, type UnresolvedElement,
} from "@hyperframes/core"; } from "@hyperframes/core";
import { import {
buildVariablesByCompScript,
inlineSubCompositions as inlineSubCompositionsShared, inlineSubCompositions as inlineSubCompositionsShared,
prepareFlattenedInnerRoot, prepareFlattenedInnerRoot,
emitRootCompositionVariableStyles, emitRootCompositionVariableStyles,
@@ -882,10 +883,21 @@ function inlineSubCompositions(
} }
} }
// Append collected inline scripts to <body> // Append collected inline scripts to <body>. The per-instance variables
if (result.scripts.length && body) { // table MUST be written before the sub-comp scripts run — their scoped
// getVariables() reads window.__hfVariablesByComp[compId]. htmlBundler
// (preview/snapshot) prepends this; the render path emitted only the CSS
// custom properties (below) and dropped the JS table, so getVariables()
// returned {} during render and parametrized sub-comps shipped blank/default
// text (issue #2064). Same shared builder as the bundler so they stay in
// lockstep.
const variablesByCompScript = buildVariablesByCompScript(result.variablesByComp);
const inlineScripts = variablesByCompScript
? [variablesByCompScript, ...result.scripts]
: result.scripts;
if (inlineScripts.length && body) {
const scriptEl = document.createElement("script"); const scriptEl = document.createElement("script");
scriptEl.textContent = result.scripts.join("\n;\n"); scriptEl.textContent = inlineScripts.join("\n;\n");
body.appendChild(scriptEl); body.appendChild(scriptEl);
} }