TABLE OF CONTENTS

Advanced Navigation Of Single Page Applications Inside An IFrame

Author Sagi Liba
Sagi Liba on Feb 27, 2022
22 min 🕐

Recently, I've had to tackle an interesting use case. I've had two applications where the first application is a single-page application that is in charge of authentication and displays a navigation bar and an iframe once you are logged in. The iframe shows the actual application you've logged in to use, which is another single-page application.

The demo project can be found on GitHub, the image of the demo:

application-with-iframe

IFrame Communication

Article terminology:
  • Parent Application, is the application that shows the iframe.
  • Child Application, is the application inside the iframe.

When you use an iframe, you will encounter cases where you need the parent and the child to communicate with each other, it could be to:

  • Pass information.
  • Trigger functionality from either side.
  • Navigate the applications.

Parent To Child Communication

Copy
// Vanilla JS
const iframeElement = document.getElementById("iframe-app");
iframeElement.contentWindow.postMessage(message, "*");

To send a message to the child application from the parent you will have to:

  • Get the iframe by its id.
  • Get the window object the iframe is showing, by using the propertycontentWindow.
    • contentWindow is read-only, you cannot change its properties.
  • Use the postMessage method to send a message.
    • postMessage enables cross-origin communication between window objects.
    • postMessage second parameter is the origin you wish to send the message to, currently, I've told it to send it to any origin using the asterisk, but for security purposes, you should be specific.

Child To Parent Communication

To send a message from the child application to the parent, you will need to access the window object's parent and then use postMessage again.

Copy
// Vanilla JS
window.parent.postMessage(message, "*");

In your project be specific about the origin, currently, it's any origin by specifying an asterisk.

Receiving Messages

Now that we know how to send the messages usingpostMessage, we can listen for them in our parent & child applications, using the message event.

Copy
window.addEventListener("message", (event: MessageEvent) => {
// Make sure the messages come from the correct origin:
if (event.origin !== "https://localhost:<port>") {
console.log("Incorrect Origin");
return;
}
const message = _.get(event, "data");
});

The message event receives all the messages directed at the window you are listening to, it could be the messages you've sent, or other messages from your Webpack dev server, sending its own messages behind the scenes, or it could be malicious activity trying to attack your application.

I'd recommend using an origin check to validate where your messages are coming from, and I'd also recommend that you'll send your messages using a type/action enum so that you know what kind of messages are allowed.

An attacker can easily fabricate the messages, therefore, you must make sure to sanitize any information you receive.

Multiple Child IFrames Communication

When your parent application serves as a single-sign-on, which then serves your applications, you could have multiple applications, one in each IFrame, or routing a single IFrame each time to a different application when navigating from the parent application using its navbar.

While supporting multiple applications there could be many interesting use-cases, to mitigate the friction of duplicate/similar messages being sent from those child applications, you should add a parameter to the message that will indicate where it came from, it will help you differentiate the applications from one another.

Copy
// Child
window.parent.postMessage({ applicationType: ApplicationType.CHAT }, "*");
// -----------------------------------------------------------------------
const AllowedOrigins = ["https://chat.com", "https://blog.com"];
// Parent
window.addEventListener("message", (event: MessageEvent) => {
// Whitelist of origins.
if (AllowedOrigins.includes(event.origin)) {
console.log("Incorrect Origin");
return;
}
const message = _.get(event, "data");
const applicationType = _.get(data, "applicationType");
if (applicationType === AppTypes.CHAT) {
// Handle chat app messages.
}
// add other if statements for supported applications.
});

Routing Child SPAs

When you work with Single Page Applications one of your main objectives is toload the applications only once and then never refresh them, all the routing happens internally using a router.

When serving a SPA from an iframe you will need to make sure:

  • The SPA's router handles the navigation.
  • The application serving the iframe is making sure the iframe is always mounted, I call this "Always On Behavior" and it's discussed at the end of the article.

From here on, I will be showing a real example of how to route a SPA inside an iframe, and the many use-cases I've encountered.

I will be throwing a lot of code at you, with plenty of explanations, take your time to truly understand what is going on here.

If you wish to run the example locally and skip the rest of the article, you can go here: GitHub

Make sure to install the parent application, and the child application dependencies as well.

Here is the main idea of what we are going to see:

application-with-iframe

Set The Infrastructure

We will now set the infrastructure for our example, the code samples are using the React framework, but it could be done with Angular/Vue, take your pick.

Child Application Components

The child must listen to incoming messages, and be able to route to the desired location.

RoutingListener component:
Copy
//
// This application is rendered through an IFrame by a parent application.
// Here we are listening for messages from the parent, handling routing, etc...
//
export const RoutingListener = () => {
const history = useHistory();
const handleMessages = (event) => {
// Only handle messages that came from our parent:
if (event.origin !== PARENT_APPLICATION_URL()) {
console.log("Incorrect origin", event.origin);
return;
}
const data = get(event, "data");
const action = get(data, "action");
// Act based on specific supported actions:
if (action === IFrameActions.NAVIGATION) {
const path = get(data, "path");
path && history.replace(path);
}
};
useEffect(() => {
window.addEventListener("message", handleMessages);
return () => window.removeEventListener("message", handleMessages);
}, []);
return <></>;
};

As you can see, I've:

  • Used themessageevent to listen to incoming messages.
  • Checked if the message came from our parent application's origin.
  • Got theactionthe parent application would like me to do.
  • Added support for the NAVIGATION action.
  • Used theuseHistoryhook fromreact-router-domto navigate the router to the desired path.
IMPORTANT NOTE:
It is extremely important that you usereplacefor navigation, instead of thepushmethod.
Copy
history.replace(path);

It seems that usingpushto change the browser history will leak to the parent application browser's history from inside the iframe, affecting our back and forward buttons.

*  *  *
Now let's locate ourRoutingListenercomponent correctly, it should be inside theBrowserRouterand outside theSwitch component, to be able to route and listen correctly:
Copy
function App() {
return (
<Router>
<RoutingListener />
<Switch>
<Route path="/" component={MainPage} />
<Route exact path="/events" component={Events} />
<Route exact path="/events/daily" component={DailyEvents} />
</Switch>
</Router>
);
}
*  *  *
Our child application is ready to receive messages from the parent, and route to specific paths.

Parent Application Components

Appcomponent:
Copy
import React from "react";
import "./App.css";
import { AppRoutes } from "./Components/AppRoutes";
import { Navigation } from "./Components/Navigation";
import { IFrame } from "./Components/IFrame";
function App() {
return (
<div className="App">
<Navigation />
<div className="content">
<AppRoutes />
<IFrame />
</div>
</div>
);
}
export default App;

Breakdown:

  • To the left is theNavigationbar.
  • To the right is theAppRoutes, and theIFramecomponents.
*  *  *
Navigationcomponent:
Copy
export const NAVIGATION_ROUTES = [
{
title: "IFrame Route One - Dashboard",
path: "/",
displayedURL: "/app/",
isIFrame: true,
},
{
title: "IFrame Route Two - Events",
path: "/events",
displayedURL: "/app/events",
isIFrame: true,
},
{
title: "IFrame Nested Route - Daily Events",
path: "/events/daily",
displayedURL: "/app/events/daily",
isIFrame: true,
},
{ title: "Parent Route", path: "/parentRoute" },
{ title: "Parent Nested Route", path: "/parentRoute/nested" },
];
// Render the navigation navbar.
export const Navigation = () => {
const iframeRouterContext = useContext(IFrameRouterContext);
return (
<div className="navigation">
<ul>
<h2>Navigation</h2>
{NAVIGATION_ROUTES.map((route) => {
return (
<li
key={route.title}
onClick={() => iframeRouterContext.navigate(route)}
>
{route.title}
</li>
);
})}
</ul>
</div>
);
};

Breakdown:

  • The Navigation component, uses thenavigatemethod, from a context namedIFrameRouterContext, which will be discussed soon.

  • The child app routes are added a prefix of "/app", to avoid duplicate routes used in both parent and child applications.

  • We have ourNAVIGATION_ROUTESdefined:

    • title, the name that appears in the navigation button.
    • path, where will the application be routed to.
    • isIFrame, distinguish between iframe routes (child app) or regular routes (parent app).
    • displayedURL, the URL shown in the URL address bar of the browser, when routing the child app iframe.
*  *  *
AppRoutes component:
Copy
import React from "react";
import { Switch, Route } from "react-router-dom";
const ParentRoute = () => <div>Parent Route</div>;
const NestedParentRoute = () => <div>Nested Parent Route</div>;
export const AppRoutes = () => {
return (
<Switch>
<Route exact path="/parentRoute" component={ParentRoute} />
<Route exact path="/parentRoute/nested" component={NestedParentRoute} />
</Switch>
);
};

Breakdown:

It holds our parent application routes,outside of the iframe. I've created two simple routes for testing:

  • /parentRoute
  • /parentRoute/nested
*  *  *
IFrame component:
Copy
import React, { useContext } from "react";
import { IFrameRouterContext } from "./IFrameRouterContext";
// Always mounted!
// Not always visible.
export const IFrame = () => {
const iframeRouterContext = useContext(IFrameRouterContext);
const { iframeVisibility, iframeRef, iframeSrc } = iframeRouterContext;
return (
<div className="iframe-container">
<iframe
src={iframeSrc}
ref={iframeRef}
title={"child application"}
// iframe-invisible css is "visibility: hidden".
className={!iframeVisibility ? "iframe-invisible" : ""}
></iframe>
</div>
);
};

Breakdown:

  • iframeRef, getting the iframe's reference to use in our IFrameRouterContext, so we can send postMessages to it.
  • iframeSrc, decided where the iframe should be started too, you must make sure it always stays the same, or the iframe will reload.
  • iframeVisibility, the iframe is always mounted, but not always visible.
    • This is done to make sure the child's SPA will not reload when moving between parent and child pages.

When hiding the iframe do not use "display: none", it will remove it from the DOM, causing the child app to reload when it appears again.

*  *  *
Now I'm going to add the IFrameRouterContextProvider and the BrowserRouter, making sure it is all set correctly insideindex.js:
Copy
ReactDOM.render(
<React.StrictMode>
<BrowserRouter>
<IFrameRouterContextProvider>
<App />
</IFrameRouterContextProvider>
</BrowserRouter>
</React.StrictMode>,
document.getElementById("root")
);
*  *  *

OK, so we've set the basic infrastructure to be able to navigate the child application, now we will go over the main logic inside ourIFrameRouterContext which binds all of the above together.

IFrameRouterContext

OurIFrameRouterContextholds all of the navigation logic, let's start with a simple context:

Copy
const initialState = {};
export const IFrameRouterContext = React.createContext(initial);
export const IFRAME_APP_URL = "http://localhost:3000";
export const IFrameRouterContextProvider = ({ children }) => {
// Here we will implement the logic.
const contextProperties = {};
return (
<IFrameRouterContext.Provider value={contextProperties}>
{children}
</IFrameRouterContext.Provider>
);
};
*  *  *

Let's create three utility functions that will serve us.

(I've put them above the context, you can also create them inside the context)

#1getIFrameRoute - returns the route object from the NAVIGATION_ROUTES, declared in the Navigation component.

Returned result example:

Copy
{
title: "IFrame Route Two - Events",
path: "/events",
displayedURL: "/app/events",
isIFrame: true,
},

When dealing with the iframe routes, we search for the displayedURL, the URL in the browser address bar. It is the only way to make sure we match an iframe route correctly.

Copy
const getIFrameRoute = () => {
return NAVIGATION_ROUTES.find(
(route) => route.displayedURL === window.location.pathname
);
};
#2isInIFrameRoute - checks if currently we are in an iframe route.
Copy
const isInIFrameRoute = () => {
return !!getIFrameRoute();
};
#3getIframeSource - Get the iframe's starting source attribute, if we are not in an iframe route, use the iframe's application url.
Copy
const getIframeSource = () => {
const iframeRoute = getIFrameRoute();
if (iframeRoute) {
return IFRAME_APP_URL + iframeRoute.path;
}
return IFRAME_APP_URL;
};
*  *  *

Create anIFrameActionsenum, that will be the agreed-upon actions between the parent application and the child application:

Copy
export const IFrameActions = {
NAVIGATION: "NAVIGATION",
REFRESH: "REFRESH",
};
*  *  *

Inside the context, we will need:

  • Reference to the iframe element, to send messages.
  • State that decides if the iframe is visible.
  • useHistory hook for routing the parent application.
  • iframe start source path, it should only be set once, hence we will use useMemo.

Then to make sure all the functionality is available for other components to use, put it inside "contextProperties".

Copy
export const IFrameRouterContextProvider = ({ children }) => {
const iframeRef = useRef(null);
// ---------------------------------------------
// Important that it should only be called once.
// Otherwise, it will refresh the iframe.
const iframeSrc = useMemo(getIframeSource, []);
//----------------------------------------------
const [iframeVisibility, setIframeVisibility] = useState(isInIFrameRoute());
const history = useHistory();
// exporting needed context logic
const contextProperties = {
iframeRef,
iframeSrc,
iframeVisibility,
setIframeVisibility,
};
return (
<IFrameRouterContext.Provider value={contextProperties}>
{children}
</IFrameRouterContext.Provider>
);
};
*  *  *
Handling Navigation

Thenavigatemethod navigates our application, handling both parent routes and IFrame routes. You will need to use it for all app routing, and it is also in charge of handling the IFrame visibility as well.

Copy
...
const navigate = ({ path, isIFrame, displayedURL }) => {
// Stop navigation to the same path,
// else it will add repeated routes in browser history,
// messing up the back button.
const currentPath = window.location.pathname;
if (
(isIFrame && displayedURL === currentPath) ||
(!isIFrame && path === currentPath)
) {
return;
}
if (isIFrame) {
setIframeVisibility(true);
if (iframeRef.current) {
// Navigate the child application using postMessage
iframeRef.current.contentWindow.postMessage(
{
action: IFrameActions.NAVIGATION,
path,
},
IFRAME_APP_URL
);
history.push(displayedURL);
}
} else {
// The BrowserRouter handles the navigation,
// Only hide the iframe, and navigate to new path.
setIframeVisibility(false);
history.push(path);
}
};
const contextProperties = {
iframeRef,
iframeSrc,
iframeVisibility,
setIframeVisibility,
getIFrameRoute,
isInIFrameRoute,
navigate,
};
...
Handle Back and Forward Browser Clicks:

The browser has an event calledpopstate,which is fired when the active history entries change. Once we detect the event has fired, we will handle the navigation to make sure the iframe is correctly navigated. Remember that the iframe navigates only through postMessage.

Copy
const handleBrowserBackForwardEvents = () => {
// Based on currently updated URL
if (isInIFrameRoute()) {
if (iframeRef.current) {
// Manually handle IFrame navigation
setIframeVisibility(true); // Might delay this to avoid flickering of previous route in hidden iframe
const route = getIFrameRoute();
iframeRef.current.contentWindow.postMessage(
{
action: IFrameActions.NAVIGATION,
path: route.path,
},
IFRAME_APP_URL
);
}
} else {
// BrowserRouter handles the navigation
setIframeVisibility(false);
}
};

Use a useEffect hook to set thepopstate event listener:

Copy
useEffect(() => {
// Go the child application main route
if (window.location.pathname === "/") {
navigate(NAVIGATION_ROUTES.find(({ path }) => path === "/"));
}
if (iframeRef) {
iframeRef.current.onload = () => {
console.log("IFrame loaded");
window.addEventListener("popstate", handleBrowserBackForwardEvents);
};
}
return () => {
window.removeEventListener("popstate", handleBrowserBackForwardEvents);
};
}, []);

You've probably noticed that I've also handled the use-case where the client enters the domain's root path "/", and is being redirected to the child application root path "/app/", you can disable or change it as you like.

We have finally covered all the logic of theIFrameRouterContext, all that is left is for you to test that it is working.

GitHub

For your specific use case, you might need to change the layout displayed in the demo.

Final Notes

👋 The following notes are extremely important, please pay attention.

#1Metadata

You can add an additional property called "metadata", which will be sent with each message, it could be useful when the iframe action changes, and you have a single place where each action's information is set too.

Copy
iframeRef.current.contentWindow.postMessage(
{
action: IFrameActions.NAVIGATION,
path: route.path,
metadata: {...any additional information...}
}
},
IFRAME_APP_URL
);

#2Always On Behavior

One of the issues I've found interesting is when your parent application serves pages that are not part of the Iframe, meaning it will unmount your application to display them, causing your child IFrame to remount each time you go back to the child application.

To tackle this issue you will have to make sure you load the IFrameonly once the parent application first loads and hide it when you move to other pages where the IFrame should unmount.

That's how you keep the IFrame "Always On", making sure it is always mounted, and all you have to do is change the IFrame CSS visibility property.


#3Timing issues

There could be a situation where you've navigated from the parent route to the child iframe route, but the child has not finished loading the new page and you will see a flicker of the previous page before the new page loads.

The flicker appears because the iframe was set to a different page from previous navigation, and has not loaded the requested page yet.

The easy solution, usesetTimeout on setIframeVisibility with a small and reasonable delay.

The comprehensive solution, make sure that each route inside the iframe once it's mounted, sends a post message to our parent application indicating it has been loaded, and only then make the iframe visible.

The in-between solution, make sure to add the setTimeout delay only when switching between the parent application routes and the child application routes. This avoids a delay when only switching between iframe routes or only switching parent routes.

#4 Refresh issues

Make sure that for each route when you refresh, it returns to the same page you expect it to.

Here this is done by setting the iframe source when the page loads using thegetIframeSourcefunction, and making sure it happens only once withuseMemo.

*  *  *

This was a though, and yet an important article for use-cases you might not encounter often when using an iframe.

© 2020-present Sagi Liba. All Rights Reserved