Rewrite ReactDOMSelection to use fewer ranges (#9992)

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.
This commit is contained in:
Sophie Alpert
2017-10-03 18:48:30 -07:00
committed by GitHub
parent 761decb352
commit 1ba7dfcf04
2 changed files with 316 additions and 48 deletions

View File

@@ -9,23 +9,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) {
@@ -37,59 +30,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;
/**
* 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 = -1;
let end = -1;
let indexWithinAnchor = 0;
let indexWithinFocus = 0;
let node = outerNode;
let parentNode = null;
var tempRange = currentRange.cloneRange();
tempRange.selectNodeContents(node);
tempRange.setEnd(currentRange.startContainer, currentRange.startOffset);
outer: while (true) {
let next = null;
var isTempRangeCollapsed = isCollapsed(
tempRange.startContainer,
tempRange.startOffset,
tempRange.endContainer,
tempRange.endOffset,
);
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;
}
var start = isTempRangeCollapsed ? 0 : tempRange.toString().length;
var end = start + rangeLength;
if (node.nodeType === TEXT_NODE) {
length += node.nodeValue.length;
}
// Detect whether the selection is backward.
var detectionRange = document.createRange();
detectionRange.setStart(anchorNode, anchorOffset);
detectionRange.setEnd(focusNode, focusOffset);
var isBackward = detectionRange.collapsed;
if ((next = node.firstChild) === null) {
break;
}
// Moving from `node` to its first child `next`.
parentNode = node;
node = next;
}
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;
}
// Moving from `node` to its next sibling `next`.
node = next;
}
if (start === -1 || end === -1) {
// 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,
};
}
@@ -127,6 +177,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();
@@ -147,6 +206,9 @@ var ReactDOMSelection = {
*/
getOffsets: getModernOffsets,
// For tests.
getModernOffsetsFromPoints: getModernOffsetsFromPoints,
/**
* @param {DOMElement|DOMTextNode} node
* @param {object} offsets

View File

@@ -0,0 +1,206 @@
/**
* Copyright 2013-present, Facebook, Inc.
* All rights reserved.
*
* This source code is licensed under the BSD-style license found in the
* LICENSE file in the root directory of this source tree. An additional grant
* of patent rights can be found in the PATENTS file in the same directory.
*
* @emails react-core
*/
'use strict';
var React;
var ReactDOM;
var ReactDOMSelection;
var invariant;
var getModernOffsetsFromPoints;
describe('ReactDOMSelection', () => {
beforeEach(() => {
React = require('react');
ReactDOM = require('react-dom');
ReactDOMSelection = require('ReactDOMSelection');
invariant = require('invariant');
({getModernOffsetsFromPoints} = ReactDOMSelection);
});
// Simple implementation to compare correctness. React's old implementation of
// this logic used DOM Range objects and is available for manual testing at
// https://gist.github.com/sophiebits/2e6d571f4f10f33b62ea138a6e9c265c.
function simpleModernOffsetsFromPoints(
outerNode,
anchorNode,
anchorOffset,
focusNode,
focusOffset,
) {
let start;
let end;
let length = 0;
function traverse(node) {
if (node.nodeType === Node.TEXT_NODE) {
if (node === anchorNode) {
start = length + anchorOffset;
}
if (node === focusNode) {
end = length + focusOffset;
}
length += node.nodeValue.length;
return;
}
for (let i = 0; true; i++) {
if (node === anchorNode && i === anchorOffset) {
start = length;
}
if (node === focusNode && i === focusOffset) {
end = length;
}
if (i === node.childNodes.length) {
break;
}
let n = node.childNodes[i];
traverse(n);
}
}
traverse(outerNode);
invariant(
start !== null && end !== null,
'Provided anchor/focus nodes were outside of root.',
);
return {start, end};
}
// Complicated example derived from a real-world DOM tree. Has a bit of
// everything.
function getFixture() {
return ReactDOM.render(
<div>
<div>
<div>
<div>xxxxxxxxxxxxxxxxxxxx</div>
</div>
x
<div>
<div>
x
<div>
<div>
<div>xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx</div>
<div />
<div />
<div>xxxxxxxxxxxxxxxxxx</div>
</div>
</div>
</div>
</div>
<div />
</div>
<div>
<div>
<div>
<div>xxxx</div>
<div>xxxxxxxxxxxxxxxxxxx</div>
</div>
</div>
<div>xxx</div>
<div>xxxxx</div>
<div>xxx</div>
<div>
<div>
<div>
<div>{['x', 'x', 'xxx']}</div>
</div>
</div>
</div>
</div>
<div>
<div>xxxxxx</div>
</div>
</div>,
document.createElement('div'),
);
}
it('returns correctly for base case', () => {
const node = document.createElement('div');
expect(getModernOffsetsFromPoints(node, node, 0, node, 0)).toEqual({
start: 0,
end: 0,
});
expect(simpleModernOffsetsFromPoints(node, node, 0, node, 0)).toEqual({
start: 0,
end: 0,
});
});
it('returns correctly for fuzz test', () => {
const fixtureRoot = getFixture();
const allNodes = [fixtureRoot].concat(
Array.from(fixtureRoot.querySelectorAll('*')),
);
expect(allNodes.length).toBe(27);
allNodes.slice().forEach(element => {
// Add text nodes.
allNodes.push(
...Array.from(element.childNodes).filter(n => n.nodeType === 3),
);
});
expect(allNodes.length).toBe(41);
function randomNode() {
return allNodes[(Math.random() * allNodes.length) | 0];
}
function randomOffset(node) {
return (
(Math.random() *
(1 +
(node.nodeType === 3 ? node.nodeValue : node.childNodes).length)) |
0
);
}
for (let i = 0; i < 2000; i++) {
const anchorNode = randomNode();
const anchorOffset = randomOffset(anchorNode);
const focusNode = randomNode();
const focusOffset = randomOffset(focusNode);
const offsets1 = getModernOffsetsFromPoints(
fixtureRoot,
anchorNode,
anchorOffset,
focusNode,
focusOffset,
);
const offsets2 = simpleModernOffsetsFromPoints(
fixtureRoot,
anchorNode,
anchorOffset,
focusNode,
focusOffset,
);
if (JSON.stringify(offsets1) !== JSON.stringify(offsets2)) {
throw new Error(
JSON.stringify(offsets1) +
' does not match ' +
JSON.stringify(offsets2) +
' for anchorNode=allNodes[' +
allNodes.indexOf(anchorNode) +
'], anchorOffset=' +
anchorOffset +
', focusNode=allNodes[' +
allNodes.indexOf(focusNode) +
'], focusOffset=' +
focusOffset,
);
}
}
});
});