Skip to content

Commit 6c76f2f

Browse files
committed
7128 - React document listener memory leak fix.
1 parent 6cc037b commit 6c76f2f

File tree

5 files changed

+146
-11
lines changed

5 files changed

+146
-11
lines changed

src/renderers/dom/client/ReactBrowserEventEmitter.js

Lines changed: 59 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
var EventConstants = require('EventConstants');
1515
var EventPluginRegistry = require('EventPluginRegistry');
16+
var ReactEventListener = require('ReactEventListener');
1617
var ReactEventEmitterMixin = require('ReactEventEmitterMixin');
1718
var ViewportMetrics = require('ViewportMetrics');
1819

@@ -154,6 +155,22 @@ var topEventMapping = {
154155
*/
155156
var topListenersIDKey = '_reactListenersID' + String(Math.random()).slice(2);
156157

158+
/**
159+
* Resets the document state that records registered listener tracking.
160+
* Document is passed as a parameter allowing the react app to be loaded in a single window, and within an IFRAME that
161+
* renders to the main window.
162+
* @param {object} The document holding the root container.
163+
*/
164+
ReactEventListener.setDocumentResetCallback(function(doc) {
165+
// This must be called during a document reset (@see ReactEventListener.resetDocument)
166+
// after unmounting the last component, otherwise the next render will be visible,
167+
// but document level event listeners will be missing, and input (clicks, typing etc)
168+
// wont work.
169+
reactTopListenersCounter = 0;
170+
alreadyListeningTo = {};
171+
delete doc[topListenersIDKey];
172+
});
173+
157174
function getListeningForDocument(mountAt) {
158175
// In IE8, `mountAt` is a host object and doesn't have `hasOwnProperty`
159176
// directly.
@@ -234,13 +251,37 @@ var ReactBrowserEventEmitter = Object.assign({}, ReactEventEmitterMixin, {
234251
*
235252
* @param {string} registrationName Name of listener (e.g. `onClick`).
236253
* @param {object} contentDocumentHandle Document which owns the container
254+
* @return {function} A function that removes ALL listeners that were added.
237255
*/
238256
listenTo: function(registrationName, contentDocumentHandle) {
239257
var mountAt = contentDocumentHandle;
240258
var isListening = getListeningForDocument(mountAt);
241259
var dependencies =
242260
EventPluginRegistry.registrationNameDependencies[registrationName];
243261

262+
// aggregates handler remove functions...
263+
var listenerRemovers = [];
264+
var trapBubbledEvent = function(topLevelType, handlerBaseName) {
265+
var remover = ReactBrowserEventEmitter.ReactEventListener.trapBubbledEvent(
266+
topLevelType,
267+
handlerBaseName,
268+
mountAt
269+
);
270+
if (remover && remover.remove) {
271+
listenerRemovers.push(remover.remove);
272+
}
273+
};
274+
var trapCapturedEvent = function(topLevelType, handlerBaseName, handle) {
275+
var remover = ReactBrowserEventEmitter.ReactEventListener.trapCapturedEvent(
276+
topLevelType,
277+
handlerBaseName,
278+
handle
279+
);
280+
if (remover && remover.remove) {
281+
listenerRemovers.push(remover.remove);
282+
}
283+
};
284+
244285
var topLevelTypes = EventConstants.topLevelTypes;
245286
for (var i = 0; i < dependencies.length; i++) {
246287
var dependency = dependencies[i];
@@ -250,21 +291,21 @@ var ReactBrowserEventEmitter = Object.assign({}, ReactEventEmitterMixin, {
250291
)) {
251292
if (dependency === topLevelTypes.topWheel) {
252293
if (isEventSupported('wheel')) {
253-
ReactBrowserEventEmitter.ReactEventListener.trapBubbledEvent(
294+
trapBubbledEvent(
254295
topLevelTypes.topWheel,
255296
'wheel',
256297
mountAt
257298
);
258299
} else if (isEventSupported('mousewheel')) {
259-
ReactBrowserEventEmitter.ReactEventListener.trapBubbledEvent(
300+
trapBubbledEvent(
260301
topLevelTypes.topWheel,
261302
'mousewheel',
262303
mountAt
263304
);
264305
} else {
265306
// Firefox needs to capture a different mouse scroll event.
266307
// @see http://www.quirksmode.org/dom/events/tests/scroll.html
267-
ReactBrowserEventEmitter.ReactEventListener.trapBubbledEvent(
308+
trapBubbledEvent(
268309
topLevelTypes.topWheel,
269310
'DOMMouseScroll',
270311
mountAt
@@ -273,13 +314,13 @@ var ReactBrowserEventEmitter = Object.assign({}, ReactEventEmitterMixin, {
273314
} else if (dependency === topLevelTypes.topScroll) {
274315

275316
if (isEventSupported('scroll', true)) {
276-
ReactBrowserEventEmitter.ReactEventListener.trapCapturedEvent(
317+
trapCapturedEvent(
277318
topLevelTypes.topScroll,
278319
'scroll',
279320
mountAt
280321
);
281322
} else {
282-
ReactBrowserEventEmitter.ReactEventListener.trapBubbledEvent(
323+
trapBubbledEvent(
283324
topLevelTypes.topScroll,
284325
'scroll',
285326
ReactBrowserEventEmitter.ReactEventListener.WINDOW_HANDLE
@@ -289,25 +330,25 @@ var ReactBrowserEventEmitter = Object.assign({}, ReactEventEmitterMixin, {
289330
dependency === topLevelTypes.topBlur) {
290331

291332
if (isEventSupported('focus', true)) {
292-
ReactBrowserEventEmitter.ReactEventListener.trapCapturedEvent(
333+
trapCapturedEvent(
293334
topLevelTypes.topFocus,
294335
'focus',
295336
mountAt
296337
);
297-
ReactBrowserEventEmitter.ReactEventListener.trapCapturedEvent(
338+
trapCapturedEvent(
298339
topLevelTypes.topBlur,
299340
'blur',
300341
mountAt
301342
);
302343
} else if (isEventSupported('focusin')) {
303344
// IE has `focusin` and `focusout` events which bubble.
304345
// @see http://www.quirksmode.org/blog/archives/2008/04/delegating_the.html
305-
ReactBrowserEventEmitter.ReactEventListener.trapBubbledEvent(
346+
trapBubbledEvent(
306347
topLevelTypes.topFocus,
307348
'focusin',
308349
mountAt
309350
);
310-
ReactBrowserEventEmitter.ReactEventListener.trapBubbledEvent(
351+
trapBubbledEvent(
311352
topLevelTypes.topBlur,
312353
'focusout',
313354
mountAt
@@ -318,7 +359,7 @@ var ReactBrowserEventEmitter = Object.assign({}, ReactEventEmitterMixin, {
318359
isListening[topLevelTypes.topBlur] = true;
319360
isListening[topLevelTypes.topFocus] = true;
320361
} else if (topEventMapping.hasOwnProperty(dependency)) {
321-
ReactBrowserEventEmitter.ReactEventListener.trapBubbledEvent(
362+
trapBubbledEvent(
322363
dependency,
323364
topEventMapping[dependency],
324365
mountAt
@@ -328,6 +369,14 @@ var ReactBrowserEventEmitter = Object.assign({}, ReactEventEmitterMixin, {
328369
isListening[dependency] = true;
329370
}
330371
}
372+
373+
return function() {
374+
listenerRemovers.forEach(
375+
function(remover) {
376+
remover();
377+
}
378+
);
379+
};
331380
},
332381

333382
trapBubbledEvent: function(topLevelType, handlerBaseName, handle) {

src/renderers/dom/client/ReactEventListener.js

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,17 @@ var ReactUpdates = require('ReactUpdates');
2020
var getEventTarget = require('getEventTarget');
2121
var getUnboundedScrollPosition = require('getUnboundedScrollPosition');
2222

23+
/**
24+
* Tracks all listeners added to a document, for later batch removal.
25+
* @type {Array}
26+
*/
27+
var documentEventListenerRemovers = [];
28+
29+
/**
30+
* This is invoked when the document is reset.
31+
*/
32+
var documentResetCallback = function() {};
33+
2334
/**
2435
* Find the deepest React component completely containing the root of the
2536
* passed-in instance (for use when entire React trees are nested within each
@@ -172,6 +183,40 @@ var ReactEventListener = {
172183
TopLevelCallbackBookKeeping.release(bookKeeping);
173184
}
174185
},
186+
/**
187+
* Records the remover for a previously added document event listener.
188+
* @param {function} remover A function that removes an added listener.
189+
*/
190+
addDocumentEventListenerRemover: function(remover) {
191+
if (remover) {
192+
documentEventListenerRemovers.push(remover);
193+
}
194+
},
195+
196+
/**
197+
* This callback will be invoked when resetDocument is also called.
198+
* @param callback
199+
*/
200+
setDocumentResetCallback: function(callback) {
201+
documentResetCallback = callback;
202+
},
203+
204+
/**
205+
* Removes all previously added document or global event listeners.
206+
* @param {object} The document holding the last component.
207+
*/
208+
resetDocument: function(doc) {
209+
for (;;) {
210+
var remover = documentEventListenerRemovers.pop();
211+
if (!remover) {
212+
break;
213+
}
214+
remover();
215+
}
216+
217+
documentResetCallback(doc);
218+
},
219+
175220
};
176221

177222
module.exports = ReactEventListener;

src/renderers/dom/client/ReactMount.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ var ReactDOMComponentTree = require('ReactDOMComponentTree');
1919
var ReactDOMContainerInfo = require('ReactDOMContainerInfo');
2020
var ReactDOMFeatureFlags = require('ReactDOMFeatureFlags');
2121
var ReactElement = require('ReactElement');
22+
var ReactEventListener = require('ReactEventListener');
2223
var ReactFeatureFlags = require('ReactFeatureFlags');
2324
var ReactInstanceMap = require('ReactInstanceMap');
2425
var ReactInstrumentation = require('ReactInstrumentation');
@@ -604,6 +605,10 @@ var ReactMount = {
604605
container,
605606
false
606607
);
608+
609+
if (Object.keys(instancesByReactRootID).length===0) {
610+
ReactEventListener.resetDocument(container.ownerDocument);
611+
}
607612
return true;
608613
},
609614

src/renderers/dom/client/__tests__/ReactMount-test.js

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
var React;
1515
var ReactDOM;
1616
var ReactDOMServer;
17+
var ReactEventListener;
1718
var ReactMount;
1819
var ReactTestUtils;
1920
var WebComponents;
@@ -25,6 +26,7 @@ describe('ReactMount', function() {
2526
React = require('React');
2627
ReactDOM = require('ReactDOM');
2728
ReactDOMServer = require('ReactDOMServer');
29+
ReactEventListener = require('ReactEventListener');
2830
ReactMount = require('ReactMount');
2931
ReactTestUtils = require('ReactTestUtils');
3032

@@ -278,6 +280,35 @@ describe('ReactMount', function() {
278280
expect(Object.keys(ReactMount._instancesByReactRootID).length).toBe(1);
279281
});
280282

283+
it('only calls ReactEventListener.resetDocument when without any roots', function() {
284+
var documentResetted = false;
285+
ReactEventListener.setDocumentResetCallback(function(){
286+
expect(Object.keys(ReactMount._instancesByReactRootID).length).toBe(0);
287+
documentResetted = true;
288+
});
289+
290+
// Used by devtools.
291+
var container = document.createElement('div');
292+
ReactDOM.render(<span />, container);
293+
ReactDOM.unmountComponentAtNode(container);
294+
expect(documentResetted).toBe(true);
295+
});
296+
297+
it('only calls ReactEventListener.resetDocument when without any roots 2', function() {
298+
var documentResetted = false;
299+
ReactEventListener.setDocumentResetCallback(function(){
300+
expect(Object.keys(ReactMount._instancesByReactRootID).length).toBe(0);
301+
documentResetted = true;
302+
});
303+
304+
// Used by devtools.
305+
var container = document.createElement('div');
306+
ReactDOM.render(<div />, container);
307+
ReactDOM.render(<span />, container);
308+
ReactDOM.unmountComponentAtNode(container);
309+
expect(documentResetted).toBe(true);
310+
});
311+
281312
it('marks top-level mounts', function() {
282313
var ReactFeatureFlags = require('ReactFeatureFlags');
283314

src/renderers/dom/shared/ReactDOMComponent.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ var ReactDOMInput = require('ReactDOMInput');
3232
var ReactDOMOption = require('ReactDOMOption');
3333
var ReactDOMSelect = require('ReactDOMSelect');
3434
var ReactDOMTextarea = require('ReactDOMTextarea');
35+
var ReactEventListener = require('ReactEventListener');
3536
var ReactInstrumentation = require('ReactInstrumentation');
3637
var ReactMultiChild = require('ReactMultiChild');
3738
var ReactServerRenderingTransaction = require('ReactServerRenderingTransaction');
@@ -224,7 +225,11 @@ function enqueuePutListener(inst, registrationName, listener, transaction) {
224225
var containerInfo = inst._hostContainerInfo;
225226
var isDocumentFragment = containerInfo._node && containerInfo._node.nodeType === DOC_FRAGMENT_TYPE;
226227
var doc = isDocumentFragment ? containerInfo._node : containerInfo._ownerDocument;
227-
listenTo(registrationName, doc);
228+
var listenerRemover = listenTo(registrationName, doc, !isDocumentFragment);
229+
if (!isDocumentFragment) {
230+
ReactEventListener.addDocumentEventListenerRemover(listenerRemover);
231+
}
232+
228233
transaction.getReactMountReady().enqueue(putListener, {
229234
inst: inst,
230235
registrationName: registrationName,

0 commit comments

Comments
 (0)