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