Skip to content

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

Description

@hushicai

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):

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:

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:

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:

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

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:

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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