@samitouri / QOS-React-2 / commits / 1ba7dfcf04

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.

Sophie Alpert committed Oct 3, 2017 at 18:48 UTC 1ba7dfcf049fae629015bc9efe319747cee1542a
2 files changed +317 -49
src/renderers/dom/shared/ReactDOMSelection.js
+111 -49
@@ -9,23 +9,16 @@
9
10 'use strict';
11
12 +var {TEXT_NODE} = require('HTMLNodeType');
13 +
14 var getNodeForCharacterOffset = require('getNodeForCharacterOffset');
15 var getTextContentAccessor = require('getTextContentAccessor');
16
17 /**
16 - * While `isCollapsed` is available on the Selection object and `collapsed`
17 - * is available on the Range object, IE11 sometimes gets them wrong.
18 - * If the anchor/focus nodes and offsets are the same, the range is collapsed.
19 - */
20 -function isCollapsed(anchorNode, anchorOffset, focusNode, focusOffset) {
21 - return anchorNode === focusNode && anchorOffset === focusOffset;
22 -}
23 -
24 -/**
25 - * @param {DOMElement} node
18 + * @param {DOMElement} outerNode
19 * @return {?object}
20 */
28 -function getModernOffsets(node) {
21 +function getModernOffsets(outerNode) {
22 var selection = window.getSelection && window.getSelection();
23
24 if (!selection || selection.rangeCount === 0) {
@@ -37,59 +30,116 @@ function getModernOffsets(node) {
30 var focusNode = selection.focusNode;
31 var focusOffset = selection.focusOffset;
32
40 - var currentRange = selection.getRangeAt(0);
41 -
42 - // In Firefox, range.startContainer and range.endContainer can be "anonymous
43 - // divs", e.g. the up/down buttons on an <input type="number">. Anonymous
44 - // divs do not seem to expose properties, triggering a "Permission denied
45 - // error" if any of its properties are accessed. The only seemingly possible
46 - // way to avoid erroring is to access a property that typically works for
47 - // non-anonymous divs and catch any error that may otherwise arise. See
33 + // In Firefox, anchorNode and focusNode can be "anonymous divs", e.g. the
34 + // up/down buttons on an <input type="number">. Anonymous divs do not seem to
35 + // expose properties, triggering a "Permission denied error" if any of its
36 + // properties are accessed. The only seemingly possible way to avoid erroring
37 + // is to access a property that typically works for non-anonymous divs and
38 + // catch any error that may otherwise arise. See
39 // https://bugzilla.mozilla.org/show_bug.cgi?id=208427
40 try {
41 /* eslint-disable no-unused-expressions */
51 - currentRange.startContainer.nodeType;
52 - currentRange.endContainer.nodeType;
42 + anchorNode.nodeType;
43 + focusNode.nodeType;
44 /* eslint-enable no-unused-expressions */
45 } catch (e) {
46 return null;
47 }
48
58 - // If the node and offset values are the same, the selection is collapsed.
59 - // `Selection.isCollapsed` is available natively, but IE sometimes gets
60 - // this value wrong.
61 - var isSelectionCollapsed = isCollapsed(
62 - selection.anchorNode,
63 - selection.anchorOffset,
64 - selection.focusNode,
65 - selection.focusOffset,
49 + return getModernOffsetsFromPoints(
50 + outerNode,
51 + anchorNode,
52 + anchorOffset,
53 + focusNode,
54 + focusOffset,
55 );
56 +}
57
68 - var rangeLength = isSelectionCollapsed ? 0 : currentRange.toString().length;
69 -
70 - var tempRange = currentRange.cloneRange();
71 - tempRange.selectNodeContents(node);
72 - tempRange.setEnd(currentRange.startContainer, currentRange.startOffset);
58 +/**
59 + * Returns {start, end} where `start` is the character/codepoint index of
60 + * (anchorNode, anchorOffset) within the textContent of `outerNode`, and
61 + * `end` is the index of (focusNode, focusOffset).
62 + *
63 + * Returns null if you pass in garbage input but we should probably just crash.
64 + */
65 +function getModernOffsetsFromPoints(
66 + outerNode,
67 + anchorNode,
68 + anchorOffset,
69 + focusNode,
70 + focusOffset,
71 +) {
72 + let length = 0;
73 + let start = -1;
74 + let end = -1;
75 + let indexWithinAnchor = 0;
76 + let indexWithinFocus = 0;
77 + let node = outerNode;
78 + let parentNode = null;
79 +
80 + outer: while (true) {
81 + let next = null;
82 +
83 + while (true) {
84 + if (
85 + node === anchorNode &&
86 + (anchorOffset === 0 || node.nodeType === TEXT_NODE)
87 + ) {
88 + start = length + anchorOffset;
89 + }
90 + if (
91 + node === focusNode &&
92 + (focusOffset === 0 || node.nodeType === TEXT_NODE)
93 + ) {
94 + end = length + focusOffset;
95 + }
96 +
97 + if (node.nodeType === TEXT_NODE) {
98 + length += node.nodeValue.length;
99 + }
100 +
101 + if ((next = node.firstChild) === null) {
102 + break;
103 + }
104 + // Moving from `node` to its first child `next`.
105 + parentNode = node;
106 + node = next;
107 + }
108
74 - var isTempRangeCollapsed = isCollapsed(
75 - tempRange.startContainer,
76 - tempRange.startOffset,
77 - tempRange.endContainer,
78 - tempRange.endOffset,
79 - );
109 + while (true) {
110 + if (node === outerNode) {
111 + // If `outerNode` has children, this is always the second time visiting
112 + // it. If it has no children, this is still the first loop, and the only
113 + // valid selection is anchorNode and focusNode both equal to this node
114 + // and both offsets 0, in which case we will have handled above.
115 + break outer;
116 + }
117 + if (parentNode === anchorNode && ++indexWithinAnchor === anchorOffset) {
118 + start = length;
119 + }
120 + if (parentNode === focusNode && ++indexWithinFocus === focusOffset) {
121 + end = length;
122 + }
123 + if ((next = node.nextSibling) !== null) {
124 + break;
125 + }
126 + node = parentNode;
127 + parentNode = node.parentNode;
128 + }
129
81 - var start = isTempRangeCollapsed ? 0 : tempRange.toString().length;
82 - var end = start + rangeLength;
130 + // Moving from `node` to its next sibling `next`.
131 + node = next;
132 + }
133
84 - // Detect whether the selection is backward.
85 - var detectionRange = document.createRange();
86 - detectionRange.setStart(anchorNode, anchorOffset);
87 - detectionRange.setEnd(focusNode, focusOffset);
88 - var isBackward = detectionRange.collapsed;
134 + if (start === -1 || end === -1) {
135 + // This should never happen. (Would happen if the anchor/focus nodes aren't
136 + // actually inside the passed-in node.)
137 + return null;
138 + }
139
140 return {
91 - start: isBackward ? end : start,
92 - end: isBackward ? start : end,
141 + start: start,
142 + end: end,
143 };
144 }
145
@@ -127,6 +177,15 @@ function setModernOffsets(node, offsets) {
177 var endMarker = getNodeForCharacterOffset(node, end);
178
179 if (startMarker && endMarker) {
180 + if (
181 + selection.rangeCount === 1 &&
182 + selection.anchorNode === startMarker.node &&
183 + selection.anchorOffset === startMarker.offset &&
184 + selection.focusNode === endMarker.node &&
185 + selection.focusOffset === endMarker.offset
186 + ) {
187 + return;
188 + }
189 var range = document.createRange();
190 range.setStart(startMarker.node, startMarker.offset);
191 selection.removeAllRanges();
@@ -147,6 +206,9 @@ var ReactDOMSelection = {
206 */
207 getOffsets: getModernOffsets,
208
209 + // For tests.
210 + getModernOffsetsFromPoints: getModernOffsetsFromPoints,
211 +
212 /**
213 * @param {DOMElement|DOMTextNode} node
214 * @param {object} offsets
src/renderers/dom/shared/__tests__/ReactDOMSelection-test.js new
+206
@@ -0,0 +1,206 @@
1 +/**
2 + * Copyright 2013-present, Facebook, Inc.
3 + * All rights reserved.
4 + *
5 + * This source code is licensed under the BSD-style license found in the
6 + * LICENSE file in the root directory of this source tree. An additional grant
7 + * of patent rights can be found in the PATENTS file in the same directory.
8 + *
9 + * @emails react-core
10 + */
11 +
12 +'use strict';
13 +
14 +var React;
15 +var ReactDOM;
16 +var ReactDOMSelection;
17 +var invariant;
18 +
19 +var getModernOffsetsFromPoints;
20 +
21 +describe('ReactDOMSelection', () => {
22 + beforeEach(() => {
23 + React = require('react');
24 + ReactDOM = require('react-dom');
25 + ReactDOMSelection = require('ReactDOMSelection');
26 + invariant = require('invariant');
27 +
28 + ({getModernOffsetsFromPoints} = ReactDOMSelection);
29 + });
30 +
31 + // Simple implementation to compare correctness. React's old implementation of
32 + // this logic used DOM Range objects and is available for manual testing at
33 + // https://gist.github.com/sophiebits/2e6d571f4f10f33b62ea138a6e9c265c.
34 + function simpleModernOffsetsFromPoints(
35 + outerNode,
36 + anchorNode,
37 + anchorOffset,
38 + focusNode,
39 + focusOffset,
40 + ) {
41 + let start;
42 + let end;
43 + let length = 0;
44 +
45 + function traverse(node) {
46 + if (node.nodeType === Node.TEXT_NODE) {
47 + if (node === anchorNode) {
48 + start = length + anchorOffset;
49 + }
50 + if (node === focusNode) {
51 + end = length + focusOffset;
52 + }
53 + length += node.nodeValue.length;
54 + return;
55 + }
56 +
57 + for (let i = 0; true; i++) {
58 + if (node === anchorNode && i === anchorOffset) {
59 + start = length;
60 + }
61 + if (node === focusNode && i === focusOffset) {
62 + end = length;
63 + }
64 + if (i === node.childNodes.length) {
65 + break;
66 + }
67 + let n = node.childNodes[i];
68 + traverse(n);
69 + }
70 + }
71 + traverse(outerNode);
72 +
73 + invariant(
74 + start !== null && end !== null,
75 + 'Provided anchor/focus nodes were outside of root.',
76 + );
77 + return {start, end};
78 + }
79 +
80 + // Complicated example derived from a real-world DOM tree. Has a bit of
81 + // everything.
82 + function getFixture() {
83 + return ReactDOM.render(
84 + <div>
85 + <div>
86 + <div>
87 + <div>xxxxxxxxxxxxxxxxxxxx</div>
88 + </div>
89 + x
90 + <div>
91 + <div>
92 + x
93 + <div>
94 + <div>
95 + <div>xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx</div>
96 + <div />
97 + <div />
98 + <div>xxxxxxxxxxxxxxxxxx</div>
99 + </div>
100 + </div>
101 + </div>
102 + </div>
103 + <div />
104 + </div>
105 + <div>
106 + <div>
107 + <div>
108 + <div>xxxx</div>
109 + <div>xxxxxxxxxxxxxxxxxxx</div>
110 + </div>
111 + </div>
112 + <div>xxx</div>
113 + <div>xxxxx</div>
114 + <div>xxx</div>
115 + <div>
116 + <div>
117 + <div>
118 + <div>{['x', 'x', 'xxx']}</div>
119 + </div>
120 + </div>
121 + </div>
122 + </div>
123 + <div>
124 + <div>xxxxxx</div>
125 + </div>
126 + </div>,
127 + document.createElement('div'),
128 + );
129 + }
130 +
131 + it('returns correctly for base case', () => {
132 + const node = document.createElement('div');
133 + expect(getModernOffsetsFromPoints(node, node, 0, node, 0)).toEqual({
134 + start: 0,
135 + end: 0,
136 + });
137 + expect(simpleModernOffsetsFromPoints(node, node, 0, node, 0)).toEqual({
138 + start: 0,
139 + end: 0,
140 + });
141 + });
142 +
143 + it('returns correctly for fuzz test', () => {
144 + const fixtureRoot = getFixture();
145 + const allNodes = [fixtureRoot].concat(
146 + Array.from(fixtureRoot.querySelectorAll('*')),
147 + );
148 + expect(allNodes.length).toBe(27);
149 + allNodes.slice().forEach(element => {
150 + // Add text nodes.
151 + allNodes.push(
152 + ...Array.from(element.childNodes).filter(n => n.nodeType === 3),
153 + );
154 + });
155 + expect(allNodes.length).toBe(41);
156 +
157 + function randomNode() {
158 + return allNodes[(Math.random() * allNodes.length) | 0];
159 + }
160 + function randomOffset(node) {
161 + return (
162 + (Math.random() *
163 + (1 +
164 + (node.nodeType === 3 ? node.nodeValue : node.childNodes).length)) |
165 + 0
166 + );
167 + }
168 +
169 + for (let i = 0; i < 2000; i++) {
170 + const anchorNode = randomNode();
171 + const anchorOffset = randomOffset(anchorNode);
172 + const focusNode = randomNode();
173 + const focusOffset = randomOffset(focusNode);
174 +
175 + const offsets1 = getModernOffsetsFromPoints(
176 + fixtureRoot,
177 + anchorNode,
178 + anchorOffset,
179 + focusNode,
180 + focusOffset,
181 + );
182 + const offsets2 = simpleModernOffsetsFromPoints(
183 + fixtureRoot,
184 + anchorNode,
185 + anchorOffset,
186 + focusNode,
187 + focusOffset,
188 + );
189 + if (JSON.stringify(offsets1) !== JSON.stringify(offsets2)) {
190 + throw new Error(
191 + JSON.stringify(offsets1) +
192 + ' does not match ' +
193 + JSON.stringify(offsets2) +
194 + ' for anchorNode=allNodes[' +
195 + allNodes.indexOf(anchorNode) +
196 + '], anchorOffset=' +
197 + anchorOffset +
198 + ', focusNode=allNodes[' +
199 + allNodes.indexOf(focusNode) +
200 + '], focusOffset=' +
201 + focusOffset,
202 + );
203 + }
204 + }
205 + });
206 +});