Skip to content

Commit f4ab59b

Browse files
committed
Use react lifecycle to manage scroll
This fixes a issue where scroll restoration was not working as it was called before react renders the restored page. In this change we move the restoration to `useLayoutEffect`. In reviewing the fix, we noticed that `setActivePage` was not being dispatched in `navigateTo`, and relied on `onHistoryChange` to make the correct changes. We refactor to move the explicit nav logic from `onHistoryChange` and back to `navigateTo`.
1 parent 0380ed3 commit f4ab59b

File tree

2 files changed

+87
-79
lines changed

2 files changed

+87
-79
lines changed

superglue/lib/components/Navigation.tsx

+51-55
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import React, {
22
createContext,
33
useEffect,
4+
useLayoutEffect,
45
forwardRef,
56
useImperativeHandle,
67
ForwardedRef,
78
} from 'react'
89
import { urlToPageKey, pathWithoutBZParams } from '../utils'
9-
import { removePage, historyChange, setActivePage } from '../actions'
10+
import { removePage, setActivePage } from '../actions'
1011
import {
1112
HistoryState,
1213
RootState,
@@ -15,7 +16,6 @@ import {
1516
NavigationProviderProps,
1617
AllPages,
1718
SuperglueState,
18-
PageKey,
1919
} from '../types'
2020
import { Update } from 'history'
2121
import { useDispatch, useSelector, useStore } from 'react-redux'
@@ -53,12 +53,23 @@ const NavigationProvider = forwardRef(function NavigationProvider(
5353
const superglue = useSelector<RootState, SuperglueState>(
5454
(state) => state.superglue
5555
)
56+
const currentPageKey = useSelector<RootState, string>(
57+
(state) => state.superglue.currentPageKey
58+
)
5659
const store = useStore()
5760

5861
useEffect(() => {
5962
return history.listen(onHistoryChange)
6063
}, [])
6164

65+
useLayoutEffect(() => {
66+
const state = history.location.state as HistoryState
67+
if (state && 'superglue' in state) {
68+
const { posX, posY } = state
69+
setWindowScroll(posX, posY)
70+
}
71+
}, [currentPageKey])
72+
6273
useImperativeHandle(
6374
ref,
6475
() => {
@@ -69,36 +80,14 @@ const NavigationProvider = forwardRef(function NavigationProvider(
6980
[]
7081
)
7182

72-
const visitAndRestore = (pageKey: PageKey, posX: number, posY: number) => {
73-
// When the application visit gets called with revisit: true
74-
// - In cases where the response was not redirected, the calculated
75-
// navigationAction is set to 'none' (meaning `navigateTo` immediately returned `false`)
76-
// and so we have restore scroll and the set the active page
77-
// - In cases where the response was redirected, the calculated
78-
// navigationAction is set to 'replace', and is handled gracefully by navigateTo,
79-
// before this method gets called.
80-
// That's why we're only concerned with the first case, but we gracefully warn
81-
// if the application visit did not return the meta object like the dev was supposed to.
82-
return visit(pageKey, { revisit: true }).then((meta) => {
83-
if (meta) {
84-
if (meta.navigationAction === 'none') {
85-
dispatch(setActivePage({ pageKey }))
86-
setWindowScroll(posX, posY)
87-
}
88-
} else {
89-
console.warn(
90-
`scoll restoration was skipped. Your visit's then funtion
91-
should return the meta object it recieved if you want your
92-
application to restore the page's previous scroll.`
93-
)
94-
}
95-
})
96-
}
97-
9883
const onHistoryChange = ({ location, action }: Update): void => {
9984
const state = location.state as HistoryState
10085

101-
if (!state && location.hash !== '' && action === 'POP') {
86+
if (action !== 'POP') {
87+
return
88+
}
89+
90+
if (!state && location.hash !== '') {
10291
const nextPageKey = urlToPageKey(location.pathname + location.search)
10392
const containsKey = !!pages[nextPageKey]
10493
if (containsKey) {
@@ -119,17 +108,8 @@ const NavigationProvider = forwardRef(function NavigationProvider(
119108
}
120109

121110
if (state && 'superglue' in state) {
122-
dispatch(
123-
historyChange({
124-
pageKey: state.pageKey,
125-
})
126-
)
127-
128-
if (action !== 'POP') {
129-
return
130-
}
131-
132-
const { pageKey, posX, posY } = state
111+
const { pageKey } = state
112+
const prevPageKey = store.getState().superglue.currentPageKey
133113
const containsKey = !!pages[pageKey]
134114

135115
if (containsKey) {
@@ -138,19 +118,38 @@ const NavigationProvider = forwardRef(function NavigationProvider(
138118
switch (restoreStrategy) {
139119
case 'fromCacheOnly':
140120
dispatch(setActivePage({ pageKey }))
141-
setWindowScroll(posX, posY)
142121
break
143122
case 'fromCacheAndRevisitInBackground':
144123
dispatch(setActivePage({ pageKey }))
145-
setWindowScroll(posX, posY)
146124
visit(pageKey, { revisit: true })
147125
break
148126
case 'revisitOnly':
149127
default:
150-
visitAndRestore(pageKey, posX, posY)
128+
visit(pageKey, { revisit: true }).then(() => {
129+
const noNav =
130+
prevPageKey === store.getState().superglue.currentPageKey
131+
if (noNav) {
132+
// When "POP'ed", revisiting (using revisit: true) a page can result in
133+
// a redirect, or a render of the same page.
134+
//
135+
// When its a redirect, calculateNavAction will correctly set the
136+
// navigationAction to `replace` this is the noop scenario.
137+
//
138+
// When its the same page, navigationAction is set to `none` and
139+
// no navigation took place. In that case, we have to set the
140+
// activePage otherwise the user is stuck on the original page.
141+
dispatch(setActivePage({ pageKey }))
142+
}
143+
})
151144
}
152145
} else {
153-
visitAndRestore(pageKey, posX, posY)
146+
visit(pageKey, { revisit: true }).then(() => {
147+
const noNav =
148+
prevPageKey === store.getState().superglue.currentPageKey
149+
if (noNav) {
150+
dispatch(setActivePage({ pageKey }))
151+
}
152+
})
154153
}
155154
}
156155
}
@@ -175,7 +174,6 @@ const NavigationProvider = forwardRef(function NavigationProvider(
175174
if (hasPage) {
176175
const location = history.location
177176
const state = location.state as HistoryState
178-
const prevPageKey = state.pageKey
179177
const historyArgs = [
180178
path,
181179
{
@@ -196,26 +194,24 @@ const NavigationProvider = forwardRef(function NavigationProvider(
196194
},
197195
{
198196
...state,
199-
posY: window.pageYOffset,
200-
posX: window.pageXOffset,
197+
posY: window.scrollY,
198+
posX: window.scrollX,
201199
}
202200
)
203201
}
204202

205203
history.push(...historyArgs)
204+
dispatch(setActivePage({ pageKey: nextPageKey }))
206205
}
207206

208207
if (action === 'replace') {
209208
history.replace(...historyArgs)
210-
}
211209

212-
setActivePage({ pageKey: nextPageKey })
213-
setWindowScroll(0, 0)
214-
215-
if (action === 'replace' && prevPageKey && prevPageKey !== nextPageKey) {
216-
dispatch(removePage({ pageKey: prevPageKey }))
210+
if (currentPageKey !== nextPageKey) {
211+
dispatch(setActivePage({ pageKey: nextPageKey }))
212+
dispatch(removePage({ pageKey: currentPageKey }))
213+
}
217214
}
218-
219215
return true
220216
} else {
221217
console.warn(
@@ -228,7 +224,7 @@ const NavigationProvider = forwardRef(function NavigationProvider(
228224
}
229225
}
230226

231-
const { currentPageKey, search } = superglue
227+
const { search } = superglue
232228
const { componentIdentifier } = pages[currentPageKey]
233229
const Component = mapping[componentIdentifier]
234230

superglue/spec/lib/NavComponent.spec.jsx

+36-24
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import userEvent from '@testing-library/user-event'
88
import { NavigationContext } from '../../lib/components/Navigation'
99
import { configureStore } from '@reduxjs/toolkit'
1010
import { rootReducer } from '../../lib'
11+
import { setActivePage } from '../../lib/actions'
1112

1213
const buildStore = (preloadedState) => {
1314
let resultsReducer = (state = [], action) => {
@@ -175,13 +176,7 @@ describe('Nav', () => {
175176

176177
const expectedActions = [
177178
{
178-
type: '@@superglue/HISTORY_CHANGE',
179-
payload: {
180-
pageKey: '/home',
181-
},
182-
},
183-
{
184-
type: '@@superglue/HISTORY_CHANGE',
179+
type: '@@superglue/SET_ACTIVE_PAGE',
185180
payload: {
186181
pageKey: '/about',
187182
},
@@ -362,7 +357,7 @@ describe('Nav', () => {
362357

363358
describe('history pop', () => {
364359
describe('when the previous page was set to "revisitOnly"', () => {
365-
it('revisits the page and scrolls when finished', () => {
360+
it('revisits the page and scrolls when finished', async () => {
366361
const history = createMemoryHistory({})
367362
history.replace('/home', {
368363
superglue: true,
@@ -402,9 +397,7 @@ describe('Nav', () => {
402397
const fakeVisit = vi.fn((...args) => {
403398
return {
404399
then: vi.fn((fn) => {
405-
expect(scrollTo).not.toHaveBeenCalled()
406400
fn({ navigationAction })
407-
expect(scrollTo).toHaveBeenCalledWith(5, 5)
408401
}),
409402
}
410403
})
@@ -421,11 +414,19 @@ describe('Nav', () => {
421414
</Provider>
422415
)
423416

417+
expect(scrollTo).toHaveBeenCalledWith(10, 10)
424418
history.back()
419+
expect(scrollTo).not.toHaveBeenCalledWith(5, 5)
425420
expect(fakeVisit).toHaveBeenCalledWith('/home', { revisit: true })
421+
422+
await expect.poll(() => scrollTo.toHaveBeenCalledWith(5, 5))
426423
})
427424

428-
it('revisits the page and skips scroll when redirected (navigationAction is not "none")', () => {
425+
it('revisits the page and when redirected replaces with the new page', async () => {
426+
const Login = () => {
427+
return <h1>Login Page</h1>
428+
}
429+
429430
const history = createMemoryHistory({})
430431
history.replace('/home', {
431432
superglue: true,
@@ -451,6 +452,10 @@ describe('Nav', () => {
451452
componentIdentifier: 'about',
452453
restoreStrategy: 'revisitOnly',
453454
},
455+
'/login': {
456+
componentIdentifier: 'login',
457+
restoreStrategy: 'fromCacheOnly',
458+
},
454459
},
455460
superglue: {
456461
csrfToken: 'abc',
@@ -460,39 +465,45 @@ describe('Nav', () => {
460465
const scrollTo = vi
461466
.spyOn(window, 'scrollTo')
462467
.mockImplementation(() => {})
463-
const navigationAction = 'push'
468+
const navigationAction = 'replace'
464469

465470
const fakeVisit = vi.fn((...args) => {
466471
return {
467472
then: vi.fn((fn) => {
468-
// expect(scrollTo).not.toHaveBeenCalled()
473+
store.dispatch(setActivePage({ pageKey: '/login' }))
469474
fn({ navigationAction })
470-
expect(scrollTo).not.toHaveBeenCalled()
471475
}),
472476
}
473477
})
474478

475-
// scroll to 0 0
476-
477479
render(
478480
<Provider store={store}>
479481
<NavigationProvider
480482
store={store}
481483
visit={fakeVisit}
482-
mapping={{ home: Home, about: About }}
484+
mapping={{ home: Home, about: About, login: Login }}
483485
initialPageKey={'/home'}
484486
history={history}
485487
/>
486488
</Provider>
487489
)
488490

491+
expect(screen.getByRole('heading')).toHaveTextContent('About Page')
492+
expect(screen.getByRole('heading')).not.toHaveTextContent('Login Page')
493+
489494
history.back()
490-
expect(fakeVisit).toHaveBeenCalledWith('/home', { revisit: true })
495+
496+
await expect.poll(() =>
497+
screen.getByRole('heading').not.toHaveTextContent('About Page')
498+
)
499+
await expect.poll(() =>
500+
screen.getByRole('heading').toHaveTextContent('Login Page')
501+
)
491502
})
492503
})
493504

494505
describe('when the previous page was set to "fromCacheOnly"', () => {
495-
it('restores without visiting and scrolls', () => {
506+
it('restores without visiting and scrolls', async () => {
496507
const history = createMemoryHistory({})
497508
history.replace('/home', {
498509
superglue: true,
@@ -541,13 +552,14 @@ describe('Nav', () => {
541552
)
542553

543554
history.back()
544-
expect(scrollTo).toHaveBeenCalledWith(5, 5)
545555
expect(fakeVisit).not.toHaveBeenCalled()
556+
expect(scrollTo).not.toHaveBeenCalledWith(5, 5)
557+
await expect.poll(() => scrollTo.toHaveBeenCalledWith(5, 5))
546558
})
547559
})
548560

549561
describe('and the previous page was set to "fromCacheAndRevisitInBackground"', () => {
550-
it('restores, scrolls, and revisits the page in the background', () => {
562+
it('restores, scrolls, and revisits the page in the background', async () => {
551563
const history = createMemoryHistory({})
552564
history.replace('/home', {
553565
superglue: true,
@@ -582,9 +594,7 @@ describe('Nav', () => {
582594
.spyOn(window, 'scrollTo')
583595
.mockImplementation(() => {})
584596

585-
const fakeVisit = vi.fn((...args) => {
586-
expect(scrollTo).toHaveBeenCalledWith(5, 5)
587-
})
597+
const fakeVisit = vi.fn((...args) => {})
588598

589599
render(
590600
<Provider store={store}>
@@ -600,6 +610,8 @@ describe('Nav', () => {
600610

601611
history.back()
602612
expect(fakeVisit).toHaveBeenCalledWith('/home', { revisit: true })
613+
expect(scrollTo).not.toHaveBeenCalledWith(5, 5)
614+
await expect.poll(() => scrollTo.toHaveBeenCalledWith(5, 5))
603615
})
604616
})
605617
})

0 commit comments

Comments
 (0)