diff --git a/.changeset/cool-spies-study.md b/.changeset/cool-spies-study.md new file mode 100644 index 0000000000..d5fe62437a --- /dev/null +++ b/.changeset/cool-spies-study.md @@ -0,0 +1,25 @@ +--- +'@shopify/hydrogen': minor +--- + +Add `scroll` prop to `Link` and `navigate` to allow the scroll restoration behavior to be disabled. + +By default, when a `` component is clicked, Hydrogen emulates default browser behavior and attempts to restore the scroll position previously used in the visitor's session. For new pages, this defaults to scrolling to the top of the page. + +However, if you are building a user interface that should fetch a new server components request and update the URL but not modify scroll position, then you can disable scroll restoration using the `scroll` prop: + +```jsx +import {Link} from '@shopify/hydrogen'; +export default function Index({request}) { + const url = new URL(request.normalizedUrl); + + return ( + <> +

Current param is: {url.searchParams.get('param')}

+ + Update param to foo + + + ); +} +``` diff --git a/docs/components/framework/link.md b/docs/components/framework/link.md index 3123a0e70d..4b6515d3f9 100644 --- a/docs/components/framework/link.md +++ b/docs/components/framework/link.md @@ -19,6 +19,32 @@ export default function Index() { {% endcodeblock %} +## Scroll restoration + +By default, when you click a `` component, Hydrogen emulates default browser behavior and attempts to restore the scroll position that was previously used in the visitor's session. For new pages, the `` component defaults to scrolling to the top of the page. + +However, if you want to build a user interface that re-renders server components and updates the URL, but doesn't modify the scroll position, then you can disable scroll restoration using the `scroll` prop: + +{% codeblock file, filename: 'index.server.jsx' %} + +```jsx +import {Link} from '@shopify/hydrogen'; +export default function Index({request}) { + const url = new URL(request.normalizedUrl); + + return ( + <> +

Current param is: {url.searchParams.get('param')}

+ + Update param to foo + + + ); +} +``` + +{% endcodeblock %} + ## Props | Name | Type | Description | @@ -28,6 +54,7 @@ export default function Index() { | clientState? | any | The custom client state with the navigation. | | reloadDocument? | boolean | Whether to reload the whole document on navigation. | | prefetch? | boolean | Whether to prefetch the link source when the user signals intent. Defaults to `true`. For more information, refer to [Prefetching a link source](https://shopify.dev/custom-storefronts/hydrogen/framework/routes#prefetching-a-link-source). | +| scroll? | boolean | Whether to emulate natural browser behavior and restore scroll position on navigation. Defaults to `true`. | ## Component type diff --git a/docs/hooks/framework/usenavigate.md b/docs/hooks/framework/usenavigate.md index a2cf74950b..4cbe2f1bc5 100644 --- a/docs/hooks/framework/usenavigate.md +++ b/docs/hooks/framework/usenavigate.md @@ -27,24 +27,14 @@ export default function ClientComponent() { {% endcodeblock %} -## Arguments - -The `useNavigate` hook takes the following arguments: - -| Argument | Description | -| --------------- | ------------------------------------------------------------------------------------------ | -| replace? | Whether to update the state object or URL of the current history entry. Defaults to false. | -| reloadDocument? | Whether to reload the whole document on navigation. | -| clientState? | The custom client state with the navigation. | - ## Return value -The `useNavigate` hook returns the following values: +The `useNavigate` hook returns a function which accepts the following values: -| Name | Description | -| ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| path | The path you want to navigate to. | -| options | The options for the configuration object: `replace`, `reloadDocument`, `clientState`. For more information the options, refer to the [Link component](https://shopify.dev/api/hydrogen/components/framework/link). | +| Name | Description | +| ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| path | The path you want to navigate to. | +| options | The options for the configuration object: `replace`, `reloadDocument`, `clientState`, `scroll`. For more information the options, refer to the [Link component](https://shopify.dev/api/hydrogen/components/framework/link). | ## Considerations diff --git a/packages/hydrogen/src/components/Link/Link.client.tsx b/packages/hydrogen/src/components/Link/Link.client.tsx index cd82d4bd08..a2f8acc353 100644 --- a/packages/hydrogen/src/components/Link/Link.client.tsx +++ b/packages/hydrogen/src/components/Link/Link.client.tsx @@ -17,6 +17,8 @@ export interface LinkProps reloadDocument?: boolean; /** Whether to prefetch the link source when the user signals intent. Defaults to `true`. For more information, refer to [Prefetching a link source](https://shopify.dev/custom-storefronts/hydrogen/framework/routes#prefetching-a-link-source). */ prefetch?: boolean; + /** Whether to emulate natural browser behavior and restore scroll position on navigation. Defaults to `true`. */ + scroll?: boolean; } /** @@ -44,6 +46,7 @@ export const Link = React.forwardRef( onClick, clientState, prefetch = true, + scroll = true, } = props; const internalClick = useCallback( @@ -63,19 +66,21 @@ export const Link = React.forwardRef( navigate(to, { replace, + scroll, clientState, }); } }, [ + onClick, reloadDocument, target, _replace, - to, - clientState, - onClick, location, + to, navigate, + clientState, + scroll, ] ); @@ -141,6 +146,7 @@ export const Link = React.forwardRef( 'clientState', 'reloadDocument', 'prefetch', + 'scroll', ])} ref={ref} onClick={internalClick} diff --git a/packages/hydrogen/src/components/Link/tests/Link.test.tsx b/packages/hydrogen/src/components/Link/tests/Link.test.tsx index fade319c10..13c46be341 100644 --- a/packages/hydrogen/src/components/Link/tests/Link.test.tsx +++ b/packages/hydrogen/src/components/Link/tests/Link.test.tsx @@ -1,4 +1,5 @@ import {createBrowserHistory} from 'history'; +import {nextTick} from 'process'; import React from 'react'; import {mountWithProviders} from '../../../utilities/tests/shopifyMount'; import {Link} from '../Link.client'; @@ -42,7 +43,10 @@ describe('', () => { const unlisten = history.listen(({location}) => { try { expect(location.pathname).toBe('/products/hydrogen'); - done(); + nextTick(() => { + expect(global.window.scrollTo).toBeCalledWith(0, 0); + done(); + }); } catch (e) { done(e); } finally { @@ -62,6 +66,39 @@ describe('', () => { }); }); + it('does not scroll to top if restore is disabled', (done) => { + const history = createBrowserHistory(); + + global.window.scrollTo = jest.fn(); + + const unlisten = history.listen(({location}) => { + try { + expect(location.pathname).toBe('/products/hydrogen'); + nextTick(() => { + expect(global.window.scrollTo).not.toBeCalledWith(0, 0); + done(); + }); + } catch (e) { + done(e); + } finally { + unlisten(); + } + }); + + const component = mountWithProviders( + + Link + , + { + history, + } + ); + + component.act(() => { + component?.domNode?.click(); + }); + }); + it('updates server state on navigate', (done) => { global.window.scrollTo = jest.fn(); diff --git a/packages/hydrogen/src/foundation/Router/BrowserRouter.client.tsx b/packages/hydrogen/src/foundation/Router/BrowserRouter.client.tsx index 11cfc9d143..ad7885e700 100644 --- a/packages/hydrogen/src/foundation/Router/BrowserRouter.client.tsx +++ b/packages/hydrogen/src/foundation/Router/BrowserRouter.client.tsx @@ -33,7 +33,7 @@ export const BrowserRouter: FC<{ const history = useMemo(() => pHistory || createBrowserHistory(), [pHistory]); const [location, setLocation] = useState(history.location); - const [locationChanged, setLocationChanged] = useState(false); + const [scrollNeedsRestoration, setScrollNeedsRestoration] = useState(false); const {pending, locationServerProps, setLocationServerProps} = useInternalServerProps(); @@ -42,12 +42,12 @@ export const BrowserRouter: FC<{ location, pending, serverProps: locationServerProps, - locationChanged, - onFinishNavigating: () => setLocationChanged(false), + scrollNeedsRestoration, + onFinishNavigating: () => setScrollNeedsRestoration(false), }); useLayoutEffect(() => { - const unlisten = history.listen(({location: newLocation}) => { + const unlisten = history.listen(({location: newLocation, action}) => { positions[location.key] = window.scrollY; setLocationServerProps({ @@ -56,14 +56,23 @@ export const BrowserRouter: FC<{ }); setLocation(newLocation); - setLocationChanged(true); + + const state = (newLocation.state ?? {}) as Record; + + /** + * "pop" navigations, like forward/backward buttons, always restore scroll position + * regardless of what the original forward navigation intent was. + */ + const needsScrollRestoration = action === 'POP' || !!state.scroll; + + setScrollNeedsRestoration(needsScrollRestoration); }); return () => unlisten(); }, [ history, location, - setLocationChanged, + setScrollNeedsRestoration, setLocation, setLocationServerProps, ]); @@ -112,13 +121,13 @@ function useScrollRestoration({ location, pending, serverProps, - locationChanged, + scrollNeedsRestoration, onFinishNavigating, }: { location: Location; pending: boolean; serverProps: LocationServerProps; - locationChanged: boolean; + scrollNeedsRestoration: boolean; onFinishNavigating: () => void; }) { /** @@ -141,7 +150,7 @@ function useScrollRestoration({ useLayoutEffect(() => { // The app has just loaded - if (isFirstLoad || !locationChanged) { + if (isFirstLoad || !scrollNeedsRestoration) { isFirstLoad = false; return; } @@ -190,7 +199,7 @@ function useScrollRestoration({ pending, serverProps.pathname, serverProps.search, - locationChanged, + scrollNeedsRestoration, onFinishNavigating, ]); } diff --git a/packages/hydrogen/src/foundation/useNavigate/useNavigate.ts b/packages/hydrogen/src/foundation/useNavigate/useNavigate.ts index 60f6256fac..9790744c0a 100644 --- a/packages/hydrogen/src/foundation/useNavigate/useNavigate.ts +++ b/packages/hydrogen/src/foundation/useNavigate/useNavigate.ts @@ -9,6 +9,9 @@ type NavigationOptions = { /** The custom client state with the navigation. */ clientState?: any; + + /** Whether to emulate natural browser behavior and restore scroll position on navigation. Defaults to true. */ + scroll?: any; }; /** @@ -21,9 +24,16 @@ export function useNavigate() { path: string, options: NavigationOptions = {replace: false, reloadDocument: false} ) => { + const state = { + ...options?.clientState, + scroll: options?.scroll ?? true, + }; + // @todo wait for RSC and then change focus for a11y? - if (options?.replace) - router.history.replace(path, options?.clientState || {}); - else router.history.push(path, options?.clientState || {}); + if (options?.replace) { + router.history.replace(path, state); + } else { + router.history.push(path, state); + } }; }