From 6ccb2ed91fcadf77cbc780b0bba0974198c4c3d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Jan 2026 10:38:08 +0000 Subject: [PATCH] Fix animateSequence to gracefully handle null/undefined elements Previously, passing null elements to animateSequence (e.g., from querySelector returning null) would throw an error. This was a regression from Motion 10's permissive behavior. Changes: - Update resolveElements in motion-dom to return empty array for null/undefined input and filter null elements from arrays - Update resolveSubjects in framer-motion to handle null/undefined subjects gracefully - Add early return for null/undefined in animateSubject and animateElements to gracefully skip (distinct from throwing for selectors that match no elements) - Add tests for null element handling in sequences Fixes #3390 --- .../src/animation/animate/resolve-subjects.ts | 16 ++++++- .../src/animation/animate/subject.ts | 11 +++-- .../animators/waapi/animate-elements.ts | 5 +++ .../sequence/__tests__/index.test.ts | 44 +++++++++++++++++++ .../motion-dom/src/utils/resolve-elements.ts | 10 ++++- 5 files changed, 77 insertions(+), 9 deletions(-) diff --git a/packages/framer-motion/src/animation/animate/resolve-subjects.ts b/packages/framer-motion/src/animation/animate/resolve-subjects.ts index 4996c46d36..395144e48a 100644 --- a/packages/framer-motion/src/animation/animate/resolve-subjects.ts +++ b/packages/framer-motion/src/animation/animate/resolve-subjects.ts @@ -8,17 +8,29 @@ import { ObjectTarget } from "../sequence/types" import { isDOMKeyframes } from "../utils/is-dom-keyframes" export function resolveSubjects( - subject: string | Element | Element[] | NodeListOf | O | O[], + subject: + | string + | Element + | Element[] + | NodeListOf + | O + | O[] + | null + | undefined, keyframes: DOMKeyframesDefinition | ObjectTarget, scope?: AnimationScope, selectorCache?: SelectorCache ) { + if (subject == null) { + return [] + } + if (typeof subject === "string" && isDOMKeyframes(keyframes)) { return resolveElements(subject, scope, selectorCache) } else if (subject instanceof NodeList) { return Array.from(subject) } else if (Array.isArray(subject)) { - return subject + return subject.filter((s) => s != null) } else { return [subject] } diff --git a/packages/framer-motion/src/animation/animate/subject.ts b/packages/framer-motion/src/animation/animate/subject.ts index fc09b0b7d2..67f8cc6684 100644 --- a/packages/framer-motion/src/animation/animate/subject.ts +++ b/packages/framer-motion/src/animation/animate/subject.ts @@ -107,6 +107,11 @@ export function animateSubject( ) ) } else { + // Gracefully handle null/undefined subjects (e.g., from querySelector returning null) + if (subject == null) { + return animations + } + const subjects = resolveSubjects( subject, keyframes as DOMKeyframesDefinition, @@ -124,12 +129,6 @@ export function animateSubject( for (let i = 0; i < numSubjects; i++) { const thisSubject = subjects[i] - invariant( - thisSubject !== null, - "You're trying to perform an animation on null. Ensure that selectors are correctly finding elements and refs are correctly hydrated.", - "animate-null" - ) - const createVisualElement = thisSubject instanceof Element ? createDOMVisualElement diff --git a/packages/framer-motion/src/animation/animators/waapi/animate-elements.ts b/packages/framer-motion/src/animation/animators/waapi/animate-elements.ts index 65e5604070..7fb0945610 100644 --- a/packages/framer-motion/src/animation/animators/waapi/animate-elements.ts +++ b/packages/framer-motion/src/animation/animators/waapi/animate-elements.ts @@ -33,6 +33,11 @@ export function animateElements( options?: DynamicAnimationOptions, scope?: AnimationScope ) { + // Gracefully handle null/undefined elements (e.g., from querySelector returning null) + if (elementOrSelector == null) { + return [] + } + const elements = resolveElements(elementOrSelector, scope) as Array< HTMLElement | SVGElement > diff --git a/packages/framer-motion/src/animation/sequence/__tests__/index.test.ts b/packages/framer-motion/src/animation/sequence/__tests__/index.test.ts index c57c47c72d..1b072c7672 100644 --- a/packages/framer-motion/src/animation/sequence/__tests__/index.test.ts +++ b/packages/framer-motion/src/animation/sequence/__tests__/index.test.ts @@ -768,4 +768,48 @@ describe("createAnimationsFromSequence", () => { expect(duration).toEqual(4) expect(times).toEqual([0, 0.25, 0.25, 0.5, 0.5, 0.75, 0.75, 1]) }) + + test("It skips null elements in sequence", () => { + const animations = createAnimationsFromSequence( + [ + [a, { opacity: 1 }, { duration: 1 }], + [null as unknown as Element, { opacity: 0.5 }, { duration: 1 }], + [b, { opacity: 0 }, { duration: 1 }], + ], + undefined, + undefined, + { spring } + ) + + // Should only have animations for a and b, not the null element + expect(animations.size).toBe(2) + expect(animations.has(a)).toBe(true) + expect(animations.has(b)).toBe(true) + }) + + test("It filters null elements from array of targets", () => { + const animations = createAnimationsFromSequence( + [[[a, null as unknown as Element, b], { x: 100 }, { duration: 1 }]], + undefined, + undefined, + { spring } + ) + + // Should only have animations for a and b, not the null element + expect(animations.size).toBe(2) + expect(animations.has(a)).toBe(true) + expect(animations.has(b)).toBe(true) + }) + + test("It handles sequence with only null element gracefully", () => { + const animations = createAnimationsFromSequence( + [[null as unknown as Element, { opacity: 1 }, { duration: 1 }]], + undefined, + undefined, + { spring } + ) + + // Should return empty map when no valid elements + expect(animations.size).toBe(0) + }) }) diff --git a/packages/motion-dom/src/utils/resolve-elements.ts b/packages/motion-dom/src/utils/resolve-elements.ts index 9de64eaf79..00455617ec 100644 --- a/packages/motion-dom/src/utils/resolve-elements.ts +++ b/packages/motion-dom/src/utils/resolve-elements.ts @@ -3,6 +3,8 @@ export type ElementOrSelector = | Element[] | NodeListOf | string + | null + | undefined export interface WithQuerySelectorAll { querySelectorAll: Element["querySelectorAll"] @@ -22,6 +24,10 @@ export function resolveElements( scope?: AnimationScope, selectorCache?: SelectorCache ): Element[] { + if (elementOrSelector == null) { + return [] + } + if (elementOrSelector instanceof EventTarget) { return [elementOrSelector] } else if (typeof elementOrSelector === "string") { @@ -38,5 +44,7 @@ export function resolveElements( return elements ? Array.from(elements) : [] } - return Array.from(elementOrSelector) + return Array.from(elementOrSelector).filter( + (element): element is Element => element != null + ) }