Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .changeset/cool-spies-study.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
'@shopify/hydrogen': minor
---

Add `restoreScroll` prop to `Link` and `navigate` to allow the scroll restoration behavior to be disabled.

By default, when a `<Link>` 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 `restoreScroll` prop:

```jsx
import {Link} from '@shopify/hydrogen';
export default function Index({request}) {
const url = new URL(request.normalizedUrl);

return (
<>
<p>Current param is: {url.searchParams.get('param')}</p>
<Link to="/?param=foo" restoreScroll={false}>
Update param to foo
</Link>
</>
);
}
```
28 changes: 28 additions & 0 deletions docs/components/framework/link.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,33 @@ export default function Index() {

{% endcodeblock %}

## Scroll restoration

By default, when a `<Link>` 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 `restoreScroll` prop:
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
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 `restoreScroll` prop:
However, if you want to build a user interface that fetches a new server component's request and updates the URL, but doesn't modify the scroll position, then you can disable scroll restoration using the `restoreScroll` prop:

Copy link
Contributor

Choose a reason for hiding this comment

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

@mcvinci I suggest this instead:

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 restoreScroll prop:

Copy link
Contributor

Choose a reason for hiding this comment

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

Love it! Thanks, @blittle!



{% codeblock file, filename: 'index.server.jsx' %}

```jsx
import {Link} from '@shopify/hydrogen';
export default function Index({request}) {
const url = new URL(request.normalizedUrl);

return (
<>
<p>Current param is: {url.searchParams.get('param')}</p>
<Link to="/?param=foo" restoreScroll={false}>
Update param to foo
</Link>
</>
);
}
```

{% endcodeblock %}

## Props

| Name | Type | Description |
Expand All @@ -28,6 +55,7 @@ export default function Index() {
| clientState? | <code>any</code> | The custom client state with the navigation. |
| reloadDocument? | <code>boolean</code> | Whether to reload the whole document on navigation. |
| prefetch? | <code>boolean</code> | 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). |
| restoreScroll? | <code>boolean</code> | Whether to emulate natural browser behavior and restore scroll position on navigation. Defaults to `true`. |

## Component type

Expand Down
14 changes: 2 additions & 12 deletions docs/hooks/framework/usenavigate.md
Original file line number Diff line number Diff line change
Expand Up @@ -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). |
| options | The options for the configuration object: `replace`, `reloadDocument`, `clientState`, `restoreScroll`. For more information the options, refer to the [Link component](https://shopify.dev/api/hydrogen/components/framework/link). |

## Considerations

Expand Down
12 changes: 9 additions & 3 deletions packages/hydrogen/src/components/Link/Link.client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
restoreScroll?: boolean;
}

/**
Expand Down Expand Up @@ -44,6 +46,7 @@ export const Link = React.forwardRef<HTMLAnchorElement, LinkProps>(
onClick,
clientState,
prefetch = true,
restoreScroll = true,
} = props;

const internalClick = useCallback(
Expand All @@ -63,19 +66,21 @@ export const Link = React.forwardRef<HTMLAnchorElement, LinkProps>(

navigate(to, {
replace,
restoreScroll,
clientState,
});
}
},
[
onClick,
reloadDocument,
target,
_replace,
to,
clientState,
onClick,
location,
to,
navigate,
clientState,
restoreScroll,
]
);

Expand Down Expand Up @@ -141,6 +146,7 @@ export const Link = React.forwardRef<HTMLAnchorElement, LinkProps>(
'clientState',
'reloadDocument',
'prefetch',
'restoreScroll',
])}
ref={ref}
onClick={internalClick}
Expand Down
39 changes: 38 additions & 1 deletion packages/hydrogen/src/components/Link/tests/Link.test.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -42,7 +43,10 @@ describe('<Link />', () => {
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 {
Expand All @@ -62,6 +66,39 @@ describe('<Link />', () => {
});
});

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 to="/products/hydrogen" restoreScroll={false}>
Link
</Link>,
{
history,
}
);

component.act(() => {
component?.domNode?.click();
});
});

it('updates server state on navigate', (done) => {
global.window.scrollTo = jest.fn();

Expand Down
29 changes: 19 additions & 10 deletions packages/hydrogen/src/foundation/Router/BrowserRouter.client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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({
Expand All @@ -56,14 +56,23 @@ export const BrowserRouter: FC<{
});

setLocation(newLocation);
setLocationChanged(true);

const state = (newLocation.state ?? {}) as Record<string, any>;

/**
* "pop" navigations, like forward/backward buttons, always restore scroll position
* regardless of what the original forward navigation intent was.
Comment on lines +63 to +64
Copy link
Contributor

Choose a reason for hiding this comment

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

til

*/
const needsScrollRestoration = action === 'POP' || !!state.restoreScroll;

setScrollNeedsRestoration(needsScrollRestoration);
});

return () => unlisten();
}, [
history,
location,
setLocationChanged,
setScrollNeedsRestoration,
setLocation,
setLocationServerProps,
]);
Expand Down Expand Up @@ -112,13 +121,13 @@ function useScrollRestoration({
location,
pending,
serverProps,
locationChanged,
scrollNeedsRestoration,
onFinishNavigating,
}: {
location: Location;
pending: boolean;
serverProps: LocationServerProps;
locationChanged: boolean;
scrollNeedsRestoration: boolean;
onFinishNavigating: () => void;
}) {
/**
Expand All @@ -141,7 +150,7 @@ function useScrollRestoration({

useLayoutEffect(() => {
// The app has just loaded
if (isFirstLoad || !locationChanged) {
if (isFirstLoad || !scrollNeedsRestoration) {
isFirstLoad = false;
return;
}
Expand Down Expand Up @@ -190,7 +199,7 @@ function useScrollRestoration({
pending,
serverProps.pathname,
serverProps.search,
locationChanged,
scrollNeedsRestoration,
onFinishNavigating,
]);
}
16 changes: 13 additions & 3 deletions packages/hydrogen/src/foundation/useNavigate/useNavigate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
restoreScroll?: any;
};

/**
Expand All @@ -21,9 +24,16 @@ export function useNavigate() {
path: string,
options: NavigationOptions = {replace: false, reloadDocument: false}
) => {
const state = {
...options?.clientState,
restoreScroll: options?.restoreScroll ?? 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);
}
};
}