Skip to content

[RAC] Popover in Select with Framer Motion: animation race condition causing popover to not unmount #9158

@jeffijoe

Description

@jeffijoe

Provide a general summary of the issue here

When using custom Framer Motion animations with Popover via the isExiting prop, rapid interactions (e.g., automated Playwright tests) can cause React Aria's internal animation state machine to get stuck in the 'exiting' state, preventing the popover from unmounting.

🤔 Expected Behavior?

  • Popover opens with animation
  • Popover closes with animation
  • After animation completes, popover unmounts cleanly
  • Rapid open/close cycles should work reliably

😯 Current Behavior

During rapid open/close interactions:

  1. Custom Framer Motion animation completes successfully (isExiting becomes false)
  2. React Aria's internal useExitAnimation gets stuck returning true
  3. Popover remains in DOM because line 133 in Popover.tsx checks: if (state && !state.isOpen && !isExiting) return null
  4. Combined exit state is useExitAnimation(ref, state.isOpen) || props.isExiting, so true || false = true
  5. Popover never unmounts

I added console.logs to the code in my node_modules:

In node_modules/react-aria-components/dist/Popover.mjs (line 41):

let isExiting = useExitAnimation(ref, state.isOpen) || props.isExiting || false;
let isHidden = useIsHidden();
let { direction: direction } = useLocale();
console.log('Popover render', { isOpen: state.isOpen, isExiting, isExitingProp: props.isExiting, isHidden })

In node_modules/@react-aria/utils/dist/animation.mjs (useExitAnimation function):

function useExitAnimation(ref, isOpen) {
    let [exitState, setExitState] = useState(isOpen ? 'open' : 'closed');
    switch(exitState){
        case 'open':
            if (!isOpen) {
              console.log('useExitAnimation: not open anymore, setting state to exiting')
              setExitState('exiting')
            }
            break;
        case 'closed':
        case 'exiting':
            if (isOpen) {
              console.log('useExitAnimation: now open, setting state to open')
              setExitState('open')
            }
            break;
    }
    let isExiting = exitState === 'exiting';
    console.log('useExitAnimation: ', { isExiting, hasRef: !!ref.current})
    useAnimation(ref, isExiting, useCallback(()=>{
        console.log('useExitAnimation: useAnimation callback triggered')
        setExitState((state)=> {
          console.log('useExitAnimation: useAnimation setState setting to', state === 'exiting' ? 'closed' : state)
          return state === 'exiting' ? 'closed' : state
        });
    }, []));
    return isExiting;
}

In the same file (useAnimation function): - only the very last one actually fires

function useAnimation(ref, isActive, onEnd) {
    useLayoutEffect(()=>{
        if (isActive && ref.current) {
            if (!('getAnimations' in ref.current)) {
                onEnd();
                return;
            }
            let animations = ref.current.getAnimations();
            if (animations.length === 0) {
                console.log('no animations, ending')
                onEnd();
                return;
            }
            let canceled = false;
            Promise.all(animations.map((a)=>a.finished)).then(()=>{
                if (!canceled) flushSync(()=>{
                    console.log('all animations finished, ending')
                    onEnd();
                });
            }).catch(()=>{});
            return ()=>{
                console.log('useAnimation: animation promise check due to cleanup')
                canceled = true;
            };
        }
        console.log('useAnimation: ', {isActive, hasRef: !!ref.current})
    }, [ref, isActive, onEnd]);
}

Console logs showing the issue:

useExitAnimation: not open anymore, setting state to exiting
useAnimation:  {isActive: true, hasRef: false}  ← Can't set up listeners
Currently exiting - closing entirely              ← Custom animation completes
Animation state closed                            ← Custom state correct
useExitAnimation:  {isExiting: true, ...}        ← RAC stuck in exiting
Popover render {isOpen: false, isExiting: true, isExitingProp: false, ...}

💁 Possible Solution

The logs show that useAnimation's effect runs with hasRef: false, which prevents animation listeners from being set up.
This causes the completion callback that would transition from 'exiting' to 'closed' to never fire,
leaving React Aria's state machine stuck.

The effect in @react-aria/utils/dist/animation.mjs depends on the ref object identity:

}, [ref, isActive, onEnd]);

This wouldn't track changes to ref.current, so if the element gets attached to the ref after the effect runs, it wouldn't re-run to set up animation listeners.

I'm suspecting this could be a race condition where:

  1. The popover alternates between rendering as a Fragment vs PopoverInner (when isHidden bounces in collection contexts like Select)
  2. The effect runs before the element is attached to the ref
  3. ref.current is null, so the condition if (isActive && ref.current) fails
  4. Animation listeners are never set up
  5. The completion callback never fires

🔦 Context

The useIsHidden() hook from @react-aria/collections causes the popover to render as a Fragment (no DOM element) when the component is in a "hidden" context, which seems to contribute to the timing issue.

I'm using the Popover inside a Select, and the rapid timing from Playwright tests triggers this consistently.

🖥️ Steps to Reproduce

This is easiest to reproduce with automated tests (Playwright) due to the rapid timing:

  1. Create a Popover with custom Framer Motion animations using isExiting prop
  2. Use the popover inside a Select or other collection context
  3. Rapidly open and close the popover using automated interactions
  4. Add the console.log to the React Aria code as explained earlier
  5. Observe console logs showing isExiting: true, hasRef: false followed by state getting stuck

The popover remains in the DOM indefinitely because React Aria thinks it's still exiting.

Here's a CodeSandbox: https://codesandbox.io/p/sandbox/mpqm85

Using the select normally works fine, but when using Playwright it'll often get stuck.

await page.goto('https://codesandbox.io/p/sandbox/mpqm85');

for (let i = 0; i < 2000; i++) {
      const frame = page.locator('iframe').first().contentFrame();
      await frame.getByTestId('plz').click();
      await frame.getByTestId(`option-${i % 2 === 0 ? 1 : 2}`).click();
      await expect(frame.getByTestId(`option-${i % 2 === 0 ? 1 : 2}`))
          .toBeHidden();
}

Version

RAC 1.13

What browsers are you seeing the problem on?

Chrome

If other, please specify.

No response

What operating system are you using?

macOS

🧢 Your Company/Team

No response

🕷 Tracking Issue

No response

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions