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
+});