diff --git a/packages/core/src/runtime/init.subCompositionPlacement.test.ts b/packages/core/src/runtime/init.subCompositionPlacement.test.ts
index db25959a12..2d1b5c90d7 100644
--- a/packages/core/src/runtime/init.subCompositionPlacement.test.ts
+++ b/packages/core/src/runtime/init.subCompositionPlacement.test.ts
@@ -199,6 +199,29 @@ describe("runtime sub-composition placement", () => {
expect(window.__player?.getDuration()).toBe(9);
});
+ it("keeps the scene in one root after a tween first renders, when the root's own timeline is empty", async () => {
+ const runFrames = stubFrames();
+ document.body.innerHTML =
+ `
`;
+ const scene = gsap
+ .timeline({ paused: true })
+ .fromTo("#s", { x: -100 }, { x: 0, duration: 0.5, ease: "none" }, 0.5)
+ .to({}, { duration: 9 }, 0);
+ const root = gsap.timeline({ paused: true });
+ window.__timelines = { main: root, scene } as unknown as Record;
+ initSandboxRuntimeModular();
+ window.__player?.seek(1.2);
+ const bound = scene.parent;
+
+ window.__player?.seek(3);
+ await runFrames(6);
+
+ expect(scene.parent).toBe(bound);
+ expect(shownX()).toBe(0);
+ });
+
it("binds a root timeline that registers after the runtime started on its children", async () => {
const runFrames = stubFrames();
loadWithoutRootTimeline(``);
diff --git a/packages/core/src/runtime/timelineDuration.ts b/packages/core/src/runtime/timelineDuration.ts
index ec8e94dfda..e273611c71 100644
--- a/packages/core/src/runtime/timelineDuration.ts
+++ b/packages/core/src/runtime/timelineDuration.ts
@@ -1,13 +1,28 @@
import { swallow } from "./diagnostics";
import type { RuntimeTimelineLike } from "./types";
-/** A registered timeline's finite duration(); a partial, throwing or non-numeric entry reads as null. */
+type ChildTimeline = {
+ paused?: () => boolean;
+ startTime?: () => number;
+ totalDuration?: () => number;
+ timeScale?: () => number;
+};
+
+const pausedChildEnd = (child: ChildTimeline): number =>
+ child.paused?.() === true
+ ? (child.startTime?.() ?? 0) +
+ (child.totalDuration?.() ?? 0) / Math.abs(child.timeScale?.() || 1)
+ : 0;
+
+/** A timeline's finite length, counting paused child timelines; a partial or throwing entry reads as null. */
export function readTimelineDurationSeconds(
- timeline: Pick | null | undefined,
+ timeline: Pick | null | undefined,
): number | null {
try {
if (typeof timeline?.duration !== "function") return null;
- const seconds = Number(timeline.duration());
+ const held: unknown = timeline.getChildren?.(false, false, true);
+ const children = Array.isArray(held) ? (held as ChildTimeline[]) : [];
+ const seconds = Math.max(Number(timeline.duration()), ...children.map(pausedChildEnd));
return Number.isFinite(seconds) ? seconds : null;
} catch (err) {
swallow("runtime.timelineDuration", err);