From d8ca0829f8be3e857db0791b874c4324f3e8bae8 Mon Sep 17 00:00:00 2001 From: Sophie Alpert Date: Sun, 10 Sep 2017 16:45:39 -0700 Subject: [PATCH] Rewrite ReactDOMSelection to use fewer ranges 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. --- src/renderers/dom/shared/ReactDOMSelection.js | 160 +++++++++----- .../__tests__/ReactDOMSelection-test.js | 203 ++++++++++++++++++ 2 files changed, 314 insertions(+), 49 deletions(-) create mode 100644 src/renderers/dom/shared/__tests__/ReactDOMSelection-test.js diff --git a/src/renderers/dom/shared/ReactDOMSelection.js b/src/renderers/dom/shared/ReactDOMSelection.js index 4aaa9e86dd8f8..d06fdf8ecccff 100644 --- a/src/renderers/dom/shared/ReactDOMSelection.js +++ b/src/renderers/dom/shared/ReactDOMSelection.js @@ -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) { @@ -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 . 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 . 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, }; } @@ -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(); @@ -149,6 +208,9 @@ var ReactDOMSelection = { */ getOffsets: getModernOffsets, + // For tests. + getModernOffsetsFromPoints: getModernOffsetsFromPoints, + /** * @param {DOMElement|DOMTextNode} node * @param {object} offsets diff --git a/src/renderers/dom/shared/__tests__/ReactDOMSelection-test.js b/src/renderers/dom/shared/__tests__/ReactDOMSelection-test.js new file mode 100644 index 0000000000000..f4062721ef118 --- /dev/null +++ b/src/renderers/dom/shared/__tests__/ReactDOMSelection-test.js @@ -0,0 +1,203 @@ +/** + * 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( +
+
+
+
xxxxxxxxxxxxxxxxxxxx
+
+ x +
+
+ x +
+
+
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
+
+
+
xxxxxxxxxxxxxxxxxx
+
+
+
+
+
+
+
+
+
+
xxxx
+
xxxxxxxxxxxxxxxxxxx
+
+
+
xxx
+
xxxxx
+
xxx
+
+
+
+
{['x', 'x', 'xxx']}
+
+
+
+
+
+
xxxxxx
+
+
, + 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, + ); + } + } + }); +});