Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: ensured that history does not notify twice for certain actions and has index in state #3017

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
Open
85 changes: 53 additions & 32 deletions packages/history/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export interface NavigateOptions {

type SubscriberHistoryAction =
| {
type: HistoryAction | 'ROLLBACK'
type: HistoryAction
}
| {
type: 'GO'
Expand All @@ -30,6 +30,7 @@ export interface RouterHistory {
go: (index: number, navigateOpts?: NavigateOptions) => void
back: (navigateOpts?: NavigateOptions) => void
forward: (navigateOpts?: NavigateOptions) => void
canGoBack: () => boolean
createHref: (href: string) => string
block: (blocker: NavigationBlocker) => () => void
flush: () => void
Expand All @@ -51,17 +52,12 @@ export interface ParsedPath {

export interface HistoryState {
key?: string
index: number
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shouldn't this be index?: number as the index is not set for the initial entry?

also, i think we should use a more ... obscure name to not collide with user data

e.g. __TSR_index ?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's always available after we run through parseHref, this is because, if the route has no state (should be valid only for initial route), we default it to { index: 0 }. About the naming, sure, but by that logic, shouldn't the key also be renamed?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ideally, yes. but the key has been there since the beginning, but adding an index now could introduce a breaking change

}

type ShouldAllowNavigation = any

export type HistoryAction =
| 'PUSH'
| 'POP'
| 'REPLACE'
| 'FORWARD'
| 'BACK'
| 'GO'
export type HistoryAction = 'PUSH' | 'REPLACE' | 'FORWARD' | 'BACK' | 'GO'

export type BlockerFnArgs = {
currentLocation: HistoryLocation
Expand Down Expand Up @@ -105,9 +101,10 @@ export function createHistory(opts: {
back: (ignoreBlocker: boolean) => void
forward: (ignoreBlocker: boolean) => void
createHref: (path: string) => string
canGoBack?: () => boolean
flush?: () => void
destroy?: () => void
onBlocked?: (onUpdate: () => void) => void
onBlocked?: () => void
getBlockers?: () => Array<NavigationBlocker>
setBlockers?: (blockers: Array<NavigationBlocker>) => void
}): RouterHistory {
Expand All @@ -119,11 +116,8 @@ export function createHistory(opts: {
subscribers.forEach((subscriber) => subscriber({ location, action }))
}

const _notifyRollback = () => {
const _setLocation = () => {
location = opts.getLocation()
subscribers.forEach((subscriber) =>
subscriber({ location, action: { type: 'ROLLBACK' } }),
)
}

const tryNavigation = async ({
Expand All @@ -149,7 +143,7 @@ export function createHistory(opts: {
action: actionInfo.type,
})
if (isBlocked) {
opts.onBlocked?.(_notifyRollback)
opts.onBlocked?.()
return
}
}
Expand All @@ -174,7 +168,10 @@ export function createHistory(opts: {
}
},
push: (path, state, navigateOpts) => {
state = assignKey(state)
// This is a fallback for when updating the router and there are history entries without index
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
const currentIndex = location.state.index ?? 0
state = assignKey({ ...state, index: currentIndex + 1 })
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we put this logic into assigKey()?

tryNavigation({
task: () => {
opts.pushState(path, state)
Expand All @@ -187,7 +184,10 @@ export function createHistory(opts: {
})
},
replace: (path, state, navigateOpts) => {
state = assignKey(state)
// This is a fallback for when updating the router and there are history entries without index
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
const currentIndex = location.state.index ?? 0
state = assignKey({ ...state, index: currentIndex })
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same as above, can we put this logic into assigKey()?

tryNavigation({
task: () => {
opts.replaceState(path, state)
Expand All @@ -203,7 +203,7 @@ export function createHistory(opts: {
tryNavigation({
task: () => {
opts.go(index)
notify({ type: 'GO', index })
_setLocation()
},
navigateOpts,
type: 'GO',
Expand All @@ -213,7 +213,7 @@ export function createHistory(opts: {
tryNavigation({
task: () => {
opts.back(navigateOpts?.ignoreBlocker ?? false)
notify({ type: 'BACK' })
_setLocation()
},
navigateOpts,
type: 'BACK',
Expand All @@ -223,12 +223,13 @@ export function createHistory(opts: {
tryNavigation({
task: () => {
opts.forward(navigateOpts?.ignoreBlocker ?? false)
notify({ type: 'FORWARD' })
_setLocation()
},
navigateOpts,
type: 'FORWARD',
})
},
canGoBack: () => opts.canGoBack?.() ?? location.state.index !== 0,
createHref: (str) => opts.createHref(str),
block: (blocker) => {
if (!opts.setBlockers) return () => {}
Expand Down Expand Up @@ -301,6 +302,7 @@ export function createBrowserHistory(opts?: {
let currentLocation = parseLocation()
let rollbackLocation: HistoryLocation | undefined

let nextPopIsGo = false
let ignoreNextPop = false
let skipBlockerNextPop = false
let ignoreNextBeforeUnload = false
Expand Down Expand Up @@ -375,9 +377,10 @@ export function createBrowserHistory(opts?: {
}
}

const onPushPop = () => {
// NOTE: this function can probably be removed
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so can we remove it?

const onPushPop = (type: 'PUSH' | 'REPLACE') => {
currentLocation = parseLocation()
history.notify({ type: 'POP' })
history.notify({ type })
}

const onPushPopEvent = async () => {
Expand All @@ -386,30 +389,46 @@ export function createBrowserHistory(opts?: {
return
}

const nextLocation = parseLocation()
const delta = nextLocation.state.index - currentLocation.state.index
const isForward = delta === 1
const isBack = delta === -1
const isGo = (!isForward && !isBack) || nextPopIsGo
nextPopIsGo = false

const action = isGo ? 'GO' : isBack ? 'BACK' : 'FORWARD'
const notify: SubscriberHistoryAction = isGo
? {
type: 'GO',
index: delta,
}
: {
type: isBack ? 'BACK' : 'FORWARD',
}

if (skipBlockerNextPop) {
skipBlockerNextPop = false
} else {
const blockers = _getBlockers()
if (typeof document !== 'undefined' && blockers.length) {
for (const blocker of blockers) {
const nextLocation = parseLocation()
const isBlocked = await blocker.blockerFn({
currentLocation,
nextLocation,
action: 'POP',
action,
})
if (isBlocked) {
ignoreNextPop = true
win.history.go(1)
history.notify({ type: 'POP' })
history.notify(notify)
return
}
}
}
}

currentLocation = parseLocation()
history.notify({ type: 'POP' })
history.notify(notify)
}

const onBeforeUnload = (e: BeforeUnloadEvent) => {
Expand Down Expand Up @@ -462,7 +481,10 @@ export function createBrowserHistory(opts?: {
ignoreNextBeforeUnload = true
win.history.forward()
},
go: (n) => win.history.go(n),
go: (n) => {
nextPopIsGo = true
win.history.go(n)
},
createHref: (href) => createHref(href),
flush,
destroy: () => {
Expand All @@ -473,13 +495,11 @@ export function createBrowserHistory(opts?: {
})
win.removeEventListener(popStateEvent, onPushPopEvent)
},
onBlocked: (onUpdate) => {
onBlocked: () => {
// If a navigation is blocked, we need to rollback the location
// that we optimistically updated in memory.
if (rollbackLocation && currentLocation !== rollbackLocation) {
currentLocation = rollbackLocation
// Notify subscribers
onUpdate()
}
},
getBlockers: _getBlockers,
Expand All @@ -491,13 +511,13 @@ export function createBrowserHistory(opts?: {

win.history.pushState = function (...args: Array<any>) {
const res = originalPushState.apply(win.history, args as any)
if (!history._ignoreSubscribers) onPushPop()
if (!history._ignoreSubscribers) onPushPop('PUSH')
return res
}

win.history.replaceState = function (...args: Array<any>) {
const res = originalReplaceState.apply(win.history, args as any)
if (!history._ignoreSubscribers) onPushPop()
if (!history._ignoreSubscribers) onPushPop('REPLACE')
return res
}

Expand Down Expand Up @@ -559,6 +579,7 @@ export function createMemoryHistory(
go: (n) => {
index = Math.min(Math.max(index + n, 0), entries.length - 1)
},
canGoBack: () => index !== 0,
createHref: (path) => path,
})
}
Expand Down Expand Up @@ -587,7 +608,7 @@ export function parseHref(
searchIndex > -1
? href.slice(searchIndex, hashIndex === -1 ? undefined : hashIndex)
: '',
state: state || {},
state: state || { index: 0 },
}
}

Expand Down
2 changes: 2 additions & 0 deletions packages/react-router/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,8 @@ export { useRouterState } from './useRouterState'

export { useLocation } from './useLocation'

export { useCanGoBack } from './useCanGoBack'

export {
escapeJSON, // SSR
useLayoutEffect, // SSR
Expand Down
7 changes: 6 additions & 1 deletion packages/react-router/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1879,7 +1879,12 @@ export class Router<
...rest
}: BuildNextOptions & CommitLocationOptions = {}) => {
if (href) {
const parsed = parseHref(href, {})
// This is a fallback for when updating the router and there are history entries without index
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
const currentIndex = this.history.location.state.index ?? 0
const parsed = parseHref(href, {
index: replace ? currentIndex : currentIndex + 1,
})
rest.to = parsed.pathname
rest.search = this.options.parseSearch(parsed.search)
// remove the leading `#` from the hash
Expand Down
10 changes: 10 additions & 0 deletions packages/react-router/src/useCanGoBack.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { useSyncExternalStore } from 'react'
import { useRouter } from './useRouter'
import type { RouterHistory } from '@tanstack/history'

export function useCanGoBack() {
const router = useRouter()
const history: RouterHistory = router.history

return useSyncExternalStore(history.subscribe, history.canGoBack)
}
Loading
Loading