-
Notifications
You must be signed in to change notification settings - Fork 2.5k
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
Comments
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. |
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 |
This is great. Can't think of problems I've had with scroll position this wouldn't address 👍
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. |
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]);
}
} |
This works great! Just bumped into one issue: #230 |
Also, I think the default behavior shouldn't do anything for submission navigations unless the action redirected. |
I've tried to adjust this to work with 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])
} |
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])
} |
Unfortunately I have a situation where this doesn't work well. -> GET 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 😬 |
Maybe that'll work, but I can't test it because #240 🤷♂️ |
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? |
The API we intend to ship will be useScrollRestoration(key?), which gives you total control of when to restore/reset scroll. |
Sweet. Looking forward to it. If you need an early tester let me know :) |
The current component also does't support conditional scroll depending on the link you click. Next.js has a nice way to implement this: For now, I've just made a copy of the current component and added a condition before the 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. |
I'm also encountering some scrolling issues. I have some code using
If I use |
@wladiston's solution worked for me! 😄 (with |
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 AFAIK, Can this be re-opened? |
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 |
Both Ryan and Kent have posted code for |
@machour That specific API has not been added, but 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 🙏 |
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. |
@efkann @TomerAberbach @wladiston
Where/how do you write this code ? If I go to 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... 🥸
@gzaripov This is exactly my use case ! |
@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 😉 |
cool !
then I updated the and here it goes ! 🥂 thanks for the tip @wladiston, it works very well |
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? |
@dani-mp The best thing would be to open a new discussion to bring the subject back to life. |
The solution #186 (comment) worked for me too, thank you! 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 |
Is there a way to preserve scroll height on Remix's Link tag (when 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 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 |
To fix that, you can slightly alter the behavior and always return 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 |
@ryanflorence is https://reactrouter.com/en/main/components/link#preventscrollreset |
The solution by @tavoyne doesn't seem to work for me. Did anyone make it work for them? |
Worked for me. |
I tried using I had to use the custom |
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>
);
} |
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
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:
If you want to stop scroll restoration, pass it
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
anduseMatches
to turn off scroll restoration:Scroll Containers
Sometimes your UI has large scrollable containers instead of window that need their position restored. For that we have
useElementScrollRestoration
.And same thing, can pass a boolean to prevent the behavior:
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.
key
is so powerful, we might get away with not needing therestore
option and just haveuseScrollRestoration(key?)
The text was updated successfully, but these errors were encountered: