diff --git a/docs/framework/react/api/router.md b/docs/framework/react/api/router.md index 4078ee2536..f45194db4b 100644 --- a/docs/framework/react/api/router.md +++ b/docs/framework/react/api/router.md @@ -35,6 +35,7 @@ title: Router API - Hooks - [`useAwaited`](./router/useAwaitedHook.md) - [`useBlocker`](./router/useBlockerHook.md) + - [`useCanGoBack`](./router//useCanGoBack.md) - [`useChildMatches`](./router/useChildMatchesHook.md) - [`useLinkProps`](./router/useLinkPropsHook.md) - [`useLoaderData`](./router/useLoaderDataHook.md) diff --git a/docs/framework/react/api/router/useCanGoBack.md b/docs/framework/react/api/router/useCanGoBack.md new file mode 100644 index 0000000000..39b84fbdf9 --- /dev/null +++ b/docs/framework/react/api/router/useCanGoBack.md @@ -0,0 +1,40 @@ +--- +id: useCanGoBack +title: useCanGoBack hook +--- + +The `useCanGoBack` hook returns a boolean representing if the router history can safely go back without exiting the application. + +> ⚠️ The following new `useCanGoBack` API is currently _experimental_. + +## useCanGoBack returns + +- If the router history is not at index `0`, `true`. +- If the router history is at index `0`, `false`. + +## Limitations + +The router history index is reset after a navigation with [`reloadDocument`](./NavigateOptionsType.md#reloaddocument) set as `true`. This causes the router history to consider the new location as the initial one and will cause `useCanGoBack` to return `false`. + +## Examples + +### Showing a back button + +```tsx +import { useRouter, useCanGoBack } from '@tanstack/react-router' + +function Component() { + const router = useRouter() + const canGoBack = useCanGoBack() + + return ( +
+ {canGoBack ? ( + + ) : null} + + {/* ... */} +
+ ) +} +``` diff --git a/e2e/react-router/basic-file-based/src/routes/__root.tsx b/e2e/react-router/basic-file-based/src/routes/__root.tsx index 3e1e2220a2..33f6322fd2 100644 --- a/e2e/react-router/basic-file-based/src/routes/__root.tsx +++ b/e2e/react-router/basic-file-based/src/routes/__root.tsx @@ -1,5 +1,11 @@ import * as React from 'react' -import { Link, Outlet, createRootRoute } from '@tanstack/react-router' +import { + Link, + Outlet, + createRootRoute, + useCanGoBack, + useRouter, +} from '@tanstack/react-router' import { TanStackRouterDevtools } from '@tanstack/router-devtools' export const Route = createRootRoute({ @@ -15,9 +21,15 @@ export const Route = createRootRoute({ }) function RootComponent() { + const router = useRouter() + const canGoBack = useCanGoBack() + return ( <> -
+
+ {' '} { 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

+ + + + + ) + } + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: IndexComponent, + }) + + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/posts', + component: () => { + const navigate = useNavigate() + + return ( + <> +

Posts

+ + + ) + }, + }) + + 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 ( + <> + + 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() + }) +})