Repository navigation
fix: unmount overlays when their exit animation is cancelled - #10783
Open
pete-woods wants to merge 1 commit into
Open
pete-woods wants to merge 1 commit into
pete-woods wants to merge 1 commit into
Conversation
pete-woods
force-pushed
the
issue-10772
branch
from
October 20, 2026 21:26
a0174b5 to
6687702
Compare
Cancelling an animation rejects its finished promise, so Promise.all in useAnimation rejected and onEnd never ran, leaving exiting overlays mounted. Wait for every promise to settle instead, as adobe#9772 did. Settling on cancellation means an interrupted run (e.g. an overlay reopened while exiting) could end a later one, which the effect cleanup removed in adobe#10674 used to prevent. Track the current run in a ref instead of restoring that cleanup, since StrictMode re-runs the effect on mount and the isActiveRef guard would skip restarting it. Closes adobe#10772 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
pete-woods
force-pushed
the
issue-10772
branch
from
October 21, 2026 08:44
6687702 to
eef23e0
Compare
snowystinger
left a comment
Member
There was a problem hiding this comment.
Looks good, I have one nit about the comment. While I was reviewing my AI came back with one concern, but I don't think it's very likely. I'm including it in the comment for posterity should we ever run into this situation, but there's no action you need to take.
AI provided additional test
it('ignores an interrupted enter animation that settles while not ready', async () => {
let first = deferred();
let second = deferred();
let onEnter = jest.fn().mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise);
// Mirrors how Popover gates its enter animation: `useEnterAnimation(ref, !!placement && isOpen)`.
// A consumer of the public hook can invalidate that gate (e.g. dropping back to an unmeasured
// placement) while the animation is still in flight.
function Test({placement, isOpen}: {placement: string | null; isOpen: boolean}) {
let ref = useRef<HTMLDivElement>(null);
let isEntering = useEnterAnimation(ref, !!placement && isOpen, onEnter);
return <div ref={ref} data-testid="el" data-entering={isEntering || undefined} />;
}
let {getByTestId, rerender} = render(<Test placement="bottom" isOpen />);
let el = getByTestId('el');
expect(el).toHaveAttribute('data-entering');
expect(onEnter).toHaveBeenCalledTimes(1);
// Interrupt the run before it settles, then let the interrupted run settle.
rerender(<Test placement={null} isOpen />);
expect(el).not.toHaveAttribute('data-entering');
await act(async () => first.resolve());
// The interrupted run must not end the animation. Becoming ready again restarts it,
// rather than skipping it because the stale run already called onEnd.
rerender(<Test placement="top" isOpen />);
expect(onEnter).toHaveBeenCalledTimes(2);
expect(el).toHaveAttribute('data-entering');
await act(async () => second.resolve());
expect(el).not.toHaveAttribute('data-entering');
});
Comment on lines
+139
to
+143
| // Cancelled animations reject their finished promise, so wait for every promise to settle | ||
| // rather than resolve. Otherwise onEnd never runs and an exiting overlay never unmounts. | ||
| // Ignore a run that was interrupted (e.g. an overlay reopened while exiting) so it can't end a | ||
| // later one. This can't be an effect cleanup: StrictMode re-runs the effect on mount, and the | ||
| // isActiveRef guard above would skip restarting it. |
Member
There was a problem hiding this comment.
This is a bit overly verbose, please trim it
Most of it is probably explained by the tests
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #10772
Summary
Intent: an overlay (Modal, Popover) should unmount once its exit animations settle, whether they finish or are cancelled. That is how it behaved in 3.52, and #9772 fixed it for #8786.
Root cause: #10674 changed
useAnimationinpackages/react-aria/src/utils/animation.tsfromPromise.allSettledback toPromise.all(...).catch(() => {}), so it could also wait on the newonEnter/onExitpromises. Cancelling an animation rejects itsfinishedpromise, soPromise.allrejects, the.catchswallows it, andonEndnever runs. The overlay stays in theexitingstate and never unmounts.Fix: go back to
Promise.allSettledover the start promise plus the animations'finishedpromises, and add a short comment so it doesn't regress again. TheisActiveRefguard and theonEnter/onExitsupport from #10674 stay as they are. With this change a rejectedonEnter/onExitpromise also ends the animation instead of leaving the element stuck. None of the current callers (the Modal and the Sheet) ever reject.Interrupted runs: settling on cancellation means a run can now end after it was interrupted. For example: close (exit 1 starts with a pending
onExitpromise), reopen, close again (exit 2). If exit 1's promise resolves first, it unmounts the overlay before exit 2 has finished. Before #10674 an effect cleanup prevented this. Restoring that cleanup isn't safe with theisActiveRefguard: StrictMode runs the effect, cleans it up and runs it again on mount, the guard skips the second run, and an element that starts animating on mount stays indata-entering. Instead, each run stores a token in a ref, which is cleared when the hook goes inactive, andonEndonly fires if its token is still current.✅ Pull Request Checklist:
Unit tests are added. No storybook or doc changes, because this restores existing behaviour. No accessibility impact: it only changes when the overlay unmounts.
AI disclosure: this change was prepared with Claude Code (Claude Opus 5.5).
📝 Test Instructions:
Automated:
yarn jest packages/react-aria-components/test/Dialog.test.js -t 'exit animation is cancelled'fakes a running exit animation, closes the modal, and then rejects the animation'sfinishedpromise the wayAnimation.cancel()does. It fails onmain.yarn jest packages/react-aria/test/utils/animation.test.tsxcovers:mainand without the run tracking.The mount test renders its own root
<StrictMode>. In React 19 effects only re-run on mount when<StrictMode>is the root element, and theSTRICT_MODE=1wrapper nests it inside a component, so the rest of the suite doesn't exercise that re-run.The react-aria-components, S2 and react-aria utils suites pass with
STRICT_MODE=1, andoxlintandoxfmt --checkare clean.yarn check-typesreports three errors innode_modules/@mdx-js/react, which happen onmaintoo.Manual steps for reviewers (I haven't run these in a browser yet, only the automated tests):
document.querySelectorAll('[data-exiting]').forEach(el => el.getAnimations().forEach(a => a.cancel()))in the console.mainit stays in the DOM withdata-exiting.Not tested manually: any browser, touch or screen reader. The change is in shared promise handling, not in anything specific to input or rendering.
🧢 Your Project:
CircleCI's user interface.