Skip to content

Commit

Permalink
Make React selection handling be multi-window aware.
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
mihaip committed Jun 29, 2017
1 parent 9b2e063 commit 94b759b
Show file tree
Hide file tree
Showing 9 changed files with 203 additions and 11 deletions.
4 changes: 3 additions & 1 deletion src/renderers/dom/client/ReactBrowserEventEmitter.js
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,8 @@ var ReactBrowserEventEmitter = assign({}, ReactEventEmitterMixin, {
mountAt
);
} else {
// This an IE8 only code path, we don't care about accesing the
// global window.
ReactBrowserEventEmitter.ReactEventListener.trapBubbledEvent(
topLevelTypes.topScroll,
'scroll',
Expand Down Expand Up @@ -356,7 +358,7 @@ var ReactBrowserEventEmitter = assign({}, ReactEventEmitterMixin, {
*
* @see http://www.quirksmode.org/dom/events/scroll.html
*/
ensureScrollValueMonitoring: function(){
ensureScrollValueMonitoring: function() {
if (hasEventPageXY === undefined) {
hasEventPageXY =
document.createEvent && 'pageX' in document.createEvent('MouseEvent');
Expand Down
109 changes: 109 additions & 0 deletions src/renderers/dom/client/ReactCurrentWindow.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/**
* Copyright 2017 Quip
* 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.
*
* @providesModule ReactCurrentWindow
*/

'use strict';

var ReactInstanceMap = require('ReactInstanceMap');
var ReactMount = require('ReactMount');
var ReactLastActiveThing = require('ReactLastActiveThing');

var lastCurrentWindow;

function warn(message) {
if (__DEV__) {
console.warn(message);
}
}

function windowFromNode(node) {
if (node.ownerDocument) {
return node.ownerDocument.defaultView ||
node.ownerDocument.parentWindow;
}
return null;
}

function extractCurrentWindow() {
var thing = ReactLastActiveThing.thing;
if (!thing) {
warn('No active thing.');
return null;
}

// We can't use instanceof checks since the object may be from a different
// window and thus have a different constructor (from a different JS
// context).
if (thing.window === thing) {
// Already a window
return thing;
}

if (typeof thing.nodeType !== 'undefined') {
// DOM node
var nodeParentWindow = windowFromNode(thing);
if (nodeParentWindow) {
return nodeParentWindow;
} else {
warn('Could not determine node parent window.');
return null;
}
}

if (thing.getPublicInstance) {
// Component
var component = thing.getPublicInstance();
if (!component) {
warn('Could not get component public instance.');
return null;
}
if (!ReactInstanceMap.has(component)) {
warn('Component is not in the instance map.');
return null;
}
var componentNode = ReactMount.getNodeFromInstance(component);
if (!componentNode) {
warn('Could not get node from component.');
return null;
}
var componentParentWindow = windowFromNode(componentNode);
if (componentParentWindow) {
return componentParentWindow;
}
warn('Could not determine component node parent window.');
return null;
}

warn('Fallthrough, unexpected active thing type');
return null;
}

var ReactCurrentWindow = {
currentWindow: function() {
if (window.top === window) {
// Fast path for non-frame cases.
return window;
}

var currentWindow = extractCurrentWindow();
if (currentWindow) {
lastCurrentWindow = ReactLastActiveThing.thing = currentWindow;
return currentWindow;
}
if (lastCurrentWindow) {
warn('Could not determine current window, using the last value');
return lastCurrentWindow;
}
warn('Could not determine the current window, using the global value');
return window;
},
};

module.exports = ReactCurrentWindow;
16 changes: 11 additions & 5 deletions src/renderers/dom/client/ReactDOMSelection.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ function isCollapsed(anchorNode, anchorOffset, focusNode, focusOffset) {
* @return {object}
*/
function getIEOffsets(node) {
// This an IE8 only code path, we don't care about accesing the global
// window.
var selection = document.selection;
var selectedRange = selection.createRange();
var selectedLength = selectedRange.text.length;
Expand All @@ -63,7 +65,8 @@ function getIEOffsets(node) {
* @return {?object}
*/
function getModernOffsets(node) {
var selection = window.getSelection && window.getSelection();
var currentWindow = node.ownerDocument.defaultView;
var selection = currentWindow.getSelection && currentWindow.getSelection();

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

// Detect whether the selection is backward.
var detectionRange = document.createRange();
var detectionRange = node.ownerDocument.createRange();
detectionRange.setStart(anchorNode, anchorOffset);
detectionRange.setEnd(focusNode, focusOffset);
var isBackward = detectionRange.collapsed;
Expand All @@ -135,6 +138,8 @@ function getModernOffsets(node) {
* @param {object} offsets
*/
function setIEOffsets(node, offsets) {
// This an IE8 only code path, we don't care about accesing the global
// window.
var range = document.selection.createRange().duplicate();
var start, end;

Expand Down Expand Up @@ -169,11 +174,12 @@ function setIEOffsets(node, offsets) {
* @param {object} offsets
*/
function setModernOffsets(node, offsets) {
if (!window.getSelection) {
var currentWindow = node.ownerDocument.defaultView;
if (!currentWindow.getSelection) {
return;
}

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

if (startMarker && endMarker) {
var range = document.createRange();
var range = node.ownerDocument.createRange();
range.setStart(startMarker.node, startMarker.offset);
selection.removeAllRanges();

Expand Down
4 changes: 2 additions & 2 deletions src/renderers/dom/client/ReactInputSelection.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,10 @@ var ReactDOMSelection = require('ReactDOMSelection');

var containsNode = require('containsNode');
var focusNode = require('focusNode');
var getActiveElement = require('getActiveElement');
var getActiveElement = require('getActiveElementForCurrentWindow');

function isInDocument(node) {
return containsNode(document.documentElement, node);
return node.ownerDocument && containsNode(node.ownerDocument.documentElement, node);
}

/**
Expand Down
2 changes: 2 additions & 0 deletions src/renderers/dom/client/ReactMount.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ var ReactElement = require('ReactElement');
var ReactEmptyComponentRegistry = require('ReactEmptyComponentRegistry');
var ReactInstanceHandles = require('ReactInstanceHandles');
var ReactInstanceMap = require('ReactInstanceMap');
var ReactLastActiveThing = require('ReactLastActiveThing');
var ReactMarkupChecksum = require('ReactMarkupChecksum');
var ReactPerf = require('ReactPerf');
var ReactReconciler = require('ReactReconciler');
Expand Down Expand Up @@ -591,6 +592,7 @@ var ReactMount = {
},

_renderSubtreeIntoContainer: function(parentComponent, nextElement, container, callback) {
ReactLastActiveThing.thing = container;
invariant(
ReactElement.isValidElement(nextElement),
'ReactDOM.render(): Invalid component element.%s',
Expand Down
9 changes: 6 additions & 3 deletions src/renderers/dom/client/eventPlugins/SelectEventPlugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ var ExecutionEnvironment = require('ExecutionEnvironment');
var ReactInputSelection = require('ReactInputSelection');
var SyntheticEvent = require('SyntheticEvent');

var getActiveElement = require('getActiveElement');
var getActiveElement = require('getActiveElementForCurrentWindow');
var isTextInputElement = require('isTextInputElement');
var keyOf = require('keyOf');
var shallowEqual = require('shallowEqual');
Expand Down Expand Up @@ -68,21 +68,24 @@ var ON_SELECT_KEY = keyOf({onSelect: null});
* @return {object}
*/
function getSelection(node) {
var currentWindow = node.ownerDocument.defaultView;
if ('selectionStart' in node &&
ReactInputSelection.hasSelectionCapabilities(node)) {
return {
start: node.selectionStart,
end: node.selectionEnd,
};
} else if (window.getSelection) {
var selection = window.getSelection();
} else if (currentWindow.getSelection) {
var selection = currentWindow.getSelection();
return {
anchorNode: selection.anchorNode,
anchorOffset: selection.anchorOffset,
focusNode: selection.focusNode,
focusOffset: selection.focusOffset,
};
} else if (document.selection) {
// This an IE8 only code path, we don't care about accesing the global
// window.
var range = document.selection.createRange();
return {
parentElement: range.parentElement(),
Expand Down
36 changes: 36 additions & 0 deletions src/renderers/dom/client/getActiveElementForCurrentWindow.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/**
* Copyright Quip 2017
* 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.
*
* @providesModule getActiveElementForCurrentWindow
* @typechecks
*/


/**
* Re-implementation of getActiveElement from fbjs that uses ReactCurrentWindow
* to get the active element in the window that the currently executing component
* is rendered into.
*/
'use strict';

var ReactCurrentWindow = require('ReactCurrentWindow');

function getActiveElement() /*?DOMElement*/{
var currentWindow = ReactCurrentWindow.currentWindow();
var document = currentWindow.document;
if (typeof document === 'undefined') {
return null;
}
try {
return document.activeElement || document.body;
} catch (e) {
return document.body;
}
}

module.exports = getActiveElement;
28 changes: 28 additions & 0 deletions src/renderers/shared/reconciler/ReactLastActiveThing.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/**
* Copyright 2017 Quip
* 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.
*
* @providesModule ReactLastActiveComponent
*/

'use strict';

/**
* Stores a reference to the most recently component DOM container (for the
* initial render) or updated component (for updates). Meant to be used by
* {@code ReactCurrentWindow} to determine the window that components are
* currently being rendered into.
*/
var ReactLastActiveThing = {
/**
* @type {Window|DOMElement|ReactComponent|null}
*/
thing: null,

};

module.exports = ReactLastActiveThing;
6 changes: 6 additions & 0 deletions src/renderers/shared/reconciler/ReactUpdates.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

var CallbackQueue = require('CallbackQueue');
var PooledClass = require('PooledClass');
var ReactLastActiveThing = require('ReactLastActiveThing');
var ReactPerf = require('ReactPerf');
var ReactReconciler = require('ReactReconciler');
var Transaction = require('Transaction');
Expand Down Expand Up @@ -142,6 +143,8 @@ function runBatchedUpdates(transaction) {
// that performUpdateIfNecessary is a noop.
var component = dirtyComponents[i];

ReactLastActiveThing.thing = component;

// If performUpdateIfNecessary happens to enqueue any new updates, we
// shouldn't execute the callbacks until the next render happens, so
// stash the callbacks first
Expand Down Expand Up @@ -171,6 +174,7 @@ var flushBatchedUpdates = function() {
// updates enqueued by setState callbacks and asap calls.
while (dirtyComponents.length || asapEnqueued) {
if (dirtyComponents.length) {
ReactLastActiveThing.thing = dirtyComponents[0];
var transaction = ReactUpdatesFlushTransaction.getPooled();
transaction.perform(runBatchedUpdates, null, transaction);
ReactUpdatesFlushTransaction.release(transaction);
Expand All @@ -196,6 +200,8 @@ flushBatchedUpdates = ReactPerf.measure(
* list of functions which will be executed once the rerender occurs.
*/
function enqueueUpdate(component) {
ReactLastActiveThing.thing = component;

ensureInjected();

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

0 comments on commit 94b759b

Please sign in to comment.