Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[RFC] Per React container event listening/dispatching #2050

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 67 additions & 35 deletions src/browser/ReactBrowserEventEmitter.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,10 @@ var EventPluginRegistry = require('EventPluginRegistry');
var ReactEventEmitterMixin = require('ReactEventEmitterMixin');
var ViewportMetrics = require('ViewportMetrics');

var invariant = require('invariant');
var isEventSupported = require('isEventSupported');
var merge = require('merge');
var warning = require('warning');

/**
* Summary of `ReactBrowserEventEmitter` event handling:
Expand Down Expand Up @@ -83,9 +85,7 @@ var merge = require('merge');
* React Core . General Purpose Event Plugin System
*/

var alreadyListeningTo = {};
var isMonitoringScrollValue = false;
var reactTopListenersCounter = 0;

// For events like 'submit' which don't consistently bubble (which we trap at a
// lower node than `document`), binding at `document` would cause duplicate
Expand Down Expand Up @@ -130,19 +130,28 @@ var topEventMapping = {
topWheel: 'wheel'
};

/**
* To ensure no conflicts with other potential React instances on the page
*/
var topListenersIDKey = "_reactListenersID" + String(Math.random()).slice(2);
// TODO: (chenglou) Alternatively, we could use an internal
// map<IDOfRootNodeInsideContainer, map<eventRegistrationName, eventPlugin>>
var eventsKey = '_reactEvents';

function getListeningForDocument(mountAt) {
function getListenedEvents(mountAt) {
// In IE8, `mountAt` is a host object and doesn't have `hasOwnProperty`
// directly.
if (!Object.prototype.hasOwnProperty.call(mountAt, topListenersIDKey)) {
mountAt[topListenersIDKey] = reactTopListenersCounter++;
alreadyListeningTo[mountAt[topListenersIDKey]] = {};
if (!Object.prototype.hasOwnProperty.call(mountAt, eventsKey)) {
mountAt[eventsKey] = {};
}
return mountAt[eventsKey];
}

function removeListenedEvents(mountAt) {
if (!Object.prototype.hasOwnProperty.call(mountAt, eventsKey)) {
warning(
true,
'Tried to remove a React root level listener, but it was not found.'
);
return;
}
return alreadyListeningTo[mountAt[topListenersIDKey]];
delete mountAt[eventsKey];
}

/**
Expand Down Expand Up @@ -196,7 +205,7 @@ var ReactBrowserEventEmitter = merge(ReactEventEmitterMixin, {
},

/**
* We listen for bubbled touch events on the document object.
* We listen for bubbled touch events on a root container.
*
* Firefox v8.01 (and possibly others) exhibited strange behavior when
* mounting `onmousemove` events at some node that was not the document
Expand All @@ -216,36 +225,37 @@ var ReactBrowserEventEmitter = merge(ReactEventEmitterMixin, {
* @param {string} registrationName Name of listener (e.g. `onClick`).
* @param {object} contentDocumentHandle Document which owns the container
*/
listenTo: function(registrationName, contentDocumentHandle) {
var mountAt = contentDocumentHandle;
var isListening = getListeningForDocument(mountAt);
var dependencies = EventPluginRegistry.
registrationNameDependencies[registrationName];
listenTo: function(registrationName, mountAt) {
var events = getListenedEvents(mountAt);
var dependencies =
EventPluginRegistry.registrationNameDependencies[registrationName];

var topLevelTypes = EventConstants.topLevelTypes;
for (var i = 0, l = dependencies.length; i < l; i++) {
// `events` is a mapping of dependency -> event. The map does two
// things: store the fact that a dependency has already been registered,
// and store the event for later removal when the node's unmounted.
var dependency = dependencies[i];
if (!(
isListening.hasOwnProperty(dependency) &&
isListening[dependency]
)) {
var event;

if (!events.hasOwnProperty(dependency) || events[dependency] == null) {
if (dependency === topLevelTypes.topWheel) {
if (isEventSupported('wheel')) {
ReactBrowserEventEmitter.ReactEventListener.trapBubbledEvent(
event = ReactBrowserEventEmitter.trapBubbledEvent(
topLevelTypes.topWheel,
'wheel',
mountAt
);
} else if (isEventSupported('mousewheel')) {
ReactBrowserEventEmitter.ReactEventListener.trapBubbledEvent(
event = ReactBrowserEventEmitter.trapBubbledEvent(
topLevelTypes.topWheel,
'mousewheel',
mountAt
);
} else {
// Firefox needs to capture a different mouse scroll event.
// @see http://www.quirksmode.org/dom/events/tests/scroll.html
ReactBrowserEventEmitter.ReactEventListener.trapBubbledEvent(
event = ReactBrowserEventEmitter.trapBubbledEvent(
topLevelTypes.topWheel,
'DOMMouseScroll',
mountAt
Expand All @@ -254,61 +264,83 @@ var ReactBrowserEventEmitter = merge(ReactEventEmitterMixin, {
} else if (dependency === topLevelTypes.topScroll) {

if (isEventSupported('scroll', true)) {
ReactBrowserEventEmitter.ReactEventListener.trapCapturedEvent(
event = ReactBrowserEventEmitter.trapCapturedEvent(
topLevelTypes.topScroll,
'scroll',
mountAt
);
} else {
ReactBrowserEventEmitter.ReactEventListener.trapBubbledEvent(
event = ReactBrowserEventEmitter.trapBubbledEvent(
topLevelTypes.topScroll,
'scroll',
ReactBrowserEventEmitter.ReactEventListener.WINDOW_HANDLE
ReactBrowserEventEmitter.WINDOW_HANDLE
);
}
} else if (dependency === topLevelTypes.topFocus ||
dependency === topLevelTypes.topBlur) {
var event2;

if (isEventSupported('focus', true)) {
ReactBrowserEventEmitter.ReactEventListener.trapCapturedEvent(
event = ReactBrowserEventEmitter.trapCapturedEvent(
topLevelTypes.topFocus,
'focus',
mountAt
);
ReactBrowserEventEmitter.ReactEventListener.trapCapturedEvent(
event2 = ReactBrowserEventEmitter.trapCapturedEvent(
topLevelTypes.topBlur,
'blur',
mountAt
);
} 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(
event = ReactBrowserEventEmitter.trapBubbledEvent(
topLevelTypes.topFocus,
'focusin',
mountAt
);
ReactBrowserEventEmitter.ReactEventListener.trapBubbledEvent(
event2 = ReactBrowserEventEmitter.trapBubbledEvent(
topLevelTypes.topBlur,
'focusout',
mountAt
);
}

// to make sure blur and focus event listeners are only attached once
isListening[topLevelTypes.topBlur] = true;
isListening[topLevelTypes.topFocus] = true;
events[topLevelTypes.topFocus] = event;
events[topLevelTypes.topBlur] = event2;
} else if (topEventMapping.hasOwnProperty(dependency)) {
ReactBrowserEventEmitter.ReactEventListener.trapBubbledEvent(
event = ReactBrowserEventEmitter.trapBubbledEvent(
dependency,
topEventMapping[dependency],
mountAt
);
}
// As mentioned above, events like `submit` don't bubble to document and
// thus are not attached to it. In that case, there's no `event` (and a
// `remove`) to store. We'll put a `true` placeholder here.
events[dependency] = event || true;
}
}
},

isListening[dependency] = true;
removeListenedEvents: function(container) {
var events = getListenedEvents(container);
if (!events) {
// Might be that no event was (lazily) added in the first place.
return;
}
for (var key in events) {
if (!events.hasOwnProperty(key)) {
continue;
}
if (events[key].remove) {
// See `listenTo`. The event might be a `true` placeholder for things
// like `onSubmit`.
events[key].remove();
}
}
removeListenedEvents(container);
},

trapBubbledEvent: function(topLevelType, handlerBaseName, handle) {
Expand Down
54 changes: 38 additions & 16 deletions src/browser/__tests__/ReactBrowserEventEmitter-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,15 @@
"use strict";

require('mock-modules')
.dontMock('EventPluginHub')
.dontMock('ReactMount')
.dontMock('ReactBrowserEventEmitter')
.dontMock('ReactInstanceHandles')
.dontMock('EventPluginHub')
.dontMock('TapEventPlugin')
.dontMock('TouchEventUtils')
.dontMock('keyOf');
.dontMock('EventListener')
.dontMock('EventPluginHub')
.dontMock('keyOf')
.dontMock('ReactBrowserEventEmitter')
.dontMock('ReactEventListener')
.dontMock('ReactInstanceHandles')
.dontMock('ReactMount')
.dontMock('TapEventPlugin')
.dontMock('TouchEventUtils');


var keyOf = require('keyOf');
Expand Down Expand Up @@ -373,26 +374,47 @@ describe('ReactBrowserEventEmitter', function() {
expect(idCallOrder.length).toBe(0);
});

it('should attach the event to the root container', function() {
var div = document.createElement('div');
ReactBrowserEventEmitter.listenTo(ON_CLICK_KEY, div);
expect(div._reactEvents.topClick.remove).toBeDefined();
});

it('should be able to remove listeners on the root container', function() {
var div = document.createElement('div');
spyOn(div, 'removeEventListener').andCallThrough();
ReactBrowserEventEmitter.listenTo(ON_CLICK_KEY, div);
ReactBrowserEventEmitter.listenTo(ON_CHANGE_KEY, div);
ReactBrowserEventEmitter.removeListenedEvents(div);
// Once for click, 7 times for change.
expect(div.removeEventListener.argsForCall.length).toBe(8);
expect(div._reactEvents).toBe(undefined);
});


it('should listen to events only once', function() {
spyOn(EventListener, 'listen');
ReactBrowserEventEmitter.listenTo(ON_CLICK_KEY, document);
ReactBrowserEventEmitter.listenTo(ON_CLICK_KEY, document);
spyOn(EventListener, 'listen').andCallThrough();
var div = document.createElement('div');
ReactBrowserEventEmitter.listenTo(ON_CLICK_KEY, div);
ReactBrowserEventEmitter.listenTo(ON_CLICK_KEY, div);
expect(EventListener.listen.callCount).toBe(1);
});

it('should work with event plugins without dependencies', function() {
spyOn(EventListener, 'listen');

ReactBrowserEventEmitter.listenTo(ON_CLICK_KEY, document);
var div = document.createElement('div');
ReactBrowserEventEmitter.listenTo(ON_CLICK_KEY, div);

expect(EventListener.listen.argsForCall[0][1]).toBe('click');
});

it('should work with event plugins with dependencies', function() {
spyOn(EventListener, 'listen');
spyOn(EventListener, 'capture');
spyOn(EventListener, 'listen').andCallThrough();
spyOn(EventListener, 'capture').andCallThrough();

ReactBrowserEventEmitter.listenTo(ON_CHANGE_KEY, document);
var div = document.createElement('div');
ReactBrowserEventEmitter.listenTo(ON_CHANGE_KEY, div);

var setEventListeners = [];
var listenCalls = EventListener.listen.argsForCall;
Expand All @@ -405,7 +427,7 @@ describe('ReactBrowserEventEmitter', function() {
}

var module =
ReactBrowserEventEmitter.registrationNameModules[ON_CHANGE_KEY];
ReactBrowserEventEmitter.registrationNameModules[ON_CHANGE_KEY];
var dependencies = module.eventTypes.change.dependencies;
expect(setEventListeners.length).toEqual(dependencies.length);

Expand Down
11 changes: 2 additions & 9 deletions src/browser/ui/ReactDOMComponent.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,17 +35,13 @@ var keyOf = require('keyOf');
var merge = require('merge');
var mixInto = require('mixInto');

var deleteListener = ReactBrowserEventEmitter.deleteListener;
var listenTo = ReactBrowserEventEmitter.listenTo;
var registrationNameModules = ReactBrowserEventEmitter.registrationNameModules;

// For quickly matching children type, to test if can be treated as content.
var CONTENT_TYPES = {'string': true, 'number': true};

var STYLE = keyOf({style: null});

var ELEMENT_NODE_TYPE = 1;

/**
* @param {?object} props
*/
Expand All @@ -68,10 +64,7 @@ function assertValidProps(props) {
function putListener(id, registrationName, listener, transaction) {
var container = ReactMount.findReactContainerForID(id);
if (container) {
var doc = container.nodeType === ELEMENT_NODE_TYPE ?
container.ownerDocument :
container;
listenTo(registrationName, doc);
ReactBrowserEventEmitter.listenTo(registrationName, container);
}
transaction.getPutListenerQueue().enqueuePutListener(
id,
Expand Down Expand Up @@ -283,7 +276,7 @@ ReactDOMComponent.Mixin = {
}
}
} else if (registrationNameModules.hasOwnProperty(propKey)) {
deleteListener(this._rootNodeID, propKey);
ReactBrowserEventEmitter.deleteListener(this._rootNodeID, propKey);
} else if (
DOMProperty.isStandardName[propKey] ||
DOMProperty.isCustomAttribute(propKey)) {
Expand Down
2 changes: 2 additions & 0 deletions src/browser/ui/ReactMount.js
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,8 @@ var ReactMount = {
if (!component) {
return false;
}

ReactBrowserEventEmitter.removeListenedEvents(container);
ReactMount.unmountComponentFromNode(component, container);
delete instancesByReactRootID[reactRootID];
delete containersByReactRootID[reactRootID];
Expand Down
2 changes: 2 additions & 0 deletions src/vendor/stubs/EventListener.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
* @typechecks
*/

'use strict';

var emptyFunction = require('emptyFunction');

/**
Expand Down