Skip to content

Commit

Permalink
Switch out event delegation for most events
Browse files Browse the repository at this point in the history
This commit replaces the traditional event delegation technique
employed by React with direct attachment to DOM nodes. This is a test
case to evaluate performance differences.
  • Loading branch information
nhunzaker committed Nov 14, 2017
1 parent 5797664 commit 0fcbfbd
Show file tree
Hide file tree
Showing 8 changed files with 64 additions and 200 deletions.
4 changes: 2 additions & 2 deletions fixtures/dom/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ coverage

# production
build
public/react.development.js
public/react-dom.development.js
public/react.*.js
public/react-dom.*.js

# misc
.DS_Store
Expand Down
2 changes: 1 addition & 1 deletion fixtures/dom/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
},
"scripts": {
"start": "react-scripts start",
"prestart": "cp ../../build/dist/{react,react-dom}.development.js public/",
"prestart": "cp ../../build/dist/{react,react-dom}.{development,production.min}.js public/",
"build": "react-scripts build && cp build/index.html build/200.html",
"test": "react-scripts test --env=jsdom",
"eject": "react-scripts eject"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -373,26 +373,27 @@ describe('ReactBrowserEventEmitter', () => {
});

it('should listen to events only once', () => {
let button = document.createElement('button');
spyOn(EventTarget.prototype, 'addEventListener');
ReactBrowserEventEmitter.listenTo(ON_CLICK_KEY, document);
ReactBrowserEventEmitter.listenTo(ON_CLICK_KEY, document);
ReactBrowserEventEmitter.listenTo(ON_CLICK_KEY, button);
ReactBrowserEventEmitter.listenTo(ON_CLICK_KEY, button);
expect(EventTarget.prototype.addEventListener.calls.count()).toBe(1);
});

it('should work with event plugins without dependencies', () => {
let button = document.createElement('button');
spyOn(EventTarget.prototype, 'addEventListener');

ReactBrowserEventEmitter.listenTo(ON_CLICK_KEY, document);

ReactBrowserEventEmitter.listenTo(ON_CLICK_KEY, button);
expect(EventTarget.prototype.addEventListener.calls.argsFor(0)[0]).toBe(
'click',
);
});

it('should work with event plugins with dependencies', () => {
let button = document.createElement('button');
spyOn(EventTarget.prototype, 'addEventListener');

ReactBrowserEventEmitter.listenTo(ON_CHANGE_KEY, document);
ReactBrowserEventEmitter.listenTo(ON_CHANGE_KEY, button);

var setEventListeners = [];
var listenCalls = EventTarget.prototype.addEventListener.calls.allArgs();
Expand Down
154 changes: 10 additions & 144 deletions packages/react-dom/src/client/ReactDOMFiberComponent.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import * as ReactDOMFiberTextarea from './ReactDOMFiberTextarea';
import * as inputValueTracking from './inputValueTracking';
import setInnerHTML from './setInnerHTML';
import setTextContent from './setTextContent';
import {listenTo, trapBubbledEvent} from '../events/ReactBrowserEventEmitter';
import {listenTo} from '../events/ReactBrowserEventEmitter';
import * as CSSPropertyOperations from '../shared/CSSPropertyOperations';
import {Namespaces, getIntrinsicNamespace} from '../shared/DOMNamespaces';
import {getPropertyInfo, shouldSetAttribute} from '../shared/DOMProperty';
Expand Down Expand Up @@ -188,16 +188,6 @@ if (__DEV__) {
};
}

function ensureListeningTo(rootContainerElement, registrationName) {
var isDocumentOrFragment =
rootContainerElement.nodeType === DOCUMENT_NODE ||
rootContainerElement.nodeType === DOCUMENT_FRAGMENT_NODE;
var doc = isDocumentOrFragment
? rootContainerElement
: rootContainerElement.ownerDocument;
listenTo(registrationName, doc);
}

function getOwnerDocumentFromRootContainer(
rootContainerElement: Element | Document,
): Document {
Expand All @@ -206,47 +196,6 @@ function getOwnerDocumentFromRootContainer(
: rootContainerElement.ownerDocument;
}

// There are so many media events, it makes sense to just
// maintain a list rather than create a `trapBubbledEvent` for each
var mediaEvents = {
topAbort: 'abort',
topCanPlay: 'canplay',
topCanPlayThrough: 'canplaythrough',
topDurationChange: 'durationchange',
topEmptied: 'emptied',
topEncrypted: 'encrypted',
topEnded: 'ended',
topError: 'error',
topLoadedData: 'loadeddata',
topLoadedMetadata: 'loadedmetadata',
topLoadStart: 'loadstart',
topPause: 'pause',
topPlay: 'play',
topPlaying: 'playing',
topProgress: 'progress',
topRateChange: 'ratechange',
topSeeked: 'seeked',
topSeeking: 'seeking',
topStalled: 'stalled',
topSuspend: 'suspend',
topTimeUpdate: 'timeupdate',
topVolumeChange: 'volumechange',
topWaiting: 'waiting',
};

function trapClickOnNonInteractiveElement(node: HTMLElement) {
// Mobile Safari does not fire properly bubble click events on
// non-interactive elements, which means delegated click listeners do not
// fire. The workaround for this bug involves attaching an empty click
// listener on the target node.
// http://www.quirksmode.org/blog/archives/2010/09/click_event_del.html
// Just set it using the onclick property so that we don't have to manage any
// bookkeeping for it. Not sure if we need to clear it when the listener is
// removed.
// TODO: Only do this for the relevant Safaris maybe?
node.onclick = emptyFunction;
}

function setInitialDOMProperties(
tag: string,
domElement: Element,
Expand Down Expand Up @@ -300,7 +249,7 @@ function setInitialDOMProperties(
if (__DEV__ && typeof nextProp !== 'function') {
warnForInvalidEventListener(propKey, nextProp);
}
ensureListeningTo(rootContainerElement, propKey);
listenTo(propKey, domElement, rootContainerElement);
}
} else if (isCustomComponentTag) {
DOMPropertyOperations.setValueForAttribute(domElement, propKey, nextProp);
Expand Down Expand Up @@ -455,47 +404,12 @@ export function setInitialProperties(
// TODO: Make sure that we check isMounted before firing any of these events.
var props: Object;
switch (tag) {
case 'iframe':
case 'object':
trapBubbledEvent('topLoad', 'load', domElement);
props = rawProps;
break;
case 'video':
case 'audio':
// Create listener for each media event
for (var event in mediaEvents) {
if (mediaEvents.hasOwnProperty(event)) {
trapBubbledEvent(event, mediaEvents[event], domElement);
}
}
props = rawProps;
break;
case 'source':
trapBubbledEvent('topError', 'error', domElement);
props = rawProps;
break;
case 'img':
case 'image':
trapBubbledEvent('topError', 'error', domElement);
trapBubbledEvent('topLoad', 'load', domElement);
props = rawProps;
break;
case 'form':
trapBubbledEvent('topReset', 'reset', domElement);
trapBubbledEvent('topSubmit', 'submit', domElement);
props = rawProps;
break;
case 'details':
trapBubbledEvent('topToggle', 'toggle', domElement);
props = rawProps;
break;
case 'input':
ReactDOMFiberInput.initWrapperState(domElement, rawProps);
props = ReactDOMFiberInput.getHostProps(domElement, rawProps);
trapBubbledEvent('topInvalid', 'invalid', domElement);
// For controlled components we always need to ensure we're listening
// to onChange. Even if there is no listener.
ensureListeningTo(rootContainerElement, 'onChange');
listenTo('onChange', domElement, rootContainerElement);
break;
case 'option':
ReactDOMFiberOption.validateProps(domElement, rawProps);
Expand All @@ -504,18 +418,16 @@ export function setInitialProperties(
case 'select':
ReactDOMFiberSelect.initWrapperState(domElement, rawProps);
props = ReactDOMFiberSelect.getHostProps(domElement, rawProps);
trapBubbledEvent('topInvalid', 'invalid', domElement);
// For controlled components we always need to ensure we're listening
// to onChange. Even if there is no listener.
ensureListeningTo(rootContainerElement, 'onChange');
listenTo('onChange', domElement);
break;
case 'textarea':
ReactDOMFiberTextarea.initWrapperState(domElement, rawProps);
props = ReactDOMFiberTextarea.getHostProps(domElement, rawProps);
trapBubbledEvent('topInvalid', 'invalid', domElement);
// For controlled components we always need to ensure we're listening
// to onChange. Even if there is no listener.
ensureListeningTo(rootContainerElement, 'onChange');
listenTo('onChange', domElement);
break;
default:
props = rawProps;
Expand Down Expand Up @@ -551,10 +463,6 @@ export function setInitialProperties(
ReactDOMFiberSelect.postMountWrapper(domElement, rawProps);
break;
default:
if (typeof props.onClick === 'function') {
// TODO: This cast may not be sound for SVG, MathML or custom elements.
trapClickOnNonInteractiveElement(((domElement: any): HTMLElement));
}
break;
}
}
Expand Down Expand Up @@ -599,13 +507,6 @@ export function diffProperties(
default:
lastProps = lastRawProps;
nextProps = nextRawProps;
if (
typeof lastProps.onClick !== 'function' &&
typeof nextProps.onClick === 'function'
) {
// TODO: This cast may not be sound for SVG, MathML or custom elements.
trapClickOnNonInteractiveElement(((domElement: any): HTMLElement));
}
break;
}

Expand Down Expand Up @@ -736,7 +637,7 @@ export function diffProperties(
if (__DEV__ && typeof nextProp !== 'function') {
warnForInvalidEventListener(propKey, nextProp);
}
ensureListeningTo(rootContainerElement, propKey);
listenTo(propKey, domElement, rootContainerElement);
}
if (!updatePayload && lastProp !== nextProp) {
// This is a special case. If any listener updates we need to ensure
Expand Down Expand Up @@ -823,57 +724,26 @@ export function diffHydratedProperties(

// TODO: Make sure that we check isMounted before firing any of these events.
switch (tag) {
case 'iframe':
case 'object':
trapBubbledEvent('topLoad', 'load', domElement);
break;
case 'video':
case 'audio':
// Create listener for each media event
for (var event in mediaEvents) {
if (mediaEvents.hasOwnProperty(event)) {
trapBubbledEvent(event, mediaEvents[event], domElement);
}
}
break;
case 'source':
trapBubbledEvent('topError', 'error', domElement);
break;
case 'img':
case 'image':
trapBubbledEvent('topError', 'error', domElement);
trapBubbledEvent('topLoad', 'load', domElement);
break;
case 'form':
trapBubbledEvent('topReset', 'reset', domElement);
trapBubbledEvent('topSubmit', 'submit', domElement);
break;
case 'details':
trapBubbledEvent('topToggle', 'toggle', domElement);
break;
case 'input':
ReactDOMFiberInput.initWrapperState(domElement, rawProps);
trapBubbledEvent('topInvalid', 'invalid', domElement);
// For controlled components we always need to ensure we're listening
// to onChange. Even if there is no listener.
ensureListeningTo(rootContainerElement, 'onChange');
listenTo('onChange', rootContainerElement);
break;
case 'option':
ReactDOMFiberOption.validateProps(domElement, rawProps);
break;
case 'select':
ReactDOMFiberSelect.initWrapperState(domElement, rawProps);
trapBubbledEvent('topInvalid', 'invalid', domElement);
// For controlled components we always need to ensure we're listening
// to onChange. Even if there is no listener.
ensureListeningTo(rootContainerElement, 'onChange');
listenTo('onChange', rootContainerElement);
break;
case 'textarea':
ReactDOMFiberTextarea.initWrapperState(domElement, rawProps);
trapBubbledEvent('topInvalid', 'invalid', domElement);
// For controlled components we always need to ensure we're listening
// to onChange. Even if there is no listener.
ensureListeningTo(rootContainerElement, 'onChange');
listenTo('onChange', rootContainerElement);
break;
}

Expand Down Expand Up @@ -940,7 +810,7 @@ export function diffHydratedProperties(
if (__DEV__ && typeof nextProp !== 'function') {
warnForInvalidEventListener(propKey, nextProp);
}
ensureListeningTo(rootContainerElement, propKey);
listenTo(propKey, domElement);
}
} else if (__DEV__) {
// Validate that the properties correspond to their expected values.
Expand Down Expand Up @@ -1052,10 +922,6 @@ export function diffHydratedProperties(
// TODO: Consider not doing this for input and textarea.
break;
default:
if (typeof rawProps.onClick === 'function') {
// TODO: This cast may not be sound for SVG, MathML or custom elements.
trapClickOnNonInteractiveElement(((domElement: any): HTMLElement));
}
break;
}

Expand Down
29 changes: 27 additions & 2 deletions packages/react-dom/src/events/ReactBrowserEventEmitter.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,31 @@ import {
trapBubbledEvent,
trapCapturedEvent,
} from './ReactDOMEventListener';
import {DOCUMENT_NODE, DOCUMENT_FRAGMENT_NODE} from '../shared/HTMLNodeType';
import isEventSupported from './isEventSupported';
import BrowserEventConstants from './BrowserEventConstants';

export * from 'events/ReactEventEmitterMixin';

var {topLevelTypes} = BrowserEventConstants;

const forceDelegation = {
topMouseOver: 1,
topMouseOut: 2,
topMouseEnter: 3,
topMouseLeave: 4,
};

function documentForRoot(rootContainerElement, registrationName) {
var isDocumentOrFragment =
rootContainerElement.nodeType === DOCUMENT_NODE ||
rootContainerElement.nodeType === DOCUMENT_FRAGMENT_NODE;

return isDocumentOrFragment
? rootContainerElement
: rootContainerElement.ownerDocument;
}

/**
* Summary of `ReactBrowserEventEmitter` event handling:
*
Expand Down Expand Up @@ -115,15 +133,22 @@ function getListeningForDocument(mountAt) {
* @param {string} registrationName Name of listener (e.g. `onClick`).
* @param {object} contentDocumentHandle Document which owns the container
*/
export function listenTo(registrationName, contentDocumentHandle) {
export function listenTo(registrationName, contentDocumentHandle, root) {
var mountAt = contentDocumentHandle;
var isListening = getListeningForDocument(mountAt);
var dependencies = registrationNameDependencies[registrationName];

for (var i = 0; i < dependencies.length; i++) {
var dependency = dependencies[i];

if (!(isListening.hasOwnProperty(dependency) && isListening[dependency])) {
if (dependency === 'topWheel') {
if (forceDelegation.hasOwnProperty(dependency)) {
trapBubbledEvent(
dependency,
topLevelTypes[dependency],
documentForRoot(root),
);
} else if (dependency === 'topWheel') {
if (isEventSupported('wheel')) {
trapBubbledEvent('topWheel', 'wheel', mountAt);
} else if (isEventSupported('mousewheel')) {
Expand Down
Loading

0 comments on commit 0fcbfbd

Please sign in to comment.