htmx icon indicating copy to clipboard operation
htmx copied to clipboard

Safari (iOS 16) odd history behaviour (browser back/forward buttons)

Open croxton opened this issue 1 year ago • 3 comments

I'm seeing some odd behaviour with Safari on iOS when navigating using the back/forwards buttons in the browser app and using boosted links. Sometimes pages are skipped and the browser loads a previous website or a page out of sequence.

This is much more likely to occur when htmx is loaded in the <body> or when a request for a dynamically generated page or html fragment loads slowly or is quite large. Moving htmx to the <head> and static caching the html (so content loads more quickly) reduces the chances of it happening.

Further strangeness: if you open a new browser tab and load the site it does not tend to happen. If however you follow a link to the site, or visit another site in the same tab before visiting your site, then it is far more likely to happen.

This does not happen with React or Vue/Nuxt SPAs in the latest Safari, the problem seems to be specific to htmx.

Possibly related to #854

croxton avatar Oct 12 '22 14:10 croxton

In case anybody comes across this in the future, I believe I have resolved this by doing the following:

  • Put htmx in the <head>, to ensure that it executes only once.
  • Add this header so that Safari doesn't try to use bfcache when restoring the initial page visited: "Cache-Control: no-cache, max-age=0, must-revalidate, no-store".
  • Add this script to partial html fragments loaded by htmx (that are not full html documents), to reload the URL should the user return to the page from an external website via the back / forward buttons:
<script>
if ( !!window.performance && window.performance.getEntriesByType("navigation")[0].type === "back_forward") {
  window.location.reload();
}
</script>

One thing to note is that the history API in Safari will behave in unpredictable ways if you are using a self-signed SSL certificate, even if you have trusted the certificate (for example, during development).

croxton avatar Oct 13 '22 09:10 croxton

Reopening this because I hadn't fully solved the mystery after all :/

What I think is happening is that the value of currentPathForHistory is changed by pushUrlIntoHistory() and replaceUrlInHistory(path) before it is able to be pushed into history by saveCurrentPageToHistory(). This seems to happen when a request takes more than 50ms or so to respond, and only in Safari.

Using a timeout to delay updating the value of currentPathForHistory resolves the problem as far as I can tell, but I'll do some more testing tomorrow.

function saveCurrentPageToHistory() {
    var elt = getHistoryElement();
    var path = currentPathForHistory || location.pathname+location.search;
    triggerEvent(getDocument().body, "htmx:beforeHistorySave", {path:path, historyElt:elt});
    if(htmx.config.historyEnabled) history.replaceState({htmx:true}, getDocument().title, window.location.href);
    saveToHistoryCache(path, cleanInnerHtmlForHistory(elt), getDocument().title, window.scrollY);
}

function pushUrlIntoHistory(path) {
    if(htmx.config.historyEnabled)  history.pushState({htmx:true}, "", path);
    setTimeout(function() {
        currentPathForHistory = path;
    }, 100)
}

function replaceUrlInHistory(path) {
    if(htmx.config.historyEnabled)  history.replaceState({htmx:true}, "", path);
    setTimeout(function() {
        currentPathForHistory = path;
    }, 100)
}

croxton avatar Oct 13 '22 21:10 croxton

I've been down a rabbit hole, but I may have some insight on what is happening here.

The fix I posted above solves history navigation between htmx'd links, providing you enter the website and only click htmx'd links. But, it still occurs when you click two or more conventional links within the same website, before you click any htmx'd links.

To illustrate:

  1. Click normal link to /page1

  2. Click normal link to /page2

  3. Click htmx boosted link to /page3 [...waiting for request to finish...] [Log] saveCurrentPageToHistory (history.replaceState): window.location.href= https://my-website/page2 [Log] pushUrlIntoHistory (history.pushState): path= /page3

  4. Click htmx boosted link to /page4 [...waiting for request to finish...] [Log] saveCurrentPageToHistory (history.replaceState): window.location.href= https://my-website/page3 [Log] pushUrlIntoHistory (history.pushState): /page4

  5. Click back button: Expected: shows /page3 Actual: shows /page1 (full page load)

  6. Click forward button from /page1 Shows /page4

Now, the interesting thing is that 5) does actually work as expected but only if /page3 and /page4 load quickly, at a guess under 500ms. But if either take a longer to load (for example, the page has a large image or slow network), then the Safari does not write an entry for the page into history, and so they are skipped when pressing back :(

I was able to confirm this by manually inserting the history entries for /page3 and /page4 in the console before step 5). Things then work as expected.

My gut feeling is that Safari is not inserting the history items (despite receiving them correctly in the right order) because the time from the last user interaction exceeded a certain margin (500ms). I can only imagine this is a security feature intended to prevent websites inserting multiple history items without human interaction (for example to stop users leaving a website).

Another explanation might be that the history.replaceState and history.pushState calls occur too close together, triggering some other security mechanism in Safari when history already has entries.

So, the question is how can we solve this? Is it a consequence of the specific way htmx uses the history api?

Some thoughts:

  1. I'm wondering why the history.replaceState here is necessary on every state change in htmx, immediately followed by a history.pushstate. Shouldn't replaceState only be used on the initial the window.load event?

(I'm no expert, I'm just looking at how it's done in this bare bones example: https://github.com/lesjames/history-api-demo).

  1. Assuming it's needed, should the history.replaceState state change happen before the ajax request finishes, so that 500ms limit isn't exceeded and there's some time between the two calls?

  2. I'm right outta ideas, I'll have no hair left at this rate.

croxton avatar Oct 15 '22 12:10 croxton

For anyone finding this, Apple reverted to the previous back/forward button behaviour in Safari on iOS 16.1 (i.e. they removed or refactored the intervention that was breaking htmx history). History state interventions may reappear in future versions of Safari or other browsers, so the PR referenced above may be needed at a future date.

croxton avatar Nov 03 '22 10:11 croxton