Bug Summary
When multiple sub-compositions share the same element IDs (e.g. <canvas id="gl-canvas">), the scoped document.getElementById() proxy in wrapScopedCompositionScript() returns null for all but the first sub-composition, causing scripts to crash.
Root Cause
In compositionScoping.ts, the scoped getElementById proxy is implemented as:
if (prop === "getElementById") {
return function(id) {
var found = target.getElementById(id); // global DOM lookup → returns FIRST match
return found && __hfContains(found) ? found : null; // containment check
};
}
The issue: target.getElementById(id) always returns the first element with that ID in the entire document. When multiple sub-compositions are inlined (each with their own <canvas id="gl-canvas">, <div id="s1">, etc.), the containment check __hfContains(found) rejects elements belonging to other compositions, returning null.
Why querySelector/querySelectorAll work correctly
The scoped querySelectorAll uses __hfQueryAll, which fetches ALL matching elements and filters by containment:
var __hfQueryAll = function(selector) {
// ...
return Array.prototype.filter.call(
window.document.querySelectorAll(/* ... */),
function(node) { return __hfContains(node); }
);
};
This correctly handles ID collisions by filtering from the full result set.
Reproduction
- Create a parent composition with two sub-compositions that each use WebGL (e.g.
flash-through-white and cinematic-zoom):
<!-- index.html -->
<div data-composition-src="compositions/scene1.html"
data-composition-id="comp-a" data-duration="4"
data-width="1920" data-height="1080" data-start="0"></div>
<div data-composition-src="compositions/scene2.html"
data-composition-id="comp-b" data-duration="4"
data-width="1920" data-height="1080" data-start="4"></div>
- Both sub-compositions contain
<canvas id="gl-canvas"> and call document.getElementById("gl-canvas").
- Render:
hyperframes render . -o out.mp4 --no-browser-gpu
Expected: Both sub-compositions render their WebGL content.
Actual: The second sub-composition fails with:
[Compiler] Composition script failed comp-b TypeError: Cannot read properties of null (reading 'getContext')
The first sub-composition works because its <canvas> is the first match in DOM order.
Suggested Fix
Change the getElementById proxy to search within the composition root first:
if (prop === "getElementById") {
return function(id) {
var root = __hfFindRoot();
if (root) {
return root.querySelector("#" + CSS.escape(id)) || null;
}
return target.getElementById(id);
};
}
This mirrors how querySelector/querySelectorAll already scope correctly, and avoids the global-first-match problem. The CSS.escape() handles IDs with special characters.
Alternatively, fall back to the current approach when no root is found (degenerate case).
Impact
- All registry WebGL blocks (
flash-through-white, cinematic-zoom, cross-warp-morph, etc.) are affected when used as sub-compositions in the same parent composition, since they all use <canvas id="gl-canvas"> and document.getElementById("gl-canvas").
- Any sub-composition using
document.getElementById() with non-unique IDs across sibling sub-compositions will silently fail.
- Standalone renders (no sub-compositions) are not affected.
Workaround
Render each scene as a standalone project and concatenate with ffmpeg. This avoids the sub-composition inlining entirely.
Environment
- HyperFrames: v0.4.45
- OS: Linux (no GPU, using
--no-browser-gpu)
- Node: v24.14.0
Test Case
A unit test could be added to compositionScoping.test.ts:
it("getElementById returns the correct element when IDs collide across compositions", () => {
const { document } = parseHTML(`
<div data-composition-id="scene-a"><canvas id="gl-canvas"></canvas></div>
<div data-composition-id="scene-b"><canvas id="gl-canvas"></canvas></div>
`);
const fakeWindow = { document, __result: "" };
const wrapped = wrapScopedCompositionScript(
\`window.__result = document.getElementById("gl-canvas")?.closest("[data-composition-id]")?.getAttribute("data-composition-id") || "null";\`,
"scene-b"
);
new Function("window", wrapped)(fakeWindow);
expect(fakeWindow.__result).toBe("scene-b");
});
This test currently fails — __result would be "null" because getElementById returns the canvas from scene-a but the containment check rejects it.
Bug Summary
When multiple sub-compositions share the same element IDs (e.g.
<canvas id="gl-canvas">), the scopeddocument.getElementById()proxy inwrapScopedCompositionScript()returnsnullfor all but the first sub-composition, causing scripts to crash.Root Cause
In
compositionScoping.ts, the scopedgetElementByIdproxy is implemented as:The issue:
target.getElementById(id)always returns the first element with that ID in the entire document. When multiple sub-compositions are inlined (each with their own<canvas id="gl-canvas">,<div id="s1">, etc.), the containment check__hfContains(found)rejects elements belonging to other compositions, returningnull.Why
querySelector/querySelectorAllwork correctlyThe scoped
querySelectorAlluses__hfQueryAll, which fetches ALL matching elements and filters by containment:This correctly handles ID collisions by filtering from the full result set.
Reproduction
flash-through-whiteandcinematic-zoom):<canvas id="gl-canvas">and calldocument.getElementById("gl-canvas").hyperframes render . -o out.mp4 --no-browser-gpuExpected: Both sub-compositions render their WebGL content.
Actual: The second sub-composition fails with:
The first sub-composition works because its
<canvas>is the first match in DOM order.Suggested Fix
Change the
getElementByIdproxy to search within the composition root first:This mirrors how
querySelector/querySelectorAllalready scope correctly, and avoids the global-first-match problem. TheCSS.escape()handles IDs with special characters.Alternatively, fall back to the current approach when no root is found (degenerate case).
Impact
flash-through-white,cinematic-zoom,cross-warp-morph, etc.) are affected when used as sub-compositions in the same parent composition, since they all use<canvas id="gl-canvas">anddocument.getElementById("gl-canvas").document.getElementById()with non-unique IDs across sibling sub-compositions will silently fail.Workaround
Render each scene as a standalone project and concatenate with
ffmpeg. This avoids the sub-composition inlining entirely.Environment
--no-browser-gpu)Test Case
A unit test could be added to
compositionScoping.test.ts:This test currently fails —
__resultwould be"null"becausegetElementByIdreturns the canvas fromscene-abut the containment check rejects it.