Skip to content

Commit 9c2d1a6

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 9c2d1a6

File tree

6 files changed

+171
-18
lines changed

6 files changed

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

0 commit comments

Comments
 (0)