Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 21 additions & 3 deletions src/motion/timeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,27 @@ const resolveAt = (at: TimelineStep['at'], previousEnd: number): number => {
return previousEnd;
};

const normalizeDuration = (options?: KeyframeAnimationOptions): number =>
resolveTimeValue(options?.duration as number | string | undefined) +
resolveTimeValue(options?.endDelay as number | string | undefined);
const normalizeDuration = (options?: KeyframeAnimationOptions): number => {
const baseDuration = resolveTimeValue(options?.duration as number | string | undefined);
const endDelay = resolveTimeValue(options?.endDelay as number | string | undefined);
const rawIterations = options?.iterations ?? 1;

// Handle infinite iterations - treat as a special case with a very large duration
// In practice, infinite iterations shouldn't be used in timelines as they never end
if (rawIterations === Infinity) {
// Return a large sentinel value - timeline calculations will be incorrect,
// but this at least prevents NaN/Infinity from breaking scheduling
return Number.MAX_SAFE_INTEGER;
}

// Per Web Animations spec, iterations must be a non-negative number
// Treat negative as 0 (only endDelay duration)
const iterations = Math.max(0, rawIterations);

// Total duration = (baseDuration * iterations) + endDelay
// Note: endDelay is applied once at the end, after all iterations
return baseDuration * iterations + endDelay;
};

const scheduleSteps = (steps: TimelineStep[]) => {
let previousEnd = 0;
Expand Down
160 changes: 160 additions & 0 deletions tests/motion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,166 @@ describe('motion/timeline', () => {
// Clean up
tl.stop();
});

it('calculates duration correctly with iterations', () => {
const el = document.createElement('div');
const animation = createMockAnimation();
(el as HTMLElement).animate = mock(() => animation) as unknown as Element['animate'];

const tl = timeline([
{
target: el,
keyframes: [{ opacity: 0 }, { opacity: 1 }],
options: { duration: 100, iterations: 3 },
at: 0,
},
]);

// Duration should be 100ms * 3 iterations = 300ms
expect(tl.duration()).toBe(300);
});

it('accounts for iterations in scheduling relative steps', () => {
const el1 = document.createElement('div');
const el2 = document.createElement('div');
const animation1 = createMockAnimation();
const animation2 = createMockAnimation();

let animateCallCount = 0;
const mockAnimate = mock(() => {
animateCallCount += 1;
return animateCallCount === 1 ? animation1 : animation2;
});

(el1 as HTMLElement).animate = mockAnimate as unknown as Element['animate'];
(el2 as HTMLElement).animate = mockAnimate as unknown as Element['animate'];

const tl = timeline([
{
target: el1,
keyframes: [{ opacity: 0 }, { opacity: 1 }],
options: { duration: 100, iterations: 2 },
at: 0,
},
{
target: el2,
keyframes: [{ opacity: 0 }, { opacity: 1 }],
options: { duration: 100 },
// This should start after el1's 200ms (100ms * 2 iterations)
},
]);

// Total duration should be 200ms (el1) + 100ms (el2) = 300ms
expect(tl.duration()).toBe(300);
});

it('handles iterations with endDelay correctly', () => {
const el = document.createElement('div');
const animation = createMockAnimation();
(el as HTMLElement).animate = mock(() => animation) as unknown as Element['animate'];

const tl = timeline([
{
target: el,
keyframes: [{ opacity: 0 }, { opacity: 1 }],
options: { duration: 100, iterations: 2, endDelay: 50 },
at: 0,
},
]);

// Duration should be (100ms * 2 iterations) + 50ms endDelay = 250ms
expect(tl.duration()).toBe(250);
});

it('handles infinite iterations gracefully', () => {
const el = document.createElement('div');
const animation = createMockAnimation();
(el as HTMLElement).animate = mock(() => animation) as unknown as Element['animate'];

const tl = timeline([
{
target: el,
keyframes: [{ opacity: 0 }, { opacity: 1 }],
options: { duration: 100, iterations: Infinity },
at: 0,
},
]);

// Should return a very large number instead of Infinity
const duration = tl.duration();
expect(duration).toBe(Number.MAX_SAFE_INTEGER);
expect(Number.isFinite(duration)).toBe(true);
});

it('handles zero iterations correctly', () => {
const el = document.createElement('div');
const animation = createMockAnimation();
(el as HTMLElement).animate = mock(() => animation) as unknown as Element['animate'];

const tl = timeline([
{
target: el,
keyframes: [{ opacity: 0 }, { opacity: 1 }],
options: { duration: 100, iterations: 0, endDelay: 50 },
at: 0,
},
]);

// With 0 iterations, duration should be only the endDelay
expect(tl.duration()).toBe(50);
});

it('handles negative iterations gracefully', () => {
const el = document.createElement('div');
const animation = createMockAnimation();
(el as HTMLElement).animate = mock(() => animation) as unknown as Element['animate'];

const tl = timeline([
{
target: el,
keyframes: [{ opacity: 0 }, { opacity: 1 }],
options: { duration: 100, iterations: -5, endDelay: 25 },
at: 0,
},
]);

// Negative iterations should be treated as 0, so only endDelay remains
expect(tl.duration()).toBe(25);
});

it('accounts for iterations with delay option in scheduling', () => {
const el1 = document.createElement('div');
const el2 = document.createElement('div');
const animation1 = createMockAnimation();
const animation2 = createMockAnimation();

let animateCallCount = 0;
const mockAnimate = mock(() => {
animateCallCount += 1;
return animateCallCount === 1 ? animation1 : animation2;
});

(el1 as HTMLElement).animate = mockAnimate as unknown as Element['animate'];
(el2 as HTMLElement).animate = mockAnimate as unknown as Element['animate'];

const tl = timeline([
{
target: el1,
keyframes: [{ opacity: 0 }, { opacity: 1 }],
options: { duration: 100, iterations: 2, delay: 50 },
at: 0,
},
{
target: el2,
keyframes: [{ opacity: 0 }, { opacity: 1 }],
options: { duration: 100 },
// Should start after el1 completes: delay(50) + duration*iterations(200) = 250ms
},
]);

// Total timeline duration: el1 (50 delay + 200ms) + el2 (100ms) = 350ms
expect(tl.duration()).toBe(350);
});
});

describe('motion/spring', () => {
Expand Down
Loading