diff --git a/scripts/fiber/tests-failing.txt b/scripts/fiber/tests-failing.txt index 805b7a5513a6f..47add5471b4f8 100644 --- a/scripts/fiber/tests-failing.txt +++ b/scripts/fiber/tests-failing.txt @@ -1,3 +1,6 @@ +src/renderers/dom/fiber/__tests__/ReactDOMFiber-test.js +* should not onMouseLeave when staying in the portal + src/renderers/dom/shared/__tests__/ReactDOMServerIntegration-test.js * should not blow away user-entered text on successful reconnect to an uncontrolled checkbox * should not blow away user-entered text on successful reconnect to a controlled checkbox diff --git a/scripts/fiber/tests-passing.txt b/scripts/fiber/tests-passing.txt index c0a53983ff004..0db6417f8adf7 100644 --- a/scripts/fiber/tests-passing.txt +++ b/scripts/fiber/tests-passing.txt @@ -555,14 +555,6 @@ src/renderers/__tests__/ReactTreeTraversal-test.js * should not traverse when traversing outside DOM * should traverse two phase across component boundary * should traverse two phase at shallowest node -* should not traverse when enter/leaving outside DOM -* should not traverse if enter/leave the same node -* should traverse enter/leave to sibling - avoids parent -* should traverse enter/leave to parent - avoids parent -* should enter from the window -* should enter from the window to the shallowest -* should leave to the window -* should leave to the window from the shallowest * should determine the first common ancestor correctly src/renderers/__tests__/ReactUpdates-test.js @@ -657,7 +649,6 @@ src/renderers/dom/fiber/__tests__/ReactDOMFiber-test.js * should update portal context if it changes due to re-render * findDOMNode should find dom element after expanding a fragment * should bubble events from the portal to the parent -* should not onMouseLeave when staying in the portal * should not update event handlers until commit * should not crash encountering low-priority tree * throws if non-element passed to top-level render @@ -1398,7 +1389,11 @@ src/renderers/dom/shared/eventPlugins/__tests__/ChangeEventPlugin-test.js * should only fire events when the value changes for range inputs src/renderers/dom/shared/eventPlugins/__tests__/EnterLeaveEventPlugin-test.js -* should set relatedTarget properly in iframe +* should use native mouseenter if supported +* should use native mouseleave is supported +* should use the relatedTarget from mouseover +* should use the relatedTarget from mouseout +* should set relatedTarget to the iframe window src/renderers/dom/shared/eventPlugins/__tests__/FallbackCompositionState-test.js * extracts value via `getText()` diff --git a/src/renderers/__tests__/ReactTreeTraversal-test.js b/src/renderers/__tests__/ReactTreeTraversal-test.js index da17f4d257812..9e51a6fcccdf8 100644 --- a/src/renderers/__tests__/ReactTreeTraversal-test.js +++ b/src/renderers/__tests__/ReactTreeTraversal-test.js @@ -100,91 +100,6 @@ describe('ReactTreeTraversal', () => { }); }); - describe('traverseEnterLeave', () => { - it('should not traverse when enter/leaving outside DOM', () => { - ReactTreeTraversal.traverseEnterLeave(null, null, callback, ARG, ARG2); - expect(mockFn).not.toHaveBeenCalled(); - }); - - it('should not traverse if enter/leave the same node', () => { - var parent = renderParentIntoDocument(); - var leave = getInst(parent.refs.P_P1_C1.refs.DIV_1); - var enter = getInst(parent.refs.P_P1_C1.refs.DIV_1); - ReactTreeTraversal.traverseEnterLeave(leave, enter, callback, ARG, ARG2); - expect(mockFn).not.toHaveBeenCalled(); - }); - - it('should traverse enter/leave to sibling - avoids parent', () => { - var parent = renderParentIntoDocument(); - var leave = getInst(parent.refs.P_P1_C1.refs.DIV_1); - var enter = getInst(parent.refs.P_P1_C1.refs.DIV_2); - var expectedCalls = [ - ['P_P1_C1__DIV_1', 'bubbled', ARG], - // enter/leave shouldn't fire anything on the parent - ['P_P1_C1__DIV_2', 'captured', ARG2], - ]; - ReactTreeTraversal.traverseEnterLeave(leave, enter, callback, ARG, ARG2); - expect(mockFn.mock.calls).toEqual(expectedCalls); - }); - - it('should traverse enter/leave to parent - avoids parent', () => { - var parent = renderParentIntoDocument(); - var leave = getInst(parent.refs.P_P1_C1.refs.DIV_1); - var enter = getInst(parent.refs.P_P1_C1.refs.DIV); - var expectedCalls = [['P_P1_C1__DIV_1', 'bubbled', ARG]]; - ReactTreeTraversal.traverseEnterLeave(leave, enter, callback, ARG, ARG2); - expect(mockFn.mock.calls).toEqual(expectedCalls); - }); - - it('should enter from the window', () => { - var parent = renderParentIntoDocument(); - var leave = null; // From the window or outside of the React sandbox. - var enter = getInst(parent.refs.P_P1_C1.refs.DIV); - var expectedCalls = [ - ['P', 'captured', ARG2], - ['P_P1', 'captured', ARG2], - ['P_P1_C1__DIV', 'captured', ARG2], - ]; - ReactTreeTraversal.traverseEnterLeave(leave, enter, callback, ARG, ARG2); - expect(mockFn.mock.calls).toEqual(expectedCalls); - }); - - it('should enter from the window to the shallowest', () => { - var parent = renderParentIntoDocument(); - var leave = null; // From the window or outside of the React sandbox. - var enter = getInst(parent.refs.P); - var expectedCalls = [['P', 'captured', ARG2]]; - ReactTreeTraversal.traverseEnterLeave(leave, enter, callback, ARG, ARG2); - expect(mockFn.mock.calls).toEqual(expectedCalls); - }); - - it('should leave to the window', () => { - var parent = renderParentIntoDocument(); - var enter = null; // From the window or outside of the React sandbox. - var leave = getInst(parent.refs.P_P1_C1.refs.DIV); - var expectedCalls = [ - ['P_P1_C1__DIV', 'bubbled', ARG], - ['P_P1', 'bubbled', ARG], - ['P', 'bubbled', ARG], - ]; - ReactTreeTraversal.traverseEnterLeave(leave, enter, callback, ARG, ARG2); - expect(mockFn.mock.calls).toEqual(expectedCalls); - }); - - it('should leave to the window from the shallowest', () => { - var parent = renderParentIntoDocument(); - var enter = null; // From the window or outside of the React sandbox. - var leave = getInst(parent.refs.P_P1_C1.refs.DIV); - var expectedCalls = [ - ['P_P1_C1__DIV', 'bubbled', ARG], - ['P_P1', 'bubbled', ARG], - ['P', 'bubbled', ARG], - ]; - ReactTreeTraversal.traverseEnterLeave(leave, enter, callback, ARG, ARG2); - expect(mockFn.mock.calls).toEqual(expectedCalls); - }); - }); - describe('getFirstCommonAncestor', () => { it('should determine the first common ancestor correctly', () => { var parent = renderParentIntoDocument(); diff --git a/src/renderers/dom/shared/ReactBrowserEventEmitter.js b/src/renderers/dom/shared/ReactBrowserEventEmitter.js index 1c2661b57f9ab..7da7cd9bde868 100644 --- a/src/renderers/dom/shared/ReactBrowserEventEmitter.js +++ b/src/renderers/dom/shared/ReactBrowserEventEmitter.js @@ -171,6 +171,25 @@ var ReactBrowserEventEmitter = Object.assign({}, ReactEventEmitterMixin, { 'scroll', mountAt, ); + } else if ( + dependency === 'topMouseEnter' || + dependency === 'topMouseLeave' + ) { + if (isEventSupported('mouseenter', true)) { + ReactDOMEventListener.trapCapturedEvent( + 'topMouseEnter', + 'mouseenter', + mountAt, + ); + ReactDOMEventListener.trapCapturedEvent( + 'topMouseLeave', + 'mouseleave', + mountAt, + ); + } + + isListening.topMouseEnter = true; + isListening.topMouseLeave = true; } else if (dependency === 'topFocus' || dependency === 'topBlur') { ReactDOMEventListener.trapCapturedEvent('topFocus', 'focus', mountAt); ReactDOMEventListener.trapCapturedEvent('topBlur', 'blur', mountAt); diff --git a/src/renderers/dom/shared/eventPlugins/EnterLeaveEventPlugin.js b/src/renderers/dom/shared/eventPlugins/EnterLeaveEventPlugin.js index fa9925f1448cd..0dcbeb11d72be 100644 --- a/src/renderers/dom/shared/eventPlugins/EnterLeaveEventPlugin.js +++ b/src/renderers/dom/shared/eventPlugins/EnterLeaveEventPlugin.js @@ -12,23 +12,145 @@ 'use strict'; var EventPropagators = require('EventPropagators'); +var EventPluginHub = require('EventPluginHub'); +var ReactTreeTraversal = require('ReactTreeTraversal'); var ReactDOMComponentTree = require('ReactDOMComponentTree'); var SyntheticMouseEvent = require('SyntheticMouseEvent'); +var isEventSupported = require('isEventSupported'); +var containsNode = require('fbjs/lib/containsNode'); + +var isEnterLeaveSupported = isEventSupported('mouseenter', true); var eventTypes = { mouseEnter: { registrationName: 'onMouseEnter', - dependencies: ['topMouseOut', 'topMouseOver'], + dependencies: ['topMouseOut', 'topMouseOver', 'topMouseEnter'], }, mouseLeave: { registrationName: 'onMouseLeave', - dependencies: ['topMouseOut', 'topMouseOver'], + dependencies: ['topMouseOut', 'topMouseOver', 'topMouseLeave'], }, }; +function getNativeEnterLeave( + topLevelType, + targetInst, + nativeEvent, + nativeEventTarget, +) { + if (topLevelType === 'topMouseEnter' || topLevelType === 'topMouseLeave') { + if (targetInst) { + var eventType; + + if (topLevelType === 'topMouseEnter') { + eventType = 'mouseEnter'; + } else { + eventType = 'mouseLeave'; + } + + var event = SyntheticMouseEvent.getPooled( + eventTypes[eventType], + targetInst, + nativeEvent, + nativeEventTarget, + ); + + event.type = eventType.toLowerCase(); + + EventPropagators.accumulateDirectDispatches(event); + return event; + } + return null; + } +} + +/** + * Traverse the current target instance ancestors + * until it reaches an instance with a listener for the + * specified eventType + */ +function getEventDelegateTargetInst(targetInst, eventType) { + var registrationName = eventType.registrationName; + + return ReactTreeTraversal.traverseUntil( + targetInst, + nextInst => !!EventPluginHub.getListener(nextInst, registrationName), + ); +} + +function getEnterLeavePolyfill( + topLevelType, + targetInst, + nativeEvent, + nativeEventTarget, +) { + if ( + (topLevelType !== 'topMouseOut' && topLevelType !== 'topMouseOver') || + !targetInst + ) { + return null; + } + + var win; + if (nativeEventTarget.window === nativeEventTarget) { + // `nativeEventTarget` is probably a window object. + win = nativeEventTarget; + } else { + // TODO: Figure out why `ownerDocument` is sometimes undefined in IE8. + var doc = nativeEventTarget.ownerDocument; + if (doc) { + win = doc.defaultView || doc.parentWindow; + } else { + win = window; + } + } + + var eventType = topLevelType === 'topMouseOut' ? 'mouseLeave' : 'mouseEnter'; + + // Get the closest instance listening for this event + var delegateTargetInst = getEventDelegateTargetInst( + targetInst, + eventTypes[eventType], + ); + + // if this or a parent isn't listening for enter|leave + // there is nothing else to do. + if (!delegateTargetInst) { + return null; + } + + var target = ReactDOMComponentTree.getNodeFromInstance(delegateTargetInst); + var related = nativeEvent.relatedTarget || nativeEvent.toElement; + + // Trigger _only_ when focus moves into or out of the listening node; not + // when focus shifts between children on the listening node + if (!related || (related !== target && !containsNode(target, related))) { + related = related || win; + + var event = SyntheticMouseEvent.getPooled( + eventTypes[eventType], + delegateTargetInst, + nativeEvent, + target, + ); + + event.type = eventType.toLowerCase(); + event.relatedTarget = related; + + EventPropagators.accumulateDirectDispatches(event); + + return event; + } +} + var EnterLeaveEventPlugin = { eventTypes: eventTypes, + /** + * Exposed for testing + */ + isEnterLeaveSupported: isEnterLeaveSupported, + /** * For almost every interaction we care about, there will be both a top-level * `mouseover` and `mouseout` event that occurs. Only use `mouseout` so that @@ -42,80 +164,11 @@ var EnterLeaveEventPlugin = { nativeEvent, nativeEventTarget, ) { - if ( - topLevelType === 'topMouseOver' && - (nativeEvent.relatedTarget || nativeEvent.fromElement) - ) { - return null; - } - if (topLevelType !== 'topMouseOut' && topLevelType !== 'topMouseOver') { - // Must not be a mouse in or mouse out - ignoring. - return null; - } - - var win; - if (nativeEventTarget.window === nativeEventTarget) { - // `nativeEventTarget` is probably a window object. - win = nativeEventTarget; - } else { - // TODO: Figure out why `ownerDocument` is sometimes undefined in IE8. - var doc = nativeEventTarget.ownerDocument; - if (doc) { - win = doc.defaultView || doc.parentWindow; - } else { - win = window; - } - } - - var from; - var to; - if (topLevelType === 'topMouseOut') { - from = targetInst; - var related = nativeEvent.relatedTarget || nativeEvent.toElement; - to = related - ? ReactDOMComponentTree.getClosestInstanceFromNode(related) - : null; - } else { - // Moving to a node from outside the window. - from = null; - to = targetInst; - } - - if (from === to) { - // Nothing pertains to our managed components. - return null; - } - - var fromNode = from == null - ? win - : ReactDOMComponentTree.getNodeFromInstance(from); - var toNode = to == null - ? win - : ReactDOMComponentTree.getNodeFromInstance(to); - - var leave = SyntheticMouseEvent.getPooled( - eventTypes.mouseLeave, - from, - nativeEvent, - nativeEventTarget, - ); - leave.type = 'mouseleave'; - leave.target = fromNode; - leave.relatedTarget = toNode; - - var enter = SyntheticMouseEvent.getPooled( - eventTypes.mouseEnter, - to, - nativeEvent, - nativeEventTarget, - ); - enter.type = 'mouseenter'; - enter.target = toNode; - enter.relatedTarget = fromNode; - - EventPropagators.accumulateEnterLeaveDispatches(leave, enter, from, to); + var getEvent = EnterLeaveEventPlugin.isEnterLeaveSupported + ? getNativeEnterLeave + : getEnterLeavePolyfill; - return [leave, enter]; + return getEvent(topLevelType, targetInst, nativeEvent, nativeEventTarget); }, }; diff --git a/src/renderers/dom/shared/eventPlugins/__tests__/EnterLeaveEventPlugin-test.js b/src/renderers/dom/shared/eventPlugins/__tests__/EnterLeaveEventPlugin-test.js index 010e151a4541e..9e8a6026c791a 100644 --- a/src/renderers/dom/shared/eventPlugins/__tests__/EnterLeaveEventPlugin-test.js +++ b/src/renderers/dom/shared/eventPlugins/__tests__/EnterLeaveEventPlugin-test.js @@ -11,51 +11,158 @@ 'use strict'; -var EnterLeaveEventPlugin; -var React; -var ReactDOM; -var ReactDOMComponentTree; - -describe('EnterLeaveEventPlugin', () => { - beforeEach(() => { - jest.resetModules(); - - EnterLeaveEventPlugin = require('EnterLeaveEventPlugin'); - React = require('react'); - ReactDOM = require('react-dom'); - ReactDOMComponentTree = require('ReactDOMComponentTree'); +var ReactTestUtils = require('ReactTestUtils'); +var EnterLeaveEventPlugin = require('EnterLeaveEventPlugin'); +var React = require('React'); +var ReactDOM = require('ReactDOM'); +var ReactDOMComponentTree = require('ReactDOMComponentTree'); + +function createIframe() { + var iframe = document.createElement('iframe'); + document.body.appendChild(iframe); + + EnterLeaveEventPlugin.isEnterLeaveSupported = false; + + var iframeDocument = iframe.contentDocument; + + iframeDocument.write( + '
', + ); + iframeDocument.close(); + + return iframe; +} + +describe('EnterLeaveEventPlugin', function() { + it('should use native mouseenter if supported', function() { + if (!EnterLeaveEventPlugin.isEnterLeaveSupported) { + return; + } + + var called = 0; + + function onEnter(e) { + called += 1; + expect(e.type).toBe('mouseenter'); + expect(e.relatedTarget).toBe(root); + } + + var inst = ReactTestUtils.renderIntoDocument( +
+
+ foo +
+
, + ); + + var root = ReactDOM.findDOMNode(inst); + var inner = root.firstChild; + + ReactTestUtils.SimulateNative.mouseEnter(inner, {relatedTarget: root}); + expect(called).toBe(1); }); - it('should set relatedTarget properly in iframe', () => { - var iframe = document.createElement('iframe'); - document.body.appendChild(iframe); + it('should use native mouseleave is supported', function() { + if (!EnterLeaveEventPlugin.isEnterLeaveSupported) { + return; + } - var iframeDocument = iframe.contentDocument; + var called = 0; - iframeDocument.write( - '
', + function onLeave(e) { + called += 1; + expect(e.type).toBe('mouseleave'); + expect(e.relatedTarget).toBe(root); + } + + var inst = ReactTestUtils.renderIntoDocument( +
+
+ foo +
+
, ); - iframeDocument.close(); - var component = ReactDOM.render( -
, + var root = ReactDOM.findDOMNode(inst); + var inner = root.firstChild; + + ReactTestUtils.SimulateNative.mouseLeave(inner, {relatedTarget: root}); + expect(called).toBe(1); + }); + + describe('EnterLeave Polyfill', function() { + beforeEach(function() { + EnterLeaveEventPlugin.isEnterLeaveSupported = false; + }); + + it('should use the relatedTarget from mouseover', function() { + var called = 0; + + function onEnter(e) { + called += 1; + expect(e.type).toBe('mouseenter'); + expect(e.relatedTarget).toBe(root); + } + + var inst = ReactTestUtils.renderIntoDocument( +
+
+ foo +
+
, + ); + + var root = ReactDOM.findDOMNode(inst); + var inner = root.firstChild; + + ReactTestUtils.SimulateNative.mouseOver(inner, {relatedTarget: root}); + expect(called).toBe(1); + }); + + it('should use the relatedTarget from mouseout', function() { + var called = 0; + + function onLeave(e) { + called += 1; + expect(e.type).toBe('mouseleave'); + expect(e.relatedTarget).toBe(root); + } + + var inst = ReactTestUtils.renderIntoDocument( +
+
+ foo +
+
, + ); + + var root = ReactDOM.findDOMNode(inst); + var inner = root.firstChild; + + ReactTestUtils.SimulateNative.mouseOut(inner, {relatedTarget: root}); + expect(called).toBe(1); + }); + }); + + it('should set relatedTarget to the iframe window', function() { + var noop = function() {}; + var iframe = createIframe(); + var iframeDocument = iframe.contentDocument; + + var div = ReactDOM.render( +
, iframeDocument.body.getElementsByTagName('div')[0], ); - var div = ReactDOM.findDOMNode(component); - var extracted = EnterLeaveEventPlugin.extractEvents( + var inst = ReactDOMComponentTree.getInstanceFromNode(div); + + var enter = EnterLeaveEventPlugin.extractEvents( 'topMouseOver', - ReactDOMComponentTree.getInstanceFromNode(div), + inst, {target: div}, div, ); - expect(extracted.length).toBe(2); - - var leave = extracted[0]; - var enter = extracted[1]; - expect(leave.target).toBe(iframe.contentWindow); - expect(leave.relatedTarget).toBe(div); expect(enter.target).toBe(div); expect(enter.relatedTarget).toBe(iframe.contentWindow); }); diff --git a/src/renderers/shared/shared/ReactTreeTraversal.js b/src/renderers/shared/shared/ReactTreeTraversal.js index c6c8aa265610b..456c26202cf39 100644 --- a/src/renderers/shared/shared/ReactTreeTraversal.js +++ b/src/renderers/shared/shared/ReactTreeTraversal.js @@ -91,6 +91,16 @@ function getParentInstance(inst) { return getParent(inst); } +/** + * Traversal up until the condition is met or the tree root is reached + */ +function traverseUntil(inst, fn) { + while (inst && !fn(inst)) { + inst = getParent(inst); + } + return inst; +} + /** * Simulates the traversal of a two-phase, capture/bubble event dispatch. */ @@ -109,38 +119,10 @@ function traverseTwoPhase(inst, fn, arg) { } } -/** - * Traverses the ID hierarchy and invokes the supplied `cb` on any IDs that - * should would receive a `mouseEnter` or `mouseLeave` event. - * - * Does not invoke the callback on the nearest common ancestor because nothing - * "entered" or "left" that element. - */ -function traverseEnterLeave(from, to, fn, argFrom, argTo) { - var common = from && to ? getLowestCommonAncestor(from, to) : null; - var pathFrom = []; - while (from && from !== common) { - pathFrom.push(from); - from = getParent(from); - } - var pathTo = []; - while (to && to !== common) { - pathTo.push(to); - to = getParent(to); - } - var i; - for (i = 0; i < pathFrom.length; i++) { - fn(pathFrom[i], 'bubbled', argFrom); - } - for (i = pathTo.length; i-- > 0; ) { - fn(pathTo[i], 'captured', argTo); - } -} - module.exports = { isAncestor: isAncestor, getLowestCommonAncestor: getLowestCommonAncestor, getParentInstance: getParentInstance, + traverseUntil: traverseUntil, traverseTwoPhase: traverseTwoPhase, - traverseEnterLeave: traverseEnterLeave, }; diff --git a/src/renderers/shared/shared/event/BrowserEventConstants.js b/src/renderers/shared/shared/event/BrowserEventConstants.js index 0f460486be72c..bac5357b86d76 100644 --- a/src/renderers/shared/shared/event/BrowserEventConstants.js +++ b/src/renderers/shared/shared/event/BrowserEventConstants.js @@ -64,6 +64,8 @@ var topLevelTypes = { topLoadedMetadata: 'loadedmetadata', topLoadStart: 'loadstart', topMouseDown: 'mousedown', + topMouseEnter: 'mousenter', + topMouseLeave: 'mouseleave', topMouseMove: 'mousemove', topMouseOut: 'mouseout', topMouseOver: 'mouseover',