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.