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

feat(router): Add option to not reset scroll to the top on navigate/link #11380

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
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
54 changes: 54 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,58 @@ 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)
})
})
9 changes: 6 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,9 @@ 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 +41,7 @@ const createHistory = () => {
}

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

Expand Down
6 changes: 3 additions & 3 deletions packages/router/src/location.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,12 +82,12 @@ 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