Skip to content

Allow scrolling of individual elements #10468

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

Open
wants to merge 75 commits into
base: dev
Choose a base branch
from

Conversation

joshkel
Copy link

@joshkel joshkel commented May 9, 2023

See #9495.

This is an updated version of #9573, in hopes of moving things along.

This version uses refs, since the linked discussion seemed to prefer that interface.

@changeset-bot
Copy link

changeset-bot bot commented May 9, 2023

🦋 Changeset detected

Latest commit: 32e39fb

The changes in this PR will be included in the next version bump.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@remix-cla-bot
Copy link
Contributor

remix-cla-bot bot commented May 9, 2023

Hi @joshkel,

Welcome, and thank you for contributing to React Router!

Before we consider your pull request, we ask that you sign our Contributor License Agreement (CLA). We require this only once.

You may review the CLA and sign it by adding your name to contributors.yml.

Once the CLA is signed, the CLA Signed label will be added to the pull request.

If you have already signed the CLA and received this response in error, or if you have any questions, please contact us at hello@remix.run.

Thanks!

- The Remix team

@remix-cla-bot
Copy link
Contributor

remix-cla-bot bot commented May 9, 2023

Thank you for signing the Contributor License Agreement. Let's get this merged! 🥳

@david-crespo
Copy link
Contributor

Nice. Would love to see this merged, but in the meantime I'll test it with patch-package.

@jrnail23
Copy link

jrnail23 commented May 23, 2023

Just some feedback from trying this on my own: maybe consider supporting elementId as well as elementRef (presumably mutually exclusive) -- I was able to get this PR's general approach working in my app using elementId, but still having trouble getting it to work with elementRef. In my case, elementRef.current is still empty when it's time to restore the scroll position, so it never gets restored.

I'm really having a hard time understanding how to ensure the scroll container's ref gets populated by the time the scroll restore happens. If ScrollRestoration is placed higher in the React tree than your scroll container, is that even possible? What am I missing here?

Uh, yeah, I managed to stumble across the ScrollRestoration example, and I see that I should've been putting <ScrollRestoration /> after my scroll container. It works now!

homer-bushes

@brophdawg11
Copy link
Contributor

@joshkel Thanks for the PR (and thanks @david-crespo for the initial PR this is based on)! I did some local testing and found/fixed one small issue if an app has different <ScrollRestoration> components per layout. There's an example of this type of layout at https://codesandbox.io/s/react-router-scroll-restoration-non-window-forked-gf2lpm?file=/src/App.tsx - it's not functional without this patch, but I was able to test it out locally.

I also added support for passing a CSS selector instead of a ref and thus changed the prop name to scrollContainer.

Let me know if this still looks good to you and I think we should be able to get this merged!

@brophdawg11 brophdawg11 changed the base branch from main to dev May 26, 2023 12:57
@@ -81,6 +104,32 @@ Or you may want to only use the pathname for some paths, and use the normal beha
/>
```

## `scrollContainer`

By default, this component will capture/restore scroll positions on `window`. If your UI scrolls via a different container element with `overflow:scroll`, you will want to use that instead of `window`. You can specify that via the `scrollContainer` prop using a selector or a `ref`:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know if there's simple guidance about whether to prefer ref or selector that fits in a sentence, but if there is, it would be good to add it. I feel ref is safer and easier to be confident about. But maybe people can figure that out.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, hard to say. I sort of envisioned refs being used more when the scrollable thing is easily accessible from the component rendering <ScrollRestoration> and selectors if it's a few components away and you don't want to prop-drill a ref around. But I'm not sure if one is technically any better than another?

@brophdawg11 brophdawg11 self-assigned this May 26, 2023
@ryanflorence
Copy link
Member

Thanks for the PR!

I've wanted to allow for this for a long time but I think it needs a bit more design than what's here.

The main thing is you should be able to have multiple scroll containers at the same time, not just one (like both window as well as a sidebar etc.)

Additionally, this should be able to be done outside of react router with useLocation (I'm not sure why the implementation inside React Router is reaching into deeper state than that). Would love to see that happen first to help drive the API we finalize into react router.

@jrnail23
Copy link

jrnail23 commented Jun 2, 2023

@ryanflorence, I think being able to support multiple scroll containers here would mostly be matter of the shape of the savedScrollPosition object, perhaps a map of keys containing another map of scrollContainer identifiers/css selectors, etc. (of some kind) to their respective scrollPositions. Although that gets a bit trickier when you use refs rather than selectors for the containers though, since you've got to serialize and deserialize from sessionStorage (at least with this current approach).

Additionally, this should be able to be done outside of react router with useLocation (I'm not sure why the implementation inside React Router is reaching into deeper state than that).

Can you elaborate a bit more on what you're thinking here?

@david-crespo
Copy link
Contributor

I'm interested in trying to write this hook from userland. Seems like the main reason it's integrated into RR deeply is so it can detect when a nav is about to happen, so it can save the scroll position. With useLocation(), the location would only change after the nav, so it would be too late. How would you catch the about-to-nav moment? Could you get there with a useEffect watching for useNavigation().state === 'loading'?

@jrnail23
Copy link

jrnail23 commented Jun 2, 2023

I think this might be where useBlocker comes in handy.

@ryanflorence
Copy link
Member

Oops, I meant useNavigation

@jrnail23
Copy link

jrnail23 commented Jun 3, 2023

But useNavigation doesn't do anything useful unless you're doing async loaders, right?

EDIT: what I mean here is that there's not any useful info in the navigation object unless you're using async loaders, etc., right?

@david-crespo
Copy link
Contributor

david-crespo commented Jun 3, 2023

This seems to work. Lol. Lmao.

export function useScrollRestoration(elementRef: React.RefObject<HTMLElement>) {
  const cache = useRef(new Map<string, number>())
  const { key } = useLocation()
  const { state } = useNavigation()
  useEffect(() => {
    if (state === 'loading') {
      cache.current.set(key, elementRef.current?.scrollTop ?? 0)
    } else if (state === 'idle' && cache.current.has(key)) {
      elementRef.current?.scrollTo(0, cache.current.get(key)!)
    }
  }, [key, state, elementRef])
}

@jrnail23
Copy link

jrnail23 commented Jun 5, 2023

oh wow, @david-crespo , I'm gonna give that a try!
I'm guessing it's going to need some work to support scroll restore on page reload, but this could be a good starting point.

@jrnail23
Copy link

jrnail23 commented Jun 5, 2023

@ryanflorence, am I correct in saying that you'd be fine with (or even prefer) a solution that isn't coupled to the existing ScrollRestoration functionality?

brophdawg11 and others added 25 commits September 10, 2024 12:43
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
@kkatsi
Copy link

kkatsi commented Nov 24, 2024

Thanks for that hook! I came across a scenario where I needed to preserve not just the Y (vertical) scroll position but also the X (horizontal) position. Here a fixed version that supports vertical scroll restoration too

import { useEffect } from 'react';
import { useLocation, useNavigation } from 'react-router-dom';

function getScrollPosition(key: string) {
  const pos = window.sessionStorage.getItem(key);
  return pos ? JSON.parse(pos) : { scrollTop: 0, scrollLeft: 0 };
}

function setScrollPosition(key: string, position: { scrollTop: number; scrollLeft: number }) {
  window.sessionStorage.setItem(key, JSON.stringify(position));
}

/**
 * Given a ref to a scrolling container element, keep track of its scroll
 * position before navigation and restore it on return (e.g., back/forward nav).
 * Note that `location.key` is used in the cache key, not `location.pathname`,
 * so the same path navigated to at different points in the history stack will
 * not share the same scroll position.
 */
export function useElementScrollRestoration(container: React.RefObject<HTMLElement>) {
  const key = `scroll-position-${useLocation().key}`;
  const { state } = useNavigation();

  useEffect(() => {
    if (state === 'loading') {
      setScrollPosition(key, {
        scrollTop: container.current?.scrollTop ?? 0,
        scrollLeft: container.current?.scrollLeft ?? 0,
      });
    } else if (state === 'idle') {
      const position = getScrollPosition(key);
      container.current?.scrollTo(position.scrollLeft, position.scrollTop);
    }
  }, [key, state, container]);
}

@joshkel joshkel force-pushed the element-scroll-restoration branch from b1bbd43 to 32e39fb Compare December 11, 2024 16:14
@jacob-ebey
Copy link
Member

Here is an updated version that handles nested containers:

import { useEffect } from "react";
import { useLocation, useNavigation } from "react-router";

function getScrollPosition(key: string) {
	const pos = window.sessionStorage.getItem(key);
	return pos ? JSON.parse(pos) : { scrollTop: 0, scrollLeft: 0 };
}

function setScrollPosition(
	key: string,
	position: { scrollTop: number; scrollLeft: number },
) {
	window.sessionStorage.setItem(key, JSON.stringify(position));
}

/**
 * Given a ref to a scrolling container element, keep track of its scroll
 * position before navigation and restore it on return (e.g., back/forward nav).
 * Note that `location.key` is used in the cache key, not `location.pathname`,
 * so the same path navigated to at different points in the history stack will
 * not share the same scroll position.
 */
export function useElementScrollRestoration(
	container: React.RefObject<HTMLElement | null>,
) {
	const key = `scroll-position-${useLocation().key}`;
	const { state } = useNavigation();

	useEffect(() => {
		if (container.current) {
			container.current.setAttribute("data-scroll-restoration", "");
		}

		const hasChildScrollRestoration = !!container.current?.querySelector(
			"[data-scroll-restoration]",
		);

		if (state === "idle" && !hasChildScrollRestoration) {
			const position = getScrollPosition(key);
			container.current?.scrollTo(position.scrollLeft, position.scrollTop);
		} else {
			if (hasChildScrollRestoration) return;
			setScrollPosition(key, {
				scrollTop: container.current?.scrollTop ?? 0,
				scrollLeft: container.current?.scrollLeft ?? 0,
			});
		}
	}, [key, state, container]);
}

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

Successfully merging this pull request may close these issues.