Skip to content

Commit f27c4e7

Browse files
committed
fix(navigation): don't scroll to hash on replace navigation within page
When we do a client side page navigation via wouter, if there is a hash anchor in the url, we scroll to that anchor to match the default browser behavior. This means that if we want to update the URL to match the user's current scroll location in the document, we end up triggering a second scroll. To avoid this, do not scroll to the anchor if the following conditions are met: - The navigation is happening on the same page: (i.e. from some/url#foo to some/url#bar) - The navigation was triggered by a history.replaceState() action
1 parent b6fd7a9 commit f27c4e7

File tree

4 files changed

+59
-9
lines changed

4 files changed

+59
-9
lines changed

packages/fastify-renderer/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "fastify-renderer",
3-
"version": "0.3.0",
3+
"version": "0.3.1",
44
"description": "Simple, high performance client side app renderer for Fastify.",
55
"exports": {
66
".": {

packages/fastify-renderer/src/client/react/Root.tsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import React, { useEffect, useState } from 'react'
2-
import { Route, Router, Switch, useLocation } from 'wouter'
2+
import { Route, Router, Switch, useLocation, useRouter } from 'wouter'
33
import { usePromise } from './fetcher'
4-
import { useNavigationDetails, useTransitionLocation } from './locationHook'
4+
import { shouldScrollToHash, useNavigationDetails, useTransitionLocation } from './locationHook'
55
import { matcher } from './matcher'
66

77
export interface LayoutProps {
@@ -47,6 +47,7 @@ export function Root<BootProps>(props: {
4747
<Route path={route} key={route}>
4848
{(params) => {
4949
const [location] = useLocation()
50+
const router = useRouter()
5051
const backendPath = location.split('#')[0] // remove current anchor for fetching data from the server side
5152

5253
const payload = usePromise<{ props: Record<string, any> }>(props.basePath + backendPath, async () => {
@@ -65,9 +66,11 @@ export function Root<BootProps>(props: {
6566
}
6667
})
6768

68-
// navigate to the anchor in the url after rendering
69+
// Navigate to the anchor in the url after rendering, unless we're using replaceState and
70+
// the destination page and previous page have the same base route (i.e. before '#')
71+
// We would do this for example to update the url to the correct anchor as the user scrolls.
6972
useEffect(() => {
70-
if (window.location.hash) {
73+
if (window.location.hash && shouldScrollToHash(router.navigationHistory)) {
7174
document.getElementById(window.location.hash.slice(1))?.scrollIntoView()
7275
}
7376
}, [location])

packages/fastify-renderer/src/client/react/locationHook.ts

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { unstable_useTransition as useTransition, useCallback, useEffect, useRef, useState } from 'react'
2-
import { useLocation } from 'wouter'
2+
import { NavigationHistory, useLocation, useRouter } from 'wouter'
33

44
/**
55
* History API docs @see https://developer.mozilla.org/en-US/docs/Web/API/History
@@ -20,6 +20,7 @@ export const useTransitionLocation = ({ base = '' } = {}) => {
2020
const [path, update] = useState(() => currentPathname(base)) // @see https://reactjs.org/docs/hooks-reference.html#lazy-initial-state
2121
const prevLocation = useRef(path + location.search + location.hash)
2222
const [startTransition, isPending] = useTransition()
23+
const router = useRouter()
2324

2425
useEffect(() => {
2526
// this function checks if the location has been changed since the
@@ -31,9 +32,13 @@ export const useTransitionLocation = ({ base = '' } = {}) => {
3132

3233
if (prevLocation.current !== destination) {
3334
prevLocation.current = destination
34-
startTransition(() => {
35+
if (shouldScrollToHash(router.navigationHistory)) {
36+
startTransition(() => {
37+
update(destination)
38+
})
39+
} else {
3540
update(destination)
36-
})
41+
}
3742
}
3843
}
3944

@@ -61,11 +66,23 @@ export const useTransitionLocation = ({ base = '' } = {}) => {
6166
return
6267
}
6368

69+
const path = base + to
70+
71+
if (!router.navigationHistory) router.navigationHistory = {}
72+
if (router.navigationHistory?.current) {
73+
router.navigationHistory.previous = { ...router.navigationHistory.current }
74+
}
75+
76+
router.navigationHistory.current = {
77+
path,
78+
replace,
79+
}
80+
6481
history[replace ? eventReplaceState : eventPushState](
6582
null,
6683
'',
6784
// handle nested routers and absolute paths
68-
base + to
85+
path
6986
)
7087
},
7188
[base]
@@ -105,3 +122,16 @@ export const useNavigationDetails = (): [boolean, string] => {
105122

106123
const currentPathname = (base, path = location.pathname + location.search + location.hash) =>
107124
!path.toLowerCase().indexOf(base.toLowerCase()) ? path.slice(base.length) || '/' : '~' + path
125+
126+
export const navigatingOnSamePage = (history?: NavigationHistory): boolean => {
127+
const { current, previous } = history || {}
128+
129+
if (!history) return false
130+
if (!current || !previous) return false
131+
132+
return current.path.split('#')[0] == previous.path.split('#')[0]
133+
}
134+
135+
export const shouldScrollToHash = (history?: NavigationHistory): boolean => {
136+
return !(navigatingOnSamePage(history) && history?.current?.replace)
137+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import 'wouter'
2+
3+
declare module 'wouter' {
4+
export interface RouterProps {
5+
navigationHistory?: NavigationHistory
6+
}
7+
8+
export interface NavigationHistory {
9+
current?: NavigationHistoryItem
10+
previous?: NavigationHistoryItem
11+
}
12+
13+
export interface NavigationHistoryItem {
14+
path: string
15+
replace: boolean
16+
}
17+
}

0 commit comments

Comments
 (0)