javascriptsafarihistorymanualscroll-position

history.scrollRestoration = "manual" doesn't prevent Safari from restoring scroll position


Suppose you set window.history.scrollRestoration = "manual" on a webpage.

In Chrome/Firefox

Whenever you click an anchor, the page scrolls to the linked element's position, and whenever you go back/forward through history, the scroll position remains the same but the fragment part of the url is updated (#sectionXYZ).

In Safari

Whenever you click an anchor nothing happens, and whenever you navigate back/forward through history, the page scrolls to the position of the element linked to the current page fragment (#sectionXYZ).

When I say "navigate through history" I mean by either using window.history.back(), window.history.forward() and window.history.go(N) or by using the browser's back/forward buttons.

In the example below you have 2 buttons (blue and red) which will push 2 different state onto the history stack when clicked.
Try to click them several times and navigate back/forward through history to replicate the behaviors I described.

Why is Safari restoring the scroll position of the page even when history.scrollRestoration is set to manual ? Is there a way to prevent this behavior like Chrome and Firefox do ?

html,body,div {
    height: 100vh;
    width: 100vw;
    margin: 0;
    overflow-x: hidden;
}    
#nav {
    display:flex;
    justify-content:center;
    position:fixed;
    width: 100%;
    height: 100px;
    background: rgba(0,0,0,0.7);
}
nav > a {
    display:grid;
    justify-content:center;
    align-items:center;
    text-decoration:none;
    color:white;
    width: 30%;
    height: 90%;
    font-size:120%;
    cursor:pointer;
}
#a1, #blue{
    background-color:blue;
}   
#a2, #red {
    background-color:red;
}
<body>
    <nav id = "nav">
        <a id = "a1" href = "#blue">BLUE</a> 
        <a id = "a2" href = "#red">RED</a> 
    </nav>
    <div id = "blue"></div>
    <div id = "red"></div>

    <script>
        window.history.scrollRestoration = "manual";
        window.addEventListener("popstate", () => {
            console.log("blue: ", document.getElementById("blue").getBoundingClientRect());
            console.log("red: ",  document.getElementById("red").getBoundingClientRect());
        });
        document.getElementById("a1").addEventListener("click", () => window.history.pushState("blue", "", "#blue"));
        document.getElementById("a2").addEventListener("click", () => window.history.pushState("red",  "", "#red"));
    </script>
</body>


Solution

  • I (think I) finally have all the answers!

    TL;DR

    Safari only jumps to position when you call history.pushState and the 3rd parameter (the url) contains a valid hash for the current page.
    To fix that just pass to history.pushState a modified hash (as the 3rd parameter) that you will parse in the popstate event listener.

    For instance:

    history.pushState("myState", "", "#validHashWithADot."); //note the final "."
    
    window.addEventListener("popstate", (event) => {
      //Remove the initial "#" and the final "."
      const parsedHash = window.location.hash.slice(1, -1);
    
      //Do your custom scrolling/action...
    }, {passive:true})
    
    //Now go back and forward through history and 
    //you will notice that safari won't scroll 
    //(as we initially expected). 
    

    What is this happening?

    According to the HTML5 spec, whenever a history entry that contains a valid fragment is pushed onto the history's stack, the page:

    1. [...] will scroll to the fragment given in what is now the document's URL.

    My understanding of this bit is that every browser should always scroll to a fragment if its history entry is valid and that Safari doesn't follow the spec whenever scrollRestoration is set to "manual" and this is why the provided example won't scroll the the #red or #blue sections.

    Other than (supposedly) not following the spec, Safari had a different popstate/scrollRestoration behavior than Chrome and Firefox.

    Infact, my first question was:

    "Why is Safari restoring the scroll position of the page even when history.scrollRestoration is set to "manual"?"

    In this case the HTML5 spec states that:

    A scroll restoration mode indicates whether the user agent should restore the persisted scroll position (if any) when traversing to an entry. A scroll restoration mode is one of the following:

    "auto" The user agent is responsible for restoring the scroll position upon navigation.

    "manual" The page is responsible for restoring the scroll position and the user agent does not attempt to do so automatically

    Since Safari never scrolled to #red or #blue sections it should have never saved any persisted scroll position of any restorable scrollable region (or at least they shouldn't be the correct/updated ones) while I assume that Chrome and Firefox saved them whenever an anchor was clicked.

    When navigating through the provided example's history Chrome and Firefox never restored any scrollPosition and that is exaclty what the scrollRestoration spec says. Safari on the other hand seems to never save the persisted scroll positions and so it can't possibly restore any.

    I suppose that scrollRestoration="manual" on Chrome/Firefox means "save the scroll positions but do not restore them whenever the user traverse the history", whilst on Safari it seems to mean "do not save any scroll position" (which implies it can't restore any).

    There's a problem though: Safari scrolls to the correct fragment whenever the user traverse the history even when scrollRestoration="manual". This led me to think that safari internally always checks the current fragment, if it's a valid one it scrolls to its position and then fires the popstate event (which tecnically isn't breaking the spec but its making the scrollRestoration parameter completly useless).

    What's a possible solution?

    My second question was:

    Is there a way to prevent this behavior like Chrome and Firefox do ?

    Since Safari breaks the scrollRestoration feature for anchors, we can't rely on that (it's still useful for Chrome/Firefox though).

    Before I supposed that Safari internally always checks the scroll position of the current fragment and if it's valid it scrolls to it: good news this seems to actually be the case. Infact a hacky yet functional solution I found was to just "pass the wrong" fragment inside the URL whenever we call pushState.

    e.g.

    //By pushing the "wrong" fragments Safari won't be able to parse them
    //and so it won't scroll to their respective positions.
    window.history.pushState("blue", "", "#blue.")); //Note the final "."
    window.history.pushState("red", "", "#red."));   //Note the final "."
    
    //Inside my popstate eventHandler I'll just parse the fragments
    //to their correct counterparts
    window.addEventListener("onpopstate" (e) => {
      const _fragment = window.location.hash.slice(1, -1);
      //Do whatever you want with the current fragment...
    })
     
    //This will trigger the navigation to #blue 
    //but not the scrolling as before 
    window.history.back();
    

    This solves the fact that Safari always restores the scroll position at the cost of having to add 1 line of URL parsing inside the "onpopstate" eventListener.

    This is the fixed original example:

    html,body,div {
        height: 100vh;
        width: 100vw;
        margin: 0;
        overflow-x: hidden;
    }    
    #nav {
        display:flex;
        justify-content:center;
        position:fixed;
        width: 100%;
        height: 100px;
        background: rgba(0,0,0,0.7);
    }
    nav > a {
        display:grid;
        justify-content:center;
        align-items:center;
        text-decoration:none;
        color:white;
        width: 30%;
        height: 90%;
        font-size:120%;
        cursor:pointer;
    }
    #a1, #blue{
        background-color:blue;
    }   
    #a2, #red {
        background-color:red;
    }
    <body>
        <nav id = "nav">
            <a id = "a1" href = "#blue">BLUE</a> 
            <a id = "a2" href = "#red">RED</a> 
        </nav>
        <div id = "blue"></div>
        <div id = "red"></div>
    
        <script>
            window.history.scrollRestoration = "manual";
            window.addEventListener("popstate", () => {       
                console.log("blue: ", document.getElementById("blue").getBoundingClientRect());
                console.log("red: ",  document.getElementById("red").getBoundingClientRect());
            });
            document.getElementById("a1").addEventListener("click", () => window.history.pushState("blue", "", "#blue."));
            document.getElementById("a2").addEventListener("click", () => window.history.pushState("red",  "", "#red."));
        </script>
    </body>

    Side note

    If you're trying to achieve a custom anchor (smooth) scrolling you may be interested in the API I wrote for smooth scrolling and the uss.hrefSetup function: https://github.com/CristianDavideConte/universalSmoothScroll