Skip to content

Commit b98c0fd

Browse files
authored
Use correct owner document for useInteractOutside (#5306)
Use correct owner document for useInteractOutside (#5306)
1 parent 0e7d746 commit b98c0fd

File tree

4 files changed

+328
-18
lines changed

4 files changed

+328
-18
lines changed

packages/@react-aria/interactions/src/useInteractOutside.ts

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@
1515
// NOTICE file in the root directory of this source tree.
1616
// See https://github.com/facebook/react/tree/cc7c1aece46a6b69b41958d731e0fd27c94bfc6c/packages/react-interactions
1717

18+
import {getOwnerDocument, useEffectEvent} from '@react-aria/utils';
1819
import {RefObject, useEffect, useRef} from 'react';
19-
import {useEffectEvent} from '@react-aria/utils';
2020

2121
export interface InteractOutsideProps {
2222
ref: RefObject<Element>,
@@ -58,6 +58,9 @@ export function useInteractOutside(props: InteractOutsideProps) {
5858
return;
5959
}
6060

61+
const element = ref.current;
62+
const documentObject = getOwnerDocument(element);
63+
6164
// Use pointer events if available. Otherwise, fall back to mouse and touch events.
6265
if (typeof PointerEvent !== 'undefined') {
6366
let onPointerUp = (e) => {
@@ -68,12 +71,12 @@ export function useInteractOutside(props: InteractOutsideProps) {
6871
};
6972

7073
// changing these to capture phase fixed combobox
71-
document.addEventListener('pointerdown', onPointerDown, true);
72-
document.addEventListener('pointerup', onPointerUp, true);
74+
documentObject.addEventListener('pointerdown', onPointerDown, true);
75+
documentObject.addEventListener('pointerup', onPointerUp, true);
7376

7477
return () => {
75-
document.removeEventListener('pointerdown', onPointerDown, true);
76-
document.removeEventListener('pointerup', onPointerUp, true);
78+
documentObject.removeEventListener('pointerdown', onPointerDown, true);
79+
documentObject.removeEventListener('pointerup', onPointerUp, true);
7780
};
7881
} else {
7982
let onMouseUp = (e) => {
@@ -93,16 +96,16 @@ export function useInteractOutside(props: InteractOutsideProps) {
9396
state.isPointerDown = false;
9497
};
9598

96-
document.addEventListener('mousedown', onPointerDown, true);
97-
document.addEventListener('mouseup', onMouseUp, true);
98-
document.addEventListener('touchstart', onPointerDown, true);
99-
document.addEventListener('touchend', onTouchEnd, true);
99+
documentObject.addEventListener('mousedown', onPointerDown, true);
100+
documentObject.addEventListener('mouseup', onMouseUp, true);
101+
documentObject.addEventListener('touchstart', onPointerDown, true);
102+
documentObject.addEventListener('touchend', onTouchEnd, true);
100103

101104
return () => {
102-
document.removeEventListener('mousedown', onPointerDown, true);
103-
document.removeEventListener('mouseup', onMouseUp, true);
104-
document.removeEventListener('touchstart', onPointerDown, true);
105-
document.removeEventListener('touchend', onTouchEnd, true);
105+
documentObject.removeEventListener('mousedown', onPointerDown, true);
106+
documentObject.removeEventListener('mouseup', onMouseUp, true);
107+
documentObject.removeEventListener('touchstart', onPointerDown, true);
108+
documentObject.removeEventListener('touchend', onTouchEnd, true);
106109
};
107110
}
108111
}, [ref, isDisabled, onPointerDown, triggerInteractOutside]);

packages/@react-aria/interactions/test/useInteractOutside.test.js

Lines changed: 238 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,15 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13-
import {fireEvent, installPointerEvent, render} from '@react-spectrum/test-utils';
13+
import {fireEvent, installPointerEvent, render, waitFor} from '@react-spectrum/test-utils';
1414
import React, {useRef} from 'react';
15+
import {render as ReactDOMRender} from 'react-dom';
1516
import {useInteractOutside} from '../';
1617

1718
function Example(props) {
1819
let ref = useRef();
1920
useInteractOutside({ref, ...props});
20-
return <div ref={ref}>test</div>;
21+
return <div ref={ref} data-testid="example">test</div>;
2122
}
2223

2324
function pointerEvent(type, opts) {
@@ -206,3 +207,238 @@ describe('useInteractOutside', function () {
206207
});
207208
});
208209
});
210+
211+
describe('useInteractOutside (iframes)', function () {
212+
let iframe;
213+
let iframeRoot;
214+
let iframeDocument;
215+
beforeEach(() => {
216+
iframe = document.createElement('iframe');
217+
window.document.body.appendChild(iframe);
218+
iframeDocument = iframe.contentWindow.document;
219+
iframeRoot = iframeDocument.createElement('div');
220+
iframeDocument.body.appendChild(iframeRoot);
221+
});
222+
223+
afterEach(() => {
224+
iframe.remove();
225+
});
226+
227+
const IframeExample = (props) => {
228+
React.useEffect(() => {
229+
ReactDOMRender(<Example {...props} />, iframeRoot);
230+
}, [props]);
231+
232+
return null;
233+
};
234+
235+
// TODO: JSDOM doesn't yet support pointer events. Once they do, convert these tests.
236+
// https://github.com/jsdom/jsdom/issues/2527
237+
describe('pointer events', function () {
238+
installPointerEvent();
239+
240+
it('should fire interact outside events based on pointer events', async function () {
241+
let onInteractOutside = jest.fn();
242+
render(
243+
<IframeExample onInteractOutside={onInteractOutside} />
244+
);
245+
246+
await waitFor(() => {
247+
expect(document.querySelector('iframe').contentWindow.document.body.querySelector('div[data-testid="example"]')).toBeTruthy();
248+
});
249+
250+
const el = document.querySelector('iframe').contentWindow.document.body.querySelector('div[data-testid="example"]');
251+
fireEvent(el, pointerEvent('pointerdown'));
252+
fireEvent(el, pointerEvent('pointerup'));
253+
expect(onInteractOutside).not.toHaveBeenCalled();
254+
255+
fireEvent(iframeDocument.body, pointerEvent('pointerdown'));
256+
fireEvent(iframeDocument.body, pointerEvent('pointerup'));
257+
expect(onInteractOutside).toHaveBeenCalledTimes(1);
258+
});
259+
260+
it('should only listen for the left mouse button', async function () {
261+
let onInteractOutside = jest.fn();
262+
render(
263+
<IframeExample onInteractOutside={onInteractOutside} />
264+
);
265+
266+
await waitFor(() => {
267+
expect(document.querySelector('iframe').contentWindow.document.body.querySelector('div[data-testid="example"]')).toBeTruthy();
268+
});
269+
270+
fireEvent(iframeDocument.body, pointerEvent('pointerdown', {button: 1}));
271+
fireEvent(iframeDocument.body, pointerEvent('pointerup', {button: 1}));
272+
expect(onInteractOutside).not.toHaveBeenCalled();
273+
274+
fireEvent(iframeDocument.body, pointerEvent('pointerdown', {button: 0}));
275+
fireEvent(iframeDocument.body, pointerEvent('pointerup', {button: 0}));
276+
expect(onInteractOutside).toHaveBeenCalledTimes(1);
277+
});
278+
279+
it('should not fire interact outside if there is a pointer up event without a pointer down first', async function () {
280+
// Fire pointer down before component with useInteractOutside is mounted
281+
fireEvent(iframeDocument.body, pointerEvent('pointerdown'));
282+
283+
let onInteractOutside = jest.fn();
284+
render(
285+
<IframeExample onInteractOutside={onInteractOutside} />
286+
);
287+
288+
await waitFor(() => {
289+
expect(document.querySelector('iframe').contentWindow.document.body.querySelector('div[data-testid="example"]')).toBeTruthy();
290+
});
291+
fireEvent(iframeDocument.body, pointerEvent('pointerup'));
292+
expect(onInteractOutside).not.toHaveBeenCalled();
293+
});
294+
});
295+
296+
describe('mouse events', function () {
297+
it('should fire interact outside events based on mouse events', async function () {
298+
let onInteractOutside = jest.fn();
299+
render(
300+
<IframeExample onInteractOutside={onInteractOutside} />
301+
);
302+
303+
await waitFor(() => {
304+
expect(document.querySelector('iframe').contentWindow.document.body.querySelector('div[data-testid="example"]')).toBeTruthy();
305+
});
306+
307+
const el = document.querySelector('iframe').contentWindow.document.body.querySelector('div[data-testid="example"]');
308+
fireEvent.mouseDown(el);
309+
fireEvent.mouseUp(el);
310+
expect(onInteractOutside).not.toHaveBeenCalled();
311+
312+
fireEvent.mouseDown(iframeDocument.body);
313+
fireEvent.mouseUp(iframeDocument.body);
314+
expect(onInteractOutside).toHaveBeenCalledTimes(1);
315+
});
316+
317+
it('should only listen for the left mouse button', async function () {
318+
let onInteractOutside = jest.fn();
319+
render(
320+
<IframeExample onInteractOutside={onInteractOutside} />
321+
);
322+
323+
await waitFor(() => {
324+
expect(document.querySelector('iframe').contentWindow.document.body.querySelector('div[data-testid="example"]')).toBeTruthy();
325+
});
326+
327+
fireEvent.mouseDown(iframeDocument.body, {button: 1});
328+
fireEvent.mouseUp(iframeDocument.body, {button: 1});
329+
expect(onInteractOutside).not.toHaveBeenCalled();
330+
331+
fireEvent.mouseDown(iframeDocument.body, {button: 0});
332+
fireEvent.mouseUp(iframeDocument.body, {button: 0});
333+
expect(onInteractOutside).toHaveBeenCalledTimes(1);
334+
});
335+
336+
it('should not fire interact outside if there is a mouse up event without a mouse down first', async function () {
337+
// Fire mouse down before component with useInteractOutside is mounted
338+
fireEvent.mouseDown(iframeDocument.body);
339+
340+
let onInteractOutside = jest.fn();
341+
render(
342+
<IframeExample onInteractOutside={onInteractOutside} />
343+
);
344+
345+
await waitFor(() => {
346+
expect(document.querySelector('iframe').contentWindow.document.body.querySelector('div[data-testid="example"]')).toBeTruthy();
347+
});
348+
fireEvent.mouseUp(iframeDocument.body);
349+
expect(onInteractOutside).not.toHaveBeenCalled();
350+
});
351+
});
352+
353+
describe('touch events', function () {
354+
it('should fire interact outside events based on mouse events', async function () {
355+
let onInteractOutside = jest.fn();
356+
render(
357+
<IframeExample onInteractOutside={onInteractOutside} />
358+
);
359+
360+
await waitFor(() => {
361+
expect(document.querySelector('iframe').contentWindow.document.body.querySelector('div[data-testid="example"]')).toBeTruthy();
362+
});
363+
364+
const el = document.querySelector('iframe').contentWindow.document.body.querySelector('div[data-testid="example"]');
365+
fireEvent.touchStart(el);
366+
fireEvent.touchEnd(el);
367+
expect(onInteractOutside).not.toHaveBeenCalled();
368+
369+
fireEvent.touchStart(iframeDocument.body);
370+
fireEvent.touchEnd(iframeDocument.body);
371+
expect(onInteractOutside).toHaveBeenCalledTimes(1);
372+
});
373+
374+
it('should ignore emulated mouse events', async function () {
375+
let onInteractOutside = jest.fn();
376+
render(
377+
<IframeExample onInteractOutside={onInteractOutside} />
378+
);
379+
380+
await waitFor(() => {
381+
expect(document.querySelector('iframe').contentWindow.document.body.querySelector('div[data-testid="example"]')).toBeTruthy();
382+
});
383+
384+
const el = document.querySelector('iframe').contentWindow.document.body.querySelector('div[data-testid="example"]');
385+
fireEvent.touchStart(el);
386+
fireEvent.touchEnd(el);
387+
fireEvent.mouseUp(el);
388+
expect(onInteractOutside).not.toHaveBeenCalled();
389+
390+
fireEvent.touchStart(iframeDocument.body);
391+
fireEvent.touchEnd(iframeDocument.body);
392+
fireEvent.mouseUp(iframeDocument.body);
393+
expect(onInteractOutside).toHaveBeenCalledTimes(1);
394+
});
395+
396+
it('should not fire interact outside if there is a touch end event without a touch start first', function () {
397+
// Fire mouse down before component with useInteractOutside is mounted
398+
fireEvent.touchStart(iframeDocument.body);
399+
400+
let onInteractOutside = jest.fn();
401+
render(
402+
<IframeExample onInteractOutside={onInteractOutside} />
403+
);
404+
405+
fireEvent.touchEnd(iframeDocument.body);
406+
expect(onInteractOutside).not.toHaveBeenCalled();
407+
});
408+
});
409+
410+
describe('disable interact outside events', function () {
411+
it('does not handle pointer events if disabled', function () {
412+
let onInteractOutside = jest.fn();
413+
render(
414+
<IframeExample isDisabled onInteractOutside={onInteractOutside} />
415+
);
416+
417+
fireEvent(iframeDocument.body, pointerEvent('mousedown'));
418+
fireEvent(iframeDocument.body, pointerEvent('mouseup'));
419+
expect(onInteractOutside).not.toHaveBeenCalled();
420+
});
421+
422+
it('does not handle touch events if disabled', function () {
423+
let onInteractOutside = jest.fn();
424+
render(
425+
<IframeExample isDisabled onInteractOutside={onInteractOutside} />
426+
);
427+
428+
fireEvent.touchStart(iframeDocument.body);
429+
fireEvent.touchEnd(iframeDocument.body);
430+
expect(onInteractOutside).not.toHaveBeenCalled();
431+
});
432+
433+
it('does not handle mouse events if disabled', function () {
434+
let onInteractOutside = jest.fn();
435+
render(
436+
<IframeExample isDisabled onInteractOutside={onInteractOutside} />
437+
);
438+
439+
fireEvent.mouseDown(iframeDocument.body);
440+
fireEvent.mouseUp(iframeDocument.body);
441+
expect(onInteractOutside).not.toHaveBeenCalled();
442+
});
443+
});
444+
});
Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
1-
export const getOwnerDocument = (el?: Element | null): Document => {
1+
export const getOwnerDocument = (el: Element | null | undefined): Document => {
22
return el?.ownerDocument ?? document;
33
};
44

55
export const getOwnerWindow = (
6-
el?: Element | null
6+
el: (Window & typeof global) | Element | null | undefined
77
): Window & typeof global => {
8-
return el?.ownerDocument?.defaultView ?? window;
8+
if (el && 'window' in el && el.window === el) {
9+
return el;
10+
}
11+
12+
const doc = getOwnerDocument(el as Element | null | undefined);
13+
return doc.defaultView || window;
914
};

0 commit comments

Comments
 (0)