Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions packages/react-aria-components/test/Dialog.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -525,6 +525,25 @@ describe('Dialog', () => {
expect(overlay).not.toBeInTheDocument();
});

it('unmounts when the exit animation is cancelled', async () => {
let {getByRole, getByTestId} = render(<TestModal />);

await user.click(getByRole('button'));
let overlay = getByTestId('overlay');

let reject;
let finished = new Promise((_, r) => (reject = r));
Element.prototype.getAnimations = () => [{playState: 'running', finished}];

await user.click(getByRole('button', {name: 'Close'}));
expect(overlay).toBeInTheDocument();
expect(overlay).toHaveAttribute('data-exiting');

// Cancelling an animation rejects its finished promise.
await act(async () => reject(new DOMException('The animation was cancelled', 'AbortError')));
expect(overlay).not.toBeInTheDocument();
});

it('calls the latest onEnter and onExit callbacks', async () => {
let enterPromise = deferred();
let exitPromise = deferred();
Expand Down
19 changes: 15 additions & 4 deletions packages/react-aria/src/utils/animation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ function useAnimation(
onEnd: () => void
): void {
let isActiveRef = useRef<boolean | null>(null);
let currentRun = useRef<object | null>(null);
let start = useEffectEvent(onStart);
useLayoutEffect(() => {
if (isActive && ref.current && isActiveRef.current !== isActive) {
Expand All @@ -135,15 +136,25 @@ function useAnimation(
return;
}

Promise.all([startPromise, ...animations.map(a => a.finished)])
.then(() => {
// 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.
Comment on lines +139 to +143

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

let run = {};
currentRun.current = run;
Promise.allSettled([startPromise, ...animations.map(a => a.finished)]).then(() => {
if (currentRun.current === run) {
flushSync(() => {
onEnd();
});
})
.catch(() => {});
}
});
Comment thread
snowystinger marked this conversation as resolved.
Resolved
}

if (!isActive) {
currentRun.current = null;
}
isActiveRef.current = isActive;
}, [ref, isActive, onEnd]);
}
87 changes: 87 additions & 0 deletions packages/react-aria/test/utils/animation.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/*
* Copyright 2026 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/

import {act, render} from '@react-spectrum/test-utils-internal';
import React, {StrictMode, useRef} from 'react';
import {render as renderWithoutWrapper} from '@testing-library/react';
import {useEnterAnimation, useExitAnimation} from '../../src/utils/animation';

function deferred() {
let resolve!: () => void;
let promise = new Promise<void>(r => (resolve = r));
return {promise, resolve};
}

describe('animation', () => {
beforeEach(() => {
// jsdom skips animations unless getAnimations is implemented.
Element.prototype.getAnimations = () => [];
});

afterEach(() => {
// @ts-expect-error
delete Element.prototype.getAnimations;
});

it('finishes an enter animation that starts on mount in StrictMode', async () => {
let enter = deferred();
let onEnter = jest.fn(() => enter.promise);
function Test() {
let ref = useRef<HTMLDivElement>(null);
let isEntering = useEnterAnimation(ref, true, onEnter);
return <div ref={ref} data-testid="el" data-entering={isEntering || undefined} />;
}

// StrictMode only re-runs effects on mount when it is the root element, which the
// STRICT_MODE wrapper used by render is not.
let {getByTestId} = renderWithoutWrapper(
<StrictMode>
<Test />
</StrictMode>
);
let el = getByTestId('el');
expect(onEnter).toHaveBeenCalledTimes(1);
expect(el).toHaveAttribute('data-entering');

await act(async () => enter.resolve());
expect(el).not.toHaveAttribute('data-entering');
});

it('ignores an interrupted exit animation that settles during a later exit', async () => {
let first = deferred();
let second = deferred();
let onExit = jest.fn().mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise);
function Test({isOpen}: {isOpen: boolean}) {
let ref = useRef<HTMLDivElement>(null);
let isExiting = useExitAnimation(ref, isOpen, onExit);
if (!isOpen && !isExiting) {
return null;
}
return <div ref={ref} data-testid="el" data-exiting={isExiting || undefined} />;
}

let {getByTestId, rerender} = render(<Test isOpen />);
let el = getByTestId('el');

rerender(<Test isOpen={false} />);
rerender(<Test isOpen />);
rerender(<Test isOpen={false} />);
expect(onExit).toHaveBeenCalledTimes(2);

await act(async () => first.resolve());
expect(el).toBeInTheDocument();
expect(el).toHaveAttribute('data-exiting');

await act(async () => second.resolve());
expect(el).not.toBeInTheDocument();
});
});
Loading