@samitouri / QOS-React-2 / commits / b565f49531

Minimally support iframes (nested browsing contexts) in selection event handling (#12037)

* Prefer node’s window and document over globals * Support active elements in nested browsing contexts * Avoid invoking defaultView getter unnecessarily * Prefer node’s window and document over globals * Support active elements in nested browsing contexts * Avoid invoking defaultView getter unnecessarily * Implement selection event fixtures * Prefer node’s window and document over globals * Avoid invoking defaultView getter unnecessarily * Fix react-scripts to work with alphas after 16.0.0 The current logic just checks if the version is an alpha with a major version of 16 to account for weirdness with the 16 RC releases, but now we have alphas for newer minor releases that don't have weirdness * Run prettier on new selection events fixtures * Add fixture for onSelect in iframes, remove DraftJS fixture The DraftJs fixture wasn't really working in all supported browsers anyways, so just drop it and try to cover our bases without using it directly * Purge remnants of draft.js from fixtures * Use prop-types import instead of window global * Make fixtures’ Iframe component Firefox-compatible * Fix switch case for SelectionEventsFixture * Remove draft.js / immutable.js dependencies * Cache owner doc as var to avoid reading it twice * Add documentation for getActiveElementDeep to explain try/catch Add documentation for getActiveElementDeep to explain try/catch * Ensure getActiveElement always returns DOM element * Tighten up isNode and isTextNode * Remove ie8 compatibility * Specify cross-origin example in getActiveElementDeep * Revert back to returning null if document is not defined

Andrew Patton committed Aug 2, 2018 at 15:23 UTC b565f495319750d98628425d120312997bee410b
13 files changed +253 -41
fixtures/dom/package.json
+1 -1
@@ -12,7 +12,7 @@
12 "query-string": "^4.2.3",
13 "react": "^15.4.1",
14 "react-dom": "^15.4.1",
15 - "semver": "^5.3.0"
15 + "semver": "^5.5.0"
16 },
17 "scripts": {
18 "start": "react-scripts start",
fixtures/dom/src/components/Fixture.js
+1 -1
@@ -1,4 +1,4 @@
1 -const PropTypes = window.PropTypes;
1 +import PropTypes from 'prop-types';
2 const React = window.React;
3
4 const propTypes = {
fixtures/dom/src/components/Header.js
+1
@@ -66,6 +66,7 @@ class Header extends React.Component {
66 <option value="/media-events">Media Events</option>
67 <option value="/pointer-events">Pointer Events</option>
68 <option value="/mouse-events">Mouse Events</option>
69 + <option value="/selection-events">Selection Events</option>
70 </select>
71 </label>
72 <label htmlFor="react_version">
fixtures/dom/src/components/Iframe.js new
+57
@@ -0,0 +1,57 @@
1 +const React = window.React;
2 +const ReactDOM = window.ReactDOM;
3 +
4 +class IframePortal extends React.Component {
5 + iframeRef = null;
6 +
7 + handleRef = ref => {
8 + if (ref !== this.iframeRef) {
9 + this.iframeRef = ref;
10 + if (ref) {
11 + if (ref.contentDocument && this.props.head) {
12 + ref.contentDocument.head.innerHTML = this.props.head;
13 + }
14 + // Re-render must take place in the next tick (Firefox)
15 + setTimeout(() => {
16 + this.forceUpdate();
17 + });
18 + }
19 + }
20 + };
21 +
22 + render() {
23 + const ref = this.iframeRef;
24 + let portal = null;
25 + if (ref && ref.contentDocument) {
26 + portal = ReactDOM.createPortal(
27 + this.props.children,
28 + ref.contentDocument.body
29 + );
30 + }
31 +
32 + return (
33 + <div>
34 + <iframe
35 + style={{border: 'none', height: this.props.height}}
36 + ref={this.handleRef}
37 + />
38 + {portal}
39 + </div>
40 + );
41 + }
42 +}
43 +
44 +class IframeSubtree extends React.Component {
45 + warned = false;
46 + render() {
47 + if (!this.warned) {
48 + console.error(
49 + `IFrame has not yet been implemented for React v${React.version}`
50 + );
51 + this.warned = true;
52 + }
53 + return <div>{this.props.children}</div>;
54 + }
55 +}
56 +
57 +export default (ReactDOM.createPortal ? IframePortal : IframeSubtree);
fixtures/dom/src/components/fixtures/index.js
+3
@@ -13,6 +13,7 @@ import CustomElementFixtures from './custom-elements';
13 import MediaEventsFixtures from './media-events';
14 import PointerEventsFixtures from './pointer-events';
15 import MouseEventsFixtures from './mouse-events';
16 +import SelectionEventsFixtures from './selection-events';
17
18 const React = window.React;
19
@@ -52,6 +53,8 @@ function FixturesPage() {
53 return <PointerEventsFixtures />;
54 case '/mouse-events':
55 return <MouseEventsFixtures />;
56 + case '/selection-events':
57 + return <SelectionEventsFixtures />;
58 default:
59 return <p>Please select a test fixture.</p>;
60 }
fixtures/dom/src/components/fixtures/selection-events/OnSelectEventTestCase.js new
+50
@@ -0,0 +1,50 @@
1 +import TestCase from '../../TestCase';
2 +import Iframe from '../../Iframe';
3 +const React = window.React;
4 +
5 +class OnSelectIframe extends React.Component {
6 + state = {count: 0, value: 'Select Me!'};
7 +
8 + _onSelect = event => {
9 + this.setState(({count}) => ({count: count + 1}));
10 + };
11 +
12 + _onChange = event => {
13 + this.setState({value: event.target.value});
14 + };
15 +
16 + render() {
17 + const {count, value} = this.state;
18 + return (
19 + <Iframe height={60}>
20 + Selection Event Count: {count}
21 + <input
22 + type="text"
23 + onSelect={this._onSelect}
24 + value={value}
25 + onChange={this._onChange}
26 + />
27 + </Iframe>
28 + );
29 + }
30 +}
31 +
32 +export default class OnSelectEventTestCase extends React.Component {
33 + render() {
34 + return (
35 + <TestCase
36 + title="onSelect events within iframes"
37 + description="onSelect events should fire for elements rendered inside iframes">
38 + <TestCase.Steps>
39 + <li>Highlight some of the text in the input below</li>
40 + <li>Move the cursor around using the arrow keys</li>
41 + </TestCase.Steps>
42 + <TestCase.ExpectedResult>
43 + The displayed count should increase as you highlight or move the
44 + cursor
45 + </TestCase.ExpectedResult>
46 + <OnSelectIframe />
47 + </TestCase>
48 + );
49 + }
50 +}
fixtures/dom/src/components/fixtures/selection-events/ReorderedInputsTestCase.js new
+44
@@ -0,0 +1,44 @@
1 +import TestCase from '../../TestCase';
2 +import Iframe from '../../Iframe';
3 +const React = window.React;
4 +
5 +export default class ReorderedInputsTestCase extends React.Component {
6 + state = {count: 0};
7 +
8 + componentDidMount() {
9 + this.interval = setInterval(() => {
10 + this.setState({count: this.state.count + 1});
11 + }, 2000);
12 + }
13 +
14 + componentWillUnmount() {
15 + clearInterval(this.interval);
16 + }
17 +
18 + renderInputs() {
19 + const inputs = [
20 + <input key={1} defaultValue="Foo" />,
21 + <input key={2} defaultValue="Bar" />,
22 + ];
23 + if (this.state.count % 2 === 0) {
24 + inputs.reverse();
25 + }
26 + return inputs;
27 + }
28 +
29 + render() {
30 + return (
31 + <TestCase title="Reordered input elements in iframes" description="">
32 + <TestCase.Steps>
33 + <li>The two inputs below swap positions every two seconds</li>
34 + <li>Select the text in either of them</li>
35 + <li>Wait for the swap to occur</li>
36 + </TestCase.Steps>
37 + <TestCase.ExpectedResult>
38 + The selection you made should be maintained
39 + </TestCase.ExpectedResult>
40 + <Iframe height={50}>{this.renderInputs()}</Iframe>
41 + </TestCase>
42 + );
43 + }
44 +}
fixtures/dom/src/components/fixtures/selection-events/index.js new
+19
@@ -0,0 +1,19 @@
1 +import FixtureSet from '../../FixtureSet';
2 +import ReorderedInputsTestCase from './ReorderedInputsTestCase';
3 +import OnSelectEventTestCase from './OnSelectEventTestCase';
4 +const React = window.React;
5 +
6 +export default function SelectionEvents() {
7 + return (
8 + <FixtureSet
9 + title="Selection Restoration"
10 + description="
11 + When React commits changes it may perform operations which cause existing
12 + selection state to be lost. This is manually managed by reading the
13 + selection state before commits and then restoring it afterwards.
14 + ">
15 + <ReorderedInputsTestCase />
16 + <OnSelectEventTestCase />
17 + </FixtureSet>
18 + );
19 +}
fixtures/dom/src/react-loader.js
+5 -1
@@ -1,3 +1,5 @@
1 +import semver from 'semver';
2 +
3 /**
4 * Take a version from the window query string and load a specific
5 * version of React.
@@ -42,9 +44,11 @@ export default function loadReact() {
44 let version = query.version || 'local';
45
46 if (version !== 'local') {
47 + const {major, minor, prerelease} = semver(version);
48 + const [preReleaseStage, preReleaseVersion] = prerelease;
49 // The file structure was updated in 16. This wasn't the case for alphas.
50 // Load the old module location for anything less than 16 RC
47 - if (parseInt(version, 10) >= 16 && version.indexOf('alpha') < 0) {
51 + if (major >= 16 && !(minor === 0 && preReleaseStage === 'alpha')) {
52 REACT_PATH =
53 'https://unpkg.com/react@' + version + '/umd/react.development.js';
54 DOM_PATH =
fixtures/dom/yarn.lock
+17 -1
@@ -2153,6 +2153,14 @@ dotenv@4.0.0:
2153 version "4.0.0"
2154 resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-4.0.0.tgz#864ef1379aced55ce6f95debecdce179f7a0cd1d"
2155
2156 +draft-js@^0.10.5:
2157 + version "0.10.5"
2158 + resolved "https://registry.yarnpkg.com/draft-js/-/draft-js-0.10.5.tgz#bfa9beb018fe0533dbb08d6675c371a6b08fa742"
2159 + dependencies:
2160 + fbjs "^0.8.15"
2161 + immutable "~3.7.4"
2162 + object-assign "^4.1.0"
2163 +
2164 duplexer2@^0.1.4:
2165 version "0.1.4"
2166 resolved "https://registry.yarnpkg.com/duplexer2/-/duplexer2-0.1.4.tgz#8b12dab878c0d69e3e7891051662a32fc6bddcc1"
@@ -2672,7 +2680,7 @@ fbjs@^0.8.1, fbjs@^0.8.4:
2680 setimmediate "^1.0.5"
2681 ua-parser-js "^0.7.9"
2682
2675 -fbjs@^0.8.16:
2683 +fbjs@^0.8.15, fbjs@^0.8.16:
2684 version "0.8.16"
2685 resolved "https://registry.yarnpkg.com/fbjs/-/fbjs-0.8.16.tgz#5e67432f550dc41b572bf55847b8aca64e5337db"
2686 dependencies:
@@ -3302,6 +3310,10 @@ ignore@^3.3.3:
3310 version "3.3.3"
3311 resolved "https://registry.yarnpkg.com/ignore/-/ignore-3.3.3.tgz#432352e57accd87ab3110e82d3fea0e47812156d"
3312
3313 +immutable@~3.7.4:
3314 + version "3.7.6"
3315 + resolved "https://registry.yarnpkg.com/immutable/-/immutable-3.7.6.tgz#13b4d3cb12befa15482a26fe1b2ebae640071e4b"
3316 +
3317 imurmurhash@^0.1.4:
3318 version "0.1.4"
3319 resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea"
@@ -5878,6 +5890,10 @@ semver@^5.0.3:
5890 version "5.4.1"
5891 resolved "https://registry.yarnpkg.com/semver/-/semver-5.4.1.tgz#e059c09d8571f0540823733433505d3a2f00b18e"
5892
5893 +semver@^5.5.0:
5894 + version "5.5.0"
5895 + resolved "https://registry.yarnpkg.com/semver/-/semver-5.5.0.tgz#dc4bbc7a6ca9d916dee5d43516f0092b58f7b8ab"
5896 +
5897 send@0.14.1:
5898 version "0.14.1"
5899 resolved "https://registry.yarnpkg.com/send/-/send-0.14.1.tgz#a954984325392f51532a7760760e459598c89f7a"
packages/react-dom/src/client/ReactDOMSelection.js
+7 -7
@@ -14,7 +14,9 @@ import {TEXT_NODE} from '../shared/HTMLNodeType';
14 * @return {?object}
15 */
16 export function getOffsets(outerNode) {
17 - const selection = window.getSelection && window.getSelection();
17 + const {ownerDocument} = outerNode;
18 + const win = (ownerDocument && ownerDocument.defaultView) || window;
19 + const selection = win.getSelection && win.getSelection();
20
21 if (!selection || selection.rangeCount === 0) {
22 return null;
@@ -150,11 +152,9 @@ export function getModernOffsetsFromPoints(
152 * @param {object} offsets
153 */
154 export function setOffsets(node, offsets) {
153 - if (!window.getSelection) {
154 - return;
155 - }
156 -
157 - const selection = window.getSelection();
155 + const doc = node.ownerDocument || document;
156 + const win = doc ? doc.defaultView : window;
157 + const selection = win.getSelection();
158 const length = node[getTextContentAccessor()].length;
159 let start = Math.min(offsets.start, length);
160 let end = offsets.end === undefined ? start : Math.min(offsets.end, length);
@@ -180,7 +180,7 @@ export function setOffsets(node, offsets) {
180 ) {
181 return;
182 }
183 - const range = document.createRange();
183 + const range = doc.createRange();
184 range.setStart(startMarker.node, startMarker.offset);
185 selection.removeAllRanges();
186
packages/react-dom/src/client/ReactInputSelection.js
+25 -21
@@ -10,24 +10,8 @@ import getActiveElement from './getActiveElement';
10 import * as ReactDOMSelection from './ReactDOMSelection';
11 import {ELEMENT_NODE, TEXT_NODE} from '../shared/HTMLNodeType';
12
13 -// TODO: this code is originally inlined from fbjs.
14 -// It is likely that we don't actually need all these checks
15 -// for the particular use case in this file.
16 -function isNode(object) {
17 - const doc = object ? object.ownerDocument || object : document;
18 - const defaultView = doc.defaultView || window;
19 - return !!(
20 - object &&
21 - (typeof defaultView.Node === 'function'
22 - ? object instanceof defaultView.Node
23 - : typeof object === 'object' &&
24 - typeof object.nodeType === 'number' &&
25 - typeof object.nodeName === 'string')
26 - );
27 -}
28 -
29 -function isTextNode(object) {
30 - return isNode(object) && object.nodeType === TEXT_NODE;
13 +function isTextNode(node) {
14 + return node && node.nodeType === TEXT_NODE;
15 }
16
17 function containsNode(outerNode, innerNode) {
@@ -49,7 +33,27 @@ function containsNode(outerNode, innerNode) {
33 }
34
35 function isInDocument(node) {
52 - return containsNode(document.documentElement, node);
36 + return (
37 + node &&
38 + node.ownerDocument &&
39 + containsNode(node.ownerDocument.documentElement, node)
40 + );
41 +}
42 +
43 +function getActiveElementDeep() {
44 + let win = window;
45 + let element = getActiveElement();
46 + while (element instanceof win.HTMLIFrameElement) {
47 + // Accessing the contentDocument of a HTMLIframeElement can cause the browser
48 + // to throw, e.g. if it has a cross-origin src attribute
49 + try {
50 + win = element.contentDocument.defaultView;
51 + } catch (e) {
52 + return element;
53 + }
54 + element = getActiveElement(win.document);
55 + }
56 + return element;
57 }
58
59 /**
@@ -80,7 +84,7 @@ export function hasSelectionCapabilities(elem) {
84 }
85
86 export function getSelectionInformation() {
83 - const focusedElem = getActiveElement();
87 + const focusedElem = getActiveElementDeep();
88 return {
89 focusedElem: focusedElem,
90 selectionRange: hasSelectionCapabilities(focusedElem)
@@ -95,7 +99,7 @@ export function getSelectionInformation() {
99 * nodes and place them back in, resulting in focus being lost.
100 */
101 export function restoreSelection(priorSelectionInformation) {
98 - const curFocusedElem = getActiveElement();
102 + const curFocusedElem = getActiveElementDeep();
103 const priorFocusedElem = priorSelectionInformation.focusedElem;
104 const priorSelectionRange = priorSelectionInformation.selectionRange;
105 if (curFocusedElem !== priorFocusedElem && isInDocument(priorFocusedElem)) {
packages/react-dom/src/events/SelectEventPlugin.js
+23 -9
@@ -72,8 +72,10 @@ function getSelection(node) {
72 start: node.selectionStart,
73 end: node.selectionEnd,
74 };
75 - } else if (window.getSelection) {
76 - const selection = window.getSelection();
75 + } else {
76 + const win =
77 + (node.ownerDocument && node.ownerDocument.defaultView) || window;
78 + const selection = win.getSelection();
79 return {
80 anchorNode: selection.anchorNode,
81 anchorOffset: selection.anchorOffset,
@@ -83,10 +85,25 @@ function getSelection(node) {
85 }
86 }
87
88 +/**
89 + * Get document associated with the event target.
90 + *
91 + * @param {object} nativeEventTarget
92 + * @return {Document}
93 + */
94 +function getEventTargetDocument(eventTarget) {
95 + return eventTarget.window === eventTarget
96 + ? eventTarget.document
97 + : eventTarget.nodeType === DOCUMENT_NODE
98 + ? eventTarget
99 + : eventTarget.ownerDocument;
100 +}
101 +
102 /**
103 * Poll selection to see whether it's changed.
104 *
105 * @param {object} nativeEvent
106 + * @param {object} nativeEventTarget
107 * @return {?SyntheticEvent}
108 */
109 function constructSelectEvent(nativeEvent, nativeEventTarget) {
@@ -94,10 +111,12 @@ function constructSelectEvent(nativeEvent, nativeEventTarget) {
111 // selection (this matches native `select` event behavior). In HTML5, select
112 // fires only on input and textarea thus if there's no focused element we
113 // won't dispatch.
114 + const doc = getEventTargetDocument(nativeEventTarget);
115 +
116 if (
117 mouseDown ||
118 activeElement == null ||
100 - activeElement !== getActiveElement()
119 + activeElement !== getActiveElement(doc)
120 ) {
121 return null;
122 }
@@ -148,12 +167,7 @@ const SelectEventPlugin = {
167 nativeEvent,
168 nativeEventTarget,
169 ) {
151 - const doc =
152 - nativeEventTarget.window === nativeEventTarget
153 - ? nativeEventTarget.document
154 - : nativeEventTarget.nodeType === DOCUMENT_NODE
155 - ? nativeEventTarget
156 - : nativeEventTarget.ownerDocument;
170 + const doc = getEventTargetDocument(nativeEventTarget);
171 // Track whether all listeners exists for this plugin. If none exist, we do
172 // not extract events. See #3639.
173 if (!doc || !isListeningToAllDependencies('onSelect', doc)) {