The back button "hack"

Amal Jossy,javascriptbrowsersiframes

Chrome 120 added CloseWatcher API (opens in a new tab) and I got pinged by a coworker asking if it's time I retired the “back button hack”. Sadly, it was not.

The back button hack does two things

  1. Prevent the user from navigating back out of the page
  2. Trigger any dismiss action applicable in the current state of the app

Let me explain.

The illusion of “no going back”

The hack is to create a new history entry as soon as the page loads and add a popstate event listener which adds another history entry.

history.pushState(null, document.title, location.href);
window.addEventListener('popstate', function (event)
{
  history.pushState(null, document.title, location.href);
});

The back button still works and takes the user back in history. But the app creates a duplicate history entry whenever that happens. No page reload is involved, and it seems like you're still on the same page regardless of how many times you click the back button.

This works on the back button you see on the navbar of desktop browsers, physical back button on phones, browser back gestures etc. (There’s one catch with gestures on Safari, will come to it later)

Iframes and the “history inspection problem”

The apps I work on at smallcase Gateway open up inside an iframe. We offer a javascript SDK to customers who add the SDK as a script on their page. The methods on the SDK create an iframe in the customers’ page and open our pages inside it. When users are done with the embedded page, the SDK removes the iframe from the customers’ page.

The pages that use the back button hack always create one extra history entry. Even if the iframe is removed, the history entry still stays and the user doesn't see anything when they click back. There was an episode of the HTTP203 podcast (opens in a new tab) that called this the “history inspection problem”. The navigation API (opens in a new tab) could solve the problem but today we have to rely on the old history API as the navigation API is still experimental.

Since SDK removes the iframe, it can also adjust the current history index using history.go(). history.go(-1) should be enough to counteract the changes made by the page, unless the page performed some other navigations the SDK did not know about. Sadly it does.

Thankfully, we have history.length. All we have to do is check the length before we open the iframe and after we close it and window.go() the difference. Unfortunately, history.length does mean the index in the history stack we are in. Just to total entries in the stack [ref (opens in a new tab)]. That can also be changed. If we do history.pushState() at any index, all the forward history indexes will get removed and a new index will be created. Checkinghistory.length after pushState() will give you the value of i + 1 where i is the history index we need to return to. After closing the iframe, we do the same thing and get history.length as i + x where x is the number of indices we must go back by (history.back(-x)).

A helper function that holds this logic can look like this

const createBrowserHistoryHelper = () => {
  // purge all forward history and get i + 1
  window.history.pushState(null, document.title, window.location.href);
  const startingLength = window.history.length - 1;

  return function restoreHistory() {
    // purge forward history and get current length, i + x
    window.history.pushState(null, document.title, window.location.href);
    const currentLength = window.history.length;
    
    // negative integer indicating the no of history entries we have to travel
    let lengthToPop = startingLength - currentLength; // i - (i + x) = -x
    // go back in history
    window.history.go(lengthToPop);
  };
};

calling createBrowserHistoryHelper() before opening the iframe would give you a restoreHistory() function to call after the iframe is removed with the value of i it needs in its closure. This code goes in the SDK and the “no going back” code goes in the app we load in iframe.

Who takes better care of your browser history?

Do you need the history indices of pages that were inside the iframe once it was removed? what are you going to do with it? Without the iframe present moving across those disowned indices would be confusing. You are going backwards or forward without seeing anything in your browser.

Firefox made an effort here, once the iframe is removed, all the indices created from the iframe are also removed. Should that affect our logic? we are creating one extra index after the iframe is removed. The math still checks out. But browser math is 0.1 + 0.2 not being equal to 0.3. Firefox didn't forget our unwanted history. history.length after pushState() gives you a number that includes the disowned indices. history.go(-x) now takes you too far back because the math counted some indices that Firefox had decided to remove.

At this point, we can think about adding browser-specific logic.

const createBrowserHistoryHelper = () => {
  // purge all forward history and get the correct length
  window.history.pushState(null, document.title, window.location.href);
 
  const startingLength = window.history.length - 1;
  return function restoreHistory() {
    const lengthBeforePush = window.history.length;
 
    // purge forward history and get the current length
    window.history.pushState(null, document.title, window.location.href);
    const currentLength = window.history.length;
 
    // negative integer indicating the no of history entries we have to travel
    let lengthToPop = startingLength - currentLength - 1;
    // unless pushState added more than one entry
    if (currentLength - lengthBeforePush > 1) {
      //Firefox removes all the iFrame history entries
      lengthToPop = -1;
    }
    // go back in the history
    window.history.go(lengthToPop);
  };
};

We don't have to check for the exact browser here. If pushState() increased history.length by more than 1, we can assume it is Firefox or some other browser that uses the same logic(hopefully). Since Firefox removed all the extra indices, we have to go back just the one extra index we created.

Remember the “no going back” code?

function disableBackButton() {
  window.history.pushState(null, document.title, window.location.href);
  window.onpopstate = () => {
    window.history.pushState(null, document.title, window.location.href);
  };
}

When this is put in an iframe, there is another hurdle. Safari ignores the seemingly pointless pushState() you did right after the iframe loaded. I am still trying to understand why it does this but it must be to take good care of your browser history.

Apply a quick fix and …

function disableBackButton() {
  const startLength = window.history.length;
  window.history.pushState(null, document.title, window.location.href);
  const newLength = window.history.length;
 
  // pushstate from iframe right after load in Safari doesn't do anything
  // try again
  if (newLength === startLength) {
    window.history.pushState(null, document.title, window.location.href);
  }
  window.onpopstate = () => {
    window.history.pushState(null, document.title, window.location.href);
  };
}

… second time’s the charm.

Fake CloseWatcher API

I mentioned that the back button hack had 2 objectives. The second one is to trigger any sort of dismiss action applicable in the current UI state. This means dismissing any open dialog-boxes or aborting any action the user was doing. The closewatcher API could help in these cases.

const watcher = new CloseWatcher();
 
// This fires when the user sends a close request, e.g. by pressing Esc on
// desktop or by pressing Android's back button.
watcher.onclose = () => {
  myModal.close();
};
 
// You should destroy watchers which are no longer needed, e.g. if the
// modal closes normally. This will prevent future events on this watcher.
myModalCloseButton.onclick = () => {
  watcher.destroy();
  myModal.close();
};

Since the API is not live on all browsers I have a “stupid-but-works” implementation

const listeners = [];
 
function disableBackButton() {
  const startLength = window.history.length;
  window.history.pushState(null, document.title, window.location.href);
  const newLength = window.history.length;
 
  // pushstate from iframe right after load in safari doesnt do anything
  // try again
  if (newLength === startLength) {
    window.history.pushState(null, document.title, window.location.href);
  }
  window.onpopstate = () => {
		// call listeners in order of insertion
    for (let i = listeners.length - 1; i >= 0; i--) {
      // if any listener returned true, a meaningful action has been performed and should not call other actions
      if (listeners[i]()) break;
    }
    window.history.pushState(null, document.title, window.location.href);
  };
}

However, In my apps, most-recently-inserted may not always want to handle the event so we call all listeners till one of them returns true.

Dont leave my app, please

We have beforeunload event that can be used to trigger a browser-generated confirmation dialog that asks users to confirm if they want to leave the page. We can't customise the appearance of the confirmation Dialog but we can use the current setup to show our confirmation UI without using beforeunload.

Custom beforeunload dialog

All we have to do is to set the first callback in the listeners’ list to be a callback that triggers your custom Dialog. This way if no other listeners handled the browser back event, the very first one get executed and trigger the Dialog

When safari was just extra

The back button on your browser is not the only way to go back in history. In case of safari for example you have a “swipe between pages” gesture. Which is fine, the implementation isnt particular about how you decide to go back. As long as a popstate event is recieved, the code will work. The problem is the animation that accompanies the gesture.

safari-back-gesture.gif

Even if user is on the same page, safari will show an animation, that too in a very glitchy way as you can see in the gif. The solution was to hack away the gesture by disabling tapable area on the edges of screen. To be clear, call preventDefault on touchstart events while on ios safari(no fixes for mac safari)