diff --git a/src/renderers/dom/client/ReactBrowserEventEmitter.js b/src/renderers/dom/client/ReactBrowserEventEmitter.js index dd7334f48463b..03e4e9ee38089 100644 --- a/src/renderers/dom/client/ReactBrowserEventEmitter.js +++ b/src/renderers/dom/client/ReactBrowserEventEmitter.js @@ -189,6 +189,15 @@ var ReactBrowserEventEmitter = Object.assign({}, ReactEventEmitterMixin, { ReactEventListener.setHandleTopLevel( ReactBrowserEventEmitter.handleTopLevel ); + ReactEventListener.setDocumentResetCallback(function(doc) { + // This must be called during a document reset (@see ReactEventListener.resetDocument) + // after unmounting the last component, otherwise the next render will be visible, + // but document level event listeners will be missing, and input (clicks, typing etc) + // wont work. + reactTopListenersCounter = 0; + alreadyListeningTo = {}; + delete doc[topListenersIDKey]; + }); ReactBrowserEventEmitter.ReactEventListener = ReactEventListener; }, }, @@ -234,6 +243,7 @@ var ReactBrowserEventEmitter = Object.assign({}, ReactEventEmitterMixin, { * * @param {string} registrationName Name of listener (e.g. `onClick`). * @param {object} contentDocumentHandle Document which owns the container + * @return {function} A function that removes ALL listeners that were added. */ listenTo: function(registrationName, contentDocumentHandle) { var mountAt = contentDocumentHandle; @@ -241,6 +251,29 @@ var ReactBrowserEventEmitter = Object.assign({}, ReactEventEmitterMixin, { var dependencies = EventPluginRegistry.registrationNameDependencies[registrationName]; + // aggregates handler remove functions... + var listenerRemovers = []; + var trapBubbledEvent = function(topLevelType, handlerBaseName) { + var remover = ReactBrowserEventEmitter.ReactEventListener.trapBubbledEvent( + topLevelType, + handlerBaseName, + mountAt + ); + if (remover && remover.remove) { + listenerRemovers.push(remover.remove); + } + }; + var trapCapturedEvent = function(topLevelType, handlerBaseName, handle) { + var remover = ReactBrowserEventEmitter.ReactEventListener.trapCapturedEvent( + topLevelType, + handlerBaseName, + handle + ); + if (remover && remover.remove) { + listenerRemovers.push(remover.remove); + } + }; + var topLevelTypes = EventConstants.topLevelTypes; for (var i = 0; i < dependencies.length; i++) { var dependency = dependencies[i]; @@ -250,13 +283,13 @@ var ReactBrowserEventEmitter = Object.assign({}, ReactEventEmitterMixin, { )) { if (dependency === topLevelTypes.topWheel) { if (isEventSupported('wheel')) { - ReactBrowserEventEmitter.ReactEventListener.trapBubbledEvent( + trapBubbledEvent( topLevelTypes.topWheel, 'wheel', mountAt ); } else if (isEventSupported('mousewheel')) { - ReactBrowserEventEmitter.ReactEventListener.trapBubbledEvent( + trapBubbledEvent( topLevelTypes.topWheel, 'mousewheel', mountAt @@ -264,7 +297,7 @@ var ReactBrowserEventEmitter = Object.assign({}, ReactEventEmitterMixin, { } else { // Firefox needs to capture a different mouse scroll event. // @see http://www.quirksmode.org/dom/events/tests/scroll.html - ReactBrowserEventEmitter.ReactEventListener.trapBubbledEvent( + trapBubbledEvent( topLevelTypes.topWheel, 'DOMMouseScroll', mountAt @@ -273,13 +306,13 @@ var ReactBrowserEventEmitter = Object.assign({}, ReactEventEmitterMixin, { } else if (dependency === topLevelTypes.topScroll) { if (isEventSupported('scroll', true)) { - ReactBrowserEventEmitter.ReactEventListener.trapCapturedEvent( + trapCapturedEvent( topLevelTypes.topScroll, 'scroll', mountAt ); } else { - ReactBrowserEventEmitter.ReactEventListener.trapBubbledEvent( + trapBubbledEvent( topLevelTypes.topScroll, 'scroll', ReactBrowserEventEmitter.ReactEventListener.WINDOW_HANDLE @@ -289,12 +322,12 @@ var ReactBrowserEventEmitter = Object.assign({}, ReactEventEmitterMixin, { dependency === topLevelTypes.topBlur) { if (isEventSupported('focus', true)) { - ReactBrowserEventEmitter.ReactEventListener.trapCapturedEvent( + trapCapturedEvent( topLevelTypes.topFocus, 'focus', mountAt ); - ReactBrowserEventEmitter.ReactEventListener.trapCapturedEvent( + trapCapturedEvent( topLevelTypes.topBlur, 'blur', mountAt @@ -302,12 +335,12 @@ var ReactBrowserEventEmitter = Object.assign({}, ReactEventEmitterMixin, { } else if (isEventSupported('focusin')) { // IE has `focusin` and `focusout` events which bubble. // @see http://www.quirksmode.org/blog/archives/2008/04/delegating_the.html - ReactBrowserEventEmitter.ReactEventListener.trapBubbledEvent( + trapBubbledEvent( topLevelTypes.topFocus, 'focusin', mountAt ); - ReactBrowserEventEmitter.ReactEventListener.trapBubbledEvent( + trapBubbledEvent( topLevelTypes.topBlur, 'focusout', mountAt @@ -318,7 +351,7 @@ var ReactBrowserEventEmitter = Object.assign({}, ReactEventEmitterMixin, { isListening[topLevelTypes.topBlur] = true; isListening[topLevelTypes.topFocus] = true; } else if (topEventMapping.hasOwnProperty(dependency)) { - ReactBrowserEventEmitter.ReactEventListener.trapBubbledEvent( + trapBubbledEvent( dependency, topEventMapping[dependency], mountAt @@ -328,6 +361,14 @@ var ReactBrowserEventEmitter = Object.assign({}, ReactEventEmitterMixin, { isListening[dependency] = true; } } + + return function() { + listenerRemovers.forEach( + function(remover) { + remover(); + } + ); + }; }, trapBubbledEvent: function(topLevelType, handlerBaseName, handle) { diff --git a/src/renderers/dom/client/ReactEventListener.js b/src/renderers/dom/client/ReactEventListener.js index be78b7c131188..9c6e56a586629 100644 --- a/src/renderers/dom/client/ReactEventListener.js +++ b/src/renderers/dom/client/ReactEventListener.js @@ -20,6 +20,17 @@ var ReactUpdates = require('ReactUpdates'); var getEventTarget = require('getEventTarget'); var getUnboundedScrollPosition = require('getUnboundedScrollPosition'); +/** + * Tracks all listeners added to a document, for later batch removal. + * @type {Array} + */ +var documentEventListenerRemovers = []; + +/** + * This is invoked when the document is reset. + */ +var documentResetCallback = function() {}; + /** * Find the deepest React component completely containing the root of the * passed-in instance (for use when entire React trees are nested within each @@ -172,6 +183,40 @@ var ReactEventListener = { TopLevelCallbackBookKeeping.release(bookKeeping); } }, + /** + * Records the remover for a previously added document event listener. + * @param {function} remover A function that removes an added listener. + */ + addDocumentEventListenerRemover: function(remover) { + if (remover) { + documentEventListenerRemovers.push(remover); + } + }, + + /** + * This callback will be invoked when resetDocument is also called. + * @param callback + */ + setDocumentResetCallback: function(callback) { + documentResetCallback = callback; + }, + + /** + * Removes all previously added document or global event listeners. + * @param {object} The document holding the last component. + */ + resetDocument: function(doc) { + for (;;) { + var remover = documentEventListenerRemovers.pop(); + if (!remover) { + break; + } + remover(); + } + + documentResetCallback(doc); + }, + }; module.exports = ReactEventListener; diff --git a/src/renderers/dom/client/ReactMount.js b/src/renderers/dom/client/ReactMount.js index 9a0d15ba272af..5e34db0b81d25 100644 --- a/src/renderers/dom/client/ReactMount.js +++ b/src/renderers/dom/client/ReactMount.js @@ -19,6 +19,7 @@ var ReactDOMComponentTree = require('ReactDOMComponentTree'); var ReactDOMContainerInfo = require('ReactDOMContainerInfo'); var ReactDOMFeatureFlags = require('ReactDOMFeatureFlags'); var ReactElement = require('ReactElement'); +var ReactEventListener = require('ReactEventListener'); var ReactFeatureFlags = require('ReactFeatureFlags'); var ReactInstanceMap = require('ReactInstanceMap'); var ReactInstrumentation = require('ReactInstrumentation'); @@ -593,6 +594,10 @@ var ReactMount = { container, false ); + + if (Object.keys(instancesByReactRootID).length===0) { + ReactEventListener.resetDocument(container.ownerDocument); + } return true; }, diff --git a/src/renderers/dom/client/__tests__/ReactEventListener-test.js b/src/renderers/dom/client/__tests__/ReactEventListener-test.js index 049284f8f7054..c0e62ae7e5759 100644 --- a/src/renderers/dom/client/__tests__/ReactEventListener-test.js +++ b/src/renderers/dom/client/__tests__/ReactEventListener-test.js @@ -209,4 +209,52 @@ describe('ReactEventListener', function() { expect(calls[0][EVENT_TARGET_PARAM]) .toBe(ReactDOMComponentTree.getInstanceFromNode(instance.getInner())); }); + + it('documentResetCallback called when resetDocument called with provided document', function() { + var iframe =document.createElement('iframe'); + var doc = iframe.contentDocument; + var resetCalled = false; + ReactEventListener.setDocumentResetCallback(function(d) { + expect(d).toBe(doc); + resetCalled = true; + }); + ReactEventListener.resetDocument(doc); + expect(resetCalled).toBe(true); + }); + + it('document event remover called once and removed upon first resetDocument', function() { + var removed = false; + ReactEventListener.addDocumentEventListenerRemover(function() { + expect(removed).toBe(false); + removed = true; + }); + ReactEventListener.resetDocument(document); + expect(removed).toBe(true); + ReactEventListener.resetDocument(document); + ReactEventListener.resetDocument(document); + }); + + it('multiple document event removers each called once and removed upon first resetDocument call', function() { + var i = false; + ReactEventListener.addDocumentEventListenerRemover(function() { + expect(i).toBe(false); + i = true; + }); + var j = false; + ReactEventListener.addDocumentEventListenerRemover(function() { + expect(j).toBe(false); + j = true; + }); + var k = false; + ReactEventListener.addDocumentEventListenerRemover(function() { + expect(k).toBe(false); + k = true; + }); + ReactEventListener.resetDocument(document); + expect(i).toBe(true); + expect(j).toBe(true); + expect(k).toBe(true); + ReactEventListener.resetDocument(document); + ReactEventListener.resetDocument(document); + }); }); diff --git a/src/renderers/dom/client/__tests__/ReactMount-test.js b/src/renderers/dom/client/__tests__/ReactMount-test.js index b72a26dec551f..5596adb2a58d3 100644 --- a/src/renderers/dom/client/__tests__/ReactMount-test.js +++ b/src/renderers/dom/client/__tests__/ReactMount-test.js @@ -14,6 +14,7 @@ var React; var ReactDOM; var ReactDOMServer; +var ReactEventListener; var ReactMount; var ReactTestUtils; var WebComponents; @@ -25,6 +26,7 @@ describe('ReactMount', function() { React = require('React'); ReactDOM = require('ReactDOM'); ReactDOMServer = require('ReactDOMServer'); + ReactEventListener = require('ReactEventListener'); ReactMount = require('ReactMount'); ReactTestUtils = require('ReactTestUtils'); @@ -278,6 +280,71 @@ describe('ReactMount', function() { expect(Object.keys(ReactMount._instancesByReactRootID).length).toBe(1); }); + it('only calls ReactEventListener.resetDocument when without any roots', function() { + var mustResetNow = false; + var documentResetted = false; + + ReactEventListener.setDocumentResetCallback(function() { + expect(mustResetNow).toBe(true); + documentResetted = true; + }); + + var container = document.createElement('div'); + document.body.appendChild(container); + ReactDOM.render(, container); + + mustResetNow = true; + ReactDOM.unmountComponentAtNode(container); + expect(documentResetted).toBe(true); + + // second time... + mustResetNow = false; + documentResetted=false; + ReactDOM.render(, container); + + mustResetNow = true; + ReactDOM.unmountComponentAtNode(container); + expect(documentResetted).toBe(true); + }); + + it('multiple containers only calls ReactEventListener.resetDocument when without any roots', function() { + var mustResetNow = false; + var documentResetted = false; + ReactEventListener.setDocumentResetCallback(function() { + expect(mustResetNow).toBe(true); + documentResetted = true; + }); + + var container1 = document.createElement('div'); + document.body.appendChild(container1); + ReactDOM.render(, container1); + + var container2 = document.createElement('div'); + document.body.appendChild(container2); + ReactDOM.render(, container2); + + ReactDOM.unmountComponentAtNode(container1); + + mustResetNow = true; + ReactDOM.unmountComponentAtNode(container2); + expect(documentResetted).toBe(true); + }); + + it('only calls ReactEventListener.resetDocument when without any roots #2', function() { + var documentResetted = false; + ReactEventListener.setDocumentResetCallback(function() { + documentResetted=true; + }); + + var container = document.createElement('div'); + document.body.appendChild(container); + ReactDOM.render(
, container); + ReactDOM.render(, container); + + ReactDOM.unmountComponentAtNode(container); + expect(documentResetted).toBe(true); + }); + it('marks top-level mounts', function() { var ReactFeatureFlags = require('ReactFeatureFlags'); diff --git a/src/renderers/dom/shared/ReactDOMComponent.js b/src/renderers/dom/shared/ReactDOMComponent.js index 33d9f6c00f0df..2f3e9ebbcb544 100644 --- a/src/renderers/dom/shared/ReactDOMComponent.js +++ b/src/renderers/dom/shared/ReactDOMComponent.js @@ -32,6 +32,7 @@ var ReactDOMInput = require('ReactDOMInput'); var ReactDOMOption = require('ReactDOMOption'); var ReactDOMSelect = require('ReactDOMSelect'); var ReactDOMTextarea = require('ReactDOMTextarea'); +var ReactEventListener = require('ReactEventListener'); var ReactInstrumentation = require('ReactInstrumentation'); var ReactMultiChild = require('ReactMultiChild'); var ReactServerRenderingTransaction = require('ReactServerRenderingTransaction'); @@ -224,7 +225,11 @@ function enqueuePutListener(inst, registrationName, listener, transaction) { var containerInfo = inst._hostContainerInfo; var isDocumentFragment = containerInfo._node && containerInfo._node.nodeType === DOC_FRAGMENT_TYPE; var doc = isDocumentFragment ? containerInfo._node : containerInfo._ownerDocument; - listenTo(registrationName, doc); + var listenerRemover = listenTo(registrationName, doc, !isDocumentFragment); + if (!isDocumentFragment) { + ReactEventListener.addDocumentEventListenerRemover(listenerRemover); + } + transaction.getReactMountReady().enqueue(putListener, { inst: inst, registrationName: registrationName,