Skip to content

Commit 94b759b

Browse files
committed
Make React selection handling be multi-window aware.
React generally handles being rendered into another window context correctly (we have been doing this for a while in native Mac popovers). The main place where there are global window/document accesses are in places where we deal with the DOM selection (window.getSelection() and document.activeElement). There has been some discussion about this in the public React GitHub repo: facebook/fbjs#188 facebook#7866 facebook#7936 facebook#9184 While this was a good starting point, those proposed changes did not go far enough, since they assumed that React was executing in the top-most window, and the focus was in a child frame (in the same origin). Thus for them it was possible to check document.activeElement in the top window, find which iframe had focus and then recurse into it. In our case, the controller and view frames are siblings, and the top window is in another origin, so we can't use that code path. The main reason why we can't get the current window/document is that ReactInputSelection runs as a transaction wrapper, which doesn't have access to components or DOM nodes (and may run across multiple nodes). To work around this I added a ReactLastActiveThing which keeps track of the last DOM node that we mounted a component into (for the initial render) or the last component that we updated (for re-renders). It's kind of gross, but I couldn't think of any better alternatives. All of the modifications are no-ops when not running inside a frame, so this should have no impact for non-elements uses. I did not update any of the IE8 selection API code paths, we don't support it.
1 parent 9b2e063 commit 94b759b

File tree

9 files changed

+203
-11
lines changed

9 files changed

+203
-11
lines changed

src/renderers/dom/client/ReactBrowserEventEmitter.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,8 @@ var ReactBrowserEventEmitter = assign({}, ReactEventEmitterMixin, {
278278
mountAt
279279
);
280280
} else {
281+
// This an IE8 only code path, we don't care about accesing the
282+
// global window.
281283
ReactBrowserEventEmitter.ReactEventListener.trapBubbledEvent(
282284
topLevelTypes.topScroll,
283285
'scroll',
@@ -356,7 +358,7 @@ var ReactBrowserEventEmitter = assign({}, ReactEventEmitterMixin, {
356358
*
357359
* @see http://www.quirksmode.org/dom/events/scroll.html
358360
*/
359-
ensureScrollValueMonitoring: function(){
361+
ensureScrollValueMonitoring: function() {
360362
if (hasEventPageXY === undefined) {
361363
hasEventPageXY =
362364
document.createEvent && 'pageX' in document.createEvent('MouseEvent');
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
/**
2+
* Copyright 2017 Quip
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+
* @providesModule ReactCurrentWindow
10+
*/
11+
12+
'use strict';
13+
14+
var ReactInstanceMap = require('ReactInstanceMap');
15+
var ReactMount = require('ReactMount');
16+
var ReactLastActiveThing = require('ReactLastActiveThing');
17+
18+
var lastCurrentWindow;
19+
20+
function warn(message) {
21+
if (__DEV__) {
22+
console.warn(message);
23+
}
24+
}
25+
26+
function windowFromNode(node) {
27+
if (node.ownerDocument) {
28+
return node.ownerDocument.defaultView ||
29+
node.ownerDocument.parentWindow;
30+
}
31+
return null;
32+
}
33+
34+
function extractCurrentWindow() {
35+
var thing = ReactLastActiveThing.thing;
36+
if (!thing) {
37+
warn('No active thing.');
38+
return null;
39+
}
40+
41+
// We can't use instanceof checks since the object may be from a different
42+
// window and thus have a different constructor (from a different JS
43+
// context).
44+
if (thing.window === thing) {
45+
// Already a window
46+
return thing;
47+
}
48+
49+
if (typeof thing.nodeType !== 'undefined') {
50+
// DOM node
51+
var nodeParentWindow = windowFromNode(thing);
52+
if (nodeParentWindow) {
53+
return nodeParentWindow;
54+
} else {
55+
warn('Could not determine node parent window.');
56+
return null;
57+
}
58+
}
59+
60+
if (thing.getPublicInstance) {
61+
// Component
62+
var component = thing.getPublicInstance();
63+
if (!component) {
64+
warn('Could not get component public instance.');
65+
return null;
66+
}
67+
if (!ReactInstanceMap.has(component)) {
68+
warn('Component is not in the instance map.');
69+
return null;
70+
}
71+
var componentNode = ReactMount.getNodeFromInstance(component);
72+
if (!componentNode) {
73+
warn('Could not get node from component.');
74+
return null;
75+
}
76+
var componentParentWindow = windowFromNode(componentNode);
77+
if (componentParentWindow) {
78+
return componentParentWindow;
79+
}
80+
warn('Could not determine component node parent window.');
81+
return null;
82+
}
83+
84+
warn('Fallthrough, unexpected active thing type');
85+
return null;
86+
}
87+
88+
var ReactCurrentWindow = {
89+
currentWindow: function() {
90+
if (window.top === window) {
91+
// Fast path for non-frame cases.
92+
return window;
93+
}
94+
95+
var currentWindow = extractCurrentWindow();
96+
if (currentWindow) {
97+
lastCurrentWindow = ReactLastActiveThing.thing = currentWindow;
98+
return currentWindow;
99+
}
100+
if (lastCurrentWindow) {
101+
warn('Could not determine current window, using the last value');
102+
return lastCurrentWindow;
103+
}
104+
warn('Could not determine the current window, using the global value');
105+
return window;
106+
},
107+
};
108+
109+
module.exports = ReactCurrentWindow;

src/renderers/dom/client/ReactDOMSelection.js

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ function isCollapsed(anchorNode, anchorOffset, focusNode, focusOffset) {
4040
* @return {object}
4141
*/
4242
function getIEOffsets(node) {
43+
// This an IE8 only code path, we don't care about accesing the global
44+
// window.
4345
var selection = document.selection;
4446
var selectedRange = selection.createRange();
4547
var selectedLength = selectedRange.text.length;
@@ -63,7 +65,8 @@ function getIEOffsets(node) {
6365
* @return {?object}
6466
*/
6567
function getModernOffsets(node) {
66-
var selection = window.getSelection && window.getSelection();
68+
var currentWindow = node.ownerDocument.defaultView;
69+
var selection = currentWindow.getSelection && currentWindow.getSelection();
6770

6871
if (!selection || selection.rangeCount === 0) {
6972
return null;
@@ -119,7 +122,7 @@ function getModernOffsets(node) {
119122
var end = start + rangeLength;
120123

121124
// Detect whether the selection is backward.
122-
var detectionRange = document.createRange();
125+
var detectionRange = node.ownerDocument.createRange();
123126
detectionRange.setStart(anchorNode, anchorOffset);
124127
detectionRange.setEnd(focusNode, focusOffset);
125128
var isBackward = detectionRange.collapsed;
@@ -135,6 +138,8 @@ function getModernOffsets(node) {
135138
* @param {object} offsets
136139
*/
137140
function setIEOffsets(node, offsets) {
141+
// This an IE8 only code path, we don't care about accesing the global
142+
// window.
138143
var range = document.selection.createRange().duplicate();
139144
var start, end;
140145

@@ -169,11 +174,12 @@ function setIEOffsets(node, offsets) {
169174
* @param {object} offsets
170175
*/
171176
function setModernOffsets(node, offsets) {
172-
if (!window.getSelection) {
177+
var currentWindow = node.ownerDocument.defaultView;
178+
if (!currentWindow.getSelection) {
173179
return;
174180
}
175181

176-
var selection = window.getSelection();
182+
var selection = currentWindow.getSelection();
177183
var length = node[getTextContentAccessor()].length;
178184
var start = Math.min(offsets.start, length);
179185
var end = typeof offsets.end === 'undefined' ?
@@ -191,7 +197,7 @@ function setModernOffsets(node, offsets) {
191197
var endMarker = getNodeForCharacterOffset(node, end);
192198

193199
if (startMarker && endMarker) {
194-
var range = document.createRange();
200+
var range = node.ownerDocument.createRange();
195201
range.setStart(startMarker.node, startMarker.offset);
196202
selection.removeAllRanges();
197203

src/renderers/dom/client/ReactInputSelection.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,10 @@ var ReactDOMSelection = require('ReactDOMSelection');
1515

1616
var containsNode = require('containsNode');
1717
var focusNode = require('focusNode');
18-
var getActiveElement = require('getActiveElement');
18+
var getActiveElement = require('getActiveElementForCurrentWindow');
1919

2020
function isInDocument(node) {
21-
return containsNode(document.documentElement, node);
21+
return node.ownerDocument && containsNode(node.ownerDocument.documentElement, node);
2222
}
2323

2424
/**

src/renderers/dom/client/ReactMount.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ var ReactElement = require('ReactElement');
1919
var ReactEmptyComponentRegistry = require('ReactEmptyComponentRegistry');
2020
var ReactInstanceHandles = require('ReactInstanceHandles');
2121
var ReactInstanceMap = require('ReactInstanceMap');
22+
var ReactLastActiveThing = require('ReactLastActiveThing');
2223
var ReactMarkupChecksum = require('ReactMarkupChecksum');
2324
var ReactPerf = require('ReactPerf');
2425
var ReactReconciler = require('ReactReconciler');
@@ -591,6 +592,7 @@ var ReactMount = {
591592
},
592593

593594
_renderSubtreeIntoContainer: function(parentComponent, nextElement, container, callback) {
595+
ReactLastActiveThing.thing = container;
594596
invariant(
595597
ReactElement.isValidElement(nextElement),
596598
'ReactDOM.render(): Invalid component element.%s',

src/renderers/dom/client/eventPlugins/SelectEventPlugin.js

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ var ExecutionEnvironment = require('ExecutionEnvironment');
1717
var ReactInputSelection = require('ReactInputSelection');
1818
var SyntheticEvent = require('SyntheticEvent');
1919

20-
var getActiveElement = require('getActiveElement');
20+
var getActiveElement = require('getActiveElementForCurrentWindow');
2121
var isTextInputElement = require('isTextInputElement');
2222
var keyOf = require('keyOf');
2323
var shallowEqual = require('shallowEqual');
@@ -68,21 +68,24 @@ var ON_SELECT_KEY = keyOf({onSelect: null});
6868
* @return {object}
6969
*/
7070
function getSelection(node) {
71+
var currentWindow = node.ownerDocument.defaultView;
7172
if ('selectionStart' in node &&
7273
ReactInputSelection.hasSelectionCapabilities(node)) {
7374
return {
7475
start: node.selectionStart,
7576
end: node.selectionEnd,
7677
};
77-
} else if (window.getSelection) {
78-
var selection = window.getSelection();
78+
} else if (currentWindow.getSelection) {
79+
var selection = currentWindow.getSelection();
7980
return {
8081
anchorNode: selection.anchorNode,
8182
anchorOffset: selection.anchorOffset,
8283
focusNode: selection.focusNode,
8384
focusOffset: selection.focusOffset,
8485
};
8586
} else if (document.selection) {
87+
// This an IE8 only code path, we don't care about accesing the global
88+
// window.
8689
var range = document.selection.createRange();
8790
return {
8891
parentElement: range.parentElement(),
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/**
2+
* Copyright Quip 2017
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+
* @providesModule getActiveElementForCurrentWindow
10+
* @typechecks
11+
*/
12+
13+
14+
/**
15+
* Re-implementation of getActiveElement from fbjs that uses ReactCurrentWindow
16+
* to get the active element in the window that the currently executing component
17+
* is rendered into.
18+
*/
19+
'use strict';
20+
21+
var ReactCurrentWindow = require('ReactCurrentWindow');
22+
23+
function getActiveElement() /*?DOMElement*/{
24+
var currentWindow = ReactCurrentWindow.currentWindow();
25+
var document = currentWindow.document;
26+
if (typeof document === 'undefined') {
27+
return null;
28+
}
29+
try {
30+
return document.activeElement || document.body;
31+
} catch (e) {
32+
return document.body;
33+
}
34+
}
35+
36+
module.exports = getActiveElement;
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/**
2+
* Copyright 2017 Quip
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+
* @providesModule ReactLastActiveComponent
10+
*/
11+
12+
'use strict';
13+
14+
/**
15+
* Stores a reference to the most recently component DOM container (for the
16+
* initial render) or updated component (for updates). Meant to be used by
17+
* {@code ReactCurrentWindow} to determine the window that components are
18+
* currently being rendered into.
19+
*/
20+
var ReactLastActiveThing = {
21+
/**
22+
* @type {Window|DOMElement|ReactComponent|null}
23+
*/
24+
thing: null,
25+
26+
};
27+
28+
module.exports = ReactLastActiveThing;

src/renderers/shared/reconciler/ReactUpdates.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
var CallbackQueue = require('CallbackQueue');
1515
var PooledClass = require('PooledClass');
16+
var ReactLastActiveThing = require('ReactLastActiveThing');
1617
var ReactPerf = require('ReactPerf');
1718
var ReactReconciler = require('ReactReconciler');
1819
var Transaction = require('Transaction');
@@ -142,6 +143,8 @@ function runBatchedUpdates(transaction) {
142143
// that performUpdateIfNecessary is a noop.
143144
var component = dirtyComponents[i];
144145

146+
ReactLastActiveThing.thing = component;
147+
145148
// If performUpdateIfNecessary happens to enqueue any new updates, we
146149
// shouldn't execute the callbacks until the next render happens, so
147150
// stash the callbacks first
@@ -171,6 +174,7 @@ var flushBatchedUpdates = function() {
171174
// updates enqueued by setState callbacks and asap calls.
172175
while (dirtyComponents.length || asapEnqueued) {
173176
if (dirtyComponents.length) {
177+
ReactLastActiveThing.thing = dirtyComponents[0];
174178
var transaction = ReactUpdatesFlushTransaction.getPooled();
175179
transaction.perform(runBatchedUpdates, null, transaction);
176180
ReactUpdatesFlushTransaction.release(transaction);
@@ -196,6 +200,8 @@ flushBatchedUpdates = ReactPerf.measure(
196200
* list of functions which will be executed once the rerender occurs.
197201
*/
198202
function enqueueUpdate(component) {
203+
ReactLastActiveThing.thing = component;
204+
199205
ensureInjected();
200206

201207
// Various parts of our code (such as ReactCompositeComponent's

0 commit comments

Comments
 (0)