From 692d68555297a459127c593c59af9f4e943eaca9 Mon Sep 17 00:00:00 2001 From: JIHOON LEE Date: Mon, 11 Aug 2025 17:47:19 +0900 Subject: [PATCH 1/3] feat: allow navigation from 404 pages when blocker is active - #4881 --- packages/react-router/src/useBlocker.tsx | 29 +++-- .../react-router/tests/useBlocker.test.tsx | 123 ++++++++++++++++++ 2 files changed, 144 insertions(+), 8 deletions(-) diff --git a/packages/react-router/src/useBlocker.tsx b/packages/react-router/src/useBlocker.tsx index 735277d100..8c25a68e0a 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..663b06280e 100644 --- a/packages/react-router/tests/useBlocker.test.tsx +++ b/packages/react-router/tests/useBlocker.test.tsx @@ -440,4 +440,127 @@ describe('useBlocker', () => { expect(window.location.pathname).toBe('/invoices') }) + + test('should allow navigation from 404 page when blocker is active', async () => { + const rootRoute = createRootRoute({ + 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: () => { + 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') + }) }) From d69dadb77d2d209f123da4d5a0407cf35388eb6c Mon Sep 17 00:00:00 2001 From: JIHOON LEE Date: Mon, 11 Aug 2025 18:30:48 +0900 Subject: [PATCH 2/3] feat: add blocker support for external URL navigation with ignoreBlocker option --- .../react-router/tests/useBlocker.test.tsx | 30 +++++++++++++++++++ packages/router-core/src/router.ts | 22 +++++++++++++- 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/packages/react-router/tests/useBlocker.test.tsx b/packages/react-router/tests/useBlocker.test.tsx index 663b06280e..dfd7c20f00 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, @@ -563,4 +564,33 @@ describe('useBlocker', () => { 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 { From 4ddec20e31f9581a29f8054ca10f180164c2f3b1 Mon Sep 17 00:00:00 2001 From: JIHOON LEE Date: Mon, 11 Aug 2025 21:27:58 +0900 Subject: [PATCH 3/3] fix: update useBlocker behavior and eslint config for 404 page handling --- packages/react-router/eslint.config.ts | 1 + packages/react-router/src/useBlocker.tsx | 2 +- packages/react-router/tests/useBlocker.test.tsx | 4 ++-- 3 files changed, 4 insertions(+), 3 deletions(-) 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 8c25a68e0a..649700ac40 100644 --- a/packages/react-router/src/useBlocker.tsx +++ b/packages/react-router/src/useBlocker.tsx @@ -185,7 +185,7 @@ export function useBlocker( routeId: '__notFound__', fullPath: pathname, pathname: pathname, - params: matchedRoutes.routeParams || {}, + params: matchedRoutes.routeParams, search: router.options.parseSearch(location.search), } } diff --git a/packages/react-router/tests/useBlocker.test.tsx b/packages/react-router/tests/useBlocker.test.tsx index dfd7c20f00..c11cd054f1 100644 --- a/packages/react-router/tests/useBlocker.test.tsx +++ b/packages/react-router/tests/useBlocker.test.tsx @@ -444,7 +444,7 @@ describe('useBlocker', () => { test('should allow navigation from 404 page when blocker is active', async () => { const rootRoute = createRootRoute({ - notFoundComponent: () => { + notFoundComponent: function NotFoundComponent() { const navigate = useNavigate() useBlocker({ shouldBlockFn: () => true }) @@ -512,7 +512,7 @@ describe('useBlocker', () => { test('should handle blocker navigation from 404 to another 404', async () => { const rootRoute = createRootRoute({ - notFoundComponent: () => { + notFoundComponent: function NotFoundComponent() { const navigate = useNavigate() useBlocker({ shouldBlockFn: () => true })