fix(core): scope getElementById inside compositions - #655
Conversation
jrusso1020
left a comment
There was a problem hiding this comment.
LGTM — surgical fix to a real bug, preserves the fast path, anticipates the broader bug class.
What I verified
Root-cause analysis matches the source. The bug at compositionScoping.ts:144-150 (pre-fix) does exactly what issue #646 describes: target.getElementById(id) returns the first global DOM match, then __hfContains(found) rejects it for any composition past the first → null. Issue's reproduction is correct and the suggested fix matches the implementation here (with one improvement — see below).
The fast-path preservation is correct. When the global lookup happens to land in the active composition (most common case — unique IDs, or first composition), the implementation skips the scoped query entirely. No perf regression for the happy path.
Special-character ID handling is the catch-beyond-the-bug-report. Issue #646 mentions CSS.escape only as part of the suggested fix. This PR's second test (clip:1) is the genuinely valuable one — it pins behavior for any ID that's a valid HTML id but not a valid bare CSS selector. A naive "#" + id would have shipped with the issue's bug fixed but a new bug introduced. Catching this and writing the regression for it is the right shape.
The non-DOM-environment fallback (__hfEscapeAttr + [id="..."]) is necessary, not over-engineering. I traced this:
compositionScoping.ts:152—if (typeof CSS !== "undefined" && CSS && typeof CSS.escape === "function")- The unit tests use
new Function("window", wrapped)(fakeWindow)wherefakeWindowonly has{document, __selectedComp, __timelines}. NoCSSglobal. Without the fallback, the tests themselves would fail on aCSS is not definedReferenceError. The fallback isn't speculative — it's required by the test harness setup.
Helper definitions exist and compose correctly:
__hfEscapeAttrat:106-108— was already in the file; the new code reuses it__hfFindRootat:121-126— caches the root in__hfRootafter first lookup__hfContainsat:127-130—!root || node === root || root.contains(node). Whenrootis null (composition root not found), returnstruefor any node — meaning the original behavior in the no-active-root degenerate case was "let the global match through." The new implementation preserves that: theif (found && __hfContains(found)) return found;guard returns the global match in that edge case before the scoped fallback runs. ✓
Tiny observations (non-blocking)
-
if (!root) return found || null;at:148is functionally unreachable in normal operation. Reasoning:__hfFindRoot()caches in__hfRoot; if the cached value is null, then__hfContains(found)returnstrue(because of the!root || ...short-circuit), soif (found && __hfContains(found)) return found;already returned. Hitting thisif (!root)line requires the cache to flip from non-null to null between the two__hfFindRoot()calls in this function, which the cache doesn't allow. Harmless dead code; not worth removing in this PR (could be slightly destabilizing if I'm missing a reentrant case). -
Test gap for "ID on the root element itself."
:150hasif (root.id === idValue) return root;— this handles<div data-composition-id="scene-b" id="my-root">…</div>callingdocument.getElementById("my-root"). Logic looks right but no test pins it. Easy follow-up. -
Empty
catch {}blocks at:155and:159. They swallowquerySelectorerrors silently — ifroot.querySelector("#" + CSS.escape(...))ever throws (very unusual but theoretically possible if CSS.escape produces something the engine still can't parse), the user getsnulland no signal. Matches the file's existing style; flagging only because debugging "I called getElementById and got null" would be hard if these ever fired. Not blocking.
Praise worth surfacing
- Test fidelity is high.
new Function("window", wrapped)(fakeWindow)executes the actual generated wrapper code against a real parsed DOM, rather than mocking the proxy or stubbinggetElementById. That's the right shape — and it's why I trust the regression coverage. - The browser-based proof. Generating
qa-artifacts/issue-646/proof.html+ capturing PNG + WebM withagent-browserand three independent assertions (byIdRoot,specialIdRoot,queryRootall returning"scene-b") is more rigorous than the unit tests alone. Confirms the fix works in a realCSS.escape-supporting environment, not just the test harness's fallback path. - Order of operations in the fast path → scoped path → fallback chain. Each transition has a clear precondition: fast path requires global match in active comp; scoped path requires active root; fallback CSS.escape is only used when the global is available. Reads cleanly without nested branching.
Ship it.
— Review by Rames Jusso (pr-review)
Problem
Fixes #646.
When sibling sub-compositions contain the same element IDs, scoped
document.getElementById()can returnnullfor every composition after the first. This breaks blocks that reuse IDs like<canvas id="gl-canvas">and then calldocument.getElementById("gl-canvas").getContext(...)from their scoped script.How to reproduce
Create two composition roots with the same ID-bearing child, then execute the scoped wrapper for the second composition:
Before this patch, wrapping that script with
wrapScopedCompositionScript(..., "scene-b")produces"null"because the global DOM lookup findsscene-afirst and the scoping containment check rejects it.What this fixes
document.getElementById()scoped to the active composition root when duplicate IDs exist across sibling sub-compositions.getElementById()already returns an element inside the active root.clip:1, instead of relying on a fragile"#" + idselector.Root cause
wrapScopedCompositionScript()proxieddocument.getElementById()by calling the real document's globalgetElementById(id)first and then checking whether that one result lived inside the scoped composition root.That works only when the first global match is inside the active composition. With duplicate IDs, the browser returns the first match in document order, so the second composition sees the first composition's element, rejects it during containment filtering, and returns
nullwithout searching inside its own root.The fix falls back to the scoped root when the global result is outside the active composition: it checks the root element itself, then queries inside the root using
CSS.escape()when available and a quoted[id="..."]fallback for non-browser/test environments.Verification
Local checks
bun test packages/core/src/compiler/compositionScoping.test.tsExpected: "scene-b" / Received: "null".bunx oxlint packages/core/src/compiler/compositionScoping.ts packages/core/src/compiler/compositionScoping.test.tsbunx oxfmt --check packages/core/src/compiler/compositionScoping.ts packages/core/src/compiler/compositionScoping.test.tsbun run --filter @hyperframes/core build:hyperframes-runtimebun run --filter @hyperframes/core testafter generating the runtime inline module: 47 files, 665 tests passed.bun run --filter @hyperframes/core typecheckafter generating the runtime inline module.bun run --filter @hyperframes/core build.Browser verification
Used
agent-browseragainst a generated proof page that embeds the actual patchedwrapScopedCompositionScript()output and duplicates bothgl-canvasandclip:1acrossscene-aandscene-b.The browser page reported:
{ "byIdRoot": "scene-b", "specialIdRoot": "scene-b", "queryRoot": "scene-b" }Saved local proof artifacts:
qa-artifacts/issue-646/proof.htmlqa-artifacts/issue-646/proof.pngqa-artifacts/issue-646/proof.webmffprobeconfirmed the recording is a VP8 WebM at 1440x1000.Notes
646/getElementById compositionScopingdid not find an existing fix.