-
Notifications
You must be signed in to change notification settings - Fork 1.3k
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:
- Custom Framer Motion animation completes successfully (
isExitingbecomesfalse) - React Aria's internal
useExitAnimationgets stuck returningtrue - Popover remains in DOM because line 133 in
Popover.tsxchecks:if (state && !state.isOpen && !isExiting) return null - Combined exit state is
useExitAnimation(ref, state.isOpen) || props.isExiting, sotrue || false = true - 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:
- The popover alternates between rendering as a Fragment vs PopoverInner (when
isHiddenbounces in collection contexts like Select) - The effect runs before the element is attached to the ref
ref.currentisnull, so the conditionif (isActive && ref.current)fails- Animation listeners are never set up
- 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:
- Create a Popover with custom Framer Motion animations using
isExitingprop - Use the popover inside a Select or other collection context
- Rapidly open and close the popover using automated interactions
- Add the
console.logto the React Aria code as explained earlier - Observe console logs showing
isExiting: true, hasRef: falsefollowed 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