fix: activate nested child timelines during renderSeek

The renderSeek override in init.ts called seekTimelineAndAdapters() which
only did rootTimeline.totalTime(t) without activating child timelines.
GSAP does not propagate totalTime() to internally paused children.

Also simplifies pollSubCompositionTimelines to always call rebind when
timelines are ready, removing the before/after count comparison that
could skip the rebind on fast page loads.
This commit is contained in:
Miguel Ángel
2026-05-19 15:42:07 -04:00
parent 29ad7df90c
commit 0d12a465a3
4 changed files with 195 additions and 9 deletions
@@ -0,0 +1,88 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
width: 1920px;
height: 1080px;
overflow: hidden;
background: #07110d;
}
#root {
position: relative;
width: 1920px;
height: 1080px;
overflow: hidden;
background: #07110d;
}
.scene {
position: absolute;
inset: 0;
overflow: hidden;
opacity: 0;
}
#scene-hook {
opacity: 1;
}
</style>
</head>
<body>
<div
id="root"
data-composition-id="main"
data-start="0"
data-duration="6"
data-width="1920"
data-height="1080"
>
<!-- First sub-comp at near-zero start: the bug trigger -->
<div
id="scene-hook"
class="scene"
data-layout-allow-overflow
data-composition-id="hook"
data-composition-src="compositions/hook.html"
data-start="0.001"
data-duration="2.0"
data-track-index="0"
></div>
<!-- Second sub-comp at a later start: works correctly -->
<div
id="scene-later"
class="scene"
data-layout-allow-overflow
data-composition-id="later"
data-composition-src="compositions/later.html"
data-start="2.5"
data-duration="3.5"
data-track-index="1"
></div>
</div>
<script>
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({ paused: true });
// Transition: hook zooms out at 2.5s
tl.to("#scene-hook", { scale: 2.5, opacity: 0, duration: 0.4, ease: "power3.in" }, 2.5);
tl.fromTo(
"#scene-later",
{ scale: 0.5, opacity: 0 },
{ scale: 1, opacity: 1, duration: 0.4, ease: "power3.out" },
2.65,
);
window.__timelines["main"] = tl;
</script>
</body>
</html>
+69
View File
@@ -444,6 +444,75 @@ describe("initSandboxRuntimeModular", () => {
expect(video.currentTime).toBe(0);
});
it("activates sub-composition timelines at data-start near 0 during renderSeek", () => {
// Regression: sub-compositions starting at or near t=0 had their GSAP
// sub-timelines ignored during render because renderSeek did not
// activate (unpause) nested child timelines before seeking the root.
// The children were added to the root while paused, and GSAP's
// totalTime() does not propagate to paused children.
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-duration", "24");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);
const hookHost = document.createElement("div");
hookHost.setAttribute("data-composition-id", "hook");
hookHost.setAttribute("data-start", "0.001");
hookHost.setAttribute("data-duration", "2");
hookHost.setAttribute("data-track-index", "0");
hookHost.classList.add("clip");
root.appendChild(hookHost);
const laterHost = document.createElement("div");
laterHost.setAttribute("data-composition-id", "tweet");
laterHost.setAttribute("data-start", "1.5");
laterHost.setAttribute("data-duration", "4.5");
laterHost.setAttribute("data-track-index", "1");
laterHost.classList.add("clip");
root.appendChild(laterHost);
const hookTimeline = createMockTimeline(2);
const tweetTimeline = createMockTimeline(4.5);
const rootTimeline = createMockTimeline(24);
(window as Window & { __timelines?: Record<string, RuntimeTimelineLike> }).__timelines = {
main: rootTimeline,
hook: hookTimeline,
tweet: tweetTimeline,
};
initSandboxRuntimeModular();
const player = (
window as Window & {
__player?: { renderSeek: (timeSeconds: number) => void };
}
).__player;
expect(player).toBeDefined();
// Simulate that the hook timeline was paused (as happens when
// children are added to a paused root timeline in GSAP)
hookTimeline.paused!(true);
tweetTimeline.paused!(true);
// Seek to 0.5s — well within the hook's window [0.001, 2.001]
player?.renderSeek(0.5);
// renderSeek should activate (unpause) all child timelines before
// seeking the root. Without the fix, children stay paused and GSAP's
// totalTime() propagation skips them, leaving elements at initial CSS
// state (opacity: 0).
expect(hookTimeline.paused!()).toBe(false);
expect(tweetTimeline.paused!()).toBe(false);
// The hook host should be visible at t=0.5
expect(hookHost.style.visibility).toBe("visible");
});
it("plays scheduled child timelines without a captured root timeline when audio has failed", () => {
const raf = createManualRaf();
vi.spyOn(performance, "now").mockImplementation(() => raf.now());
+30 -2
View File
@@ -1724,9 +1724,37 @@ export function initSandboxRuntimeModular(): void {
}
};
const seekTimelineAndAdapters = (t: number) => {
const activateNestedChildTimelines = (masterTimeline: RuntimeTimelineLike) => {
const timelines = (window.__timelines ?? {}) as Record<string, RuntimeTimelineLike | undefined>;
for (const tl of Object.values(timelines)) {
if (!tl || tl === masterTimeline) continue;
try {
const tlWithPaused = tl as RuntimeTimelineLike & {
paused?: (value?: boolean) => unknown;
};
if (typeof tlWithPaused.paused === "function") {
tlWithPaused.paused(false);
}
} catch (err) {
swallow("runtime.init.activateNested", err);
}
}
};
const seekTimelineAndAdapters = (t: number, activateChildren = false) => {
const tl = state.capturedTimeline;
if (tl) {
// When rendering frame-by-frame (activateChildren=true), ensure all
// nested child timelines are unpaused before seeking the root. GSAP
// does not propagate totalTime() to children that are internally
// paused, which leaves sub-compositions at their initial CSS state
// (typically opacity:0). This mirrors the activateSiblingTimelines
// call in player.ts renderSeek and is critical for sub-compositions
// whose data-start is at or near 0 — they are added to the root
// while it is paused and may never receive an explicit play().
if (activateChildren) {
activateNestedChildTimelines(tl);
}
try {
if (typeof tl.totalTime === "function") {
tl.totalTime(t, false);
@@ -2001,7 +2029,7 @@ export function initSandboxRuntimeModular(): void {
state.currentTime = clock.now();
state.isPlaying = false;
state.mediaForceSyncNextTick = true;
seekTimelineAndAdapters(state.currentTime);
seekTimelineAndAdapters(state.currentTime, true);
syncMediaForCurrentState();
postState(true);
};