diff --git a/packages/react-router/eslint.config.ts b/packages/react-router/eslint.config.ts index a2f1715667..5d879181f7 100644 --- a/packages/react-router/eslint.config.ts +++ b/packages/react-router/eslint.config.ts @@ -19,6 +19,7 @@ export default [ '@eslint-react/dom/no-missing-button-type': 'off', 'react-hooks/exhaustive-deps': 'error', 'react-hooks/rules-of-hooks': 'error', + '@typescript-eslint/no-unnecessary-condition': 'off', }, }, ] diff --git a/packages/react-router/src/useBlocker.tsx b/packages/react-router/src/useBlocker.tsx index 735277d100..649700ac40 100644 --- a/packages/react-router/src/useBlocker.tsx +++ b/packages/react-router/src/useBlocker.tsx @@ -176,26 +176,39 @@ export function useBlocker( function getLocation( location: HistoryLocation, ): AnyShouldBlockFnLocation { - const parsedLocation = router.parseLocation(location) - const matchedRoutes = router.getMatchedRoutes( - parsedLocation.pathname, - undefined, - ) + const pathname = location.pathname + + const matchedRoutes = router.getMatchedRoutes(pathname, undefined) + if (matchedRoutes.foundRoute === undefined) { - throw new Error(`No route found for location ${location.href}`) + return { + routeId: '__notFound__', + fullPath: pathname, + pathname: pathname, + params: matchedRoutes.routeParams, + search: router.options.parseSearch(location.search), + } } + return { routeId: matchedRoutes.foundRoute.id, fullPath: matchedRoutes.foundRoute.fullPath, - pathname: parsedLocation.pathname, + pathname: pathname, params: matchedRoutes.routeParams, - search: parsedLocation.search, + search: router.options.parseSearch(location.search), } } const current = getLocation(blockerFnArgs.currentLocation) const next = getLocation(blockerFnArgs.nextLocation) + if ( + current.routeId === '__notFound__' && + next.routeId !== '__notFound__' + ) { + return false + } + const shouldBlock = await shouldBlockFn({ action: blockerFnArgs.action, current, diff --git a/packages/react-router/tests/useBlocker.test.tsx b/packages/react-router/tests/useBlocker.test.tsx index 3b780775bd..c11cd054f1 100644 --- a/packages/react-router/tests/useBlocker.test.tsx +++ b/packages/react-router/tests/useBlocker.test.tsx @@ -7,6 +7,7 @@ import { z } from 'zod' import { RouterProvider, createBrowserHistory, + createMemoryHistory, createRootRoute, createRoute, createRouter, @@ -440,4 +441,156 @@ describe('useBlocker', () => { expect(window.location.pathname).toBe('/invoices') }) + + test('should allow navigation from 404 page when blocker is active', async () => { + const rootRoute = createRootRoute({ + notFoundComponent: function NotFoundComponent() { + const navigate = useNavigate() + + useBlocker({ shouldBlockFn: () => true }) + + return ( + <> +

Not Found

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

Index

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

Posts

+ + ) + }, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postsRoute]), + history, + }) + + render() + + await router.navigate({ to: '/non-existent' as any }) + + expect( + await screen.findByRole('heading', { name: 'Not Found' }), + ).toBeInTheDocument() + + expect(window.location.pathname).toBe('/non-existent') + + const homeButton = await screen.findByRole('button', { name: 'Go Home' }) + fireEvent.click(homeButton) + + expect( + await screen.findByRole('heading', { name: 'Index' }), + ).toBeInTheDocument() + + expect(window.location.pathname).toBe('/') + }) + + test('should handle blocker navigation from 404 to another 404', async () => { + const rootRoute = createRootRoute({ + notFoundComponent: function NotFoundComponent() { + const navigate = useNavigate() + + useBlocker({ shouldBlockFn: () => true }) + + return ( + <> +

Not Found

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

Index

+ + ) + }, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute]), + history, + }) + + render() + + await router.navigate({ to: '/non-existent' }) + + expect( + await screen.findByRole('heading', { name: 'Not Found' }), + ).toBeInTheDocument() + + const anotherButton = await screen.findByRole('button', { + name: 'Go to Another 404', + }) + fireEvent.click(anotherButton) + + expect( + await screen.findByRole('heading', { name: 'Not Found' }), + ).toBeInTheDocument() + + expect(window.location.pathname).toBe('/non-existent') + }) + + test('navigate function should handle external URLs with ignoreBlocker', async () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
Home
, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute]), + history: createMemoryHistory({ + initialEntries: ['/'], + }), + }) + + await expect( + router.navigate({ + to: 'https://example.com', + ignoreBlocker: true, + }), + ).resolves.toBeUndefined() + + await expect( + router.navigate({ + to: 'https://example.com', + }), + ).resolves.toBeUndefined() + }) }) diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index f6bf304292..e8c1c5951c 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -1778,7 +1778,7 @@ export class RouterCore< }) } - navigate: NavigateFn = ({ to, reloadDocument, href, ...rest }) => { + navigate: NavigateFn = async ({ to, reloadDocument, href, ...rest }) => { if (!reloadDocument && href) { try { new URL(`${href}`) @@ -1791,6 +1791,26 @@ export class RouterCore< const location = this.buildLocation({ to, ...rest } as any) href = this.history.createHref(location.href) } + + // Check blockers for external URLs unless ignoreBlocker is true + if (!rest.ignoreBlocker) { + // Cast to access internal getBlockers method + const historyWithBlockers = this.history as any + const blockers = historyWithBlockers.getBlockers?.() ?? [] + for (const blocker of blockers) { + if (blocker?.blockerFn) { + const shouldBlock = await blocker.blockerFn({ + currentLocation: this.latestLocation, + nextLocation: this.latestLocation, // External URLs don't have a next location in our router + action: 'PUSH', + }) + if (shouldBlock) { + return Promise.resolve() + } + } + } + } + if (rest.replace) { window.location.replace(href) } else {