Skip to content

Commit

Permalink
Merge pull request #393 from gadget-inc/droberts/GGT-1931-no-scroll-o…
Browse files Browse the repository at this point in the history
…n-replace-nav-on-page

fix(navigation): don't scroll to hash on replace navigation within page
  • Loading branch information
danroberts authored Aug 10, 2022
2 parents b6fd7a9 + fd2dc86 commit 4de7d87
Show file tree
Hide file tree
Showing 7 changed files with 220 additions and 9 deletions.
2 changes: 1 addition & 1 deletion packages/fastify-renderer/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "fastify-renderer",
"version": "0.3.0",
"version": "0.3.1",
"description": "Simple, high performance client side app renderer for Fastify.",
"exports": {
".": {
Expand Down
11 changes: 7 additions & 4 deletions packages/fastify-renderer/src/client/react/Root.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React, { useEffect, useState } from 'react'
import { Route, Router, Switch, useLocation } from 'wouter'
import { Route, Router, Switch, useLocation, useRouter } from 'wouter'
import { usePromise } from './fetcher'
import { useNavigationDetails, useTransitionLocation } from './locationHook'
import { shouldScrollToHash, useNavigationDetails, useTransitionLocation } from './locationHook'
import { matcher } from './matcher'

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

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

// navigate to the anchor in the url after rendering
// Navigate to the anchor in the url after rendering, unless we're using replaceState and
// the destination page and previous page have the same base route (i.e. before '#')
// We would do this for example to update the url to the correct anchor as the user scrolls.
useEffect(() => {
if (window.location.hash) {
if (window.location.hash && shouldScrollToHash(router.navigationHistory)) {
document.getElementById(window.location.hash.slice(1))?.scrollIntoView()
}
}, [location])
Expand Down
47 changes: 43 additions & 4 deletions packages/fastify-renderer/src/client/react/locationHook.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { unstable_useTransition as useTransition, useCallback, useEffect, useRef, useState } from 'react'
import { useLocation } from 'wouter'
import { NavigationHistory, useLocation, useRouter } from 'wouter'

/**
* History API docs @see https://developer.mozilla.org/en-US/docs/Web/API/History
Expand All @@ -20,6 +20,16 @@ export const useTransitionLocation = ({ base = '' } = {}) => {
const [path, update] = useState(() => currentPathname(base)) // @see https://reactjs.org/docs/hooks-reference.html#lazy-initial-state
const prevLocation = useRef(path + location.search + location.hash)
const [startTransition, isPending] = useTransition()
const router = useRouter()
useEffect(() => {
if (!router.navigationHistory)
router.navigationHistory = {
current: {
path,
replace: false,
},
}
}, [])

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

if (prevLocation.current !== destination) {
prevLocation.current = destination
startTransition(() => {
if (shouldScrollToHash(router.navigationHistory)) {
startTransition(() => {
update(destination)
})
} else {
update(destination)
})
}
}
}

Expand Down Expand Up @@ -61,11 +75,23 @@ export const useTransitionLocation = ({ base = '' } = {}) => {
return
}

const path = base + to

if (!router.navigationHistory) router.navigationHistory = {}
if (router.navigationHistory?.current) {
router.navigationHistory.previous = { ...router.navigationHistory.current }
}

router.navigationHistory.current = {
path,
replace,
}

history[replace ? eventReplaceState : eventPushState](
null,
'',
// handle nested routers and absolute paths
base + to
path
)
},
[base]
Expand Down Expand Up @@ -105,3 +131,16 @@ export const useNavigationDetails = (): [boolean, string] => {

const currentPathname = (base, path = location.pathname + location.search + location.hash) =>
!path.toLowerCase().indexOf(base.toLowerCase()) ? path.slice(base.length) || '/' : '~' + path

export const navigatingOnSamePage = (history?: NavigationHistory): boolean => {
const { current, previous } = history || {}

if (!history) return false
if (!current || !previous) return false

return current.path.split('#')[0] == previous.path.split('#')[0]
}

export const shouldScrollToHash = (history?: NavigationHistory): boolean => {
return !(navigatingOnSamePage(history) && history?.current?.replace)
}
17 changes: 17 additions & 0 deletions packages/fastify-renderer/src/client/react/wouter-extension.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import 'wouter'

declare module 'wouter' {
export interface RouterProps {
navigationHistory?: NavigationHistory
}

export interface NavigationHistory {
current?: NavigationHistoryItem
previous?: NavigationHistoryItem
}

export interface NavigationHistoryItem {
path: string
replace: boolean
}
}
98 changes: 98 additions & 0 deletions packages/test-apps/simple-react/NavigationHistoryTest.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import React from 'react'
import { Link, useLocation } from 'wouter'

const NavigationHistoryTest = () => {
const [path, navigate] = useLocation()

return (
<>
<h1>Navigation Test</h1>
<p>Leaving this page will set the navigation details on the window for inspection in the tests</p>
<br />
<Link href="/navigation-history-test#section">
<a id="section-link">Go to the content</a>
</Link>
<br />
<Link href="/">
<a id="home-link">Home</a>
</Link>
<br />
<button
id="section-link-replace"
onClick={() => {
navigate('/navigation-history-test#section', { replace: true })
}}
>
Update url without scrolling
</button>
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<h2 id="section">Another Section</h2>
<p>
Lorem ipsum dolor sit amet consectetur adipisicing elit. Fuga earum maiores, excepturi aspernatur perspiciatis
doloribus suscipit voluptates ipsam nam in nostrum vel obcaecati cum illum ex quasi quo est at.
</p>
</>
)
}

export default NavigationHistoryTest
4 changes: 4 additions & 0 deletions packages/test-apps/simple-react/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ export const server = async () => {
return {}
})

server.get('/navigation-history-test', { render: require.resolve('./NavigationHistoryTest') }, async (_request) => {
return {}
})

await server.register(async (instance) => {
instance.setRenderConfig({ document: CustomDocumentTemplate })

Expand Down
50 changes: 50 additions & 0 deletions packages/test-apps/simple-react/test/navigation-history.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { Page } from 'playwright-chromium'
import { newTestPage, reactReady, rootURL } from '../../helpers'

describe('navigation details', () => {
let page: Page

beforeEach(async () => {
page = await newTestPage()
await page.goto(`${rootURL}/navigation-history-test`)
await reactReady(page)
})

test('navigating to an anchor will scroll down to the anchor', async () => {
const visibleBeforeClick = await isIntersectingViewport(page, '#section')
expect(visibleBeforeClick).toBe(false)

await page.click('#section-link')

const visibleAfterClick = await isIntersectingViewport(page, '#section')
expect(visibleAfterClick).toBe(true)
})

test('navigating to an anchor that is on the same page via replace: true will not scroll to the anchor', async () => {
const visibleBeforeClick = await isIntersectingViewport(page, '#section')
expect(visibleBeforeClick).toBe(false)

await page.click('#section-link-replace')

const visibleAfterClick = await isIntersectingViewport(page, '#section')
expect(visibleAfterClick).toBe(false)
})
})

const isIntersectingViewport = (page: Page, selector: string): Promise<boolean> => {
return page.$eval(selector, async (element) => {
const visibleRatio: number = await new Promise((resolve) => {
const observer = new IntersectionObserver((entries) => {
resolve(entries[0].intersectionRatio)
observer.disconnect()
})
observer.observe(element)
// Firefox doesn't call IntersectionObserver callback unless
// there are rafs.
requestAnimationFrame(() => {
/**/
})
})
return visibleRatio > 0
})
}

0 comments on commit 4de7d87

Please sign in to comment.