Skip to content

Commit 95a91d3

Browse files
MercerKLFDanLu
andauthored
fix: fixes a bug with runAfterTransition getting stuck when elements are removed (#8004)
* fix: fixes a bug with runAfterTransition getting stuck when elements are suddenly removed without calling transitionend/transitioncancel. * converts to isConnected and uses non-mock RAF for tests * update check --------- Co-authored-by: Daniel Lu <[email protected]>
1 parent b0118e9 commit 95a91d3

File tree

2 files changed

+151
-0
lines changed

2 files changed

+151
-0
lines changed

packages/@react-aria/utils/src/runAfterTransition.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,9 +91,25 @@ if (typeof document !== 'undefined') {
9191
}
9292
}
9393

94+
/**
95+
* Cleans up any elements that are no longer in the document.
96+
* This is necessary because we can't rely on transitionend events to fire
97+
* for elements that are removed from the document while transitioning.
98+
*/
99+
function cleanupDetachedElements() {
100+
for (const [eventTarget] of transitionsByElement) {
101+
// Similar to `eventTarget instanceof Element && !eventTarget.isConnected`, but avoids
102+
// the explicit instanceof check, since it may be different in different contexts.
103+
if ('isConnected' in eventTarget && !eventTarget.isConnected) {
104+
transitionsByElement.delete(eventTarget);
105+
}
106+
}
107+
}
108+
94109
export function runAfterTransition(fn: () => void): void {
95110
// Wait one frame to see if an animation starts, e.g. a transition on mount.
96111
requestAnimationFrame(() => {
112+
cleanupDetachedElements();
97113
// If no transitions are running, call the function immediately.
98114
// Otherwise, add it to a list of callbacks to run at the end of the animation.
99115
if (transitionsByElement.size === 0) {
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import {act} from '@testing-library/react';
2+
import {runAfterTransition} from '../src/runAfterTransition';
3+
4+
class MockTransitionEvent extends Event {
5+
propertyName: string;
6+
7+
constructor(type: string, eventInitDict?: TransitionEventInit) {
8+
super(type, eventInitDict);
9+
this.propertyName = eventInitDict?.propertyName || '';
10+
}
11+
}
12+
13+
describe('runAfterTransition', () => {
14+
const originalTransitionEvent = global.TransitionEvent;
15+
const nodes = new Set<Node>();
16+
17+
beforeAll(() => {
18+
global.TransitionEvent = MockTransitionEvent as any;
19+
});
20+
21+
afterAll(() => {
22+
global.TransitionEvent = originalTransitionEvent;
23+
});
24+
25+
beforeEach(() => {
26+
jest.useFakeTimers();
27+
});
28+
29+
afterEach(() => {
30+
jest.useRealTimers();
31+
jest.restoreAllMocks();
32+
cleanupElements();
33+
});
34+
35+
function appendElement(element: HTMLElement) {
36+
nodes.add(element);
37+
document.body.appendChild(element);
38+
return element;
39+
}
40+
41+
function cleanupElements() {
42+
for (const node of nodes) {
43+
document.body.removeChild(node);
44+
nodes.delete(node);
45+
}
46+
}
47+
48+
it('calls callback immediately when no transition is running', () => {
49+
const callback = jest.fn();
50+
runAfterTransition(callback);
51+
act(() => {jest.runOnlyPendingTimers();});
52+
expect(callback).toHaveBeenCalled();
53+
});
54+
55+
it('defers callback until transition end when a transition is running', () => {
56+
const element = appendElement(document.createElement('div'));
57+
58+
const callback = jest.fn();
59+
60+
element.dispatchEvent(
61+
new TransitionEvent('transitionrun', {
62+
propertyName: 'opacity',
63+
bubbles: true
64+
})
65+
);
66+
67+
68+
runAfterTransition(callback);
69+
act(() => {jest.runOnlyPendingTimers();});
70+
71+
// Callback should not be called immediately since a transition is active.
72+
expect(callback).not.toHaveBeenCalled();
73+
74+
// Dispatch a transitionend event to finish the transition.
75+
element.dispatchEvent(
76+
new TransitionEvent('transitionend', {
77+
propertyName: 'opacity',
78+
bubbles: true
79+
})
80+
);
81+
expect(callback).toHaveBeenCalled();
82+
});
83+
84+
it('calls multiple queued callbacks after transition ends', () => {
85+
const element = appendElement(document.createElement('div'));
86+
const callback1 = jest.fn();
87+
const callback2 = jest.fn();
88+
89+
element.dispatchEvent(
90+
new TransitionEvent('transitionrun', {
91+
propertyName: 'width',
92+
bubbles: true
93+
})
94+
);
95+
96+
runAfterTransition(callback1);
97+
act(() => {jest.runOnlyPendingTimers();});
98+
runAfterTransition(callback2);
99+
act(() => {jest.runOnlyPendingTimers();});
100+
// Callbacks should not be called during transition.
101+
expect(callback1).not.toHaveBeenCalled();
102+
expect(callback2).not.toHaveBeenCalled();
103+
104+
element.dispatchEvent(
105+
new TransitionEvent('transitionend', {
106+
propertyName: 'width',
107+
bubbles: true
108+
})
109+
);
110+
111+
expect(callback1).toHaveBeenCalled();
112+
expect(callback2).toHaveBeenCalled();
113+
});
114+
115+
it('clears out detached elements from transitionsByElement', () => {
116+
const element = document.createElement('div');
117+
element.id = 'test-element';
118+
appendElement(element);
119+
const callback = jest.fn();
120+
121+
element.dispatchEvent(
122+
new TransitionEvent('transitionrun', {
123+
propertyName: 'width',
124+
bubbles: true
125+
})
126+
);
127+
128+
cleanupElements();
129+
130+
runAfterTransition(callback);
131+
act(() => {jest.runOnlyPendingTimers();});
132+
133+
expect(callback).toHaveBeenCalled();
134+
});
135+
});

0 commit comments

Comments
 (0)