Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add useScrollRestoration #186

Closed
ryanflorence opened this issue May 25, 2021 · 37 comments
Closed

Add useScrollRestoration #186

ryanflorence opened this issue May 25, 2021 · 37 comments

Comments

@ryanflorence
Copy link
Member

ryanflorence commented May 25, 2021

Browsers have built-in scroll restoration for both document transitions and history push/pop events. However, for history events the behavior is synchronous: as soon as the event fires, the browser tries to restore the scroll position. This means your data on both pop and push events needs to be synchronous as well. This is, of course, not realistic.

These are all typical bugs around scroll position with client routed SPAs

  1. The scroll position stays at the bottom of a new page after a link is clicked
  2. The scroll position changes to the top of the old page before the data loads for the new page (this one really freaks me out)
  3. The scroll position goes to the top on every history event, instead of restoring to the position it was at that entry in the stack

Additionally, one day React's rendering itself will be asynchronous with concurrent mode, so even if you solve the data case (you can't though) the browser would restore scroll before the new page is ready causing bug (2) on pop events.

In order to have automatic scroll restoration with async transitions we need to once again emulate the browser's behavior with document transitions, which is to wait for the next page to be ready before restoring scroll positions.

window Scroll

This will scroll to the top on push events and restore scroll position on window on pop events:

export function Root() {
  useScrollRestoration()
}

If you want to stop scroll restoration, pass it false

export function Root() {
  useScrollRestoration(false)
}

This is useful when you have an outlet that is changing a small part of the UI and don't want the page scrolling up and down as the user moves through it, a couple example are Tabs and Multi-step "Wizards". Can use handle and useMatches to turn off scroll restoration:

let matches = useMatches();
let shouldManageScroll = !(matches.some(match => match.handle.scroll === false));
useScrollRestoration(shouldManageScroll)

Scroll Containers

Sometimes your UI has large scrollable containers instead of window that need their position restored. For that we have useElementScrollRestoration.

export function Root() {
  let ref = useRef<HTMLDivElement>(null);
  useElementScrollRestoration(ref)
  return (
    <>
      <div>Sidebar</div>
      <div ref={ref} style={{ overflow: "scroll" }}>
        <Outlet/>
      </div>
    </>
  )
}

And same thing, can pass a boolean to prevent the behavior:

useElementScrollrestoration(ref, false);

Restoring by URL instead of location

Some SPAs restore scroll by URL instead of location. This is not how browsers work, but in some apps it makes sense. For example, twitter.com restores scroll position of your home feed whether it's a push or pop event and it's a solid UX (though personally I'd expect it to go to the top for a push, restore for a pop, but I'm sure user research could go against my preference).

For this we could add a bit different API with a config object since we have a couple options now besides just "shouldRestore" from earlier.

// default behavior, always restore
useScrollRestoration();

// stop restoring
useScrollRestoration({ restore: false });

// Give the developer a very sharp knife, when the same key is passed in later,
// we'll restore to that key's position, regardless of the location/url
useScrollRestoration({ key });

// restore by location (browser behavior)
useScrollRestoration({ key: location.key });

// restore by url (twitter.com behavior)
useScrollRestoration({ key: location.pathname });

// don't restore for the paths under "/wizard"
useScrollRestoration({
  key: location.pathname.startsWith('/wizard') ? 'wizard' : location.key
});

// put the route modules in charge:
let matches = useMatches();
// default to location key
let scrollKey = location.key;
for (let match of matches) {
  if (match.handle.scrollKey) {
    scrollKey = match.handle.scrollKey
  }
}

useScrollRestoration({ key });

key is so powerful, we might get away with not needing the restore option and just have useScrollRestoration(key?)

@tchak
Copy link

tchak commented May 25, 2021

I have some issues in my remix app with scroll management and I tried to use the hook proposed in this issue #20. It solved some problems but created different issues. Like it would scroll to the top of the page if a form is submitted to modify an item in a long list.

@ryanflorence
Copy link
Member Author

ryanflorence commented May 25, 2021

Oh yep, I had that too. This one ignores the location change on a form submit since we expect a redirect anyway, the scrolling will happen on the redirect target page.

And in the case of something like a todo list where adding items shouldn't scroll the page, you have the ability to shut it off. In really "app like" websites, you'd probably want to have the routes opt-in to scrolling with handle and useMatches, and for more content heavy sites you'd want them to opt-out.

@kentcdodds
Copy link
Member

This is great. Can't think of problems I've had with scroll position this wouldn't address 👍

key is so powerful, we might get away with not needing the restore option and just have useScrollRestoration(key?)

Yes, I think the key is great and I'd love to just have that rather than having two ways to do the same thing.

@ryanflorence
Copy link
Member Author

ryanflorence commented Jul 14, 2021

Here's what we use on the docs right now and will likely bring over with a few tweaks, you can use this for now though.

import { useEffect, useLayoutEffect, useRef } from "react";
import { useLocation } from "react-router-dom";
import { usePendingLocation } from "remix";

let firstRender = true;

if (typeof window !== "undefined" && window.history.scrollRestoration !== "manual") {
  window.history.scrollRestoration = "manual";
}

export function useScrollRestoration() {
  let positions = useRef<Map<string, number>>(new Map()).current;
  let location = useLocation();
  let pendingLocation = usePendingLocation();

  useEffect(() => {
    if (pendingLocation) {
      positions.set(location.key, window.scrollY);
    }
  }, [pendingLocation, location]);

  if (typeof window !== "undefined") {
    // chill React warnings, my goodness
    useLayoutEffect(() => {
      // don't restore scroll on initial render
      if (firstRender) {
        firstRender = false;
        return;
      }
      let y = positions.get(location.key);
      window.scrollTo(0, y || 0);
    }, [location]);
  }
}

export function useElementScrollRestoration(
  ref: React.MutableRefObject<HTMLElement | null>
) {
  let positions = useRef<Map<string, number>>(new Map()).current;
  let location = useLocation();
  let pendingLocation = usePendingLocation();

  useEffect(() => {
    if (!ref.current) return;
    if (pendingLocation) {
      positions.set(location.key, ref.current.scrollTop);
    }
  }, [pendingLocation, location]);

  if (typeof window !== "undefined") {
    // seriously, chill
    useLayoutEffect(() => {
      if (!ref.current) return;
      let y = positions.get(location.key);
      ref.current.scrollTo(0, y || 0);
    }, [location]);
  }
}

@kentcdodds
Copy link
Member

This works great! Just bumped into one issue: #230

@ryanflorence
Copy link
Member Author

Also, I think the default behavior shouldn't do anything for submission navigations unless the action redirected.

@kentcdodds
Copy link
Member

kentcdodds commented Aug 9, 2021

I've tried to adjust this to work with useTransition. I'm pretty sure this is correct, but I'd love to be corrected/improved :)

import {useEffect, useLayoutEffect, useRef} from 'react'
import {useLocation} from 'react-router-dom'
import {useTransition} from '@remix-run/react'

let firstRender = true

const useSSRLayoutEffect =
  typeof window === 'undefined' ? () => {} : useLayoutEffect

if (
  typeof window !== 'undefined' &&
  window.history.scrollRestoration !== 'manual'
) {
  window.history.scrollRestoration = 'manual'
}

export function useScrollRestoration(enabled: boolean = true) {
  const positions = useRef<Map<string, number>>(new Map()).current
  const location = useLocation()
  const transition = useTransition()

  useEffect(() => {
    if (transition.state === 'loading') {
      positions.set(location.key, window.scrollY)
    }
  }, [transition.state, location, positions])

  useSSRLayoutEffect(() => {
    if (!enabled) return
    if (transition.state !== 'idle') return
    // don't restore scroll on initial render
    if (firstRender) {
      firstRender = false
      return
    }
    const y = positions.get(location.key)
    window.scrollTo(0, y ?? 0)
  }, [transition.state, location, positions])
}

export function useElementScrollRestoration(
  ref: React.MutableRefObject<HTMLElement | null>,
  enabled: boolean = true,
) {
  const positions = useRef<Map<string, number>>(new Map()).current
  const location = useLocation()
  const transition = useTransition()

  useEffect(() => {
    if (!ref.current) return
    if (transition.state === 'loading') {
      positions.set(location.key, ref.current.scrollTop)
    }
  }, [transition.state, location, ref, positions])

  useSSRLayoutEffect(() => {
    if (!enabled) return
    if (transition.state !== 'idle') return
    if (!ref.current) return
    const y = positions.get(location.key)
    ref.current.scrollTo(0, y ?? 0)
  }, [transition.state, location, positions, ref])
}

@kentcdodds
Copy link
Member

I ran into #240

Got a workaround working:

import * as React from 'react'
import {useLocation} from 'react-router-dom'
import {useTransition} from '@remix-run/react'

let firstRender = true

const useSSRLayoutEffect =
  typeof window === 'undefined' ? () => {} : React.useLayoutEffect

if (
  typeof window !== 'undefined' &&
  window.history.scrollRestoration !== 'manual'
) {
  window.history.scrollRestoration = 'manual'
}

// shouldn't have to do it this way
// https://github.com/remix-run/remix/issues/240
type LocationState = undefined | {isSubmission: boolean}
export function useScrollRestoration(enabled: boolean = true) {
  const positions = React.useRef<Map<string, number>>(new Map()).current
  const location = useLocation()
  const isSubmission = (location.state as LocationState)?.isSubmission ?? false
  const transition = useTransition()

  React.useEffect(() => {
    if (isSubmission) return
    if (transition.state === 'loading') {
      positions.set(location.key, window.scrollY)
    }
  }, [transition.state, location.key, positions, isSubmission])

  useSSRLayoutEffect(() => {
    if (!enabled) return
    if (transition.state !== 'idle') return
    if (isSubmission) return
    // don't restore scroll on initial render
    if (firstRender) {
      firstRender = false
      return
    }
    const y = positions.get(location.key)
    window.scrollTo(0, y ?? 0)
  }, [transition.state, location.key, positions, isSubmission])
}

export function useElementScrollRestoration(
  ref: React.MutableRefObject<HTMLElement | null>,
  enabled: boolean = true,
) {
  const positions = React.useRef<Map<string, number>>(new Map()).current
  const location = useLocation()
  const transition = useTransition()

  React.useEffect(() => {
    if (!ref.current) return
    if (transition.state === 'loading') {
      positions.set(location.key, ref.current.scrollTop)
    }
  }, [transition.state, location.key, ref, positions])

  useSSRLayoutEffect(() => {
    if (!enabled) return
    if (transition.state !== 'idle') return
    if (!ref.current) return
    const y = positions.get(location.key)
    ref.current.scrollTo(0, y ?? 0)
  }, [transition.state, location.key, positions, ref])
}

@kentcdodds
Copy link
Member

Unfortunately I have a situation where this doesn't work well.

-> GET /route-1 -> POST / -> (REDIRECT) GET /route-1

I don't want it to scroll on the POST. This is handled by my workaround above 👍

I also don't want to scroll on the REDIRECT, but I don't know how the hook could know that the location change is a redirect to the same original route and shouldn't scroll. I mean, I could keep track of things and handle this use case directly, and maybe that's what I'll do I guess, but that seems like it would be easy to get wrong and have things not scroll when they should.

I'm not sure what to do about this situation 😬

@ryanflorence
Copy link
Member Author

ryanflorence commented Aug 10, 2021

@kentcdodds
Copy link
Member

Maybe that'll work, but I can't test it because #240 🤷‍♂️

@kentcdodds
Copy link
Member

Alrighty, I've got an interesting case. I have a list/detail UI. It's for my podcast episodes. When clicking an episode, I don't want to scroll to the top, but if I link to one of the items from a different page (like a blog post that links to a podcast episode), then I do want it to scroll to the top. Any ideas for how to customize this in a generic way?

@ryanflorence
Copy link
Member Author

The API we intend to ship will be useScrollRestoration(key?), which gives you total control of when to restore/reset scroll.

@kentcdodds
Copy link
Member

Sweet. Looking forward to it. If you need an early tester let me know :)

@wladpaiva
Copy link
Contributor

The current component also does't support conditional scroll depending on the link you click.
This is very useful for those modals scenarios where you want to keep the current background page on the same place.

Next.js has a nice way to implement this:
https://nextjs.org/docs/api-reference/next/link#disable-scrolling-to-the-top-of-the-page

For now, I've just made a copy of the current component and added a condition before the window.scrollTo(0, 0);

if (
  location.state &&
  typeof location.state === "object" &&
  (location.state as { scroll: boolean }).scroll === false
) {
  return;
}

And in my link I only set a state

<Link to="cart" state={{scroll: false}}>my link</Link>

Not sure this is an appropriate solution, that's why I haven't submitted a PR.

@TomerAberbach
Copy link
Contributor

I'm also encountering some scrolling issues.

I have some code using useSearchParams. I have the following requirements:

  1. When I call setSearchParams(newSearchParams, { replace: true }) I want the scroll position to remain where it was
  2. When navigating to a new page I want the scroll position to reset to the top

If I use <ScrollRestoration />, then 1 doesn't work, but 2 does. If I don't use <ScrollRestoration />, then 1 works, but 2 doesn't 🙃

@TomerAberbach
Copy link
Contributor

TomerAberbach commented Feb 26, 2022

@wladiston's solution worked for me! 😄 (with setSearchParams(newSearchParams, { replace: true, state: { scroll: false } }))

@chaance
Copy link
Collaborator

chaance commented Mar 18, 2022

Closing this since we've already added the feature, but if you are experiencing bugs please open a new issue so we can prioritize better!

@chaance chaance closed this as completed Mar 18, 2022
@machour
Copy link
Collaborator

machour commented Mar 19, 2022

@chaance AFAIK, useScrollRestoration() is not available, and we still need a way to enable/disable scroll restoration in user land.

Can this be re-opened?

@gzaripov
Copy link

I would like to see @wladiston solution merged in Remix. I have a simple case where I need to show modal when user clicks the button, I use modal route for that, when I show it using navigate('/landing/register') I have page scrolled to top 😞.

@kiliman
Copy link
Collaborator

kiliman commented Mar 21, 2022

Both Ryan and Kent have posted code for useScrollRestoration here in the comments. I'm not sure if it was intended to be added in Remix core.

@chaance
Copy link
Collaborator

chaance commented Mar 21, 2022

@machour That specific API has not been added, but <ScrollRestoration /> has and should provide what you need (enable/disable scroll restoration be done via conditional rendering, but perhaps there's a way for us to expand the API here with a few props). We may introduce a lower level API, but we don't need an open issue for that. The point made by @wladiston is probably something we want to consider for the Link API rather than providing a separate hook.

Feel free to open a new issue if you run into limitations or bugs with the current API. Just trying to keep issues focused on more specific, actionable items so we can better prioritize 🙏

@efkann
Copy link
Contributor

efkann commented Mar 24, 2022

I also found myself using @wladiston's solution when navigating back and forward between the routes that pops-up a dialog and it worked perfectly.

@arnaudambro
Copy link
Contributor

arnaudambro commented May 16, 2022

@efkann @TomerAberbach @wladiston
It seems you found a solution to prevent the scroll to top to happen, but I don't see how to implement it ?

For now, I've just made a copy of the current component and added a condition before the window.scrollTo(0, 0);

if (
  location.state &&
  typeof location.state === "object" &&
  (location.state as { scroll: boolean }).scroll === false
) {
  return;
}

Where/how do you write this code ?

If I go to see node_modules/react-router-dom/index.js before I extend the Link component, I see

/**
 * The public API for rendering a history-aware <a>.
 */
const Link = /*#__PURE__*/forwardRef(function LinkWithRef(_ref4, ref) {
  let {
    onClick,
    reloadDocument,
    replace = false,
    state,
    target,
    to
  } = _ref4,
      rest = _objectWithoutPropertiesLoose(_ref4, _excluded);

  let href = useHref(to);
  let internalOnClick = useLinkClickHandler(to, {
    replace,
    state,
    target
  });

  function handleClick(event) {
    if (onClick) onClick(event);

    if (!event.defaultPrevented && !reloadDocument) {
      internalOnClick(event);
    }
  }

  return (
    /*#__PURE__*/
    // eslint-disable-next-line jsx-a11y/anchor-has-content
    createElement("a", _extends({}, rest, {
      href: href,
      onClick: handleClick,
      ref: ref,
      target: target
    }))
  );
});

I don't see where to insert your code there... 🥸

I would like to see @wladiston solution merged in Remix. I have a simple case where I need to show modal when user clicks the button, I use modal route for that, when I show it using navigate('/landing/register') I have page scrolled to top 😞.

@gzaripov This is exactly my use case !

@wladpaiva
Copy link
Contributor

@arnaudambro you have to basically duplicate this file

https://github.com/remix-run/remix/blob/main/packages/remix-react/scroll-restoration.tsx

And add that snippet in. Then just replace the on your root.tsx for your new one 😉

@arnaudambro
Copy link
Contributor

arnaudambro commented May 17, 2022

cool !
what I did is patching the file instead, before a change comes (maybe)

  • I made the change manually in /node_modules/@remix-run/react/esm/scroll-restoration.js like you advised
  • I ran npx patch-package @remix-run/react
    the output is the file located in /patches/@remix-run+react+1.4.3.patch
diff --git a/node_modules/@remix-run/react/esm/scroll-restoration.js b/node_modules/@remix-run/react/esm/scroll-restoration.js
index 2ec020c..33e77c5 100644
--- a/node_modules/@remix-run/react/esm/scroll-restoration.js
+++ b/node_modules/@remix-run/react/esm/scroll-restoration.js
@@ -126,6 +126,15 @@ function useScrollRestoration() {
       } // otherwise go to the top on new locations


+      if (
+        location.state &&
+        typeof location.state === "object" &&
+        (location.state.scroll === false)
+      ) {
+        return;
+      }
+
       window.scrollTo(0, 0);
     }, [location]);
   }

then I updated the postinstall script this way: "postinstall": "remix setup node && npx patch-package"

and here it goes ! 🥂

thanks for the tip @wladiston, it works very well

@dani-mp
Copy link

dani-mp commented May 19, 2022

I had to use this patch today as well. Mentioning it here in case it's useful for the maintainers, because AFAIK there's no public API solution in place, right?

@machour
Copy link
Collaborator

machour commented May 19, 2022

@dani-mp The best thing would be to open a new discussion to bring the subject back to life.

@ryym
Copy link

ryym commented Jul 8, 2022

The solution #186 (comment) worked for me too, thank you!
Instead of patching the original ScrollRestoration component, I just wrapped it for now. It seems working.

function ConditionalScrollRestoration() {
  const location = useLocation();
  if (
    location.state != null &&
    typeof location.state === "object" &&
    (location.state as { scroll: boolean }).scroll === false
  ) {
    return null;
  }
  return <ScrollRestoration />;
}

@bill-kerr
Copy link

The solution #186 (comment) worked for me too, thank you! Instead of patching the original ScrollRestoration component, I just wrapped it for now. It seems working.

function ConditionalScrollRestoration() {
  const location = useLocation();
  if (
    location.state != null &&
    typeof location.state === "object" &&
    (location.state as { scroll: boolean }).scroll === false
  ) {
    return null;
  }
  return <ScrollRestoration />;
}

I am getting hydration mismatch errors on React 18 (on refresh) with this approach. I had to copy the file and patch useScrollRestoration to avoid it.

@shenders13
Copy link

shenders13 commented Aug 7, 2022

Is there a way to preserve scroll height on Remix's Link tag (when <ScrollRestoration /> is present)? I know Inertia does something like this:

import { Link } from '@inertiajs/inertia-react'

<Link preserveScroll href="/">Home</Link> // Don't autoscroll to the top when you land on Home

At the moment we're stuck because we want Scroll Restoration on most page transitions. So we've added <ScrollRestoration /> to root.tsx.

However on a tab navigator (that is situated 1/2 way down one of our pages), we don't want the user to be scrolled all the way back up to the top when they click on a different tab.

So a preserveScroll option on the Link tag would be super handy.

@tavoyne
Copy link

tavoyne commented Aug 15, 2022

I am getting hydration mismatch errors on React 18 (on refresh) with this approach. I had to copy the file and patch useScrollRestoration to avoid it.

To fix that, you can slightly alter the behavior and always return <ScrollRestoration /> in case it's a first render.

function ConditionalScrollRestoration() {
  const isFirstRenderRef = useRef(true);
  const location = useLocation();

  useEffect(() => {
    isFirstRenderRef.current = false;
  }, []);

  if (
    !isFirstRenderRef.current &&
    location.state != null &&
    typeof location.state === "object" &&
    (location.state as { scroll: boolean }).scroll === false
  ) {
    return null;
  }

  return <ScrollRestoration />;
}

Hacky, but temporary: soon the resetScroll prop (https://twitter.com/ryanflorence/status/1527775882797907969).

@arnaudambro
Copy link
Contributor

@ryanflorence is preventScrollReset working on remix yet ?

https://reactrouter.com/en/main/components/link#preventscrollreset

@n1ghtmare
Copy link

The solution by @tavoyne doesn't seem to work for me. Did anyone make it work for them?

@Mange
Copy link

Mange commented Oct 24, 2022

The solution by @tavoyne doesn't seem to work for me. Did anyone make it work for them?

Worked for me.

@dani-mp
Copy link

dani-mp commented Dec 19, 2022

I tried using navigate('.', { replace: true, preventScrollReset: true }) in Remix 1.9 still doesn't work for me.

I had to use the custom ConditionalScrollRestoration component mentioned above.

@xiamuguizhi
Copy link

I had a problem with the very upstairs reply today.

Thank you very much for your solutions as well.

The solution is now available on the official website, thank you very much:

https://remix.run/docs/en/main/components/scroll-restoration

This component will emulate the browser's scroll restoration on location changes after loaders have completed. This ensures the scroll position is restored to the right spot, at the right time, even across domains.

You should only render one of these, right before the component.

import {
  Scripts,
  ScrollRestoration,
} from "@remix-run/react";

export default function Root() {
  return (
    <html>
      <body>
        {/* ... */}
        <ScrollRestoration />
        <Scripts />
      </body>
    </html>
  );
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests