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

Allow scrolling of individual elements #10468

Open
wants to merge 11 commits into
base: dev
Choose a base branch
from
5 changes: 5 additions & 0 deletions .changeset/scroll-container.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"react-router-dom": minor
---

Add `<ScrollRestoration scrollContainer>` prop to support scrolling on elements other than `window`
1 change: 1 addition & 0 deletions contributors.yml
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@
- jmargeta
- johnpangalos
- jonkoops
- joshkel
- jrakotoharisoa
- kachun333
- kantuni
Expand Down
52 changes: 52 additions & 0 deletions docs/components/scroll-restoration.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,29 @@ new: true

# `<ScrollRestoration />`

<details>
<summary>Type declaration</summary>

```tsx
declare function ScrollRestoration(
props: ScrollRestorationProps
): null;

interface ScrollRestorationProps {
getKey?: GetScrollRestorationKeyFunction;
storageKey?: string;
scrollContainer?: string | React.RefObject<HTMLElement>;
}

interface GetScrollRestorationKeyFunction {
(location: Location, matches: UseMatchesMatch[]):
| string
| null;
}
```

</details>

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

<docs-warning>This feature only works if using a data router, see [Picking a Router][pickingarouter]</docs-warning>
Expand Down Expand Up @@ -81,6 +104,35 @@ 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?


```tsx
// Using a selector
<ScrollRestoration scrollContainer="#container" />
brophdawg11 marked this conversation as resolved.
Show resolved Hide resolved
<div id="container">
{/* scrollable contents */}
</div>
brophdawg11 marked this conversation as resolved.
Show resolved Hide resolved
```
brophdawg11 marked this conversation as resolved.
Show resolved Hide resolved

```tsx
// Using a ref:
let ref = React.useRef(null);
<div ref={ref}>
{/* scrollable contents */}
</div>
<ScrollRestoration scrollContainer={ref}} />
brophdawg11 marked this conversation as resolved.
Show resolved Hide resolved
```

## `storageKey`

Scroll positions are persisted in `sessionStorage` so they are available across page reloads. The default `sessionStorage` key is `react-router-scroll-positions` but you can provide your own key with the `storageKey` prop.

```tsx
<ScrollRestoration storageKey="my-app-session-storage-key" />
```

## Preventing Scroll Reset

When navigation creates new scroll keys, the scroll position is reset to the top of the page. You can prevent the "scroll to top" behavior from your links and forms:
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -118,10 +118,10 @@
"none": "15.8 kB"
},
"packages/react-router-dom/dist/react-router-dom.production.min.js": {
"none": "12.0 kB"
"none": "12.3 kB"
},
"packages/react-router-dom/dist/umd/react-router-dom.production.min.js": {
"none": "17.9 kB"
"none": "18.2 kB"
}
}
}
59 changes: 51 additions & 8 deletions packages/react-router-dom/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -786,6 +786,7 @@ if (__DEV__) {
export interface ScrollRestorationProps {
getKey?: GetScrollRestorationKeyFunction;
storageKey?: string;
scrollContainer?: string | React.RefObject<HTMLElement>;
}

/**
Expand All @@ -795,8 +796,9 @@ export interface ScrollRestorationProps {
export function ScrollRestoration({
getKey,
storageKey,
scrollContainer,
}: ScrollRestorationProps) {
useScrollRestoration({ getKey, storageKey });
useScrollRestoration({ getKey, storageKey, scrollContainer });
return null;
}

Expand Down Expand Up @@ -1194,15 +1196,44 @@ export function useFetchers(): Fetcher[] {
const SCROLL_RESTORATION_STORAGE_KEY = "react-router-scroll-positions";
let savedScrollPositions: Record<string, number> = {};

function getScrollTarget(
scrollContainer: string | React.RefObject<HTMLElement> | undefined
) {
if (typeof scrollContainer === "string") {
return document.querySelector(scrollContainer);
}
return scrollContainer ? scrollContainer.current : window;
}

function getScrollY(
scrollContainer: string | React.RefObject<HTMLElement> | undefined
) {
const el = getScrollTarget(scrollContainer);
// window has scrollY but normal elements have scrollTop
return !el ? 0 : (el as Window).scrollY || (el as HTMLElement).scrollTop;
}

function scrollY(
elementRef: string | React.RefObject<HTMLElement> | undefined,
y: number
) {
const el = getScrollTarget(elementRef);
if (el) {
el.scrollTo(0, y);
}
}

/**
* When rendered inside a RouterProvider, will restore scroll positions on navigations
*/
function useScrollRestoration({
getKey,
storageKey,
scrollContainer,
}: {
getKey?: GetScrollRestorationKeyFunction;
storageKey?: string;
scrollContainer?: string | React.RefObject<HTMLElement>;
} = {}) {
let { router } = useDataRouterContext(DataRouterHook.UseScrollRestoration);
let { restoreScrollPosition, preventScrollReset } = useDataRouterState(
Expand All @@ -1225,21 +1256,33 @@ function useScrollRestoration({
React.useCallback(() => {
if (navigation.state === "idle") {
let key = (getKey ? getKey(location, matches) : null) || location.key;
savedScrollPositions[key] = window.scrollY;
savedScrollPositions[key] = getScrollY(scrollContainer);
}
sessionStorage.setItem(
storageKey || SCROLL_RESTORATION_STORAGE_KEY,
JSON.stringify(savedScrollPositions)
);
window.history.scrollRestoration = "auto";
}, [storageKey, getKey, navigation.state, location, matches])
}, [
storageKey,
getKey,
scrollContainer,
navigation.state,
location,
matches,
])
);

// Read in any saved scroll locations
if (typeof document !== "undefined") {
// eslint-disable-next-line react-hooks/rules-of-hooks
React.useLayoutEffect(() => {
try {
// Only load once per session so it persists between unmounts/remounts
// (i.e., different instance per layout)
if (savedScrollPositions) {
return;
}
brophdawg11 marked this conversation as resolved.
Show resolved Hide resolved
let sessionPositions = sessionStorage.getItem(
storageKey || SCROLL_RESTORATION_STORAGE_KEY
);
Expand All @@ -1256,11 +1299,11 @@ function useScrollRestoration({
React.useLayoutEffect(() => {
let disableScrollRestoration = router?.enableScrollRestoration(
savedScrollPositions,
() => window.scrollY,
() => getScrollY(scrollContainer),
getKey
);
return () => disableScrollRestoration && disableScrollRestoration();
}, [router, getKey]);
}, [router, getKey, scrollContainer]);

// Restore scrolling when state.restoreScrollPosition changes
// eslint-disable-next-line react-hooks/rules-of-hooks
Expand All @@ -1272,7 +1315,7 @@ function useScrollRestoration({

// been here before, scroll to it
if (typeof restoreScrollPosition === "number") {
window.scrollTo(0, restoreScrollPosition);
scrollY(scrollContainer, restoreScrollPosition);
return;
}

Expand All @@ -1291,8 +1334,8 @@ function useScrollRestoration({
}

// otherwise go to the top on new locations
window.scrollTo(0, 0);
}, [location, restoreScrollPosition, preventScrollReset]);
scrollY(scrollContainer, 0);
}, [location, restoreScrollPosition, preventScrollReset, scrollContainer]);
brophdawg11 marked this conversation as resolved.
Show resolved Hide resolved
}
}

Expand Down