Handling navigation in iframes

Aditya Daflapurkar
WhatfixEngineeringBlog
11 min readJan 17, 2024
Photo by Anastasia Petrova on Unsplash

Introduction

Consider a web page with two cross-origin iframes in it, frame1 and frame2. The iframes and the top window have previous and next buttons rendered through react and react-router-dom is included as part of the dependencies. The next button navigates to a new page by invoking history.push method, and previous button navigates back by invoking history.back method from react-router-dom.

Here, we perform the following steps:

  1. Click on the next button inside frame1. We land on Page 2 inside frame1.

2. Click on the next button inside frame2. We land on Page 2 inside frame2.

3. Click the back button from the browser navigation bar, frame2 navigates back to Page 1(instead of the browser’s top window navigating back).

4. Click the back button from frame2, frame1 navigates back to Page 1(instead of frame2 navigating back).

5. Click the forward button from the browser navigation bar, frame1 navigates to Page 2.

Thus, we can see that the navigation within an iframe doesn’t respect isolation provided by it. A back or forward navigation triggered from the browser window can invoke iframe navigation and vice versa.

Let’s assume frame2 is an overlay iframe injected by an owner which is different from the owner of the top window and frame1. It would be required that the navigation inside the frame2 should be isolated from navigation within frame1 and top window. To achieve this, we need to get a brief understanding of the browser’s navigation mechanism. The key components of this mechanism are navigables, traversable navigables and the history API. So let’s get to know these components and relevant details of their internals.

Navigable

As per the HTML Living Standard, a navigable is anything that presents a document to the user and can be navigated. Eg: Iframe, browser window/tab.

Traversable navigable

It is a special type of navigable which controls the session history of itself and of its descendant navigables. Eg: In a browser window containing iframes, the top window is a traversable navigable and iframes are descendant navigables.

A navigable contains an active session history entry which has a session history entry data structure. Let’s look into the details of this component.

Session history entry

It is a data structure containing a number of fields out of which the following are relevant to this article:

  1. Step: It is a non-negative integer indicating the step at which the containing session history entry will be active.
  2. URL: The URL of the document which will be displayed to the user when its container session history entry becomes active.
  3. Classic history API state: It is an object containing some data associated with its container session history entry.

Active session history entry

A navigable contains an active session history entry which has the session history entry data structure discussed before. The URL in this entry is the URL of the active document and step is the value of current session history step in the traversable navigable, at which this session history entry was active.

A traversable navigable contains a joint session history data structure, a current session history step, and a session history traversal queue:

Joint session history

It is a union of all session history entries associated with the traversable navigable and the descendant navigables. Note that there is a single joint session history data structure for all navigables inside a traversable navigable and all the navigation related updates will be happening to this data structure.

Current session history step

It is a non-negative integer, initially set to 0. It indicates the number of history.pushState navigations required to reach from the initial state of the traversable to reach the current state.

Session history traversal queue

Whenever we are navigating inside any navigable, the algorithm steps needed to complete the navigation are enqueued inside this queue. The algorithm steps are executed when popped from the queue as per priority.

History API

Every navigable has a distinct history object(window.history) which provides some APIs to interact with the joint session history. Following are some of the APIs provided by the object:

  1. history.go: Takes a delta integer as a parameter and navigates that many steps in joint session history. Positive delta invokes forward navigation and negative delta invokes backward navigation.
  2. history.back: Goes back one step in joint session history. Equivalent to window.history.go(-1) or clicking the back button on the browser window.
  3. history.forward: Goes ahead one step in joint session history. Equivalent to window.history.go(1) or clicking the forward button on the browser window.
  4. history.pushState(data, title, url): Creates a new entry in the joint session history with classic history API state set to data and url set to the url provided in params. Calling history.pushState will increase the length of joint session history by 1.
  5. history.replaceState(data, title, url): Updates the active session history entry by setting classic history API state to data and URL to the url provided in params. This does not affect joint session history length.

Note that history.pushState and history.replaceState do not load the new document associated with the updated active session history entry. We need to call window.history.go() or window.location.reload() after these methods to load the document associated with the newly added session entry.

Navigation algorithm

Navigation inside a navigable involves a complex algorithm, which includes multiple sections which can vary based on different scenarios. In this section, we will only see a part of the algorithm which shows how updates are performed inside navigables during a navigation.

Let currentStep be the value of the current session history step of the traversable navigable and delta be the number of steps we need to traverse forward or backward. This delta is determined from the navigation method. For eg: history.forward sets delta = 1, history.back sets delta = -1, history.go(x) sets delta = x. On a high level, the algorithm performs the following steps for history API based navigations:

  1. When js code encounters a navigation method call, the delta is determined and algorithm steps required to traverse session history by delta are appended to the session history traversal parallel queue. The algorithm steps for a particular navigation call are executed when prioritized and popped from the queue. Amongst the several algorithm steps for a single navigation, the ones relevant to this article are listed step 2 onwards.
  2. targetStep for the traversable navigable is calculated as current step + delta.
  3. For each descendant navigable of the traversable navigable and the traversable navigable, the following steps are executed:

i) All session history entries of the navigable are fetched from the joint session history and extracted to an entries list.

ii) From entries list, the session history entry with the maximum value of step such that step <= targetStep is extracted and assigned to a targetHistoryEntry.

iii) Active session history entry is updated inside the navigable to targetHistoryEntry and document is updated inside the navigable as per the active session history entry.

4. The current step is set to targetStep in the traversable navigable.

Example of navigation algorithm

Navigation example: Initial state of components

Let us see an example for the algorithm. The above diagram shows a top window t with 2 iframes i1 and i2. Being a traversable navigable, the top window also has a session history traversal queue, a joint session history and a current session history step.

The following pushState navigations are performed and corresponding entries are created in joint session history.

i2 invokes history.pushState(“/i2-b”); history.go();

i1 invokes history.pushState(“/i1-b”); history.go();

t invokes history.pushState(“/t-b”); history.go();

i1 invokes history.pushState(“/i1-b”); history.go();

On each navigation, the current session history step is incremented and finally it is set to 4(initial value 0 + 4 navigations = 4).

Now, i1 invokes history.go(-2). The algorithm steps are enqueued in the session history traversal queue and target step is determined as 4 + (-2) = 2.

Navigation algorithm: Determining targetHistoryEntries

From the joint session history, targetHistoryEntries are selected for each navigable with maximum value of step such that step <= 2. These entries are t: 0 for top window t, i1: 2 for iframe i1 and i2: 1 for iframe i2.

Then, inside each navigable, the active session history entry is set to its corresponding targetHistoryEntry as shown in the below diagram. As per the active session history entry, t is navigated to /t-a, i1 to /i1-b and i2 to /i2-c. Finally, the current session history step is set to 2 in the top window(traversable navigable).

Navigation algorithm: Updating current step and active session history entries

It is a bit difficult to predict the final state of all navigables just by referring to the steps in the above algorithm. This is where we need Jake diagrams for ease of understanding.

Jake Diagrams

Jake diagrams are named after their creator, Jake Archibald. These provide a way to visualise the session history entries of all the navigables inside a traversable navigable. We can use this diagram to identify which URL will be loaded after a navigation within each of the navigables.

Each row represents a navigable and its transition between URLs. Each column indicates a step within joint session history. A cell in a row and column indicates the active session history entry URL for navigable in the row at a step indicated by the column.

Jake diagram: joint session history built through pushState navigations

In the above diagram, initially the top window, frame1 and frame2 are loaded with URLs /t-a, /f1-a and /f2-a respectively. Following sequence of navigations happen after the page is loaded.

  1. frame1 invokes history.pushState for /f1-b url and navigates forward. Now we are at step 1 in joint session history.
  2. frame2 invokes history.pushState for /f2-b url and navigates forward. Now we are at step 2 in joint session history.
  3. top window invokes history.pushState for /t-b url and navigates forward. Now we are at step 3 in joint session history.
  4. frame2 invokes history.pushState for /f2-c url and navigates forward. Now we are at step 4 in joint session history.
  5. Invoking history.go/back/forward at any step will navigate to the target step according to the function call and load the URLs in the target step for each of the navigables.

A few examples:

  1. If we are at step 4 and we hit the back button on the browser, we will navigate to step 3 in joint session history. At step 3, top window and frame1 would remain at /t-b and /f1-b respectively. frame2 will navigate to /f2-b.
Hitting back button at step 4

2. history.go(-2) inside any window at step 3 would navigate to step 3 + (-2) i. e. 1. Thus, top will navigate to /t-a, frame1 would remain at /f1-b and frame2 would navigate to /f2-a.

Invoking history.go(-2) at step 3

3. Invoking history.forward inside any window in step 1 would navigate to step 2. frame2 will navigate to /f2-b, top window and frame1 would remain at /t-a and /f1-b respectively.

Invoking history.forward at step 1

Thus, we can see how invoking navigation inside a navigable is navigating within another navigable within the traversable navigable.

Coming to our original problem statement. Consider a navigable overlay iframe injected within an application. How can we prevent the application’s navigables from invoking navigation within the overlay iframe and vice versa?

We can achieve this if we have only one session history entry in the overlay iframe navigable all the time.

One way to solve this is to reinject the overlay iframe whenever any navigation happens inside it. This would recreate the session history of the iframe maintaining only one entry inside the session history. But as the size of the iframe grows, this would bring performance overhead. So this approach is not feasible.

Another option is to use history.replaceState for updating the active session history entry inside the iframe instead of using history.pushState to create a new entry for every navigation.

The following Jake diagram demonstrates this approach:

history.replaceState overlay iframe and history.pushState in top window

Here the application loads at step 0 with URL /t-a. The overlay iframe is not injected yet in the application. The top window then navigates to /t-b. At this point of time, the overlay iframe with URL /f1-a is injected. Then the top window navigates to /t-c. Each time navigation is performed, history.pushState is used in the top window to add a new session history entry. Meanwhile, the iframe navigates from /f1-a to /f1-b and then to /f1-c. Each time the iframe navigates, the current session history entry is replaced and the previous entry is lost.

Consider the following scenarios:

  1. If the top window navigates back from step 2, it will navigate to step 1, its URL will change to /t-b. But iframe will remain at /f1-c as it was at the same session history entry entry even at step 1.
Back navigation in top window

2. If the top window navigates back from step 1, it will navigate to step 0 where the iframe was not injected. Here the iframe will stay on /f1-c as it did not have any entry corresponding to step 0 and the top window will navigate to /t-a.

Invoking history.back() at step 1

3. If we navigate forward from step 1 to step 2, the top window will navigate to /t-b and the overlay iframe will stay at /f1-c.

Invoking history.forward at step 0

The overlay iframe cannot use history.forward or history.back at any stage as this will invoke navigation in the top window. Similarly, it cannot use history.pushState, as this will add a new state in its session history and navigation within the top window might trigger navigation within the iframe. Every navigation inside the iframe should be a history.replaceState followed by history.go()(no delta specified, so effectively reloading the same page).

We can see that in all the above cases, the iframe’s navigation is not affected by the host application’s navigation. The host application cannot invoke a navigation within the overlay iframe and the overlay iframe’s code is written in such a way that it does not invoke a navigation on the host application.

Conclusion

Thus, as a solution for the problem statement in the introduction, frame2 can use react-router-dom’s history.replace(which internally uses native history.replaceState) for all it’s navigations instead of using history.push(which internally uses native history.pushState) to prevent any effects from the navigations inside top window and frame1. However it is important to note that the iframe still shares joint session history with other navigables. So its navigation is not completely isolated by design. We have just found a way to prevent external navigations from interfering with the iframe’s navigation adhering to the existing navigation architecture provided by the browser.

References

https://html.spec.whatwg.org/dev/browsing-the-web.html#session-history-infrastructure

https://html.spec.whatwg.org/#the-history-interface

https://www.aleksandrhovhannisyan.com/blog/react-iframes-back-navigation-bug/

https://youtu.be/F-E6GmuQxOg

--

--