+
router.history.back()}>
+ Back
+ {' '}
{
await expect(page.getByRole('heading')).toContainText('Editing A')
})
+test('useCanGoBack correctly disables back button', async ({ page }) => {
+ const getBackButtonDisabled = async () => {
+ const backButton = await page.getByRole('button', { name: 'Back' })
+ const isDisabled = (await backButton.getAttribute('disabled')) !== null
+ return isDisabled
+ }
+
+ await expect(await getBackButtonDisabled()).toBe(true)
+
+ await page.getByRole('link', { name: 'Posts' }).click()
+ await expect(await getBackButtonDisabled()).toBe(false)
+
+ await page.getByRole('link', { name: 'sunt aut facere repe' }).click()
+ await expect(await getBackButtonDisabled()).toBe(false)
+
+ await page.reload()
+ await expect(await getBackButtonDisabled()).toBe(false)
+
+ await page.goBack()
+ await expect(await getBackButtonDisabled()).toBe(false)
+
+ await page.goForward()
+ await expect(await getBackButtonDisabled()).toBe(false)
+
+ await page.goBack()
+ await expect(await getBackButtonDisabled()).toBe(false)
+
+ await page.goBack()
+ await expect(await getBackButtonDisabled()).toBe(true)
+
+ await page.reload()
+ await expect(await getBackButtonDisabled()).toBe(true)
+})
+
+test('useCanGoBack correctly disables back button, using router.history and window.history', async ({
+ page,
+}) => {
+ const getBackButtonDisabled = async () => {
+ const backButton = await page.getByRole('button', { name: 'Back' })
+ const isDisabled = (await backButton.getAttribute('disabled')) !== null
+ return isDisabled
+ }
+
+ await page.getByRole('link', { name: 'Posts' }).click()
+ await page.getByRole('link', { name: 'sunt aut facere repe' }).click()
+ await page.getByRole('button', { name: 'Back' }).click()
+ await expect(await getBackButtonDisabled()).toBe(false)
+
+ await page.reload()
+ await expect(await getBackButtonDisabled()).toBe(false)
+
+ await page.getByRole('button', { name: 'Back' }).click()
+ await expect(await getBackButtonDisabled()).toBe(true)
+
+ await page.evaluate('window.history.forward()')
+ await expect(await getBackButtonDisabled()).toBe(false)
+
+ await page.evaluate('window.history.forward()')
+ await expect(await getBackButtonDisabled()).toBe(false)
+
+ await page.evaluate('window.history.back()')
+ await expect(await getBackButtonDisabled()).toBe(false)
+
+ await page.evaluate('window.history.back()')
+ await expect(await getBackButtonDisabled()).toBe(true)
+
+ await page.reload()
+ await expect(await getBackButtonDisabled()).toBe(true)
+})
+
const testCases = [
{
description: 'Navigating to a route inside a route group',
diff --git a/packages/history/src/index.ts b/packages/history/src/index.ts
index 2a85d14950..c28d958887 100644
--- a/packages/history/src/index.ts
+++ b/packages/history/src/index.ts
@@ -8,7 +8,7 @@ export interface NavigateOptions {
type SubscriberHistoryAction =
| {
- type: HistoryAction | 'ROLLBACK'
+ type: HistoryAction
}
| {
type: 'GO'
@@ -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
@@ -51,17 +52,12 @@ export interface ParsedPath {
export interface HistoryState {
key?: string
+ __TSR_index: number
}
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
@@ -93,6 +89,7 @@ type TryNavigateArgs = {
}
)
+const stateIndexKey = '__TSR_index'
const popStateEvent = 'popstate'
const beforeUnloadEvent = 'beforeunload'
@@ -107,9 +104,11 @@ export function createHistory(opts: {
createHref: (path: string) => string
flush?: () => void
destroy?: () => void
- onBlocked?: (onUpdate: () => void) => void
+ onBlocked?: () => void
getBlockers?: () => Array
setBlockers?: (blockers: Array) => void
+ // Avoid notifying on forward/back/go, used for browser history as we already get notified by the popstate event
+ notifyOnIndexChange?: boolean
}): RouterHistory {
let location = opts.getLocation()
const subscribers = new Set<(opts: SubscriberArgs) => void>()
@@ -119,11 +118,9 @@ export function createHistory(opts: {
subscribers.forEach((subscriber) => subscriber({ location, action }))
}
- const _notifyRollback = () => {
- location = opts.getLocation()
- subscribers.forEach((subscriber) =>
- subscriber({ location, action: { type: 'ROLLBACK' } }),
- )
+ const handleIndexChange = (action: SubscriberHistoryAction) => {
+ if (opts.notifyOnIndexChange ?? true) notify(action)
+ else location = opts.getLocation()
}
const tryNavigation = async ({
@@ -149,7 +146,7 @@ export function createHistory(opts: {
action: actionInfo.type,
})
if (isBlocked) {
- opts.onBlocked?.(_notifyRollback)
+ opts.onBlocked?.()
return
}
}
@@ -174,7 +171,8 @@ export function createHistory(opts: {
}
},
push: (path, state, navigateOpts) => {
- state = assignKey(state)
+ const currentIndex = location.state[stateIndexKey]
+ state = assignKeyAndIndex(currentIndex + 1, state)
tryNavigation({
task: () => {
opts.pushState(path, state)
@@ -187,7 +185,8 @@ export function createHistory(opts: {
})
},
replace: (path, state, navigateOpts) => {
- state = assignKey(state)
+ const currentIndex = location.state[stateIndexKey]
+ state = assignKeyAndIndex(currentIndex, state)
tryNavigation({
task: () => {
opts.replaceState(path, state)
@@ -203,7 +202,7 @@ export function createHistory(opts: {
tryNavigation({
task: () => {
opts.go(index)
- notify({ type: 'GO', index })
+ handleIndexChange({ type: 'GO', index })
},
navigateOpts,
type: 'GO',
@@ -213,7 +212,7 @@ export function createHistory(opts: {
tryNavigation({
task: () => {
opts.back(navigateOpts?.ignoreBlocker ?? false)
- notify({ type: 'BACK' })
+ handleIndexChange({ type: 'BACK' })
},
navigateOpts,
type: 'BACK',
@@ -223,12 +222,13 @@ export function createHistory(opts: {
tryNavigation({
task: () => {
opts.forward(navigateOpts?.ignoreBlocker ?? false)
- notify({ type: 'FORWARD' })
+ handleIndexChange({ type: 'FORWARD' })
},
navigateOpts,
type: 'FORWARD',
})
},
+ canGoBack: () => location.state[stateIndexKey] !== 0,
createHref: (str) => opts.createHref(str),
block: (blocker) => {
if (!opts.setBlockers) return () => {}
@@ -246,13 +246,14 @@ export function createHistory(opts: {
}
}
-function assignKey(state: HistoryState | undefined) {
+function assignKeyAndIndex(index: number, state: HistoryState | undefined) {
if (!state) {
state = {} as HistoryState
}
return {
...state,
key: createRandomKey(),
+ [stateIndexKey]: index,
}
}
@@ -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
@@ -375,9 +377,10 @@ export function createBrowserHistory(opts?: {
}
}
- const onPushPop = () => {
+ // NOTE: this function can probably be removed
+ const onPushPop = (type: 'PUSH' | 'REPLACE') => {
currentLocation = parseLocation()
- history.notify({ type: 'POP' })
+ history.notify({ type })
}
const onPushPopEvent = async () => {
@@ -386,22 +389,39 @@ export function createBrowserHistory(opts?: {
return
}
+ const nextLocation = parseLocation()
+ const delta =
+ nextLocation.state[stateIndexKey] - currentLocation.state[stateIndexKey]
+ 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
}
}
@@ -409,7 +429,7 @@ export function createBrowserHistory(opts?: {
}
currentLocation = parseLocation()
- history.notify({ type: 'POP' })
+ history.notify(notify)
}
const onBeforeUnload = (e: BeforeUnloadEvent) => {
@@ -462,7 +482,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: () => {
@@ -473,17 +496,16 @@ 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,
setBlockers: _setBlockers,
+ notifyOnIndexChange: false,
})
win.addEventListener(beforeUnloadEvent, onBeforeUnload, { capture: true })
@@ -491,13 +513,13 @@ export function createBrowserHistory(opts?: {
win.history.pushState = function (...args: Array) {
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) {
const res = originalReplaceState.apply(win.history, args as any)
- if (!history._ignoreSubscribers) onPushPop()
+ if (!history._ignoreSubscribers) onPushPop('REPLACE')
return res
}
@@ -528,8 +550,12 @@ export function createMemoryHistory(
},
): RouterHistory {
const entries = opts.initialEntries
- let index = opts.initialIndex ?? entries.length - 1
- const states = entries.map(() => ({}) as HistoryState)
+ let index = opts.initialIndex
+ ? Math.min(Math.max(opts.initialIndex, 0), entries.length - 1)
+ : entries.length - 1
+ const states = entries.map((_entry, index) =>
+ assignKeyAndIndex(index, undefined),
+ )
const getLocation = () => parseHref(entries[index]!, states[index])
@@ -587,7 +613,7 @@ export function parseHref(
searchIndex > -1
? href.slice(searchIndex, hashIndex === -1 ? undefined : hashIndex)
: '',
- state: state || {},
+ state: state || { [stateIndexKey]: 0 },
}
}
diff --git a/packages/react-router/src/index.tsx b/packages/react-router/src/index.tsx
index 49d93bc593..ad31832040 100644
--- a/packages/react-router/src/index.tsx
+++ b/packages/react-router/src/index.tsx
@@ -323,6 +323,8 @@ export { useRouterState } from './useRouterState'
export { useLocation } from './useLocation'
+export { useCanGoBack } from './useCanGoBack'
+
export {
escapeJSON, // SSR
useLayoutEffect, // SSR
diff --git a/packages/react-router/src/router.ts b/packages/react-router/src/router.ts
index 43a6971e9a..df97e6e505 100644
--- a/packages/react-router/src/router.ts
+++ b/packages/react-router/src/router.ts
@@ -1879,7 +1879,10 @@ export class Router<
...rest
}: BuildNextOptions & CommitLocationOptions = {}) => {
if (href) {
- const parsed = parseHref(href, {})
+ const currentIndex = this.history.location.state.__TSR_index
+ const parsed = parseHref(href, {
+ __TSR_index: replace ? currentIndex : currentIndex + 1,
+ })
rest.to = parsed.pathname
rest.search = this.options.parseSearch(parsed.search)
// remove the leading `#` from the hash
diff --git a/packages/react-router/src/useCanGoBack.ts b/packages/react-router/src/useCanGoBack.ts
new file mode 100644
index 0000000000..9476a9d51f
--- /dev/null
+++ b/packages/react-router/src/useCanGoBack.ts
@@ -0,0 +1,5 @@
+import { useRouterState } from './useRouterState'
+
+export function useCanGoBack() {
+ return useRouterState({ select: (s) => s.location.state.__TSR_index !== 0 })
+}
diff --git a/packages/react-router/tests/router.test.tsx b/packages/react-router/tests/router.test.tsx
index afcd273214..8f6ffcb67a 100644
--- a/packages/react-router/tests/router.test.tsx
+++ b/packages/react-router/tests/router.test.tsx
@@ -16,6 +16,7 @@ import {
createRootRoute,
createRoute,
createRouter,
+ useNavigate,
} from '../src'
import type { AnyRoute, AnyRouter, RouterOptions } from '../src'
@@ -1091,3 +1092,161 @@ describe('route id uniqueness', () => {
})
})
})
+
+const createHistoryRouter = () => {
+ const rootRoute = createRootRoute()
+
+ const IndexComponent = () => {
+ const navigate = useNavigate()
+
+ return (
+ <>
+ Index
+ navigate({ to: '/' })}>Index
+ navigate({ to: '/posts' })}>Posts
+ navigate({ to: '/posts', replace: true })}>
+ Replace
+
+ >
+ )
+ }
+
+ const indexRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ component: IndexComponent,
+ })
+
+ const postsRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/posts',
+ component: () => {
+ const navigate = useNavigate()
+
+ return (
+ <>
+ Posts
+ navigate({ to: '/' })}>Index
+ >
+ )
+ },
+ })
+
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([indexRoute, postsRoute]),
+ })
+
+ return { router }
+}
+
+describe('history: History gives correct notifcations and state', () => {
+ it('should work with push and back', async () => {
+ const { router: router } = createHistoryRouter()
+
+ type Router = typeof router
+
+ const results: Array<
+ Parameters[0]>[0]['action']
+ > = []
+
+ render( )
+
+ const unsub = router.history.subscribe(({ action }) => {
+ results.push(action)
+ })
+
+ const postsButton = await screen.findByRole('button', { name: 'Posts' })
+
+ fireEvent.click(postsButton)
+
+ expect(
+ await screen.findByRole('heading', { name: 'Posts' }),
+ ).toBeInTheDocument()
+
+ expect(window.location.pathname).toBe('/posts')
+
+ act(() => router.history.back())
+
+ expect(
+ await screen.findByRole('heading', { name: 'Index' }),
+ ).toBeInTheDocument()
+
+ expect(window.location.pathname).toBe('/')
+
+ expect(results).toEqual([{ type: 'PUSH' }, { type: 'BACK' }])
+
+ unsub()
+ })
+
+ it('should work more complex scenario', async () => {
+ const { router: router } = createHistoryRouter()
+
+ type Router = typeof router
+
+ const results: Array<
+ Parameters[0]>[0]['action']
+ > = []
+
+ render( )
+
+ const unsub = router.history.subscribe(({ action }) => {
+ results.push(action)
+ })
+
+ const replaceButton = await screen.findByRole('button', { name: 'Replace' })
+
+ fireEvent.click(replaceButton)
+
+ expect(
+ await screen.findByRole('heading', { name: 'Posts' }),
+ ).toBeInTheDocument()
+
+ expect(window.location.pathname).toBe('/posts')
+
+ const indexButton = await screen.findByRole('button', { name: 'Index' })
+
+ fireEvent.click(indexButton)
+
+ expect(
+ await screen.findByRole('heading', { name: 'Index' }),
+ ).toBeInTheDocument()
+
+ expect(window.location.pathname).toBe('/')
+
+ const postsButton = await screen.findByRole('button', { name: 'Posts' })
+
+ fireEvent.click(postsButton)
+
+ expect(
+ await screen.findByRole('heading', { name: 'Posts' }),
+ ).toBeInTheDocument()
+
+ expect(window.location.pathname).toBe('/posts')
+
+ act(() => router.history.back())
+
+ expect(
+ await screen.findByRole('heading', { name: 'Index' }),
+ ).toBeInTheDocument()
+
+ expect(window.location.pathname).toBe('/')
+
+ act(() => router.history.go(1))
+
+ expect(
+ await screen.findByRole('heading', { name: 'Posts' }),
+ ).toBeInTheDocument()
+
+ expect(window.location.pathname).toBe('/posts')
+
+ expect(results).toEqual([
+ { type: 'REPLACE' },
+ { type: 'PUSH' },
+ { type: 'PUSH' },
+ { type: 'BACK' },
+ { type: 'GO', index: 1 },
+ ])
+
+ unsub()
+ })
+})
diff --git a/packages/react-router/tests/useBlocker.test-d.tsx b/packages/react-router/tests/useBlocker.test-d.tsx
index ed3149fe99..644a8036b0 100644
--- a/packages/react-router/tests/useBlocker.test-d.tsx
+++ b/packages/react-router/tests/useBlocker.test-d.tsx
@@ -102,7 +102,7 @@ test('shouldBlockFn has corrent action', () => {
.toHaveProperty('shouldBlockFn')
.parameter(0)
.toHaveProperty('action')
- .toEqualTypeOf<'PUSH' | 'POP' | 'REPLACE' | 'FORWARD' | 'BACK' | 'GO'>()
+ .toEqualTypeOf<'PUSH' | 'REPLACE' | 'FORWARD' | 'BACK' | 'GO'>()
expectTypeOf(useBlocker)
.parameter(0)
diff --git a/packages/react-router/tests/useCanGoBack.test.tsx b/packages/react-router/tests/useCanGoBack.test.tsx
new file mode 100644
index 0000000000..122e647d83
--- /dev/null
+++ b/packages/react-router/tests/useCanGoBack.test.tsx
@@ -0,0 +1,92 @@
+import { beforeEach, describe, expect, test } from 'vitest'
+import { cleanup, fireEvent, render, screen } from '@testing-library/react'
+import {
+ Link,
+ Outlet,
+ RouterProvider,
+ createMemoryHistory,
+ createRootRoute,
+ createRoute,
+ createRouter,
+ useCanGoBack,
+ useLocation,
+ useRouter,
+} from '../src'
+
+beforeEach(() => {
+ cleanup()
+})
+
+describe('useCanGoBack', () => {
+ function setup({
+ initialEntries = ['/'],
+ }: {
+ initialEntries?: Array
+ } = {}) {
+ function RootComponent() {
+ const router = useRouter()
+ const location = useLocation()
+ const canGoBack = useCanGoBack()
+
+ expect(canGoBack).toBe(location.pathname === '/' ? false : true)
+
+ return (
+ <>
+ router.history.back()}>Back
+ Home
+ About
+
+ >
+ )
+ }
+
+ const rootRoute = createRootRoute({
+ component: RootComponent,
+ })
+ const indexRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ component: () => IndexTitle ,
+ })
+ const aboutRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/about',
+ component: () => AboutTitle ,
+ })
+
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([indexRoute, aboutRoute]),
+ history: createMemoryHistory({ initialEntries }),
+ })
+
+ return render( )
+ }
+
+ test('when no location behind', async () => {
+ setup()
+
+ const indexTitle = await screen.findByText('IndexTitle')
+ expect(indexTitle).toBeInTheDocument()
+
+ const aboutLink = await screen.findByText('About')
+ fireEvent.click(aboutLink)
+
+ const aboutTitle = await screen.findByText('AboutTitle')
+ expect(aboutTitle).toBeInTheDocument()
+ })
+
+ test('when location behind', async () => {
+ setup({
+ initialEntries: ['/', '/about'],
+ })
+
+ const aboutTitle = await screen.findByText('AboutTitle')
+ expect(aboutTitle).toBeInTheDocument()
+
+ const backButton = await screen.findByText('Back')
+ fireEvent.click(backButton)
+
+ const indexTitle = await screen.findByText('IndexTitle')
+ expect(indexTitle).toBeInTheDocument()
+ })
+})