Next.js: Scroll restoration happens too early before the page gets rendered after hitting browser back button

Created on 17 Nov 2017  Â·  41Comments  Â·  Source: vercel/next.js

  • [x] I have searched the issues of this repository and believe that this is not a duplicate.

After transiting from one page to another page via next <Link />, if user clicks the back button and the previous page has getInitialProps method that takes some time to finish, the scroll position of the previous will be restored before the previous pages gets rendered.

Demo

source code can be found here
out

After clicking back button, the "go back" text should still be visible (not scroll down to previous position) until the previous page gets rendered

Most helpful comment

My solution for restoring scroll position when the browser back button clicked:

_app.js

componentDidMount() {
    const cachedPageHeight = []
    const html = document.querySelector('html')

    Router.events.on('routeChangeStart', () => {
      cachedPageHeight.push(document.documentElement.offsetHeight)
    })

    Router.events.on('routeChangeComplete', () => {
      html.style.height = 'initial'
    })

    Router.beforePopState(() => {
      html.style.height = `${cachedPageHeight.pop()}px`

      return true
    })
}

All 41 comments

This seems to happen because next's router (and <Link />) uses window.history.pushState and then window.history.back restores the scroll. This doesn't happen with common <a /> because it doesn't use pushState.

This is an interesting article about how scroll restoration works on each browser. Basically you can disable it and manage the scroll on your own:

history.scrollRestoration = 'manual'

Thanks for you explanation.

Using a plain <a /> tag would cause a full page reload, which is not what I expected. I expect the back button would either not trigger the getInitialProps method or don’t restore scroll until the getInitialProps is finished

On 28 Dec 2017, at 12:10, Pablo Varela notifications@github.com wrote:

This seems to happen because next's router (and ) uses window.history.pushState and then window.history.back restores the scroll. This doesn't happen with common because it doesn't use pushState.

This is an interesting article https://developers.google.com/web/updates/2015/09/history-api-scroll-restoration about how scroll restoration works on each browser. Basically you can disable it and manage the scroll on your own:

history.scrollRestoration = 'manual'
—
You are receiving this because you authored the thread.
Reply to this email directly, view it on GitHub https://github.com/zeit/next.js/issues/3303#issuecomment-354225323, or mute the thread https://github.com/notifications/unsubscribe-auth/AAncD7Vrn7rZJW5NYdokbPsXWn7O4N5Eks5tExSmgaJpZM4QiFwt.

Yeah, I understand. I'm not sure what the solution would be from within next.

@liweinan0423 I'm not sure but this could be a workaround

We've come across this too and it feels like something next should be doing internally. Otherwise everyone needs to write their own code to store scrollpositions and then scroll on completion of getInitialProps.

we're having a related issue where, upon clicking a link from a scrolled-down position (say, result 20 in a list) the new page shows up halfway down so the user needs to scroll up to see the item itself.

here's an example screen capture of the issue we're having:

scroll

We're also running into this issue and it makes the user experience very frustrating to use. @arunoda Any thoughts or way to fix this?

I was having this same issue as @mgiraldo awhile back and I originally was just using router.push(), but as soon as I switched to using the tag, my problem resolved itself with out needing to implement a hack like scroll-to-top in component did mount or something ghetto like that. Curious if you are using a Link tag at all?

it is a regular <Link /> see: https://github.com/dpla/dpla-frontend/blob/master/components/ExhibitionsComponents/AllExhibitions/components/ExhibitionsList/index.js#L8-L18

but we ended up using componentDidMount with window.scrollTo in the destination page: https://github.com/dpla/dpla-frontend/blob/master/pages/exhibitions/exhibition/index.js#L25-L27

feels very hacky, though... and we have the Back button issue mentioned in the issue in other parts of the site.

you can see the problem in action here: https://dp.la/exhibitions (scroll down and click)... the patch will be up tomorrow

you can see the patched version here: https://beta-staging.dp.la/exhibitions

@mgiraldo I see a slight flash when using the back button. We're running into the same issue.

@mgiraldo In another part of my site I forgot I did this:

Router.push("/account")
window.scrollTo(0, 0)
document.body.focus()

which works when navigating TO a page, but like you said when you hit the back button, its at the top of the window because I think the window recorded that in its history....
What are you doing to mitigate that back button issue? Its like I can get one to work but not the other.

@spencersmb What do you mean switch to using the tag? What tag is that?

We've a similar issues reported here: https://spectrum.chat/thread/3d65b436-b085-4eef-ad84-0941c473e09d

Next.js doesn't hijack scrolling. But this is a real issue. Here's what's happening.
This is because of browser's default scrolling behavior. See: https://github.com/zeit/next.js/issues/3303#issuecomment-354225323

When you go back, browser tries to go to the previous scroll position for better UX. But in the ^^ case, the data is not loaded yet. But the data will be there in a bit with the latency of getInitialProps(). When the data rendered, it's not the correct position as before.

So, in order fix this you can do a few things:

  • Cache the data locally and render the page from that cache. You can even update the data in behind the scene. In this case data will be there as the user hit the back button.
  • Control scrolling manually via Router.push(). (This will return a promise and it'll get resolved when the page is rendered)

We've no plan to manage scrolling inside Next.js yet. But you can do it in the userland and publish a new Link component like LinkBetterScrolling.

I mean every time a javascript library gets popular and people start using it in production ... some majors issues show up and the official response is: "_Fix it yourself._" 😢

I fail to understand how does Router.push() fix scrolling issue that happens when you press button back in a browser.

@mgiraldo Posted a link to a patched version of his website.

1) Go here: https://beta-staging.dp.la/exhibitions
2) Scroll to the bottom
3) Click on activism in the US
4) Click on button back in your browser or just swipe back

There are two major issues:
1) Before the list gets rendered. Item detail scrolls to the bottom for no reason.
2) When the list gets rendered you are not at the bottom of the list but somewhere in the middle.

I believe it would be nice to _at least consider fixing this issue in the future_.

Other than that thanks for all the hard work that you put in nextjs. It might take a little longer but we are getting closer to stable libraries in javascript community.

@developer239 We have the exact same issue with our Next.js app as documented here: https://spectrum.chat/thread/3d65b436-b085-4eef-ad84-0941c473e09d

It completely kills the user experience.

Basically, the current page scrolls to the bottom of the page because it's trying to restore scroll position before the previous page is rendered.

@arunoda Could you explain why the Next team isn't considering an official fix to this issue? It seems like it's a breaking bug that completely ruins the user experience.

if i understand correctly the back button/scroll position functionality is a native browser behavior that assumes a standard page refresh (user goes from url x to url y via a standard http request). what client-side javascript-based frameworks do is implement a page transition without making use of the native browser behavior (via pushState or similar). the browser is counting on itself having a cached version of the page to return you to, but it doesn't (it has an incomplete version that is completed by the framework after the jump is made). however, if your app is not fast enough to produce page transitions/content using the framework (in either direction, backwards or forward), you will get this jarring, second-rate experience.

what i think the nextjs team is saying is the effort of implementing a cross-browser solution to this problem is beyond the scope they are willing to take on.

I think if a next.js team provides its own routing management they should care about similar critical issues as well. For example react-router doesn't have this issue and they don't think that is should be implemented by another developer themselves.
Definitely this should be fixed because it makes the user experience very frustrating and confusing.

Hi to all! I think i solve this issue by following the advice of @pablopunk
history.scrollRestoration = 'manual' using it inside componentDidMount() inside the _app.js file.
However, after some minutes i removed it and the problem seems that has gone.

Strange thing, by using and removing it, it seems that it also fix the issue in a build that is deployed with now. Maybe is only a browser issue?

I came across mentioned issue while building my portfolio site and I think this is really a big breaking thing in user experience overall.
I can confirm that problem occurs when the page to go back has more height than the current page. And (IMHO) this has nothing to do with getInitialProps as was stated in official next team response. I get similar bad result with or without using getInitialProps . More likely it happens because scroll restoration occurs before route change but not after it. Try to set simple page transitions and you'll see that scroll height jumps instantly despite the content of current page is still there.
In my case I found temporary solution. This involves setting the height of "html" tag to a very high value (like 15000px) on onRouteChangeStart event, and restoring its original height in 2 seconds after onRouteChangeComplete. This works for me and hope it will help someone until we get official fix. Good luck.

My solution for restoring scroll position when the browser back button clicked:

_app.js

componentDidMount() {
    const cachedPageHeight = []
    const html = document.querySelector('html')

    Router.events.on('routeChangeStart', () => {
      cachedPageHeight.push(document.documentElement.offsetHeight)
    })

    Router.events.on('routeChangeComplete', () => {
      html.style.height = 'initial'
    })

    Router.beforePopState(() => {
      html.style.height = `${cachedPageHeight.pop()}px`

      return true
    })
}

@hardwit well done!
however it works for me only if I restore height asynchronously like this:

Router.events.on("routeChangeComplete", () => { setTimeout(() => { html.style.height = 'initial' }, 1500); });

My solution for restoring scroll position when the browser back button clicked:

_app.js

componentDidMount() {
    const cachedPageHeight = []
    const html = document.querySelector('html')

    Router.events.on('routeChangeStart', () => {
      cachedPageHeight.push(document.documentElement.offsetHeight)
    })

    Router.events.on('routeChangeComplete', () => {
      html.style.height = 'initial'
    })

    Router.beforePopState(() => {
      html.style.height = `${cachedPageHeight.pop()}px`

      return true
    })
}

Where does Router come from?

This worked for me:

_app.js

  componentDidMount() {
    window.history.scrollRestoration = "manual";

    const cachedScroll = [];

    Router.events.on("routeChangeStart", () => {
      cachedScroll.push([window.scrollX, window.scrollY]);
    });

    Router.beforePopState(() => {
      const [x, y] = cachedScroll.pop();
      setTimeout(() => {
        window.scrollTo(x, y);
      }, 100);

      return true;
    });
  }

For me, the above didn't work. I'm not sure if it was related to the fixed timeout, but beforePopState didn't trigger at the scroll at the right time.

What I ended up with was storing a potential scroll and then triggering it in onRouteChangeComplete.

  componentDidMount() {
    window.history.scrollRestoration = "manual";
    const cachedScrollPositions = [];
    let shouldScrollRestore = false;

    Router.events.on("routeChangeStart", () => {
      cachedScrollPositions.push([window.scrollX, window.scrollY]);
    });

    Router.events.on("routeChangeComplete", () => {
      if (shouldScrollRestore) {
        const { x, y } = shouldScrollRestore;
        window.scrollTo(x, y);
        shouldScrollRestore = false;
      }
    });

    Router.beforePopState(() => {
      const [x, y] = cachedScroll.pop();
      shouldScrollRestore = { x, y };

      return true;
    });
  }

I found some issues with window.history.scrollRestoration = "manual" on moving through few pages and then when we returning back and reloading page - it's jumping through the top to previous location.

So i found solution to swithing window.history.scrollRestoration on different window lifeCycles:

_app.tsx

  componentDidMount() {
    window.history.scrollRestoration = 'auto';
    const cachedScrollPositions: number[][] = [];
    let shouldScrollRestore: { x: number, y: number };

    Router.events.on('routeChangeStart', () => {
      cachedScrollPositions.push([window.scrollX, window.scrollY]);
    });

    Router.events.on('routeChangeComplete', () => {
      if (shouldScrollRestore) {
        const { x, y } = shouldScrollRestore;
        window.scrollTo(x, y);
        shouldScrollRestore = null;
      }
      window.history.scrollRestoration = 'auto';
    });

    Router.beforePopState(() => {
      if (cachedScrollPositions.length > 0) {
        const [x, y] = cachedScrollPositions.pop();
        shouldScrollRestore = { x, y };
      }
      window.history.scrollRestoration = 'manual';
      return true;
    });
  }

I'm curious why people are still trying to manually scroll Next.js pages. @arunoda gave a clear explanation of the problem here: https://github.com/zeit/next.js/issues/3303#issuecomment-376804494

It's because getInitialProps runs even when someone clicks on the browser back button. This means it'll try to grab fresh data. But the browser expects the data to be there already. Just cache any remote data from getInitialProps in the client and the problem should go away. I gave an example here: https://levelup.gitconnected.com/6-tips-using-next-js-for-your-next-web-app-e3f056fa46

I added this code and it works perfect for me

`componentDidMount() {
    if ('scrollRestoration' in window.history) {
      window.history.scrollRestoration = 'manual';
      const cachedScrollPositions = [];
      let shouldScrollRestore;

      Router.events.on('routeChangeStart', () => {
        cachedScrollPositions.push([window.scrollX, window.scrollY]);
      });

      Router.events.on('routeChangeComplete', () => {
        if (shouldScrollRestore) {
          const { x, y } = shouldScrollRestore;
          window.scrollTo(x, y);
          shouldScrollRestore = false;
        }
      });

      Router.beforePopState(() => {
        const [x, y] = cachedScrollPositions.pop();
        shouldScrollRestore = { x, y };

        return true;
      });
    }
  }`

@pavlo-vasylkivskyi-scx thank you!

I little bit changed your code for using with functional component.

import Router from 'next/router'

let cachedScrollPositions = [];

const Home = props => {
  useEffect(() => {
    if ('scrollRestoration' in window.history) {
      window.history.scrollRestoration = 'manual';
      let shouldScrollRestore;

      Router.events.on('routeChangeStart', () => {
        cachedScrollPositions.push([window.scrollX, window.scrollY]);
      });

      Router.events.on('routeChangeComplete', () => {
        if (shouldScrollRestore) {
          const { x, y } = shouldScrollRestore;
          window.scrollTo(x, y);
          shouldScrollRestore = false;
        }
      });

      Router.beforePopState(() => {
        const [x, y] = cachedScrollPositions.pop();
        shouldScrollRestore = { x, y };

        return true;
      });
    }
  }, []);

  return (...long list of elements with links)
}

Also I cached my list like this https://levelup.gitconnected.com/6-tips-using-next-js-for-your-next-web-app-e3f056fa46. Thank you @jlei523

@pavlo-vasylkivskyi-scx Thank you!

But an error occurred when I refreshed the page and click browser back button.

TypeError: Invalid attempt to destructure none-iterable instance

so I changed your code.

componentDidMount() {
    if ("scrollRestoration" in window.history) {
        window.history.scrollRestoration = "manual";
        const cachedScrollPositions: Array<any> = [];
        let shouldScrollRestore;

        Router.events.on("routeChangeStart", () => {
            if (!shouldScrollRestore) {    // <---- Only recording for history push.
                cachedScrollPositions.push([window.scrollX, window.scrollY]);
            }
        });

        Router.events.on("routeChangeComplete", () => {
            if (shouldScrollRestore) {
                const { x, y } = shouldScrollRestore;
                window.scrollTo(x, y);
                shouldScrollRestore = false;
            }
        });

        Router.beforePopState(() => {
            if (cachedScrollPositions.length > 0) {  // <---- Add this !
                const [x, y] = cachedScrollPositions.pop();
                shouldScrollRestore = { x, y };
            }
            return true;
        });
    }
}

I've been working for a whole week to find a reliable solution to this problem. I couldn't fine one which:

1) works reliably with both Firefox and Chrome.
2) works reliably using both the back-button and the forward-button across your website.
3) works reliably when you hit the back-/forward-button coming from an external website.
4) works reliably even if you hit the refresh button on one of your pages during the session.
5) works reliably even if you hammer the back-button or the forward-button multiple times.

I tried various approaches which I found across the web. I tried with different approaches of my own. Not a single one worked reliably. The approach which took me the farthest was one which used scrollRestoration = 'manual' and sessionStorage to keep track of the position history. It worked flawlessly except for 3..

I'm wondering, has anyone found a solution which works for all items from the list above?

I had this requirement too on an actual project and I came up with the following solution:

https://gist.github.com/schmidsi/2719fef211df9160a43808d505e30b4e

It does not address all points from @feluxe, but it works pretty well for us. Maybe this can help someone as a boilerplate for their own solution.

Here's my take:

https://gist.github.com/claus/992a5596d6532ac91b24abe24e10ae81

It should satisfy all of @feluxe's requirements and behave like native scrollRestoration, although i haven't tested it too much yet. I ran it through Chrome, Firefox and Safari and it worked fine. YMMV if you do out of the ordinary stuff with the router. One caveat is that sometimes scrollTo seems to come a tiny bit too late so you see the top of the page flash for a frame or so.

Caching helped with getInitialProps, since it run client side. Now with getServerSideProps the issue is back.

Caching helped with getInitialProps, since it run client side. Now with getServerSideProps the issue is back.

Yep, when I use getServerSideProps, scroll restoration happens and after that the page gets rendered.

Im seeing this issue too with getServerSideProps, the scroll restoration happens too early

@Timer

I think we need more notice on this issue, getServerSideProps would cause scroll restoration happens too early.
The flikering causes very bad user experience, especially on mobile platform.

I came across the same issue today. It is really frustrating that Next.js doesn't have a baked in solution for this problem.

@hardwit's solution works for me.

This is a UX killer that many developers didn't notice.
Yes, there should be more notice on this issue for all Next.js users or atleast attach this thread in the common issues so people know the solutions in advance.

Thanks @hardwit for starting all the solutions and it solves for me now.

Also came across this problem, thank you everyone for the possible solutions.
This definitely should be officially addressed, huge impact in UX and may even be a necessity for a lot of use cases.

Was this page helpful?
0 / 5 - 0 ratings

Related issues

swrdfish picture swrdfish  Â·  3Comments

rauchg picture rauchg  Â·  3Comments

sospedra picture sospedra  Â·  3Comments

lixiaoyan picture lixiaoyan  Â·  3Comments

kenji4569 picture kenji4569  Â·  3Comments