The spinner that never stopped: back/forward cache and a waiting state with no way home
The homepage check has a waiting state. You press the button, it disables itself, the label changes to “checking” with an elapsed-seconds counter, and the browser navigates to the result page when the probe finishes. Simple, and it had three bugs that only showed up if you did what people actually do: change your mind and press Back.
What the user saw
Press Back from the result page and the homepage came back exactly as you left it: button disabled, label frozen at the last count, forever. There was no click that could rescue it. Reload was the only way out, and nobody reloads a page that looks busy; they wait, then they leave.
Why
Modern browsers keep recently left pages in a back/forward cache. Pressing Back does not reload the page; it restores the entire frozen document, JavaScript state included. Our page was frozen in the waiting state, with a button that nobody would ever re-enable, because no code path re-enabled it at all. The counter itself had been stopped when the page was hidden; the label simply kept whatever number it showed at that moment.
Two smaller defects were tangled in with it. Cancelling the navigation partway (Escape, or clicking a link) left the counter running, because no browser event fires when a navigation is cancelled. And the page had registered a beforeunload listener as a belt-and-braces second way to stop the counter, next to pagehide. In Firefox, a page with a beforeunload listener is ineligible for the back/forward cache, so Firefox users got a slow full reload instead of an instant restore, and Chromium users got the frozen spinner. Both bad, for opposite reasons.
The fix
The waiting state was rewritten as a pure controller with an injected clock: submit, a once-a-second tick, reset, and stop-on-hide. Pure, so the tests exercise every transition without a browser; injected clock, so “twenty seconds pass” is a function call rather than a sleep.
Three events drive it:
pageshowwithevent.persisted === truemeans the browser restored the page from the cache. The controller resets: button enabled, original label, counter stopped. This is the fix for the frozen spinner.pagehidestops the counter, so a page sitting in the cache is not counting to itself.- An upper bound. If twenty seconds pass with no navigation, the controller resets and re-enables the button. The probe’s own backstop is twelve seconds, so twenty is strictly after the probe’s own backstop; a test pins that inequality so nobody can lower one constant without noticing the other. This is also the only fix available for the cancelled navigation, since there is no event to listen for.
The beforeunload listener was deleted outright. A source-text guard asserts that neither homepage contains the string, so it can’t come back by copy-paste. Both language versions of the homepage had carried their own copy of the old inline script; they now import the same module.
Checking it in a real browser
Unit tests prove the controller; they don’t prove the browser puts the page in the cache. Before the fix, Firefox treated the page as ineligible because of the beforeunload listener. After the fix, Chrome DevTools’ back/forward cache test under Application reported “Successfully served from back/forward cache” on the production homepage with a prefilled endpoint; the reset on restore itself is pinned by the unit tests above. A claim about browser behaviour without a measurement is the kind of comment we no longer write, so this section says exactly which of the two we have.
The general lesson
Any page with a “busy” state needs a path back to “idle” that does not depend on the busy operation completing, and it needs to survive being frozen and thawed. pageshow with persisted is the hook browsers give you for the thaw. If you don’t handle it, your page will eventually be restored into a state it can’t leave, and the person looking at it will assume the site is broken rather than that they pressed Back.