LIANA GRIGORY

Home / Writing

Navigation that never lies: deriving back and forward state on iOS

A navigation control should exist only while it leads somewhere. That sounds obvious until you try to implement it on the web, where the platform declines to tell you whether there is anywhere to go.

What the platform will not tell you

history.length is not the answer. It counts entries that existed before your application did — the page the user came from, the search results before that — and it never shrinks. A fresh load into your app can report a length of nine.

There is no canGoForward. It exists in the Navigation API, which WebKit does not ship, so on iOS there is nothing to ask.

And history.back() is not detectable. Calling it and checking whether the location changed does not work, because the change is asynchronous and because a same-document entry may not change the URL at all.

The version that lied

The implementation this replaced drew both arrows unconditionally, called history.back() on tap, noticed a moment later that the location had not changed, and navigated to the site root instead. Every part of that is wrong in the same way: the person tapped a control that said "return" and arrived somewhere they had never been. A control that misrepresents what it does is worse than no control, because the user has already committed to its promise.

Keep the bookkeeping yourself

Stamp every entry with an index, and remember the furthest index reached:

function stamp(i) {
  history.replaceState({ ...history.state, navIndex: i }, '');
  const hi = Math.max(i, +sessionStorage.getItem('navHigh') || 0);
  sessionStorage.setItem('navHigh', String(hi));
}

// on a new push, what was ahead is gone: pull the ceiling down to here
function push(url, i) {
  history.pushState({ navIndex: i }, '', url);
  sessionStorage.setItem('navHigh', String(i));
}

function state() {
  const i  = history.state?.navIndex ?? 0;
  const hi = +sessionStorage.getItem('navHigh') || 0;
  return { canBack: i > 0, canForward: i < hi };
}

sessionStorage is the right store: scoped to the tab, cleared when it closes, which is exactly the lifetime of a navigation stack. Wrap the reads and writes in try/catch anyway — private windows and blocked site data make the accessor itself throw, and the navigation bar must still render.

The truncation line is the one people miss. When a user goes back twice and then taps something new, the entries that were ahead are discarded by the browser. If the remembered ceiling is not pulled down to match, the forward arrow stays visible pointing at history that no longer exists.

Recompute on pageshow, not only popstate

This is the case that actually broke, and it only reproduces on a device or in the simulator. Navigating back to a full page load restores the document from the back-forward cache. The document is not re-executed. No popstate fires. The arrows keep whatever they were showing at the moment the page was frozen, which by then is wrong.

addEventListener('pageshow', render);   // covers bfcache restores
addEventListener('popstate', render);

Leave the hole where the arrow was

When a control is absent, keep an inert element of the same size in its place, so back stays on the left, forward stays on the right, and the bar does not change height as arrows come and go. An empty <span>, not a disabled button: nothing to tap, and nothing for a screen reader to announce.

A disabled-and-greyed button is a consolation prize offered to the user in place of an answer. If the control cannot do its job, it should not be drawn.


Written by Liana Grigory, also written Liana Grigoryan — entrepreneur, technology founder and U.S. Army veteran in Los Angeles. More at Writing.