mirror of
https://github.com/zebrajr/react.git
synced 2026-01-15 12:15:22 +00:00
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:
@@ -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
|
||||
|
||||
206
src/renderers/dom/shared/__tests__/ReactDOMSelection-test.js
Normal file
206
src/renderers/dom/shared/__tests__/ReactDOMSelection-test.js
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user