diff --git a/packages/react-dom/src/__tests__/ReactDOMEventPropagation-test.js b/packages/react-dom/src/__tests__/ReactDOMEventPropagation-test.js new file mode 100644 index 0000000000000..ed524154d6212 --- /dev/null +++ b/packages/react-dom/src/__tests__/ReactDOMEventPropagation-test.js @@ -0,0 +1,1327 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +'use strict'; + +describe('ReactDOMEventListener', () => { + let React; + let OuterReactDOM; + let InnerReactDOM; + let container; + + beforeEach(() => { + jest.resetModules(); + React = require('react'); + jest.isolateModules(() => { + OuterReactDOM = require('react-dom'); + }); + jest.isolateModules(() => { + InnerReactDOM = require('react-dom'); + }); + expect(OuterReactDOM).not.toBe(InnerReactDOM); + }); + + afterEach(() => { + cleanup(); + }); + + function cleanup() { + if (container) { + OuterReactDOM.unmountComponentAtNode(container); + document.body.removeChild(container); + container = null; + } + } + + function render(tree) { + cleanup(); + container = document.createElement('div'); + document.body.appendChild(container); + OuterReactDOM.render(tree, container); + } + + describe('bubbling events', () => { + // This test will fail in legacy mode (only used in WWW) + // because we emulate the React 16 behavior where + // the click handler is attached to the document. + // @gate !enableLegacyFBSupport + it('onClick', () => { + testNativeBubblingEvent({ + type: 'div', + reactEvent: 'onClick', + nativeEvent: 'click', + dispatch(node) { + node.click(); + }, + }); + }); + + it('onKeyPress', () => { + testNativeBubblingEvent({ + type: 'input', + reactEvent: 'onKeyPress', + nativeEvent: 'keypress', + dispatch(node) { + node.dispatchEvent( + new KeyboardEvent('keypress', { + keyCode: 13, + bubbles: true, + cancelable: true, + }), + ); + }, + }); + }); + + it('onMouseDown', () => { + testNativeBubblingEvent({ + type: 'button', + reactEvent: 'onMouseDown', + nativeEvent: 'mousedown', + dispatch(node) { + node.dispatchEvent( + new MouseEvent('mousedown', { + bubbles: true, + cancelable: true, + }), + ); + }, + }); + }); + + it('onTouchStart', () => { + testNativeBubblingEvent({ + type: 'div', + reactEvent: 'onTouchStart', + nativeEvent: 'touchstart', + dispatch(node) { + node.dispatchEvent( + new Event('touchstart', { + bubbles: true, + cancelable: true, + }), + ); + }, + }); + }); + + it('onWheel', () => { + testNativeBubblingEvent({ + type: 'div', + reactEvent: 'onWheel', + nativeEvent: 'wheel', + dispatch(node) { + node.dispatchEvent( + new Event('wheel', { + bubbles: true, + cancelable: true, + }), + ); + }, + }); + }); + + it('onSubmit', () => { + testNativeBubblingEvent({ + type: 'form', + reactEvent: 'onSubmit', + nativeEvent: 'submit', + dispatch(node) { + const e = new Event('submit', { + bubbles: true, + cancelable: true, + }); + node.dispatchEvent(e); + }, + }); + }); + + it('onReset', () => { + testNativeBubblingEvent({ + type: 'form', + reactEvent: 'onReset', + nativeEvent: 'reset', + dispatch(node) { + const e = new Event('reset', { + bubbles: true, + cancelable: true, + }); + node.dispatchEvent(e); + }, + }); + }); + + it('onFocus', () => { + testNativeBubblingEvent({ + type: 'input', + reactEvent: 'onFocus', + nativeEvent: 'focusin', + dispatch(node) { + const e = new Event('focusin', { + bubbles: true, + cancelable: true, + }); + node.dispatchEvent(e); + }, + }); + }); + + it('onBlur', () => { + testNativeBubblingEvent({ + type: 'input', + reactEvent: 'onBlur', + nativeEvent: 'focusout', + dispatch(node) { + const e = new Event('focusout', { + bubbles: true, + cancelable: true, + }); + node.dispatchEvent(e); + }, + }); + }); + }); + + describe('non-bubbling events that bubble in React', () => { + it('onInvalid', () => { + testEmulatedBubblingEvent({ + type: 'input', + reactEvent: 'onInvalid', + nativeEvent: 'invalid', + dispatch(node) { + const e = new Event('invalid', { + bubbles: false, + cancelable: true, + }); + node.dispatchEvent(e); + }, + }); + }); + + it('onLoad', () => { + testEmulatedBubblingEvent({ + type: 'img', + reactEvent: 'onLoad', + nativeEvent: 'load', + dispatch(node) { + const e = new Event('load', { + bubbles: false, + cancelable: true, + }); + node.dispatchEvent(e); + }, + }); + }); + + it('onError', () => { + testEmulatedBubblingEvent({ + type: 'img', + reactEvent: 'onError', + nativeEvent: 'error', + dispatch(node) { + const e = new Event('error', { + bubbles: false, + cancelable: true, + }); + node.dispatchEvent(e); + }, + }); + }); + + it('onClose', () => { + testEmulatedBubblingEvent({ + type: 'dialog', + reactEvent: 'onClose', + nativeEvent: 'close', + dispatch(node) { + const e = new Event('close', { + bubbles: false, + cancelable: true, + }); + node.dispatchEvent(e); + }, + }); + }); + + it('onCancel', () => { + testEmulatedBubblingEvent({ + type: 'dialog', + reactEvent: 'onCancel', + nativeEvent: 'cancel', + dispatch(node) { + const e = new Event('cancel', { + bubbles: false, + cancelable: true, + }); + node.dispatchEvent(e); + }, + }); + }); + + it('onPlay', () => { + testEmulatedBubblingEvent({ + type: 'video', + reactEvent: 'onPlay', + nativeEvent: 'play', + dispatch(node) { + const e = new Event('play', { + bubbles: false, + cancelable: true, + }); + node.dispatchEvent(e); + }, + }); + }); + }); + + describe('non-bubbling events that do not bubble in React', () => { + // This test will fail outside of the no-bubbling flag + // because its bubbling emulation is currently broken. + // In particular, if the target itself doesn't have + // a handler, it will not emulate bubbling correctly. + // Instead of fixing this, we'll just turn this flag on. + // @gate disableOnScrollBubbling + it('onScroll', () => { + testNonBubblingEvent({ + type: 'div', + reactEvent: 'onScroll', + nativeEvent: 'scroll', + dispatch(node) { + const e = new Event('scroll', { + bubbles: false, + cancelable: true, + }); + node.dispatchEvent(e); + }, + }); + }); + }); + + // Events that bubble in React and in the browser. + // React delegates them to the root. + function testNativeBubblingEvent(config) { + testNativeBubblingEventWithTargetListener(config); + testNativeBubblingEventWithoutTargetListener(config); + testReactStopPropagationInOuterCapturePhase(config); + testReactStopPropagationInInnerCapturePhase(config); + testReactStopPropagationInInnerBubblePhase(config); + testReactStopPropagationInOuterBubblePhase(config); + testNativeStopPropagationInOuterCapturePhase(config); + testNativeStopPropagationInInnerCapturePhase(config); + testNativeStopPropagationInInnerBubblePhase(config); + testNativeStopPropagationInOuterBubblePhase(config); + } + + // Events that bubble in React but not in the browser. + // React attaches them to the elements. + function testEmulatedBubblingEvent(config) { + testEmulatedBubblingEventWithTargetListener(config); + testEmulatedBubblingEventWithoutTargetListener(config); + testReactStopPropagationInOuterCapturePhase(config); + testReactStopPropagationInInnerCapturePhase(config); + testReactStopPropagationInInnerBubblePhase(config); + testNativeStopPropagationInOuterCapturePhase(config); + testNativeStopPropagationInInnerCapturePhase(config); + testNativeStopPropagationInInnerEmulatedBubblePhase(config); + } + + // Events that don't bubble either in React or in the browser. + function testNonBubblingEvent(config) { + testNonBubblingEventWithTargetListener(config); + testNonBubblingEventWithoutTargetListener(config); + testReactStopPropagationInOuterCapturePhase(config); + testReactStopPropagationInInnerCapturePhase(config); + testReactStopPropagationInInnerBubblePhase(config); + testNativeStopPropagationInOuterCapturePhase(config); + testNativeStopPropagationInInnerCapturePhase(config); + } + + function testNativeBubblingEventWithTargetListener(eventConfig) { + const log = []; + const targetRef = React.createRef(); + render( + { + log.push('---- inner'); + }, + [eventConfig.reactEvent + 'Capture']: e => { + log.push('---- inner capture'); + }, + }} + parentProps={{ + [eventConfig.reactEvent]: e => { + log.push('--- inner parent'); + }, + [eventConfig.reactEvent + 'Capture']: e => { + log.push('--- inner parent capture'); + }, + }} + outerProps={{ + [eventConfig.reactEvent]: e => { + log.push('-- outer'); + }, + [eventConfig.reactEvent + 'Capture']: e => { + log.push('-- outer capture'); + }, + }} + outerParentProps={{ + [eventConfig.reactEvent]: e => { + log.push('- outer parent'); + }, + [eventConfig.reactEvent + 'Capture']: e => { + log.push('- outer parent capture'); + }, + }} + />, + ); + expect(log.length).toBe(0); + eventConfig.dispatch(targetRef.current); + // Should print all listeners. + expect(log).toEqual(unindent` + - outer parent capture + -- outer capture + --- inner parent capture + ---- inner capture + ---- inner + --- inner parent + -- outer + - outer parent + `); + } + + function testEmulatedBubblingEventWithTargetListener(eventConfig) { + const log = []; + const targetRef = React.createRef(); + render( + { + log.push('---- inner'); + }, + [eventConfig.reactEvent + 'Capture']: e => { + log.push('---- inner capture'); + }, + }} + parentProps={{ + [eventConfig.reactEvent]: e => { + log.push('--- inner parent'); + }, + [eventConfig.reactEvent + 'Capture']: e => { + log.push('--- inner parent capture'); + }, + }} + outerProps={{ + [eventConfig.reactEvent]: e => { + log.push('-- outer'); + }, + [eventConfig.reactEvent + 'Capture']: e => { + log.push('-- outer capture'); + }, + }} + outerParentProps={{ + [eventConfig.reactEvent]: e => { + log.push('- outer parent'); + }, + [eventConfig.reactEvent + 'Capture']: e => { + log.push('- outer parent capture'); + }, + }} + />, + ); + expect(log.length).toBe(0); + eventConfig.dispatch(targetRef.current); + // This event doesn't bubble natively, but React emulates it. + // Since the element is created by the inner React, the bubbling + // stops at the inner parent and never reaches the outer React. + // In the future, we might consider not bubbling these events + // at all, in in which case inner parent also wouldn't be logged. + expect(log).toEqual(unindent` + - outer parent capture + -- outer capture + --- inner parent capture + ---- inner capture + ---- inner + --- inner parent + `); + } + + function testNonBubblingEventWithTargetListener(eventConfig) { + const log = []; + const targetRef = React.createRef(); + render( + { + log.push('---- inner'); + }, + [eventConfig.reactEvent + 'Capture']: e => { + log.push('---- inner capture'); + }, + }} + parentProps={{ + [eventConfig.reactEvent]: e => { + log.push('--- inner parent'); + }, + [eventConfig.reactEvent + 'Capture']: e => { + log.push('--- inner parent capture'); + }, + }} + outerProps={{ + [eventConfig.reactEvent]: e => { + log.push('-- outer'); + }, + [eventConfig.reactEvent + 'Capture']: e => { + log.push('-- outer capture'); + }, + }} + outerParentProps={{ + [eventConfig.reactEvent]: e => { + log.push('- outer parent'); + }, + [eventConfig.reactEvent + 'Capture']: e => { + log.push('- outer parent capture'); + }, + }} + />, + ); + expect(log.length).toBe(0); + eventConfig.dispatch(targetRef.current); + // This event doesn't bubble natively, and React is + // not emulating it either. So it only reaches the + // target and stops there. + expect(log).toEqual(unindent` + - outer parent capture + -- outer capture + --- inner parent capture + ---- inner capture + ---- inner + `); + } + + function testNativeBubblingEventWithoutTargetListener(eventConfig) { + const log = []; + const targetRef = React.createRef(); + render( + { + log.push('--- inner parent'); + }, + [eventConfig.reactEvent + 'Capture']: e => { + log.push('--- inner parent capture'); + }, + }} + outerProps={{ + [eventConfig.reactEvent]: e => { + log.push('-- outer'); + }, + [eventConfig.reactEvent + 'Capture']: e => { + log.push('-- outer capture'); + }, + }} + outerParentProps={{ + [eventConfig.reactEvent]: e => { + log.push('- outer parent'); + }, + [eventConfig.reactEvent + 'Capture']: e => { + log.push('- outer parent capture'); + }, + }} + />, + ); + expect(log.length).toBe(0); + eventConfig.dispatch(targetRef.current); + // Should print all listeners except the innermost one. + expect(log).toEqual(unindent` + - outer parent capture + -- outer capture + --- inner parent capture + --- inner parent + -- outer + - outer parent + `); + } + + function testEmulatedBubblingEventWithoutTargetListener(eventConfig) { + const log = []; + const targetRef = React.createRef(); + render( + { + log.push('--- inner parent'); + }, + [eventConfig.reactEvent + 'Capture']: e => { + log.push('--- inner parent capture'); + }, + }} + outerProps={{ + [eventConfig.reactEvent]: e => { + log.push('-- outer'); + }, + [eventConfig.reactEvent + 'Capture']: e => { + log.push('-- outer capture'); + }, + }} + outerParentProps={{ + [eventConfig.reactEvent]: e => { + log.push('- outer parent'); + }, + [eventConfig.reactEvent + 'Capture']: e => { + log.push('- outer parent capture'); + }, + }} + />, + ); + expect(log.length).toBe(0); + eventConfig.dispatch(targetRef.current); + // This event doesn't bubble natively, but React emulates it. + // Since the element is created by the inner React, the bubbling + // stops at the inner parent and never reaches the outer React. + // In the future, we might consider not bubbling these events + // at all, in in which case inner parent also wouldn't be logged. + expect(log).toEqual(unindent` + - outer parent capture + -- outer capture + --- inner parent capture + --- inner parent + `); + } + + function testNonBubblingEventWithoutTargetListener(eventConfig) { + const log = []; + const targetRef = React.createRef(); + render( + { + log.push('--- inner parent'); + }, + [eventConfig.reactEvent + 'Capture']: e => { + log.push('--- inner parent capture'); + }, + }} + outerProps={{ + [eventConfig.reactEvent]: e => { + log.push('-- outer'); + }, + [eventConfig.reactEvent + 'Capture']: e => { + log.push('-- outer capture'); + }, + }} + outerParentProps={{ + [eventConfig.reactEvent]: e => { + log.push('- outer parent'); + }, + [eventConfig.reactEvent + 'Capture']: e => { + log.push('- outer parent capture'); + }, + }} + />, + ); + expect(log.length).toBe(0); + eventConfig.dispatch(targetRef.current); + // This event doesn't bubble native, and React doesn't + // emulate bubbling either. Since we don't have a target + // listener, only capture phase listeners fire. + expect(log).toEqual(unindent` + - outer parent capture + -- outer capture + --- inner parent capture + `); + } + + function testReactStopPropagationInOuterCapturePhase(eventConfig) { + const log = []; + const targetRef = React.createRef(); + render( + { + targetRef.current = node; + if (node) { + // No cleanup, assume we render once. + node.addEventListener(eventConfig.nativeEvent, e => { + // We *don't* expect this to appear in the log + // at all because the event is stopped earlier. + log.push('---- inner (native)'); + }); + } + }} + targetProps={{ + [eventConfig.reactEvent]: e => { + log.push('---- inner'); + }, + [eventConfig.reactEvent + 'Capture']: e => { + log.push('---- inner capture'); + }, + }} + parentProps={{ + [eventConfig.reactEvent]: e => { + log.push('--- inner parent'); + }, + [eventConfig.reactEvent + 'Capture']: e => { + log.push('--- inner parent capture'); + }, + }} + outerProps={{ + [eventConfig.reactEvent]: e => { + log.push('-- outer'); + }, + [eventConfig.reactEvent + 'Capture']: e => { + e.stopPropagation(); // <--------- + log.push('-- outer capture'); + }, + }} + outerParentProps={{ + [eventConfig.reactEvent]: e => { + log.push('- outer parent'); + }, + [eventConfig.reactEvent + 'Capture']: e => { + log.push('- outer parent capture'); + }, + }} + />, + ); + expect(log.length).toBe(0); + eventConfig.dispatch(targetRef.current); + // Should stop at the outer capture. + // We don't get to the inner root at all. + expect(log).toEqual(unindent` + - outer parent capture + -- outer capture + `); + } + + function testReactStopPropagationInInnerCapturePhase(eventConfig) { + const log = []; + const targetRef = React.createRef(); + render( + { + targetRef.current = node; + if (node) { + // No cleanup, assume we render once. + node.addEventListener(eventConfig.nativeEvent, e => { + // We *don't* expect this to appear in the log + // at all because the event is stopped earlier. + log.push('---- inner (native)'); + }); + } + }} + targetProps={{ + [eventConfig.reactEvent]: e => { + log.push('---- inner'); + }, + [eventConfig.reactEvent + 'Capture']: e => { + log.push('---- inner capture'); + }, + }} + parentProps={{ + [eventConfig.reactEvent]: e => { + log.push('--- inner parent'); + }, + [eventConfig.reactEvent + 'Capture']: e => { + e.stopPropagation(); // <--------- + log.push('--- inner parent capture'); + }, + }} + outerProps={{ + [eventConfig.reactEvent]: e => { + log.push('-- outer'); + }, + [eventConfig.reactEvent + 'Capture']: e => { + log.push('-- outer capture'); + }, + }} + outerParentProps={{ + [eventConfig.reactEvent]: e => { + log.push('- outer parent'); + }, + [eventConfig.reactEvent + 'Capture']: e => { + log.push('- outer parent capture'); + }, + }} + />, + ); + expect(log.length).toBe(0); + eventConfig.dispatch(targetRef.current); + // We get to the inner root, but we don't + // get to the target and we don't bubble. + expect(log).toEqual(unindent` + - outer parent capture + -- outer capture + --- inner parent capture + `); + } + + function testReactStopPropagationInInnerBubblePhase(eventConfig) { + const log = []; + const targetRef = React.createRef(); + render( + { + e.stopPropagation(); // <--------- + log.push('---- inner'); + }, + [eventConfig.reactEvent + 'Capture']: e => { + log.push('---- inner capture'); + }, + }} + parentProps={{ + [eventConfig.reactEvent]: e => { + log.push('--- inner parent'); + }, + [eventConfig.reactEvent + 'Capture']: e => { + log.push('--- inner parent capture'); + }, + }} + outerRef={node => { + if (node) { + // No cleanup, assume we render once. + node.addEventListener(eventConfig.nativeEvent, e => { + // We *don't* expect this to appear in the log + // at all because the event is stopped earlier. + log.push('-- outer (native)'); + }); + } + }} + outerProps={{ + [eventConfig.reactEvent]: e => { + log.push('-- outer'); + }, + [eventConfig.reactEvent + 'Capture']: e => { + log.push('-- outer capture'); + }, + }} + outerParentProps={{ + [eventConfig.reactEvent]: e => { + log.push('- outer parent'); + }, + [eventConfig.reactEvent + 'Capture']: e => { + log.push('- outer parent capture'); + }, + }} + />, + ); + expect(log.length).toBe(0); + eventConfig.dispatch(targetRef.current); + // Should stop at the target and not go further. + expect(log).toEqual(unindent` + - outer parent capture + -- outer capture + --- inner parent capture + ---- inner capture + ---- inner + `); + } + + function testReactStopPropagationInOuterBubblePhase(eventConfig) { + const log = []; + const targetRef = React.createRef(); + render( + { + log.push('---- inner'); + }, + [eventConfig.reactEvent + 'Capture']: e => { + log.push('---- inner capture'); + }, + }} + parentProps={{ + [eventConfig.reactEvent]: e => { + log.push('--- inner parent'); + }, + [eventConfig.reactEvent + 'Capture']: e => { + log.push('--- inner parent capture'); + }, + }} + outerProps={{ + [eventConfig.reactEvent]: e => { + e.stopPropagation(); // <--------- + log.push('-- outer'); + }, + [eventConfig.reactEvent + 'Capture']: e => { + log.push('-- outer capture'); + }, + }} + outerParentProps={{ + [eventConfig.reactEvent]: e => { + log.push('- outer parent'); + }, + [eventConfig.reactEvent + 'Capture']: e => { + log.push('- outer parent capture'); + }, + }} + />, + ); + expect(log.length).toBe(0); + eventConfig.dispatch(targetRef.current); + // Should not reach the parent outer bubble handler. + expect(log).toEqual(unindent` + - outer parent capture + -- outer capture + --- inner parent capture + ---- inner capture + ---- inner + --- inner parent + -- outer + `); + } + + function testNativeStopPropagationInOuterCapturePhase(eventConfig) { + const log = []; + const targetRef = React.createRef(); + render( + { + log.push('---- inner'); + }, + [eventConfig.reactEvent + 'Capture']: e => { + log.push('---- inner capture'); + }, + }} + parentProps={{ + [eventConfig.reactEvent]: e => { + log.push('--- inner parent'); + }, + [eventConfig.reactEvent + 'Capture']: e => { + log.push('--- inner parent capture'); + }, + }} + outerProps={{ + [eventConfig.reactEvent]: e => { + log.push('-- outer'); + }, + [eventConfig.reactEvent + 'Capture']: e => { + log.push('-- outer capture'); + }, + }} + outerParentRef={node => { + if (node) { + // No cleanup, assume we render once. + node.addEventListener( + eventConfig.nativeEvent, + e => { + log.push('- outer parent capture (native)'); + e.stopPropagation(); // <--------- + }, + {capture: true}, + ); + } + }} + outerParentProps={{ + [eventConfig.reactEvent]: e => { + log.push('- outer parent'); + }, + [eventConfig.reactEvent + 'Capture']: e => { + log.push('- outer parent capture'); + }, + }} + />, + ); + expect(log.length).toBe(0); + eventConfig.dispatch(targetRef.current); + // The outer root has already received the event, + // so the capture phrase runs for it. But the inner + // root is prevented from receiving it by the native + // handler in the outer native capture phase. + expect(log).toEqual(unindent` + - outer parent capture + -- outer capture + - outer parent capture (native) + `); + } + + function testNativeStopPropagationInInnerCapturePhase(eventConfig) { + const log = []; + const targetRef = React.createRef(); + render( + { + log.push('---- inner'); + }, + [eventConfig.reactEvent + 'Capture']: e => { + log.push('---- inner capture'); + }, + }} + parentRef={node => { + if (node) { + // No cleanup, assume we render once. + node.addEventListener( + eventConfig.nativeEvent, + e => { + log.push('--- inner parent capture (native)'); + e.stopPropagation(); // <--------- + }, + {capture: true}, + ); + } + }} + parentProps={{ + [eventConfig.reactEvent]: e => { + log.push('--- inner parent'); + }, + [eventConfig.reactEvent + 'Capture']: e => { + log.push('--- inner parent capture'); + }, + }} + outerProps={{ + [eventConfig.reactEvent]: e => { + log.push('-- outer'); + }, + [eventConfig.reactEvent + 'Capture']: e => { + log.push('-- outer capture'); + }, + }} + outerParentProps={{ + [eventConfig.reactEvent]: e => { + log.push('- outer parent'); + }, + [eventConfig.reactEvent + 'Capture']: e => { + log.push('- outer parent capture'); + }, + }} + />, + ); + expect(log.length).toBe(0); + eventConfig.dispatch(targetRef.current); + // The inner root has already received the event, so + // all React capture phase listeners should run. + // But then the native handler stops propagation + // so none of the bubbling React handlers would run. + expect(log).toEqual(unindent` + - outer parent capture + -- outer capture + --- inner parent capture + ---- inner capture + --- inner parent capture (native) + `); + } + + function testNativeStopPropagationInInnerBubblePhase(eventConfig) { + const log = []; + const targetRef = React.createRef(); + render( + { + targetRef.current = node; + if (node) { + // No cleanup, assume we render once. + node.addEventListener(eventConfig.nativeEvent, e => { + log.push('---- inner (native)'); + e.stopPropagation(); // <--------- + }); + } + }} + targetProps={{ + [eventConfig.reactEvent]: e => { + log.push('---- inner'); + }, + [eventConfig.reactEvent + 'Capture']: e => { + log.push('---- inner capture'); + }, + }} + parentProps={{ + [eventConfig.reactEvent]: e => { + log.push('--- inner parent'); + }, + [eventConfig.reactEvent + 'Capture']: e => { + log.push('--- inner parent capture'); + }, + }} + outerProps={{ + [eventConfig.reactEvent]: e => { + log.push('-- outer'); + }, + [eventConfig.reactEvent + 'Capture']: e => { + log.push('-- outer capture'); + }, + }} + outerParentProps={{ + [eventConfig.reactEvent]: e => { + log.push('- outer parent'); + }, + [eventConfig.reactEvent + 'Capture']: e => { + log.push('- outer parent capture'); + }, + }} + />, + ); + expect(log.length).toBe(0); + eventConfig.dispatch(targetRef.current); + // The capture phase is entirely unaffected. + // Then, we get into the bubble phase. + // We start with the native innermost handler. + // It stops propagation, so nothing else happens. + expect(log).toEqual(unindent` + - outer parent capture + -- outer capture + --- inner parent capture + ---- inner capture + ---- inner (native) + `); + } + + function testNativeStopPropagationInInnerEmulatedBubblePhase(eventConfig) { + const log = []; + const targetRef = React.createRef(); + render( + { + targetRef.current = node; + if (node) { + // No cleanup, assume we render once. + node.addEventListener(eventConfig.nativeEvent, e => { + log.push('---- inner (native)'); + e.stopPropagation(); // <--------- + }); + } + }} + targetProps={{ + [eventConfig.reactEvent]: e => { + log.push('---- inner'); + }, + [eventConfig.reactEvent + 'Capture']: e => { + log.push('---- inner capture'); + }, + }} + parentProps={{ + [eventConfig.reactEvent]: e => { + log.push('--- inner parent'); + }, + [eventConfig.reactEvent + 'Capture']: e => { + log.push('--- inner parent capture'); + }, + }} + outerProps={{ + [eventConfig.reactEvent]: e => { + log.push('-- outer'); + }, + [eventConfig.reactEvent + 'Capture']: e => { + log.push('-- outer capture'); + }, + }} + outerParentProps={{ + [eventConfig.reactEvent]: e => { + log.push('- outer parent'); + }, + [eventConfig.reactEvent + 'Capture']: e => { + log.push('- outer parent capture'); + }, + }} + />, + ); + expect(log.length).toBe(0); + eventConfig.dispatch(targetRef.current); + // This event does not natively bubble, so React + // attaches the listener directly to the element. + // As a result, by the time our custom native listener + // fires, it is too late to do anything -- the React + // emulated bubbilng has already happened. + expect(log).toEqual(unindent` + - outer parent capture + -- outer capture + --- inner parent capture + ---- inner capture + ---- inner + --- inner parent + ---- inner (native) + `); + } + + function testNativeStopPropagationInOuterBubblePhase(eventConfig) { + const log = []; + const targetRef = React.createRef(); + render( + { + log.push('---- inner'); + }, + [eventConfig.reactEvent + 'Capture']: e => { + log.push('---- inner capture'); + }, + }} + parentProps={{ + [eventConfig.reactEvent]: e => { + log.push('--- inner parent'); + }, + [eventConfig.reactEvent + 'Capture']: e => { + log.push('--- inner parent capture'); + }, + }} + outerRef={node => { + if (node) { + // No cleanup, assume we render once. + node.addEventListener(eventConfig.nativeEvent, e => { + log.push('-- outer (native)'); + e.stopPropagation(); // <--------- + }); + } + }} + outerProps={{ + [eventConfig.reactEvent]: e => { + log.push('-- outer'); + }, + [eventConfig.reactEvent + 'Capture']: e => { + log.push('-- outer capture'); + }, + }} + outerParentProps={{ + [eventConfig.reactEvent]: e => { + log.push('- outer parent'); + }, + [eventConfig.reactEvent + 'Capture']: e => { + log.push('- outer parent capture'); + }, + }} + />, + ); + expect(log.length).toBe(0); + eventConfig.dispatch(targetRef.current); + // The event bubbles upwards through the inner tree. + // Then it reaches the native handler which stops propagation. + // As a result, it never reaches the outer React root, + // and thus the outer React event handlers don't fire. + expect(log).toEqual(unindent` + - outer parent capture + -- outer capture + --- inner parent capture + ---- inner capture + ---- inner + --- inner parent + -- outer (native) + `); + } + + function Fixture({ + type, + targetRef, + targetProps, + parentRef, + parentProps, + outerRef, + outerProps, + outerParentRef, + outerParentProps, + }) { + const inner = React.useMemo( + () => ( + + ), + [type, targetRef, targetProps, parentProps], + ); + return ( + + {inner} + + ); + } + + function NestedReact({children}) { + const ref = React.useRef(); + React.useLayoutEffect(() => { + const parent = ref.current; + const innerContainer = document.createElement('div'); + parent.appendChild(innerContainer); + InnerReactDOM.render(children, innerContainer); + return () => { + InnerReactDOM.unmountComponentAtNode(innerContainer); + parent.removeChild(innerContainer); + }; + }, [children, ref]); + return
; + } + + function Inner({type, targetRef, targetProps, parentRef, parentProps}) { + const T = type; + return ( +
+ +
+ ); + } + + function Outer({ + outerRef, + outerProps, + outerParentProps, + outerParentRef, + children, + }) { + return ( +
+
+ {children} +
+
+ ); + } + + function unindent(str) { + return str[0] + .split('\n') + .map(s => s.trim()) + .filter(s => s !== ''); + } +});