Skip to content

Commit d05d912

Browse files
committed
Add optimistic navigation for navigateTo
`navigateTo` requires a page to exist in the `pages` slice in order to successfully navigate. You would have to create a page in the store before calling `navigateTo`. The most common scenario is creating a copy of the current page with new params before navigating. e.g, facet filters. Its common enough that we add the ability to optimistically navigate. With these changes, you can provide a `search` hash that will get merged with the existing url query params. The page state is either copied over for history `push`s or moved to a new pageKey for history `replace`s before navigating. This also encourages folks to use the browser url to reflect application state, just like EmberJS instead of `useState`. For example: resolves #135 ``` const { navigateTo, pageKey, search } = useContext(NavigationContext) ```
1 parent a0bab25 commit d05d912

File tree

6 files changed

+168
-18
lines changed

6 files changed

+168
-18
lines changed

superglue/lib/actions.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,13 @@ export const copyPage = createAction<{ from: PageKey; to: PageKey }>(
8787
'@@superglue/COPY_PAGE'
8888
)
8989

90+
/**
91+
* A redux action you can dispatch to move a page from one pageKey to another.
92+
*/
93+
export const movePage = createAction<{ from: PageKey; to: PageKey }>(
94+
'@@superglue/MOVE_PAGE'
95+
)
96+
9097
/**
9198
* A redux action you can dispatch to remove a page from your store.
9299
*

superglue/lib/components/Navigation.tsx

Lines changed: 26 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ import React, {
66
useImperativeHandle,
77
ForwardedRef,
88
} from 'react'
9-
import { urlToPageKey, pathWithoutBZParams } from '../utils'
10-
import { removePage, setActivePage } from '../actions'
9+
import { urlToPageKey, pathWithoutBZParams, mergeQuery } from '../utils'
10+
import { removePage, setActivePage, movePage, copyPage } from '../actions'
1111
import {
1212
HistoryState,
1313
RootState,
@@ -153,29 +153,31 @@ const NavigationProvider = forwardRef(function NavigationProvider(
153153
}
154154
}
155155
}
156+
const navigateTo: NavigateTo = (path, { action, search } = {}) => {
157+
action ||= 'push'
158+
search ||= {}
156159

157-
const navigateTo: NavigateTo = (
158-
path,
159-
{ action } = {
160-
action: 'push',
161-
}
162-
) => {
163160
if (action === 'none') {
164161
return false
165162
}
166163

167-
path = pathWithoutBZParams(path)
168-
const nextPageKey = urlToPageKey(path)
169-
const hasPage = Object.prototype.hasOwnProperty.call(
170-
store.getState().pages,
171-
nextPageKey
172-
)
164+
let nextPath = pathWithoutBZParams(path)
165+
const originalPageKey = urlToPageKey(nextPath)
166+
let nextPageKey = urlToPageKey(originalPageKey)
167+
// store is untyped?
168+
const page = store.getState().pages[nextPageKey]
169+
170+
if (page) {
171+
const isOptimisticNav = Object.keys(search).length > 0
172+
if (isOptimisticNav) {
173+
nextPageKey = mergeQuery(nextPageKey, search)
174+
nextPath = mergeQuery(nextPath, search)
175+
}
173176

174-
if (hasPage) {
175177
const location = history.location
176178
const state = location.state as HistoryState
177179
const historyArgs = [
178-
path,
180+
nextPath,
179181
{
180182
pageKey: nextPageKey,
181183
superglue: true,
@@ -200,14 +202,21 @@ const NavigationProvider = forwardRef(function NavigationProvider(
200202
)
201203
}
202204

205+
if (isOptimisticNav) {
206+
dispatch(copyPage({ from: originalPageKey, to: nextPageKey }))
207+
}
208+
203209
history.push(...historyArgs)
204210
dispatch(setActivePage({ pageKey: nextPageKey }))
205211
}
206212

207213
if (action === 'replace') {
208214
history.replace(...historyArgs)
209215

210-
if (currentPageKey !== nextPageKey) {
216+
if (isOptimisticNav) {
217+
dispatch(movePage({ from: originalPageKey, to: nextPageKey }))
218+
dispatch(setActivePage({ pageKey: nextPageKey }))
219+
} else if (currentPageKey !== nextPageKey) {
211220
dispatch(setActivePage({ pageKey: nextPageKey }))
212221
dispatch(removePage({ pageKey: currentPageKey }))
213222
}

superglue/lib/reducers/index.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
handleGraft,
66
historyChange,
77
copyPage,
8+
movePage,
89
setCSRFToken,
910
setActivePage,
1011
removePage,
@@ -171,6 +172,16 @@ export function pageReducer(state: AllPages = {}, action: Action): AllPages {
171172
return nextState
172173
}
173174

175+
if (movePage.match(action)) {
176+
const nextState = { ...state }
177+
const { from, to } = action.payload
178+
179+
nextState[to] = nextState[from]
180+
delete nextState[from]
181+
182+
return nextState
183+
}
184+
174185
if (handleGraft.match(action)) {
175186
const { pageKey, page } = action.payload
176187

superglue/lib/types/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -394,7 +394,8 @@ export interface BasicRequestInit extends RequestInit {
394394
export type NavigateTo = (
395395
path: Keypath,
396396
options: {
397-
action: NavigationAction
397+
action?: NavigationAction
398+
search?: Record<string, string>
398399
}
399400
) => boolean
400401

superglue/lib/utils/url.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,3 +101,13 @@ export function parsePageKey(pageKey: PageKey) {
101101
search: query,
102102
}
103103
}
104+
105+
export function mergeQuery(pageKey: PageKey, search: Record<string, string>) {
106+
const parsed = new parse(pageKey, {}, true)
107+
108+
Object.keys(search).forEach((key) => {
109+
parsed.query[key] = search[key]
110+
})
111+
112+
return parsed.toString()
113+
}

superglue/spec/lib/NavComponent.spec.jsx

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,118 @@ describe('Nav', () => {
355355
})
356356
})
357357

358+
it('navigates using "push" to a copied page with new params', () => {
359+
const history = createMemoryHistory({})
360+
history.replace('/home', {
361+
superglue: true,
362+
pageKey: '/home',
363+
posX: 5,
364+
posY: 5,
365+
})
366+
367+
vi.spyOn(window, 'scrollTo').mockImplementation(() => {})
368+
369+
const store = buildStore({
370+
pages: {
371+
'/home': {
372+
componentIdentifier: 'home',
373+
restoreStrategy: 'fromCacheOnly',
374+
},
375+
'/about': {
376+
componentIdentifier: 'about',
377+
restoreStrategy: 'fromCacheOnly',
378+
},
379+
},
380+
superglue: {
381+
csrfToken: 'abc',
382+
currentPageKey: '/home',
383+
},
384+
})
385+
386+
let instance
387+
388+
render(
389+
<Provider store={store}>
390+
<NavigationProvider
391+
store={store}
392+
ref={(node) => (instance = node)}
393+
mapping={{ home: Home, about: About }}
394+
history={history}
395+
/>
396+
</Provider>
397+
)
398+
399+
instance.navigateTo('/home', { search: { hello: 'world' } })
400+
401+
const pages = store.getState().pages
402+
expect(pages['/home?hello=world']).toMatchObject(pages['/home'])
403+
expect(pages['/home?hello=world']).not.toBe(pages['/home'])
404+
405+
expect(store.getState().superglue.currentPageKey).toEqual(
406+
'/home?hello=world'
407+
)
408+
expect(history.location.pathname).toEqual('/home')
409+
expect(history.location.search).toEqual('?hello=world')
410+
})
411+
412+
it('navigates using "replace" to a moved page with new params', () => {
413+
const history = createMemoryHistory({})
414+
history.replace('/home', {
415+
superglue: true,
416+
pageKey: '/home',
417+
posX: 5,
418+
posY: 5,
419+
})
420+
421+
vi.spyOn(window, 'scrollTo').mockImplementation(() => {})
422+
423+
const homeProps = {
424+
componentIdentifier: 'home',
425+
restoreStrategy: 'fromCacheOnly',
426+
}
427+
428+
const store = buildStore({
429+
pages: {
430+
'/home': homeProps,
431+
'/about': {
432+
componentIdentifier: 'about',
433+
restoreStrategy: 'fromCacheOnly',
434+
},
435+
},
436+
superglue: {
437+
csrfToken: 'abc',
438+
currentPageKey: '/home',
439+
},
440+
})
441+
442+
let instance
443+
444+
render(
445+
<Provider store={store}>
446+
<NavigationProvider
447+
store={store}
448+
ref={(node) => (instance = node)}
449+
mapping={{ home: Home, about: About }}
450+
history={history}
451+
/>
452+
</Provider>
453+
)
454+
455+
instance.navigateTo('/home', {
456+
action: 'replace',
457+
search: { hello: 'world' },
458+
})
459+
460+
const pages = store.getState().pages
461+
expect(pages['/home?hello=world']).toBe(homeProps)
462+
463+
expect(store.getState().superglue.currentPageKey).toEqual(
464+
'/home?hello=world'
465+
)
466+
expect(history.location.pathname).toEqual('/home')
467+
expect(history.location.search).toEqual('?hello=world')
468+
})
469+
358470
describe('history pop', () => {
359471
describe('when the previous page was set to "revisitOnly"', () => {
360472
it('revisits the page and scrolls when finished', async () => {

0 commit comments

Comments
 (0)