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:
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
// Vanilla JSconst 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.
// Vanilla JSwindow.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.
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.
// Childwindow.parent.postMessage({ applicationType: ApplicationType.CHAT }, "*");// -----------------------------------------------------------------------const AllowedOrigins = ["https://chat.com", "https://blog.com"];// Parentwindow.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:
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://// 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.
It is extremely important that you usereplacefor navigation, instead of thepushmethod.
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.
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>);}
Parent Application Components
Appcomponent: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.
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 (<likey={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.
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
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"><iframesrc={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.
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:
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:
{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.
const getIFrameRoute = () => {return NAVIGATION_ROUTES.find((route) => route.displayedURL === window.location.pathname);};
const isInIFrameRoute = () => {return !!getIFrameRoute();};
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:
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".
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 logicconst contextProperties = {iframeRef,iframeSrc,iframeVisibility,setIframeVisibility,};return (<IFrameRouterContext.Provider value={contextProperties}>{children}</IFrameRouterContext.Provider>);};
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.
...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 postMessageiframeRef.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,};...
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.
const handleBrowserBackForwardEvents = () => {// Based on currently updated URLif (isInIFrameRoute()) {if (iframeRef.current) {// Manually handle IFrame navigationsetIframeVisibility(true); // Might delay this to avoid flickering of previous route in hidden iframeconst route = getIFrameRoute();iframeRef.current.contentWindow.postMessage({action: IFrameActions.NAVIGATION,path: route.path,},IFRAME_APP_URL);}} else {// BrowserRouter handles the navigationsetIframeVisibility(false);}};
Use a useEffect hook to set thepopstate event listener:
useEffect(() => {// Go the child application main routeif (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.
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.
#1MetadataYou 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.
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.