Skip to content

fix: unmount overlays when their exit animation is cancelled - #10783

Open
pete-woods wants to merge 1 commit into
adobe:mainfrom
pete-woods:issue-10772
Open

pete-woods wants to merge 1 commit into
adobe:mainfrom
pete-woods:issue-10772

Conversation

@pete-woods

@pete-woods pete-woods commented Oct 15, 2026 •

Copy link
Copy Markdown

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 useAnimation in packages/react-aria/src/utils/animation.ts from Promise.allSettled back to Promise.all(...).catch(() => {}), so it could also wait on the new onEnter/onExit promises. Cancelling an animation rejects its finished promise, so Promise.all rejects, the .catch swallows it, and onEnd never runs. The overlay stays in the exiting state and never unmounts.

Fix: go back to Promise.allSettled over the start promise plus the animations' finished promises, and add a short comment so it doesn't regress again. The isActiveRef guard and the onEnter/onExit support from #10674 stay as they are. With this change a rejected onEnter/onExit promise 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 onExit promise), 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 the isActiveRef guard: 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 in data-entering. Instead, each run stores a token in a ref, which is cleared when the hook goes inactive, and onEnd only fires if its token is still current.

✅ Pull Request Checklist:

  • Included link to corresponding React Spectrum GitHub Issue.
  • Added/updated unit tests and storybook for this change (for new code or code which already has tests).
  • Filled out test instructions.
  • Updated documentation (if it already exists for this component).
  • Looked at the Accessibility Practices for this feature - Aria Practices
  • I understand every change in this PR and can explain why it's there.
  • If AI-assisted, I followed our AI contribution guidance and pointed my assistant at AGENTS.md.

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's finished promise the way Animation.cancel() does. It fails on main.
  • yarn jest packages/react-aria/test/utils/animation.test.tsx covers:
    • an enter animation that starts on mount in StrictMode, which fails if the old effect cleanup is restored;
    • an interrupted exit whose promise settles during a later exit, which fails on main and 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 the STRICT_MODE=1 wrapper 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, and oxlint and oxfmt --check are clean. yarn check-types reports three errors in node_modules/@mdx-js/react, which happen on main too.

Manual steps for reviewers (I haven't run these in a browser yet, only the automated tests):

  1. Open a ModalOverlay or Popover story that has an exit animation (e.g. the RAC Modal or Popover stories).
  2. Close it with Escape, the backdrop or a close button. While it is exiting, run document.querySelectorAll('[data-exiting]').forEach(el => el.getAnimations().forEach(a => a.cancel())) in the console.
  3. The overlay should unmount. On main it stays in the DOM with data-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.

Comment thread packages/react-aria/src/utils/animation.ts
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>

@snowystinger snowystinger left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a bit overly verbose, please trim it
Most of it is probably explained by the tests

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Overlay stays open when its exit animation is cancelled (regression of #8786 in 3.53.0)

2 participants