Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support iframes (nested browsing contexts) in selection event handling #9184

Closed
wants to merge 24 commits into from

Conversation

acusti
Copy link
Contributor

@acusti acusti commented Mar 15, 2017

I tried to rebase #7936 with latest master and everything was thoroughly screwed up. So rather than trying to merge master, I just made a new clean branch off master and cherry-picked the commits over. I’m not closing the original PR at this point, however, because there seem to be many interested people following the activity over there, and I don’t want to prevent them from doing so. I’ll make new built versions of everything off of these commits applied to 15-stable and update the npm / yarn instructions on using those when I get a chance.

I’ve also made a commit with a couple guards to accommodate varying cross-browser behaviors. The guards are for #7936 (comment) and summernote/summernote#1057

@acusti
Copy link
Contributor Author

acusti commented May 4, 2017

Just fixed an issue that resulted from me incompletely recreating the changes of #7936 when I originally created this new PR. Then I rebased with latest master.

The CircleCI build has 5 React DOM test failures:

 FAIL  src/renderers/dom/shared/__tests__/ReactDOMServerIntegration-test.js (38.268s)
 FAIL  src/renderers/dom/shared/__tests__/ReactDOMComponent-test.js (6.354s)
...
 FAIL  src/renderers/dom/shared/__tests__/ReactMount-test.js
...
 FAIL  src/renderers/dom/shared/__tests__/ReactRenderDocument-test.js
...
 FAIL  src/renderers/dom/shared/__tests__/ReactDOMTextComponent-test.js

First failure has a lot of output related to console.log along the lines of:

    console.log src/renderers/dom/shared/__tests__/ReactDOMServerIntegration-test.js:45
      We expected 2 warning(s), but saw 1 warning(s).

The other failures have no info in the test output. I’m at a loss as to what’s causing the failures in CI. I ran the tests locally and each one of those tests listed above passed. @spicyj Any suggestions or intuitions about the cause?

mihaip added a commit to quip/react that referenced this pull request Jun 29, 2017
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.
@nhunzaker
Copy link
Contributor

I tried to rebase #7936 with latest master and everything was thoroughly screwed up.

@acusti Haha, I know that feeling... Thank you so much for doing all of this work and sticking with us.

I'm working through this PR now. In the mean time: I had a couple of questions:

  1. Is it safe to close out Support iframes (nested browsing contexts) in selection event handling #7866 and Support iframes (nested browsing contexts) in selection event handling (stale version) #7936?
  2. CI is only failing because of some formatting. Could you run yarn prettier on this branch and send up the result?

On my end, I need to follow up on the conversation in the prior PRs, some of the associated issues linked on projects, and better understand how React input selection restoration works.

I promise to respond in no later than 2 days, even if it's just to say where I'm at.

@acusti
Copy link
Contributor Author

acusti commented Aug 21, 2017

Just got back from vacation and saw this. Really glad to hear you're gonna dig into this, @nhunzaker! I'll try to update more thoroughly once I'm settled. However, I actually found that the cross-iframe React input selection restoration completely broke focus and blur functionality for all inputs with the release of Chrome v60, so I updated our React form build to completely remove the plugin, and it'd been working great for us since then. I have the suspicion that all of that code may actually have been in place for older browsers and/or React DOM rendering strategies that are no longer relevant or in use. However, making that determination would require knowing the exact set of circumstances that at some point were affected by that plugin, and there is not enough info in the comments for me to be sure of how to verify whether or not the plugin is still needed.

However, in light of our success with our app in production without any of that code, I've been planning to create yet another branch that completely removed that particular plugin and includes other changes necessary to make React octane-agnostic, and I will run yarn prettier on that branch, thanks for the suggestion!

Would love to hear what you're digging around uncovers about how to create the proper scenario for verifying the React input selection restoration functionality! Also, we can definitely close #7866 and #7936.

mihaip added a commit to quip/react that referenced this pull request Sep 29, 2017
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.

(cherry picked from commit 94b759b in
the 0.14-stable. Appears to work mostly as is, needed to be updated to
take 5c5d2ec into account)
@acusti
Copy link
Contributor Author

acusti commented Oct 31, 2017

@nhunzaker I’m keeping this open for now in case it turns out that the React input selection restoration plugin is still necessary with React fiber and latest browsers and such. However, the latest version of this effort that I think should be reviewed and tested is #11410.

@nhunzaker
Copy link
Contributor

Cool! Thanks for... sending out yet another PR. I really appreciate it.

@acusti acusti force-pushed the iframes branch 2 times, most recently from 1dd8abb to c5e3925 Compare November 1, 2017 00:50
If an input inside a same-domain iframe is the actual activeElement,
this change will make the ReactInputSelection module get from and
restore selection to that input, rather than the iframe element itself
Had to remove some tests because they rely on DOM functionality that
jsdom doesn’t yet support, like getSelection and iframe activeElement
var doc =
nativeEventTarget.ownerDocument ||
nativeEventTarget.document ||
nativeEventTarget;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you explain what nativeEventTarget can be? When would an element be missing an owner document, but have a document? What are the usual values ofnativeEventTarget?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the event was dispatched at the window level, like if you had a window.onresize listener, for example, the event target would be the window object and would therefore have a document but no ownerDocument. And if it was dispatched on the window object of a nested browsing context, it would be the window object of that iframe, not the global one.

However, looking at where this gets invoked, I see that the call site has it’s own nested browsing context-compatible way to calculate doc that was added a year ago: 6e04bd7

I guess I’ll just repeat the same logic here, if that makes sense to you; i.e. I’ll replace these lines with:

    var doc =
      nativeEventTarget.window === nativeEventTarget
        ? nativeEventTarget.document
        : nativeEventTarget.nodeType === DOCUMENT_NODE
          ? nativeEventTarget
          : nativeEventTarget.ownerDocument;

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Considering they’re in the same file, I’m making a getEventTargetDocument function. I dropped native from the name because I don’t think that distinction is relevant at the level of this function.

return event.clipboardData;
}
var doc = (event.target && event.target.ownerDocument) || document;
return doc.defaultView.clipboardData;
},
};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does clipboard data have to do with selection?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing to do with selection; this is just another part of the codebase that relies on the global window object rather than the window object relative to the DOM node being used. That said, I just looked it up, and window.clipboardData is an IE-only API and one where it seems like the code being executed should only try to look up it’s own window.clipboardData (because of browser permissions / security settings), so I’m going to revert this set of changes.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah still, do you think it's worth landing? What do you think about sending this out in a separate PR?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don’t know really know anything about clipboard APIs in IE, but based on a quick google search, my hunch is that this change wouldn’t actually be for the better. There’s some mentions about browser settings and issues with clipboardData not being accessible across different browsing contexts, so keeping it hardcoded to window.clipboardData might actually be correct.

But I’d be happy to open a separate PR with it if someone would be able to figure that all out.

Copy link
Contributor

@nhunzaker nhunzaker left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is great work, and solves a very interesting problem. I'd like to get some opinions from other members of the team, but I'd also like to start thinking about the best way to test browser support for this. Much of that is simply because I don't have much experience with selection APIs.

So let's make a manual DOM fixture! Is there a good code example that we could pull in to dom text fixtures? I think this warrants a whole section.

Again, great work!

@jquense @aweary I think the next steps here are to do browser testing and I really want to dig into a few of the specific selection APIs. What team members do you think are most familiar with selection stuff?

I'll ping Dan when he's back from vacation :P

@acusti
Copy link
Contributor Author

acusti commented Nov 17, 2017

@nhunzaker Thanks so much for the review! I addressed all the comments and pushed up the changes. I also pushed up a fix for a bug in getElementsWithSelections which could lead to an element that is a document’s activeElement losing focus from restoreSelection trying to focus another element in the same document: 174375c

The bug came up in our internal team’s QA of this branch of work in our app and is fixed by that change.

I’d also be happy to update hasSelectionCapabilities to support other input types than just text, if that makes sense to you. And depending on if I should do that or not, I will update the tests.

@acusti
Copy link
Contributor Author

acusti commented Nov 19, 2017

@nhunzaker I decided to go ahead and update hasSelectionCapabilities and its tests to be correct in its return value for <input>s, no point in preserving the current incorrect behavior. I also broke the single test into two tests, one for input types that return true, one for those that return false. The commit: 82443f7

@acusti
Copy link
Contributor Author

acusti commented Jan 1, 2018

@nhunzaker I merged latest master and resolved the conflicts. Also added a guard based on a test failure and converted all var to make eslint pass. As of your last comment, you mentioned wanting to create a manual DOM fixture, which sounds like a good idea. I took a quick look and wasn’t sure where to start, though I definitely could give you some pretty stripped down code examples that can be used for those tests. For example, in this codepen demo, I use the following component which renders its children into an iframe:

class Iframe extends React.Component {
	componentDidMount() {
		const doc = this.getDocument();
		doc.open();
		doc.write(`<!doctype html><html><head><style>${iframeStyles}</style><body><div></div></body></html>`);
		doc.close();

		this.renderIframeContents();
	}

	componentDidUpdate() {
		this.renderIframeContents();
	}

	getDocument() {
		return ReactDOM.findDOMNode(this).contentWindow.document;
	}

	renderIframeContents() {
		const doc = this.getDocument();
		const mountTarget = doc.body.children[0];
		ReactDOM.unstable_renderSubtreeIntoContainer(this, this.props.children, mountTarget);
	}

	render() {
		return <iframe />;
	}
}

That component could be combined with an example like the one @aweary posted in another PR thread to create a fixture that verifies that all of the restoreSelection functionality continues to work across nested browsing contexts: http://react-master-restore-selection.surge.sh/

You also mentioned wanting to ping Dan when he was back from vacation. Thinking you all might be back on vacation again, but maybe 2018 will be a good opportunity to ping him and get this rolling again.

@gaearon
Copy link
Collaborator

gaearon commented Jan 5, 2018

What is the difference in bundle size?

Maybe it can be okay if:

  • the bundle size doesn't increase
  • the unit tests stop relying on private APIs and test only public APIs instead
  • somebody on the team can shepherd this through

But I'm not sure this case is important enough to offset the complexity it introduces, and I'm not very optimistic about this landing. I really appreciate your effort though.

@ahutchings
Copy link

I would love for this change to make it in. Using React portals greatly simplifies multi-window apps using the pattern documented here.

There does seem to be a fair amount of demand for rendering into iframes as well - react-frame-component has 39 dependent packages and 20,627 downloads over the past month.

In my opinion, the complexity required in fixing multi-context selection seems a worthwhile trade off in order to fully support this class of interesting React usage patterns.

@gaearon
Copy link
Collaborator

gaearon commented Jan 5, 2018

Fair enough. I'd like to see the bundle size difference I guess.

@acusti
Copy link
Contributor Author

acusti commented Jan 7, 2018

@gaearon In order to show the bundle size difference, I originally committed scripts/rollup/results.json to get the diff for sharing changes in bundle size, but then immediately got a conflict with master. So I rebased without that commit, merged master, and am just pasting the table that gets printed after running yarn build, truncated at react-dom-test-utils.development.js (UMD_DEV) because from then on, every built file has 0% difference:

Bundle size difference table
┌──────────────────────────────────────────┬───────────┬──────────────┬──────┬───────────┬──────────────┬──────┐
│ Bundle                                   │ Prev Size │ Current Size │ Diff │ Prev Gzip │ Current Gzip │ Diff │
├──────────────────────────────────────────┼───────────┼──────────────┼──────┼───────────┼──────────────┼──────┤
│ react.development.js (UMD_DEV)           │ 54.08 KB  │ 54.08 KB     │ 0 %  │ 14.67 KB  │ 14.67 KB     │ 0 %  │
├──────────────────────────────────────────┼───────────┼──────────────┼──────┼───────────┼──────────────┼──────┤
│ react.production.min.js (UMD_PROD)       │ 6.39 KB   │ 6.39 KB      │ 0 %  │ 2.72 KB   │ 2.72 KB      │ 0 %  │
├──────────────────────────────────────────┼───────────┼──────────────┼──────┼───────────┼──────────────┼──────┤
│ react.development.js (NODE_DEV)          │ 44.72 KB  │ 44.72 KB     │ 0 %  │ 12.4 KB   │ 12.4 KB      │ 0 %  │
├──────────────────────────────────────────┼───────────┼──────────────┼──────┼───────────┼──────────────┼──────┤
│ react.production.min.js (NODE_PROD)      │ 5.22 KB   │ 5.22 KB      │ 0 %  │ 2.29 KB   │ 2.29 KB      │ 0 %  │
├──────────────────────────────────────────┼───────────┼──────────────┼──────┼───────────┼──────────────┼──────┤
│ React-dev.js (FB_DEV)                    │ 44.07 KB  │ 44.07 KB     │ 0 %  │ 11.93 KB  │ 11.93 KB     │ 0 %  │
├──────────────────────────────────────────┼───────────┼──────────────┼──────┼───────────┼──────────────┼──────┤
│ React-prod.js (FB_PROD)                  │ 12.38 KB  │ 12.38 KB     │ 0 %  │ 3.33 KB   │ 3.33 KB      │ 0 %  │
├──────────────────────────────────────────┼───────────┼──────────────┼──────┼───────────┼──────────────┼──────┤
│ react-dom.development.js (UMD_DEV)       │ 547.68 KB │ 550.6 KB     │ 0 %  │ 129.61 KB │ 130.33 KB    │ 0 %  │
├──────────────────────────────────────────┼───────────┼──────────────┼──────┼───────────┼──────────────┼──────┤
│ react-dom.production.min.js (UMD_PROD)   │ 91.55 KB  │ 92.84 KB     │ +1 % │ 30.09 KB  │ 30.6 KB      │ +1 % │
├──────────────────────────────────────────┼───────────┼──────────────┼──────┼───────────┼──────────────┼──────┤
│ react-dom.development.js (NODE_DEV)      │ 532.09 KB │ 535.01 KB    │ 0 %  │ 125.99 KB │ 126.72 KB    │ 0 %  │
├──────────────────────────────────────────┼───────────┼──────────────┼──────┼───────────┼──────────────┼──────┤
│ react-dom.production.min.js (NODE_PROD)  │ 90.01 KB  │ 91.3 KB      │ +1 % │ 29.22 KB  │ 29.65 KB     │ +1 % │
├──────────────────────────────────────────┼───────────┼──────────────┼──────┼───────────┼──────────────┼──────┤
│ ReactDOM-dev.js (FB_DEV)                 │ 549.15 KB │ 552.14 KB    │ 0 %  │ 128.16 KB │ 128.94 KB    │ 0 %  │
├──────────────────────────────────────────┼───────────┼──────────────┼──────┼───────────┼──────────────┼──────┤
│ ReactDOM-prod.js (FB_PROD)               │ 258.75 KB │ 259.93 KB    │ 0 %  │ 50.02 KB  │ 50.53 KB     │ +1 % │
├──────────────────────────────────────────┼───────────┼──────────────┼──────┼───────────┼──────────────┼──────┤
├ ...

Being able to render into an iframe is very powerful. It’s actually the only way to properly solve two problems:

  • Enabling multiple visible selections in the same app at once: many apps or text editors fake this using a bunch of special selection state management, spans, and background colors, but it is a degraded experience (guaranteed not to match the native behavior of all OSes where it is run), but you can get the native browser / OS experience without extra code to fake it by having the different selectable elements in separate browsing contexts
  • 100% reliable CSS encapsulation (e.g. render a preview of something with custom CSS without worrying about loading those styles into the app window): in our case, we are building a webpage builder, so using an iframe means that not only can we completely separate our app CSS from the webpage HTML and CSS the user is building, we can even change the dimensions of the iframe in order to preview how the webpage will look at different device sizes (mobile, tablet, etc)

If you want a demo of both of those use cases in action (using this fork of react plus the draft-js fork) and a pretty compelling case for why these changes are worth it, check out Brandcast, our app. We recently replaced our usage of react-frame-component since the release of React 16 with a component that does the same thing via the Portal API (instead of ReactDOM.unstable_renderSubtreeIntoContainer).

In terms of complexity of this PR, I could simplify the changes necessary to make React work across nested browsing contexts (iframes or separate windows in multi-window apps). This set of changes is the most complete solution that handles any number of elements with visible selections across any number of nested browsing contexts with any level of recursion. I made another PR that takes the opposite approach and removes the React input selection restoration plugin entirely: #11410. However, as @sophiebits and @aweary pointed out, the plugin does still serve a purpose, though it’s a pretty edge-casey one.

My first attempt at this set of changes, #7866 (from October 2016), implemented a third approach that was simpler but less complete. It only tries to preserve and restore the one active selection in the app and does nothing about any other visible (but inactive) selections. I would be willing to redo that approach against latest master (things have changed over the past year and a quarter) if that felt to the React core team like a better overall approach. It doesn’t seem like the ideal solution, but it will at least result in a smaller increase in the size of the bundle.


Lastly, @gaearon, you mentioned:

the unit tests stop relying on private APIs and test only public APIs instead

Are you referring to the exported ReactInputSelection.hasSelectionCapabilities and ReactInputSelection.getSelection methods? I’d be happy to remove those tests; I just added them because I figured it would be a simpler, more maintainable, more pure way to test the functionality of that plugin. Would you prefer to have tests that only interface with ReactInputSelection.getSelectionInformation and ReactInputSelection.restoreSelection, the two functions that get used by ReactDOM? Or does that still qualify as a private API? Or am I misunderstanding your suggestion entirely?

Thanks for checking out the PR and giving your feedback! And as I mentioned previously to @nhunzaker, don’t hesitate to ask me for anything, whether info, documentation, questions, code, etc.

@flarnie
Copy link
Contributor

flarnie commented Jan 8, 2018

I'm looking at the related PR on Draft.js that would enable iframe support there, and it would be helpful to get a verdict on this before I consider that one.

I'm going to bring this to the other maintainers and hopefully we can get an answer one way or another. Thanks for your ongoing work on this @acusti and to all the folks guiding this work.

@flarnie
Copy link
Contributor

flarnie commented Jan 16, 2018

Just an update - the React team at Facebook has discussed this a bit, still need to discuss more, but it is on our radar and hope to have an answer soon.

@acusti
Copy link
Contributor Author

acusti commented Jan 18, 2018

@flarnie @gaearon I decided to go ahead and create a new PR that implements the minimal changes required to make React work across nested browsing contexts (with 0% or even -1% changes to the bundle size), as referred to in my last comment. The PR: #12037

I hope between these two approaches we can find something that everyone feels comfortable with being merged.

@flarnie
Copy link
Contributor

flarnie commented Jan 18, 2018

:) Thanks for doing that @acusti - we are looking for a maintainer to take point on this; might take a while but it is not forgotten about.

@gaearon
Copy link
Collaborator

gaearon commented Aug 2, 2018

We went with #12037

@gaearon gaearon closed this Aug 2, 2018
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

7 participants