Skip to content

Commit

Permalink
feat(router): Add option to not reset scroll to the top on navigate/l…
Browse files Browse the repository at this point in the history
…ink (#11380)

Co-authored-by: Tobbe Lundberg <tobbe@tlundberg.com>
  • Loading branch information
guitheengineer and Tobbe authored Jan 25, 2025
1 parent b144c6f commit 1555713
Show file tree
Hide file tree
Showing 5 changed files with 75 additions and 6 deletions.
3 changes: 3 additions & 0 deletions .changesets/11380.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
- feat(router): Add option to not reset scroll to the top on navigate/link (#11380) by @guitheengineer

You can now do ``navigate(`?id=${id}`, { scroll: false })`` and ``<Link to={`?id=${id}`} options={{ scroll: false }} />`` to not reset the scroll to the top when navigating.
6 changes: 6 additions & 0 deletions docs/docs/router.md
Original file line number Diff line number Diff line change
Expand Up @@ -614,6 +614,9 @@ const SomePage = () => {

The browser keeps track of the browsing history in a stack. By default when you navigate to a new page a new item is pushed to the history stack. But sometimes you want to replace the top item on the stack instead of appending to the stack. This is how you do that in Redwood: `navigate(routes.home(), { replace: true })`. As you can see you need to pass an options object as the second parameter to `navigate` with the option `replace` set to `true`.

By default `navigate` will scroll to the top after navigating to a new route (except for hash param changes), we can prevent this behavior by setting the `scroll` option to false:
`navigate(routes.home(), { scroll: false })`

### back

Going back is as easy as using the `back()` function that's exported from the router.
Expand Down Expand Up @@ -675,6 +678,9 @@ const SomePage = () => <Redirect to={routes.home()} />

In addition to the `to` prop, `<Redirect />` also takes an `options` prop. This is the same as [`navigate()`](#navigate)'s second argument: `navigate(_, { replace: true })`. We can use it to _replace_ the top item of the browser history stack (instead of pushing a new one). This is how you use it to have this effect: `<Redirect to={routes.home()} options={{ replace: true }}/>`.

By default redirect will scroll to the top after navigating to a new route (except for hash param changes), we can prevent this behavior by setting the `scroll` option to false:
`<Redirect to={routes.home()} options={{ scroll: false }}/>`

## Code-splitting

By default, the router will code-split on every Page, creating a separate lazy-loaded bundle for each. When navigating from page to page, the router will wait until the new Page module is loaded before re-rendering, thus preventing the "white-flash" effect.
Expand Down
53 changes: 53 additions & 0 deletions packages/router/src/__tests__/routeScrollReset.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,4 +89,57 @@ describe('Router scroll reset', () => {

expect(globalThis.scrollTo).not.toHaveBeenCalled()
})

it('when scroll option is false, does NOT reset on location/path change', async () => {
act(() =>
navigate(
// @ts-expect-error - AvailableRoutes built in project only
routes.page2(),
{
scroll: false,
},
),
)

screen.getByText('Page 2')

expect(globalThis.scrollTo).toHaveBeenCalledTimes(0)
})

it('when scroll option is false, does NOT reset on location/path and queryChange change', async () => {
act(() =>
navigate(
// @ts-expect-error - AvailableRoutes built in project only
routes.page2({
tab: 'three',
}),
{
scroll: false,
},
),
)

screen.getByText('Page 2')

expect(globalThis.scrollTo).toHaveBeenCalledTimes(0)
})

it('when scroll option is false, does NOT reset scroll on query params (search) change on the same page', async () => {
act(() =>
// We're staying on page 1, but changing the query params
navigate(
// @ts-expect-error - AvailableRoutes built in project only
routes.page1({
queryParam1: 'foo',
}),
{
scroll: false,
},
),
)

screen.getByText('Page 1')

expect(globalThis.scrollTo).toHaveBeenCalledTimes(0)
})
})
12 changes: 9 additions & 3 deletions packages/router/src/history.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
export interface NavigateOptions {
replace?: boolean
scroll?: boolean
}

export type Listener = (ev?: PopStateEvent) => any
export type Listener = (ev?: PopStateEvent, options?: NavigateOptions) => any
export type BeforeUnloadListener = (ev: BeforeUnloadEvent) => any
export type BlockerCallback = (tx: { retry: () => void }) => void
export type Blocker = { id: string; callback: BlockerCallback }
Expand All @@ -19,7 +20,12 @@ const createHistory = () => {
globalThis.addEventListener('popstate', listener)
return listenerId
},
navigate: (to: string, options?: NavigateOptions) => {
navigate: (
to: string,
options: NavigateOptions = {
scroll: true,
},
) => {
const performNavigation = () => {
const { pathname, search, hash } = new URL(
globalThis?.location?.origin + to,
Expand All @@ -38,7 +44,7 @@ const createHistory = () => {
}

for (const listener of Object.values(listeners)) {
listener()
listener(undefined, options)
}
}

Expand Down
7 changes: 4 additions & 3 deletions packages/router/src/location.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,12 +82,13 @@ class LocationProvider extends React.Component<
// componentDidMount() is not called during server rendering (aka SSR and
// prerendering)
componentDidMount() {
this.HISTORY_LISTENER_ID = gHistory.listen(() => {
this.HISTORY_LISTENER_ID = gHistory.listen((_, options) => {
const context = this.getContext()
this.setState((lastState) => {
if (
context?.pathname !== lastState?.context?.pathname ||
context?.search !== lastState?.context?.search
(context?.pathname !== lastState?.context?.pathname ||
context?.search !== lastState?.context?.search) &&
options?.scroll === true
) {
globalThis?.scrollTo(0, 0)
}
Expand Down

0 comments on commit 1555713

Please sign in to comment.