[RAC] `Popover` in `Select` with Framer Motion: animation race condition causing popover to not unmount
- Dominant language
- TypeScript
- Stars
- 15.9k
- Forks
- 1.6k
- Avg merge
- 3d 9m
- Merged PRs (30d)
- 59
Description
### 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`](https://github.com/adobe/react-spectrum/blob/ab5e6f3dba4235dafab9f81f8b5c506ce5f11230/packages/react-aria-components/src/Popover.tsx#L133) 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.log`s to the code in my `node_modules`:
**In `node_modules/react-aria-components/dist/Popover.mjs` (line 41):**
```javascript
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):**
```javascript
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
```javascript
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:
```javascript
}, [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
6.
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.
```js
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_
Contributor guide
Assessment
This issue has not been assessed yet.