Skip to content

fix(core): scoped getElementById fails with duplicate element IDs across sub-compositions #646

@learntimes

Description

@learntimes

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

  1. 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>
  2. Both sub-compositions contain <canvas id="gl-canvas"> and call document.getElementById("gl-canvas").
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions