Skip to content

Commit

Permalink
Rewrite ReactDOMSelection to use fewer ranges
Browse files Browse the repository at this point in the history
We heard from Chrome engineers that creating too many Range objects slows down Chrome because it needs to keep track of all of them for the case that anchor/focus nodes get removed from the document. We can just implement this calculation without ranges anyway.

jsdom doesn't support Range objects, but I copied the fuzz test code into my browser and manually compared it against our old implementation https://gist.github.com/sophiebits/2e6d571f4f10f33b62ea138a6e9c265c; with 200,000 trials no differences were found.
  • Loading branch information
sophiebits committed Sep 11, 2017
1 parent 6d37c05 commit e660bcd
Show file tree
Hide file tree
Showing 2 changed files with 317 additions and 49 deletions.
160 changes: 111 additions & 49 deletions src/renderers/dom/shared/ReactDOMSelection.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,23 +11,16 @@

'use strict';

var {TEXT_NODE} = require('HTMLNodeType');

var getNodeForCharacterOffset = require('getNodeForCharacterOffset');
var getTextContentAccessor = require('getTextContentAccessor');

/**
* While `isCollapsed` is available on the Selection object and `collapsed`
* is available on the Range object, IE11 sometimes gets them wrong.
* If the anchor/focus nodes and offsets are the same, the range is collapsed.
*/
function isCollapsed(anchorNode, anchorOffset, focusNode, focusOffset) {
return anchorNode === focusNode && anchorOffset === focusOffset;
}

/**
* @param {DOMElement} node
* @param {DOMElement} outerNode
* @return {?object}
*/
function getModernOffsets(node) {
function getModernOffsets(outerNode) {
var selection = window.getSelection && window.getSelection();

if (!selection || selection.rangeCount === 0) {
Expand All @@ -39,59 +32,116 @@ function getModernOffsets(node) {
var focusNode = selection.focusNode;
var focusOffset = selection.focusOffset;

var currentRange = selection.getRangeAt(0);

// In Firefox, range.startContainer and range.endContainer can be "anonymous
// divs", e.g. the up/down buttons on an <input type="number">. Anonymous
// divs do not seem to expose properties, triggering a "Permission denied
// error" if any of its properties are accessed. The only seemingly possible
// way to avoid erroring is to access a property that typically works for
// non-anonymous divs and catch any error that may otherwise arise. See
// In Firefox, anchorNode and focusNode can be "anonymous divs", e.g. the
// up/down buttons on an <input type="number">. Anonymous divs do not seem to
// expose properties, triggering a "Permission denied error" if any of its
// properties are accessed. The only seemingly possible way to avoid erroring
// is to access a property that typically works for non-anonymous divs and
// catch any error that may otherwise arise. See
// https://bugzilla.mozilla.org/show_bug.cgi?id=208427
try {
/* eslint-disable no-unused-expressions */
currentRange.startContainer.nodeType;
currentRange.endContainer.nodeType;
anchorNode.nodeType;
focusNode.nodeType;
/* eslint-enable no-unused-expressions */
} catch (e) {
return null;
}

// If the node and offset values are the same, the selection is collapsed.
// `Selection.isCollapsed` is available natively, but IE sometimes gets
// this value wrong.
var isSelectionCollapsed = isCollapsed(
selection.anchorNode,
selection.anchorOffset,
selection.focusNode,
selection.focusOffset,
return getModernOffsetsFromPoints(
outerNode,
anchorNode,
anchorOffset,
focusNode,
focusOffset,
);
}

var rangeLength = isSelectionCollapsed ? 0 : currentRange.toString().length;

var tempRange = currentRange.cloneRange();
tempRange.selectNodeContents(node);
tempRange.setEnd(currentRange.startContainer, currentRange.startOffset);
/**
* Returns {start, end} where `start` is the character/codepoint index of
* (anchorNode, anchorOffset) within the textContent of `outerNode`, and
* `end` is the index of (focusNode, focusOffset).
*
* Returns null if you pass in garbage input but we should probably just crash.
*/
function getModernOffsetsFromPoints(
outerNode,
anchorNode,
anchorOffset,
focusNode,
focusOffset,
) {
let length = 0;
let start = null;
let end = null;
let indexWithinAnchor = 0;
let indexWithinFocus = 0;
let node = outerNode;
let parentNode = null;

outer: while (true) {
let next = null;

while (true) {
if (
node === anchorNode &&
(anchorOffset === 0 || node.nodeType === TEXT_NODE)
) {
start = length + anchorOffset;
}
if (
node === focusNode &&
(focusOffset === 0 || node.nodeType === TEXT_NODE)
) {
end = length + focusOffset;
}

if (node.nodeType === TEXT_NODE) {
length += node.nodeValue.length;
}

if ((next = node.firstChild) === null) {
break;
}
// Moving from `node` to its first child `next`.
parentNode = node;
node = next;
}

var isTempRangeCollapsed = isCollapsed(
tempRange.startContainer,
tempRange.startOffset,
tempRange.endContainer,
tempRange.endOffset,
);
while (true) {
if (node === outerNode) {
// If `outerNode` has children, this is always the second time visiting
// it. If it has no children, this is still the first loop, and the only
// valid selection is anchorNode and focusNode both equal to this node
// and both offsets 0, in which case we will have handled above.
break outer;
}
if (parentNode === anchorNode && ++indexWithinAnchor === anchorOffset) {
start = length;
}
if (parentNode === focusNode && ++indexWithinFocus === focusOffset) {
end = length;
}
if ((next = node.nextSibling) !== null) {
break;
}
node = parentNode;
parentNode = node.parentNode;
}

var start = isTempRangeCollapsed ? 0 : tempRange.toString().length;
var end = start + rangeLength;
// Moving from `node` to its next sibling `next`.
node = next;
}

// Detect whether the selection is backward.
var detectionRange = document.createRange();
detectionRange.setStart(anchorNode, anchorOffset);
detectionRange.setEnd(focusNode, focusOffset);
var isBackward = detectionRange.collapsed;
if (start === null || end === null) {
// This should never happen. (Would happen if the anchor/focus nodes aren't
// actually inside the passed-in node.)
return null;
}

return {
start: isBackward ? end : start,
end: isBackward ? start : end,
start: start,
end: end,
};
}

Expand Down Expand Up @@ -129,6 +179,15 @@ function setModernOffsets(node, offsets) {
var endMarker = getNodeForCharacterOffset(node, end);

if (startMarker && endMarker) {
if (
selection.rangeCount === 1 &&
selection.anchorNode === startMarker.node &&
selection.anchorOffset === startMarker.offset &&
selection.focusNode === endMarker.node &&
selection.focusOffset === endMarker.offset
) {
return;
}
var range = document.createRange();
range.setStart(startMarker.node, startMarker.offset);
selection.removeAllRanges();
Expand All @@ -149,6 +208,9 @@ var ReactDOMSelection = {
*/
getOffsets: getModernOffsets,

// For tests.
getModernOffsetsFromPoints: getModernOffsetsFromPoints,

/**
* @param {DOMElement|DOMTextNode} node
* @param {object} offsets
Expand Down
Loading

0 comments on commit e660bcd

Please sign in to comment.