Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -730,6 +730,29 @@ describe("createAnimationsFromSequence", () => {
expect(transition.y.times).toEqual([0, 0.5, 1])
})

test("It passes repeat: Infinity through to the final transition (#2915)", () => {
const animations = createAnimationsFromSequence(
[
[
a,
{ x: [0, 100] },
{ duration: 1, repeat: Infinity, ease: "linear" },
],
],
undefined,
undefined,
{ spring }
)

expect(animations.get(a)!.keyframes.x).toEqual([0, 100])
const { duration, times, ease, repeat } =
animations.get(a)!.transition.x
expect(duration).toEqual(1)
expect(times).toEqual([0, 1])
expect(ease).toEqual(["linear", "linear"])
expect(repeat).toEqual(Infinity)
})
Comment on lines +733 to +754
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Missing test coverage for passthrough edge cases

The new test covers repeat: Infinity but leaves several important cases untested:

  1. repeatType passthrough: No assertion that repeatType (e.g. "mirror") is correctly propagated through to the final transition.
  2. repeatDelay passthrough: No assertion that a non-zero repeatDelay makes it to the final transition.
  3. Finite repeat >= MAX_REPEAT in multi-segment sequences: A test like the existing "Repeating a segment correctly places the next segment at the end" test but with repeat: 25 would expose the timing discontinuity noted in the logic comment above.
  4. repeat: 20 boundary: This is the smallest value that now follows the passthrough path, and its behavior (timing for subsequent segments) is not exercised at all.


test.skip("It correctly adds repeatDelay between repeated keyframes", () => {
const animations = createAnimationsFromSequence(
[
Expand Down
92 changes: 57 additions & 35 deletions packages/framer-motion/src/animation/sequence/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import {
import {
Easing,
getEasingForSegment,
invariant,
progress,
secondsToMilliseconds,
} from "motion-utils"
Expand Down Expand Up @@ -51,6 +50,16 @@ export function createAnimationsFromSequence(
const elementCache = {}
const timeLabels = new Map<string, number>()

/**
* Store per-value repeat options that can't be expanded into keyframes
* (e.g. repeat: Infinity) and need to be passed through to the
* final transition for the animation engine to handle.
*/
const repeatPassthrough = new Map<
ValueSequence,
Pick<Transition, "repeat" | "repeatType" | "repeatDelay">
>()

let prevTime = 0
let currentTime = 0
let totalDuration = 0
Expand Down Expand Up @@ -198,46 +207,58 @@ export function createAnimationsFromSequence(
* Handle repeat options
*/
if (repeat) {
invariant(
repeat < MAX_REPEAT,
"Repeat count too high, must be less than 20",
"repeat-count-high"
)

duration = calculateRepeatDuration(
duration,
repeat,
repeatDelay
)

const originalKeyframes = [...valueKeyframesAsList]
const originalTimes = [...times]
ease = Array.isArray(ease) ? [...ease] : [ease]
const originalEase = [...ease]
if (repeat >= MAX_REPEAT) {
/**
* For large/infinite repeat counts, don't expand keyframes.
* Pass repeat options through to the final transition
* and let the animation engine handle repeating.
*/
repeatPassthrough.set(valueSequence, {
repeat,
repeatType: repeatType as Transition["repeatType"],
repeatDelay: repeatDelay || undefined,
})
Comment on lines +216 to +220
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 repeatType: undefined spreads into final transition unconditionally

When the user doesn't provide repeatType, it destructures as undefined. Spreading { repeatType: undefined } into the final transition object will set repeatType to undefined as an explicit key, potentially overriding any repeatType set in sequenceTransition (which is spread just before this in line 409).

Suggested change
repeatPassthrough.set(valueSequence, {
repeat,
repeatType: repeatType as Transition["repeatType"],
repeatDelay: repeatDelay || undefined,
})
repeatPassthrough.set(valueSequence, {
repeat,
...(repeatType !== undefined && { repeatType: repeatType as Transition["repeatType"] }),
...(repeatDelay ? { repeatDelay } : {}),
})

} else {
Comment on lines 209 to +221
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Duration not adjusted for finite repeat >= MAX_REPEAT in multi-segment sequences

When repeat >= 20 but is still finite (e.g., repeat: 25), the passthrough path skips calling calculateRepeatDuration, so duration remains the base per-iteration duration. This means maxDuration and targetTime — used to position subsequent segments in the sequence — are based only on the single-iteration duration.

Consider this scenario:

animate([
  [el, { x: [0, 100] }, { duration: 1, repeat: 25 }],
  [el, { y: [0, 100] }, { duration: 1 }], // Should start at t=26s, actually starts at t=1s
])

For repeat: 19 (just below threshold), calculateRepeatDuration is called and the next segment correctly starts at 20s. For repeat: 20, it starts at 1s instead of 21s. There's a hard discontinuity at the MAX_REPEAT boundary.

For repeat: Infinity this is unavoidable (infinite duration can't be expanded), but for finite repeat >= 20, the correct duration could still be computed with calculateRepeatDuration and applied to maxDuration/targetTime, even if keyframe expansion is skipped.

if (repeat >= MAX_REPEAT) {
    repeatPassthrough.set(valueSequence, {
        repeat,
        repeatType: repeatType as Transition["repeatType"],
        repeatDelay: repeatDelay || undefined,
    })
    // Still adjust duration for finite repeats so subsequent segments are placed correctly
    if (isFinite(repeat)) {
        duration = calculateRepeatDuration(duration, repeat, repeatDelay)
    }
} else {

duration = calculateRepeatDuration(
duration,
repeat,
repeatDelay
)

for (let repeatIndex = 0; repeatIndex < repeat; repeatIndex++) {
valueKeyframesAsList.push(...originalKeyframes)
const originalKeyframes = [...valueKeyframesAsList]
const originalTimes = [...times]
ease = Array.isArray(ease) ? [...ease] : [ease]
const originalEase = [...ease]

for (
let keyframeIndex = 0;
keyframeIndex < originalKeyframes.length;
keyframeIndex++
let repeatIndex = 0;
repeatIndex < repeat;
repeatIndex++
) {
times.push(
originalTimes[keyframeIndex] + (repeatIndex + 1)
)
ease.push(
keyframeIndex === 0
? "linear"
: getEasingForSegment(
originalEase,
keyframeIndex - 1
)
)
valueKeyframesAsList.push(...originalKeyframes)

for (
let keyframeIndex = 0;
keyframeIndex < originalKeyframes.length;
keyframeIndex++
) {
times.push(
originalTimes[keyframeIndex] +
(repeatIndex + 1)
)
ease.push(
keyframeIndex === 0
? "linear"
: getEasingForSegment(
originalEase,
keyframeIndex - 1
)
)
}
}
}

normalizeTimes(times, repeat)
normalizeTimes(times, repeat)
}
}

const targetTime = startTime + duration
Expand Down Expand Up @@ -386,6 +407,7 @@ export function createAnimationsFromSequence(
ease: valueEasing,
times: valueOffset,
...sequenceTransition,
...repeatPassthrough.get(valueSequence),
}
}
})
Expand Down