Multi-Iframe Adventures in React

Cover image

Coding at Ingrid means groking the internals of JavaScript

by Michał Kozielski · 4 min read frontend react

Here in Ingrid we create widgets on a daily basis - checkout widget, upsell widget, tracking widget - all to accommodate different shipping scenarios and build excellent shipping-minded websites. A widget happen to be implemented as an iframe - which provides an excellent layer of isolation but also spells some trouble. From the browser perspective an frame represents a separate application, but in our world one widget can span multiple frames. To us it is undesirable to divide a widget into multiple applications when in fact it is just one application, and the multi-iframe scenario should be treated just as an implementation detail.

An iframe works as a sandbox environment, which means that a separate document and browsing context is created for each frame. Furthermore no DOM manipulation can be performed from the outside and the other way round. However there exists a remarkable exception to this - the limitation does not apply if the content comes from the same origin (i.e. same url, port, and protocol) as the manipulating or manipulated window. It is then not only possible to get the reference to the specific frame but also to manipulate its DOM structure. That leaves the room open for creating a single, multi-iframe application, and the only concern is how to make the framework aware of this fact.

Fortunately, 90% of our code is written in React, and we went to utilize its powerful features to cope with this problem. React provides the excellent React.createPortal routine which can be used to position components inside arbitrary parts of the real DOM, and thus reducing the inherent coupling between the components' structure and the resulting HTML. This is a perfect choice for our use case as now we can create a simple layer of abstraction and then be able to build the application without having to worry what components would be placed in which particular frame.

Cross-frame applicationpermalink

The architecture of our application look as follows: we create an index.html file with the corresponding Javascript bundle, and place it inside one of the frames, which we would call the "master" frame. Any remaining frame would act as a "slave" and will be initialized with a dummy html placeholder (from our domain), for example like this.

<html>
<body>
<div id="placeholder"></div>
</body>
</html>

When the application is started we should collect the references to all frames that are spawned inside the main window. A simple way to do this is by accessing window.parent.frames from the master frame to get all sibling frames.

function findSiblingFrames(): Window[] {
return Array.from(window.parent.frames)
.filter(frame => frame !== window)
}

The only problem with this approach is that window.parent.frames works differently between some browsers. In firefox the aforementioned collection will include frames created inside the Shadow DOM, but in chrome it will not. A more fire-proof approach would be for every frame to advertise itself on the main window object like this.

<script>
window.parent._ingrid_iframes.push(window);
</script>

It is still possible to access the reference of the parent document, even if it originates from a different domain, only the DOM content would be inaccessible.

Now inside the React application we just need a "frame component" that would wrap this particular part of virtual DOM that runs inside a different frame. All we need to do is to lookup the reference to the iframe, find the container inside of it, and create a portal. The code looks like this.

const CrossFramePortal: React.FC = ({ children }) => {
const frameRef = getFrameReference(...)

const container = frameRef.document.getElementById('placeholder')

return React.createPortal(children, container);
};

Event propagationpermalink

A essential part of portal technology is to make event propagation work the correct way. Events are expected to travel through the virtual DOM and not through the real DOM which is detached. This works in the multi-iframe scenario which means that React does a fairly good job at managing the portals. However there are often pieces of code that go "off-React" and rely on custom event handlers (via addEventListener), for example dropdown controllers that check whether a click has been performed somewhere in the app. The following code will not work if a click is performed in the "slave" frame.

// this will not work!
document.addEventListener('click', () => console.log('Document clicked!'))

Furthermore such clicks are usually governed by a check whether the clicked element is a descendant of the document element to rule out click in a so called "detached DOM".

function isNodeAttached(node: Node) {
while (node.parent) {
node = node.parent
}

// this check is a common pitfall...
return node === document
}

In order to fix this issue we need to collect the document element from every frame and attach listeners to it. Furthermore we need to check whether any document was reached during tree traversal and not only the "master" document.

window.parent._ingrid_frames.forEach(frame => {
frame.document.addEventListener(() => console.log('Document clicked!'))
})

// ...

function isNodeAttached(node: Node) {
while (node.parent) {
node = node.parent
}

// this will work every time.
return node instanceof HTMLDocument
}

The same restrictions apply to all modern observer APIs like MutationObserver, ResizeObserver, etc. - a observer needs to be applied to every separate frame.

Styles and 3rd-party librariespermalink

We have to make sure that specific styles are inserted or copied over to the correct frame. Major CSS libraries usually expose something like a stylesheet provider that allows to overwrite the specific document.head reference for style injection. The library should have the capability to resolve the correct styles at runtime, or we should just copy all global styles over.

In order to streamline the document resolution for styles and other use cases we should create a proper React context and provide the correct window reference to underlying code every time we shift rendering to a different frame.

// Definition

// make global `window` the default
export const FrameContext = React.createContext<Window>(window);

// Provider

const wrapper = (
<FrameContext.Provider value={frameRef}>
{children}
</FrameContext.Provider>

);

// Consumer

const frameRef = React.useContext(FrameContext)
// ...
const headRef = frameRef.document.head

The aforementioned mechanism can be applied not only to styles but also to all libraries that perform any "off-React" rendering that rely on the global document reference, for example 3rd-party modals, etc. Here is a simple example of tippy - a tiny tooltip library.

const frameRef = React.useContext(FrameContext)

// ...

const content = (
<TippyTooltip
{...props}
// append tooltip to correct iframe
appendTo={frameRef.document.body}
/>

)

Trying to run a 3rd-party library in a multi-iframe environment is a good test for its maturity, if the document reference is encoded deep in the library internals then it is a big thumb down.

Rounding uppermalink

Coding at Ingrid means making friends with the internals of Javascript and React. That means exploring the features to the very bottom to build sophisticated and fun solutions. Here we explored portals to check whether they are capable of crossing the frame boundary given the frames originate from the same domain. We found out that this approach works well and even event propagation is handled properly in such scenario. The only thing to note is that every part of the browsing context that is not managed by React should be handled separately by a proper abstraction layer.


Cover photo by @jruscello on Unsplash

Does Ingrid sound like an interesting place to work at? We are always looking for good people! Check out our open positions