Composition scope shim doesn't scope array selector targets: tweens leak across compositions (element stays visibility:hidden for a whole beat)

Author: hushicaiCreated Sep 17, 2026Updated Sep 17, 2026

Problem

In a multi-composition single-file build, the injected composition-scoping shim only filters string targets. An array of selector strings is handed to GSAP untouched and resolved against the whole document, so a set/to/from/fromTo in one composition animates elements belonging to every other composition.

Root cause

The wrapper injected per composition (__hfScopeTimeline / __hfScopedGsap / __hfRun):

javascript
var __hfResolveGsapTarget = function(target) {
  if (typeof target !== "string") return target;   // arrays/objects bypass scoping
  return __hfQueryAll(target);
};
...
timeline[method] = function(target) {
  var args = Array.prototype.slice.call(arguments);
  args[0] = __hfResolveGsapTarget(target);
  return original.apply(timeline, args);
};

__hfQueryAll -> __hfContains correctly clips string selectors to the composition root, but a non-string is returned as-is, so GSAP's own target resolution runs globally. gsap.utils.toArray(arrayOfSelectors) has the same gap (the proxy's utils.toArray hook calls the same resolver).

Steps to reproduce

8-composition project (beat-1 .. beat-8), each hosted as <div data-composition-id="beat-N">. In compositions/beat-1.html:

javascript
const tl = gsap.timeline({ paused: true });
tl.set([".bg-grid", ".sub"], { autoAlpha: 0 }, 0);

Then hyperframes snapshot . --at <t> --no-end, and in the built single file:

javascript
gsap.getTweensOf(document.querySelector('[data-composition-id="beat-7"] .sub'))[0].targets().length
// => 26  (every element matching any selector in the array, document-wide)

The 26 targets include beat-7's own .bg-grid, .bg-glow and all three .sub elements. Expected: only beat-1's elements.

Expected behavior

An array target is resolved (and clipped) per element, exactly like the equivalent comma-separated string selector.

Actual behavior

Tweens leak across composition boundaries.

The failure mode is asymmetric and silent, which is what makes it nasty:

  • If the leaked-to composition also animates that element with its own correctly-scoped string tween, the leak is overwritten and nothing is visible — most authors would never notice.
  • If it does not animate it (the documented "CSS = end state, opacity only in JS" pattern encourages leaving static elements alone), the leaked autoAlpha: 0 sticks for the entire timeline, i.e. visibility: hidden on that element and its whole subtree. Rendered frames show a blank region, yet getComputedStyle() on descendants still reports opacity: 1, bounding rects are intact and document.elementFromPoint() hits the element — so DOM inspection says everything is fine.

In my case beat-1's tl.set([... ".sub" ...], { autoAlpha: 0 }, 0) (at t=0, with immediateRender) hid the three .sub wrappers in beat-7, whose mid + bottom content lives underneath them. hyperframes snapshot and the exported video.mp4 show that beat's whole mid/bottom band empty (ink coverage 0.00% over ~24s) while the top region renders normally. hyperframes validate / check report 0 errors, inspect reports 0 issues, and no runtime error is logged, because nothing is actually broken in CSS, GSAP or the timeline — only the scope filter is bypassed.

Verify by forcing the ancestor back on at a time its content should be visible:

javascript
window.__player.enableRenderMode();
window.__player.renderSeek(178.736);
// mid band ink 0.32%, bottom 0.00%
window.gsap.set(document.querySelector('[data-composition-id="beat-7"]').querySelectorAll(".sub"), { autoAlpha: 1 });
// mid band ink 5.61%, bottom 1.07%

(Values are the fraction of pixels above a 90-luminance threshold in fixed top/mid/bottom bands of a 1080x1260 frame, measured from page.screenshot() in headless Chrome against the compiled single-file HTML, so this is not snapshot-path specific.)

Suggested fix

javascript
var __hfResolveGsapTarget = function(target) {
  if (Array.isArray(target)) return target.flatMap(__hfResolveGsapTarget);
  if (typeof target !== "string") return target;
  return __hfQueryAll(target);
};

Workaround

Use one comma-separated string selector, which goes through __hfQueryAll and the __hfContains clip:

javascript
tl.set(".bg-grid, .sub", { autoAlpha: 0 }, 0);

A lint rule flagging selector arrays in gsap/timeline calls would also have caught this before render.

Environment

hyperframes 0.8.41 (global npm install, npx)
Node.js v24.17.0 (darwin arm64, macOS)
Chrome 152 (headless, via puppeteer-core) for the DOM/pixel probes

Happy to put together a minimal 2-composition fixture repo if that helps.