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

ScrollRestoration on RR 6.4 #4844

Merged
merged 4 commits into from Dec 16, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
23 changes: 0 additions & 23 deletions packages/remix-react/components.tsx
Expand Up @@ -864,29 +864,6 @@ function dedupe(array: any[]) {
return [...new Set(array)];
}

/**
* Setup a callback to be fired on the window's `beforeunload` event. This is
* useful for saving some data to `window.localStorage` just before the page
* refreshes, which automatically happens on the next `<Link>` click when Remix
* detects a new version of the app is available on the server.
*
* Note: The `callback` argument should be a function created with
* `React.useCallback()`.
*
* @see https://remix.run/api/remix#usebeforeunload
*/
export function useBeforeUnload(
callback: (event: BeforeUnloadEvent) => any
): void {
// TODO: Export from react-router-dom
Copy link
Contributor Author

Choose a reason for hiding this comment

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

React.useEffect(() => {
window.addEventListener("beforeunload", callback);
return () => {
window.removeEventListener("beforeunload", callback);
};
}, [callback]);
}

// TODO: Can this be re-exported from RR?
export interface RouteMatch {
/**
Expand Down
2 changes: 1 addition & 1 deletion packages/remix-react/index.tsx
Expand Up @@ -12,6 +12,7 @@ export type {
export {
Form,
Outlet,
useBeforeUnload,
useFormAction,
useHref,
useLocation,
Expand Down Expand Up @@ -48,7 +49,6 @@ export {
useLoaderData,
useMatches,
useActionData,
useBeforeUnload,
} from "./components";

export type { FormMethod, FormEncType } from "./data";
Expand Down
138 changes: 40 additions & 98 deletions packages/remix-react/scroll-restoration.tsx
@@ -1,49 +1,60 @@
import * as React from "react";
import { useLocation } from "react-router-dom";
import type { ScrollRestorationProps as ScrollRestorationPropsRR } from "react-router-dom";
import {
useLocation,
UNSAFE_useScrollRestoration as useScrollRestoration,
Copy link
Contributor Author

Choose a reason for hiding this comment

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

The hook was private in both Remix and RR so kept it that way via the UNSAFE_ prefix

} from "react-router-dom";

import { useBeforeUnload, useTransition } from "./components";
import type { ScriptProps } from "./components";
import { useMatches } from "./components";

let STORAGE_KEY = "positions";

let positions: { [key: string]: number } = {};

if (typeof document !== "undefined") {
let sessionPositions = sessionStorage.getItem(STORAGE_KEY);
if (sessionPositions) {
positions = JSON.parse(sessionPositions);
}
}

/**
* This component will emulate the browser's scroll restoration on location
* changes.
*
* @see https://remix.run/api/remix#scrollrestoration
*/
export function ScrollRestoration(props: ScriptProps) {
useScrollRestoration();

// wait for the browser to restore it on its own
React.useEffect(() => {
window.history.scrollRestoration = "manual";
}, []);

// let the browser restore on it's own for refresh
useBeforeUnload(
React.useCallback(() => {
window.history.scrollRestoration = "auto";
}, [])
export function ScrollRestoration({
getKey,
...props
}: ScriptProps & {
getKey: ScrollRestorationPropsRR["getKey"];
}) {
let location = useLocation();
let matches = useMatches();

useScrollRestoration({
getKey,
storageKey: STORAGE_KEY,
});

// In order to support `getKey`, we need to compute a "key" here so we can
// hydrate that up so that SSR scroll restoration isn't waiting on React to
// hydrate. *However*, our key on the server is not the same as our key on
// the client! So if the user's getKey implementation returns the SSR
// location key, then let's ignore it and let our inline <script> below pick
// up the client side history state key
let key = React.useMemo(
() => {
if (!getKey) return null;
let userKey = getKey(location, matches);
return userKey !== location.key ? userKey : null;
},
// Nah, we only need this the first time for the SSR render
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);

let restoreScroll = ((STORAGE_KEY: string) => {
let restoreScroll = ((STORAGE_KEY: string, restoreKey: string) => {
if (!window.history.state || !window.history.state.key) {
let key = Math.random().toString(32).slice(2);
window.history.replaceState({ key }, "");
}
try {
let positions = JSON.parse(sessionStorage.getItem(STORAGE_KEY) || "{}");
let storedY = positions[window.history.state.key];
let storedY = positions[restoreKey || window.history.state.key];
if (typeof storedY === "number") {
window.scrollTo(0, storedY);
}
Expand All @@ -58,79 +69,10 @@ export function ScrollRestoration(props: ScriptProps) {
{...props}
suppressHydrationWarning
dangerouslySetInnerHTML={{
__html: `(${restoreScroll})(${JSON.stringify(STORAGE_KEY)})`,
__html: `(${restoreScroll})(${JSON.stringify(
STORAGE_KEY
)}, ${JSON.stringify(key)})`,
}}
/>
);
}

let hydrated = false;

function useScrollRestoration() {
let location = useLocation();
let transition = useTransition();

let wasSubmissionRef = React.useRef(false);

React.useEffect(() => {
if (transition.submission) {
wasSubmissionRef.current = true;
}
}, [transition]);

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

useBeforeUnload(
React.useCallback(() => {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(positions));
}, [])
);

if (typeof document !== "undefined") {
// eslint-disable-next-line
React.useLayoutEffect(() => {
// don't do anything on hydration, the component already did this with an
// inline script.
if (!hydrated) {
hydrated = true;
return;
}

let y = positions[location.key];

// been here before, scroll to it
if (y != undefined) {
window.scrollTo(0, y);
return;
}

// try to scroll to the hash
if (location.hash) {
let el = document.getElementById(location.hash.slice(1));
if (el) {
el.scrollIntoView();
return;
}
}

// don't do anything on submissions
if (wasSubmissionRef.current === true) {
wasSubmissionRef.current = false;
return;
}

// otherwise go to the top on new locations
window.scrollTo(0, 0);
}, [location]);
}

React.useEffect(() => {
if (transition.submission) {
wasSubmissionRef.current = true;
}
}, [transition]);
}