Rendering to iFrames in React

Originally posted in 2014, some things have changed in those 4 years.

Pete Hunt tweeted a little jsfiddle showing how you could render a React component to an iFrame. It was simple, yet powerful and exactly what we needed for our Embeddables platform here at Zendesk.

Why would you render to an iFrame?

There are several reasons you would want to render to an iFrame but the main benefit is style encapsulation. We can safely style our components without having to worry about style inheritence or CSS specificity and vice versa not affecting the parent document styles. The other benefits that are beyond the topic of this post are guaranteed paint layer and layout boundaries. Basically causing paints and reflows won’t effect the parent document as iFrames are essentially a new window.

Not so easy

So you’re probably thinking what else I have to add other than that example. Well for starters this demo doesn’t work in anything except Chrome. You may see the contents quickly flash in other browsers then mysteriously disappear?! Lets take you on my journey of coming up with a pretty robust solution and some of the weird attempts and issues that arose.

If you want to cut to the chase go checkout the github repo.

React composite components

So after some tweeting with a small test case to reduce the issue to its core problem, I discovered that other browsers wouldn’t persist my DOM operations inside an iFrame unless I did them after the onload event fired. “Great” I thought — I can just put the onLoad event handler on my <Frame />component and use this.transferProps to pass it along to the underlying iFrame:

var Frame = React.createComponent({
render: function() {
return this.transferPropTo(<iframe />);
},
onLoadHandler: function() {
// Handle rendering here
}
});
var Header = React.createComponent({
render: function() {
return (
<Frame onLoad={this.onLoadHandler}>
<h1>{this.props.children}</h1>
</Frame>
);
}
});
React.renderComponent(<Header>Hello</Header>, document.body);

Unfortunately the load event doesn’t bubble and React by default will attach the event to the document for performance and memory reasons. So after some investigation and discussion in the React irc channel I took a look at the React internals and how they handled the load event on images. This led me to creating a composite component for iFrames in React, which would force the event to bind to the element and not to the document.

var ReactBrowserComponentMixin = require('ReactBrowserComponentMixin');
var ReactCompositeComponent = require('ReactCompositeComponent');
var ReactDOM = require('ReactDOM');
var ReactEventEmitter = require('ReactEventEmitter');
var EventConstants = require('EventConstants');
var iframe = ReactDOM.iframe;
var ReactDOMIframe = ReactCompositeComponent.createClass({
displayName: 'ReactDOMIframe',
tagName: 'IFRAME',
mixins: [ReactBrowserComponentMixin],
render: function() {
return iframe(this.props);
},
componentWillMount: function() {
var node = this.getDOMNode();
ReactEventEmitter.trapBubbledEvent(
EventConstants.topLevelTypes.topLoad,
'load',
node
);
}
});
module.exports = ReactDOMIframe;

This didn’t work due to the way React works internally in order to capture the load event on an iframe. It needs to be attached before it’s rendered to the DOM but you can’t access the element inside componentWillMount lifecycle method of the component. This is because React has no idea what you're are rendering as render hasn't been called yet and therefore can't access the iframe element in memory. The code to not bubble events is using Reacts EventEmitter and trapping it to the current node which in our case is the iFrame.

renderComponentToString and iframe.open()/write()/close()

Normally renderComponentToString is reserved for server-side rendering but I was desperate and wanted to get a solution working. So I tried building up the iFrame contents in the componentDidMount lifecycle method. I did this using the iframe.open()/write()/close() methods and converting the children of my Frame component to a string so I could pass that to the write() method and get it rendered into the document of the iFrame.

var Frame = React.createClass({
render: function() {
return <iframe />;
},
componentDidMount: function() {
var doc = this.getDOMNode().contentDocument,
children = React.renderComponentToString(this.props.children);
doc.open();
doc.write(children);
doc.close();
}
});

Eureka it worked! But now it’s not a “live” element as we need to do the rendering with React.renderComponent for the ability to change state and update the UI and basically get all the perks of using React in the first place.

Browser event loop aka nextTick

My last resort came from a tweet saying that they got my original test case working by using setTimeout to force the rendering on the next event loop within the browser.

var Frame = React.createClass({
render: function() {
return <iframe />;
},
componentDidMount: function() {
setTimeout(this.renderFrameContent, 0);
},
renderFrameContents: function() {
React.renderComponent(this.props.children, this.getDOMNode().contentDocument.body);
}
});

This works but we’ll get to the stage where we may have to increase the setTimeout value as sometimes a component won't render as the iFrame may not be ready on nextTick. That setTimeout delay starts to become a magic number where we increase it until it seems to work. This is bad as in the wild it may fail intermittently due to varying factors.

var Frame = React.createClass({
render: function() {
return <iframe />;
},
componentDidMount: function() {
this.renderFrameContent();
},
renderFrameContents: function() {
var doc = this.getDOMNode().contentDocument
if(doc.readyState === 'complete') {
React.renderComponent(this.props.children, doc.body);
} else {
// This will be continiously called until the iFrame is ready to render into
setTimeout(this.renderFrameContents, 0);
}
}
});

As shown above, we’ve now refactored the code to be smarter and not rely on magic numbers. It’s a pretty simple addition to our original code in componentDidMount. We refactor renderFrameContents to only trigger the render on nextTick if the iFrames readyState isn't complete on first render. If not, we then trigger a setTimeout to try again on nextTick - this will continually happen until we can render to the iFrame. This is a much better approach as we're not artificially delaying the render if we don't have to.

Final component code

var Frame = React.createClass({
render: function() {
return <iframe />;
},
componentDidMount: function() {
this.renderFrameContent();
},
renderFrameContents: function() {
var doc = this.getDOMNode().contentDocument
if(doc.readyState === 'complete') {
React.renderComponent(this.props.children, doc.body);
} else {
setTimeout(this.renderFrameContents, 0);
}
},
componentDidUpdate: function() {
this.renderFrameContents();
},
componentWillUnmount: function() {
React.unmountComponentAtNode(this.getDOMNode().contentDocument.body);
}
});

We have some more code here to make sure we clean up after ourselves and hook into the component lifecycle methods componentDidUpdate & componentWillUnmount.

Using the Frame component

There are two ways you can use the Frame component.

  1. Wrap your render call in the <Frame /> component like any other component in your React application
  2. Wrap your application in the React.renderComponent call

Check out the test case showing both methods and how you can easily choose which one suits your needs.

render: function() {
classes = this.state.show ? '' : 'u-isHidden';
return (
<Frame styles={styles}>
<div onClick={this.toggleState}>
<div className={classes}>this.state.show is true and now I'm visible</div>
Goodbye {this.props.name}
</div>
</Frame>
);
}

In he above case, the <Goodbye /> component is wrapped in our render call in .

React.renderComponent(<Frame><Hello name="World" /></Frame>, document.body);

The alternative, we wrap the <Hello /> component with <Frame /> in renderComponent when rendering to the DOM.

What about CSS in the iFrame

There are two options for injecting styles into our encapsulated components:

  1. You can pass a string and render an inline <style> element.
  2. You could reference an external stylesheet using the <link>tag.

Let’s take a look at option 2, including an external stylesheet:

renderFrameContents: function() {
var doc = this.getDOMNode().contentDocument
if(doc.readyState === 'complete') {
React.renderComponent(this.props.head, doc.head);
React.renderComponent(this.props.children, doc.body);
} else {
setTimeout(this.renderFrameContents, 0);
}
}

We make a slight change to our <Frame> component to also render to the head of the iFrame document by accessing the head property. We can then pass the needed information through to render our styles into our <head>that the component will use.

var Hello = React.createClass({
render: function() {
<Frame head={
<link type='text/css' rel='stylesheet' href='path/to/styles.css' />
}>
<h1>{this.props.children}</h1>
</Frame>
}
});

<Frame> now accepts a head prop we wrap in curly braces so we can pass in some JSX syntax to render. Check out the test case.

This doesn’t work in <=IE9

The above linked test case doesn’t work in <=IE9, it throws an error SCRIPT600: Invalid target element for this operation. which is ever so helpful. After some digging around it turns out that innerHTML on the headelement is read-only in <=IE9 and behind the scenes this is how React does the initial render.

The innerHTML property is read-only on the col, colGroup, frameSet, html, head, style, table, tBody, tFoot, tHead, title, and tr objects.

So if we need to support those browsers then we’ll have to change the way our <Frame> element renders the styles.

renderFrameContents: function() {
var doc = this.getDOMNode().contentDocument
if(doc.readyState === 'complete') {
var contents = (
<div>
{this.props.head}
{this.props.children}
</div>
);
React.renderComponent(contents, doc.body);
} else {
setTimeout(this.renderFrameContents, 0);
}
}

Instead of calling renderComponent for the body and the headelements we just create a blob of html that will all go into the body including our linkelements. See the cross browser example.

But wait there’s more! The styles don’t render in IE8

For the astute reader who tried this in IE8 the styles never actually rendered?! Well this subject is a topic I wrote about a while ago where IE has the concept of NoScope elements. When used in conjunction with innerHTML it would strip out certain elements style, link and scripts if they appear without a preceding "scoped" element.

var contents = (
<div>
&shy;{this.props.head}
{this.props.children}
</div>
);

Inside our renderFrameContents method we simply use a unicode character that IE considers a "scoped" element - in our case, a soft hyphen. So if you need IE8 support for styles you'll need to consider this quirk. Take a look at the final test case.

Blank Memories from unsplash — https://unsplash.com/photos/cPxRBHechRc

React is awesome

React is awesome and we love it for our use-case. Hopefully this will help you from going crazy trying to figure this all out and let you build awesome things.