Describe the bug
Nesting paused child timelines into a parent makes the parent report duration() === 0.
Every seek then resolves to t=0, so the render is entirely blank — and lint, check and
validate all pass.
This matters because our own composition contract requires paused timelines
("GSAP timelines must be paused and registered on window.__timelines"). An author who
follows that guidance for each of two scenes and combines them with .add() gets a black
video and no warning.
Steps to reproduce
Reproduced against gsap 3.15.0, the version in the workspace (reported independently on
3.14.2):
import { gsap } from "gsap";
const mk = (paused) => {
const t = gsap.timeline({ paused });
t.to({ x: 0 }, { x: 1, duration: 3 }, 0);
return t;
};
for (const paused of [true, false]) {
const a = mk(paused), b = mk(paused);
const parent = gsap.timeline({ paused: true });
parent.add(a, 0);
parent.add(b, 0);
console.log(`children paused=${paused}: child.duration=${a.duration()} parent.duration=${parent.duration()}`);
}
children paused=true: child.duration=3 parent.duration=0
children paused=false: child.duration=3 parent.duration=3
Each child's own duration() is correct and getChildren() returns them with correct
start/duration. Only the parent's duration collapses.
Impact
packages/core/src/runtime/init.ts:675 and :721, startResolver.ts:99 and
timeline.ts:170 all derive composition duration from timeline.duration(). A registered
parent built this way reports 0, so seek(t > 0) renders the t=0 state — every element
still hidden — and the output is black.
Nothing catches it. This was found from a real composition (a matrix-rain background and a
text-decode foreground, each on its own paused timeline, combined via .add()) that
produced an all-black render and passed every gate.
Suggested fix
This is arguably GSAP behaving as designed — a paused child does not advance — so the fix
belongs on our side, as an invariant we can state exactly:
A registered timeline with getChildren().length > 0 and duration() === 0 is always
wrong.
There is no legitimate composition with children spanning time and zero total duration, so
checking it has no false-positive case. Failing loudly there converts a silent black render
into an actionable error naming the composition id.
A lint rule could catch the common authored spelling (gsap.timeline({ paused: true })
passed to .add()), but the runtime check is the one that cannot be evaded, since the
timeline may be assembled dynamically.
Describe the bug
Nesting paused child timelines into a parent makes the parent report
duration() === 0.Every seek then resolves to
t=0, so the render is entirely blank — andlint,checkandvalidateall pass.This matters because our own composition contract requires paused timelines
("GSAP timelines must be paused and registered on
window.__timelines"). An author whofollows that guidance for each of two scenes and combines them with
.add()gets a blackvideo and no warning.
Steps to reproduce
Reproduced against gsap 3.15.0, the version in the workspace (reported independently on
3.14.2):
Each child's own
duration()is correct andgetChildren()returns them with correctstart/duration. Only the parent's duration collapses.
Impact
packages/core/src/runtime/init.ts:675and:721,startResolver.ts:99andtimeline.ts:170all derive composition duration fromtimeline.duration(). A registeredparent built this way reports 0, so
seek(t > 0)renders thet=0state — every elementstill hidden — and the output is black.
Nothing catches it. This was found from a real composition (a matrix-rain background and a
text-decode foreground, each on its own paused timeline, combined via
.add()) thatproduced an all-black render and passed every gate.
Suggested fix
This is arguably GSAP behaving as designed — a paused child does not advance — so the fix
belongs on our side, as an invariant we can state exactly:
There is no legitimate composition with children spanning time and zero total duration, so
checking it has no false-positive case. Failing loudly there converts a silent black render
into an actionable error naming the composition id.
A lint rule could catch the common authored spelling (
gsap.timeline({ paused: true })passed to
.add()), but the runtime check is the one that cannot be evaded, since thetimeline may be assembled dynamically.