Skip to content

Commit f44a4a9

Browse files
committed
Use event.composePath in useClickAway to determine if component is in container
1 parent ae76d13 commit f44a4a9

File tree

2 files changed

+37
-6
lines changed

2 files changed

+37
-6
lines changed

src/hooks/test/use-click-away-test.js

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@ import { useClickAway } from '../use-click-away';
66

77
describe('useClickAway', () => {
88
let handler;
9+
let wrapper;
910

10-
const events = [new Event('mousedown'), new Event('click')];
11+
const event = name => new Event(name, { bubbles: true });
12+
const events = [event('mousedown'), event('click')];
1113

1214
// Create a fake component to mount in tests that uses the hook
1315
function FakeComponent({ enabled = true }) {
@@ -22,13 +24,21 @@ describe('useClickAway', () => {
2224
}
2325

2426
function createComponent(props) {
25-
return mount(<FakeComponent {...props} />);
27+
const container = document.createElement('div');
28+
document.body.append(container);
29+
30+
wrapper = mount(<FakeComponent {...props} />, { attachTo: container });
31+
return wrapper;
2632
}
2733

2834
beforeEach(() => {
2935
handler = sinon.stub();
3036
});
3137

38+
afterEach(() => {
39+
wrapper.unmount();
40+
});
41+
3242
events.forEach(event => {
3343
it(`should invoke callback once for events outside of element (${event.type})`, () => {
3444
const wrapper = createComponent();
@@ -50,9 +60,7 @@ describe('useClickAway', () => {
5060
// is not called again
5161
assert.calledOnce(handler);
5262
});
53-
});
5463

55-
events.forEach(event => {
5664
it(`should not invoke callback on events inside of container (${event.type})`, () => {
5765
const wrapper = createComponent();
5866
const button = wrapper.find('button');
@@ -62,7 +70,25 @@ describe('useClickAway', () => {
6270
});
6371
wrapper.update();
6472

65-
assert.equal(handler.callCount, 0);
73+
assert.notCalled(handler);
74+
});
75+
76+
it(`should not invoke callback if clicked element is removed from container (${event.type})`, () => {
77+
const wrapper = createComponent();
78+
const button = wrapper.find('button').getDOMNode();
79+
80+
act(() => {
81+
button.addEventListener(event.type, () => {
82+
// Remove the button from the DOM before next listeners are invoked,
83+
// to simulate what happens if the clicked element was removed as a
84+
// result of a state update + re-render in a child component
85+
button.remove();
86+
});
87+
button.dispatchEvent(event);
88+
});
89+
wrapper.update();
90+
91+
assert.notCalled(handler);
6692
});
6793
});
6894
});

src/hooks/use-click-away.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,12 @@ export function useClickAway(
2828
const handleAwayClick = (event: Event) => {
2929
if (
3030
container.current &&
31-
!container.current.contains(event.target as Node)
31+
// We test the composed path here to handle the case where the clicked
32+
// element was in fact in the container, but is removed from the DOM
33+
// (eg. by a re-render in a child component) before this callback is run.
34+
// The composed path reflects the DOM hierarchy at the time the event was
35+
// dispatched.
36+
!event.composedPath().includes(container.current)
3237
) {
3338
callback(event);
3439
}

0 commit comments

Comments
 (0)