From 94b759bad82bde3f45711f60a12c09971d0e023f Mon Sep 17 00:00:00 2001 From: Mihai Parparita Date: Thu, 29 Jun 2017 16:18:28 -0700 Subject: [PATCH] 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: https://github.com/facebook/fbjs/pull/188 https://github.com/facebook/react/pull/7866 https://github.com/facebook/react/pull/7936 https://github.com/facebook/react/pull/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. --- .../dom/client/ReactBrowserEventEmitter.js | 4 +- .../dom/client/ReactCurrentWindow.js | 109 ++++++++++++++++++ src/renderers/dom/client/ReactDOMSelection.js | 16 ++- .../dom/client/ReactInputSelection.js | 4 +- src/renderers/dom/client/ReactMount.js | 2 + .../client/eventPlugins/SelectEventPlugin.js | 9 +- .../getActiveElementForCurrentWindow.js | 36 ++++++ .../shared/reconciler/ReactLastActiveThing.js | 28 +++++ .../shared/reconciler/ReactUpdates.js | 6 + 9 files changed, 203 insertions(+), 11 deletions(-) create mode 100644 src/renderers/dom/client/ReactCurrentWindow.js create mode 100644 src/renderers/dom/client/getActiveElementForCurrentWindow.js create mode 100644 src/renderers/shared/reconciler/ReactLastActiveThing.js diff --git a/src/renderers/dom/client/ReactBrowserEventEmitter.js b/src/renderers/dom/client/ReactBrowserEventEmitter.js index 3477e27088f17..32c46a28d2172 100644 --- a/src/renderers/dom/client/ReactBrowserEventEmitter.js +++ b/src/renderers/dom/client/ReactBrowserEventEmitter.js @@ -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', @@ -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'); diff --git a/src/renderers/dom/client/ReactCurrentWindow.js b/src/renderers/dom/client/ReactCurrentWindow.js new file mode 100644 index 0000000000000..b95ce687d0f67 --- /dev/null +++ b/src/renderers/dom/client/ReactCurrentWindow.js @@ -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; diff --git a/src/renderers/dom/client/ReactDOMSelection.js b/src/renderers/dom/client/ReactDOMSelection.js index b483c2aea71c4..45f911e470eb8 100644 --- a/src/renderers/dom/client/ReactDOMSelection.js +++ b/src/renderers/dom/client/ReactDOMSelection.js @@ -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; @@ -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; @@ -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; @@ -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; @@ -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' ? @@ -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(); diff --git a/src/renderers/dom/client/ReactInputSelection.js b/src/renderers/dom/client/ReactInputSelection.js index af6a184b754fb..8d439732378f6 100644 --- a/src/renderers/dom/client/ReactInputSelection.js +++ b/src/renderers/dom/client/ReactInputSelection.js @@ -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); } /** diff --git a/src/renderers/dom/client/ReactMount.js b/src/renderers/dom/client/ReactMount.js index f9f27fdbffa53..619a24b50bb1e 100644 --- a/src/renderers/dom/client/ReactMount.js +++ b/src/renderers/dom/client/ReactMount.js @@ -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'); @@ -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', diff --git a/src/renderers/dom/client/eventPlugins/SelectEventPlugin.js b/src/renderers/dom/client/eventPlugins/SelectEventPlugin.js index bda881154a5a3..efb7352f26e22 100644 --- a/src/renderers/dom/client/eventPlugins/SelectEventPlugin.js +++ b/src/renderers/dom/client/eventPlugins/SelectEventPlugin.js @@ -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'); @@ -68,14 +68,15 @@ 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, @@ -83,6 +84,8 @@ function getSelection(node) { 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(), diff --git a/src/renderers/dom/client/getActiveElementForCurrentWindow.js b/src/renderers/dom/client/getActiveElementForCurrentWindow.js new file mode 100644 index 0000000000000..bcc46a8f99664 --- /dev/null +++ b/src/renderers/dom/client/getActiveElementForCurrentWindow.js @@ -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; diff --git a/src/renderers/shared/reconciler/ReactLastActiveThing.js b/src/renderers/shared/reconciler/ReactLastActiveThing.js new file mode 100644 index 0000000000000..2d98565d60eff --- /dev/null +++ b/src/renderers/shared/reconciler/ReactLastActiveThing.js @@ -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; diff --git a/src/renderers/shared/reconciler/ReactUpdates.js b/src/renderers/shared/reconciler/ReactUpdates.js index 479488b8edcf9..d32d2420f4c04 100644 --- a/src/renderers/shared/reconciler/ReactUpdates.js +++ b/src/renderers/shared/reconciler/ReactUpdates.js @@ -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'); @@ -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 @@ -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); @@ -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