Skip to content

Commit 2d7b9e5

Browse files
fix(core): guard timeline method calls for non-conformant objects (#1098)
* fix(core): guard timeline method calls for non-conformant objects User compositions can register timeline-like objects on window.__timeline where .duration is a number property (not a function) and .pause/.play may be missing entirely. The runtime player called these unconditionally, causing ~166 "duration is not a function" and ~38 "pause is not a function" errors per day. Add safeNum() and safeVoid() helpers that check typeof before calling, falling back to reading numbers as properties and silently skipping missing void methods. Applied consistently across all timeline method call sites in player.ts. * fix(core): add observability for non-conformant timeline properties
1 parent cbb7831 commit 2d7b9e5

2 files changed

Lines changed: 114 additions & 18 deletions

File tree

packages/core/src/runtime/player.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -488,6 +488,73 @@ describe("createRuntimePlayer", () => {
488488
});
489489
});
490490

491+
describe("tolerates non-conformant timeline objects", () => {
492+
it("handles duration as a number property instead of a function", () => {
493+
const timeline = {
494+
play: vi.fn(),
495+
pause: vi.fn(),
496+
seek: vi.fn(),
497+
totalTime: vi.fn(),
498+
time: vi.fn(() => 2),
499+
duration: 10,
500+
add: vi.fn(),
501+
paused: vi.fn(),
502+
set: vi.fn(),
503+
} as unknown as RuntimeTimelineLike;
504+
const deps = createMockDeps(timeline);
505+
const player = createRuntimePlayer(deps);
506+
expect(player.getDuration()).toBe(10);
507+
expect(() => player.play()).not.toThrow();
508+
});
509+
510+
it("handles missing pause method", () => {
511+
const timeline = {
512+
play: vi.fn(),
513+
seek: vi.fn(),
514+
time: vi.fn(() => 0),
515+
duration: vi.fn(() => 10),
516+
add: vi.fn(),
517+
paused: vi.fn(),
518+
set: vi.fn(),
519+
} as unknown as RuntimeTimelineLike;
520+
const deps = createMockDeps(timeline);
521+
const player = createRuntimePlayer(deps);
522+
expect(() => player.pause()).not.toThrow();
523+
expect(() => player.seek(3)).not.toThrow();
524+
});
525+
526+
it("handles missing play method", () => {
527+
const timeline = {
528+
pause: vi.fn(),
529+
seek: vi.fn(),
530+
time: vi.fn(() => 0),
531+
duration: vi.fn(() => 10),
532+
add: vi.fn(),
533+
paused: vi.fn(),
534+
set: vi.fn(),
535+
} as unknown as RuntimeTimelineLike;
536+
const deps = createMockDeps(timeline);
537+
const player = createRuntimePlayer(deps);
538+
expect(() => player.play()).not.toThrow();
539+
});
540+
541+
it("handles time as a number property instead of a function", () => {
542+
const timeline = {
543+
play: vi.fn(),
544+
pause: vi.fn(),
545+
seek: vi.fn(),
546+
time: 5,
547+
duration: vi.fn(() => 10),
548+
add: vi.fn(),
549+
paused: vi.fn(),
550+
set: vi.fn(),
551+
} as unknown as RuntimeTimelineLike;
552+
const deps = createMockDeps(timeline);
553+
const player = createRuntimePlayer(deps);
554+
expect(player.getTime()).toBe(5);
555+
});
556+
});
557+
491558
describe("getters", () => {
492559
it("getTime returns timeline time", () => {
493560
const timeline = createMockTimeline({ time: 7.5 });

packages/core/src/runtime/player.ts

Lines changed: 47 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,35 @@ import type { RuntimePlayer, RuntimeTimelineLike } from "./types";
22
import { quantizeTimeToFrame } from "../inline-scripts/parityContract";
33
import { swallow } from "./diagnostics";
44

5+
/**
6+
* Safely read a numeric value from a timeline property that may be either a
7+
* function (conformant GSAP) or a bare number (user-authored timeline-like).
8+
*/
9+
function safeNum(obj: unknown, prop: string, fallback: number): number {
10+
const val = (obj as Record<string, unknown>)?.[prop];
11+
if (typeof val === "function") return Number(val.call(obj)) || fallback;
12+
if (typeof val === "number" && Number.isFinite(val)) return val;
13+
if (val !== undefined && val !== null) {
14+
swallow("runtime.player.nonConformantNum", { prop, actual: typeof val });
15+
}
16+
return fallback;
17+
}
18+
19+
/**
20+
* Safely invoke a void method on a timeline. If the method is not a function
21+
* (missing or overwritten with a non-callable value), silently skip.
22+
*/
23+
function safeVoid(obj: unknown, method: string): void {
24+
const fn = (obj as Record<string, unknown>)?.[method];
25+
if (typeof fn === "function") {
26+
fn.call(obj);
27+
return;
28+
}
29+
if (fn !== undefined) {
30+
swallow("runtime.player.nonConformantVoid", { method, actual: typeof fn });
31+
}
32+
}
33+
534
type PlayerDeps = {
635
getTimeline: () => RuntimeTimelineLike | null;
736
setTimeline: (timeline: RuntimeTimelineLike | null) => void;
@@ -52,11 +81,11 @@ function seekTimelineDeterministically(
5281
canonicalFps: number,
5382
): number {
5483
const quantized = quantizeTimeToFrame(timeSeconds, canonicalFps);
55-
timeline.pause();
84+
safeVoid(timeline, "pause");
5685
if (typeof timeline.totalTime === "function") {
5786
timeline.totalTime(quantized, false);
5887
} else {
59-
timeline.seek(quantized, false);
88+
if (typeof timeline.seek === "function") timeline.seek(quantized, false);
6089
}
6190
return quantized;
6291
}
@@ -69,15 +98,15 @@ function seekMasterAndSiblingTimelinesDeterministically(
6998
): number {
7099
const rearmedSiblings: RuntimeTimelineLike[] = [];
71100
forEachSiblingTimeline(registry, master, (tl) => {
72-
tl.play();
101+
safeVoid(tl, "play");
73102
rearmedSiblings.push(tl);
74103
});
75104
try {
76105
return seekTimelineDeterministically(master, timeSeconds, canonicalFps);
77106
} finally {
78107
for (const tl of rearmedSiblings) {
79108
try {
80-
tl.pause();
109+
safeVoid(tl, "pause");
81110
} catch (err) {
82111
// ignore sibling failures — one broken timeline shouldn't poison seek
83112
swallow("runtime.player.site2", err);
@@ -91,7 +120,7 @@ function activateSiblingTimelines(
91120
master: RuntimeTimelineLike,
92121
): void {
93122
forEachSiblingTimeline(registry, master, (tl) => {
94-
tl.play();
123+
safeVoid(tl, "play");
95124
});
96125
}
97126

@@ -103,13 +132,13 @@ export function createRuntimePlayer(deps: PlayerDeps): RuntimePlayer {
103132
if (!timeline || deps.getIsPlaying()) return;
104133
const safeDuration = Math.max(
105134
0,
106-
Number(deps.getSafeDuration?.() ?? timeline.duration() ?? 0) || 0,
135+
Number(deps.getSafeDuration?.() ?? safeNum(timeline, "duration", 0)) || 0,
107136
);
108137
if (safeDuration > 0) {
109-
const currentTime = Math.max(0, Number(timeline.time()) || 0);
138+
const currentTime = Math.max(0, safeNum(timeline, "time", 0));
110139
if (currentTime >= safeDuration) {
111-
timeline.pause();
112-
timeline.seek(0, false);
140+
safeVoid(timeline, "pause");
141+
if (typeof timeline.seek === "function") timeline.seek(0, false);
113142
deps.onDeterministicSeek(0);
114143
deps.setIsPlaying(false);
115144
deps.onSyncMedia(0, false);
@@ -119,10 +148,10 @@ export function createRuntimePlayer(deps: PlayerDeps): RuntimePlayer {
119148
if (typeof timeline.timeScale === "function") {
120149
timeline.timeScale(deps.getPlaybackRate());
121150
}
122-
timeline.play();
151+
safeVoid(timeline, "play");
123152
forEachSiblingTimeline(deps.getTimelineRegistry?.(), timeline, (tl) => {
124153
if (typeof tl.timeScale === "function") tl.timeScale(deps.getPlaybackRate());
125-
tl.play();
154+
safeVoid(tl, "play");
126155
});
127156
deps.onDeterministicPlay();
128157
deps.setIsPlaying(true);
@@ -132,11 +161,11 @@ export function createRuntimePlayer(deps: PlayerDeps): RuntimePlayer {
132161
pause: () => {
133162
const timeline = deps.getTimeline();
134163
if (!timeline) return;
135-
timeline.pause();
164+
safeVoid(timeline, "pause");
136165
forEachSiblingTimeline(deps.getTimelineRegistry?.(), timeline, (tl) => {
137-
tl.pause();
166+
safeVoid(tl, "pause");
138167
});
139-
const time = Math.max(0, Number(timeline.time()) || 0);
168+
const time = Math.max(0, safeNum(timeline, "time", 0));
140169
deps.onDeterministicSeek(time);
141170
deps.onDeterministicPause();
142171
deps.setIsPlaying(false);
@@ -162,10 +191,10 @@ export function createRuntimePlayer(deps: PlayerDeps): RuntimePlayer {
162191
if (typeof timeline.timeScale === "function") {
163192
timeline.timeScale(deps.getPlaybackRate());
164193
}
165-
timeline.play();
194+
safeVoid(timeline, "play");
166195
forEachSiblingTimeline(deps.getTimelineRegistry?.(), timeline, (tl) => {
167196
if (typeof tl.timeScale === "function") tl.timeScale(deps.getPlaybackRate());
168-
tl.play();
197+
safeVoid(tl, "play");
169198
});
170199
deps.onDeterministicPlay();
171200
deps.onShowNativeVideos();
@@ -199,8 +228,8 @@ export function createRuntimePlayer(deps: PlayerDeps): RuntimePlayer {
199228
deps.onRenderFrameSeek(quantized);
200229
deps.onStatePost(true);
201230
},
202-
getTime: () => Number(deps.getTimeline()?.time() ?? 0),
203-
getDuration: () => Number(deps.getTimeline()?.duration() ?? 0),
231+
getTime: () => safeNum(deps.getTimeline(), "time", 0),
232+
getDuration: () => safeNum(deps.getTimeline(), "duration", 0),
204233
isPlaying: () => deps.getIsPlaying(),
205234
setPlaybackRate: (rate: number) => deps.setPlaybackRate(rate),
206235
getPlaybackRate: () => deps.getPlaybackRate(),

0 commit comments

Comments
 (0)