From 6b7dea887dfbf1b3b787108ac733a12306c32493 Mon Sep 17 00:00:00 2001 From: Carlos Alcaraz <193642530+calcarazgre646@users.noreply.github.com> Date: Thu, 18 Jun 2026 21:56:55 -0300 Subject: [PATCH] fix(runtime): guard dotlottie v1 seek against zero duration The dotlottie-web v1 seek branch computed (time / duration) * 100 with duration = anim.duration ?? 1. Nullish coalescing does not replace 0, so a player reporting duration 0 (not yet loaded, the bootstrap-time discover->seek window) produced (time / 0) * 100 = NaN, and seek(NaN) blanked the first frame at composition time 0. The v2 branch right above already guards with if (totalFrames > 0). Mirror that guard: default duration to 0 and seek only when it is positive, with the percentage clamped to [0, 100]. --- .../core/src/runtime/adapters/lottie.test.ts | 21 +++++++++++++++++++ packages/core/src/runtime/adapters/lottie.ts | 14 +++++++++---- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/packages/core/src/runtime/adapters/lottie.test.ts b/packages/core/src/runtime/adapters/lottie.test.ts index a8e5eb4a53..b3f5402149 100644 --- a/packages/core/src/runtime/adapters/lottie.test.ts +++ b/packages/core/src/runtime/adapters/lottie.test.ts @@ -121,6 +121,27 @@ describe("lottie adapter", () => { adapter.seek({ time: -5 }); expect(anim.goToAndStop).toHaveBeenCalledWith(0, false); }); + + it("seeks a v1 dotlottie player by percentage when duration is known", () => { + // v1 player: has seek() but no setCurrentRawFrameValue. + const seek = vi.fn(); + const player = { play: vi.fn(), pause: vi.fn(), duration: 4, seek }; + lottieWindow.__hfLottie = [player]; + const adapter = createLottieAdapter(); + adapter.seek({ time: 1 }); // 1 / 4 * 100 = 25 + expect(seek).toHaveBeenCalledWith(25); + }); + + it("does not seek a v1 dotlottie player whose duration is 0 (not yet loaded)", () => { + // duration 0 before load: an unguarded (time / 0) * 100 = NaN used to + // reach seek() and blank the first frame at composition time 0. + const seek = vi.fn(); + const player = { play: vi.fn(), pause: vi.fn(), duration: 0, seek }; + lottieWindow.__hfLottie = [player]; + const adapter = createLottieAdapter(); + adapter.seek({ time: 0 }); + expect(seek).not.toHaveBeenCalled(); + }); }); describe("pause", () => { diff --git a/packages/core/src/runtime/adapters/lottie.ts b/packages/core/src/runtime/adapters/lottie.ts index e304f61f76..5a303dc74e 100644 --- a/packages/core/src/runtime/adapters/lottie.ts +++ b/packages/core/src/runtime/adapters/lottie.ts @@ -102,10 +102,16 @@ export function createLottieAdapter(): RuntimeDeterministicAdapter { anim.setCurrentRawFrameValue(Math.min(frame, totalFrames - 1)); } } else if (typeof anim.seek === "function") { - // dotlottie-web v1: seek(percentage 0-100) - const duration = anim.duration ?? 1; - const percentage = Math.min(100, (time / duration) * 100); - anim.seek(percentage); + // dotlottie-web v1: seek(percentage 0-100). Mirror the v2 guard + // above: a not-yet-loaded player reports duration 0, and `0 ?? 1` + // stays 0 (nullish coalescing does not replace 0), so an unguarded + // (time / 0) * 100 = NaN reached seek() and blanked the first frame + // at composition time 0. + const duration = anim.duration ?? 0; + if (duration > 0) { + const percentage = Math.max(0, Math.min(100, (time / duration) * 100)); + anim.seek(percentage); + } } } } catch (err) {