Skip to content

Commit c35f70e

Browse files
authored
fix(router-core): useBlocker navigation issues for 404 pages and external URLs (#4917)
1 parent 8f5d93b commit c35f70e

File tree

6 files changed

+356
-4
lines changed

6 files changed

+356
-4
lines changed

packages/react-router/eslint.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export default [
1919
'@eslint-react/dom/no-missing-button-type': 'off',
2020
'react-hooks/exhaustive-deps': 'error',
2121
'react-hooks/rules-of-hooks': 'error',
22+
'@typescript-eslint/no-unnecessary-condition': 'off',
2223
},
2324
},
2425
]

packages/react-router/src/useBlocker.tsx

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -179,20 +179,34 @@ export function useBlocker(
179179
const parsedLocation = router.parseLocation(location)
180180
const matchedRoutes = router.getMatchedRoutes(parsedLocation.pathname)
181181
if (matchedRoutes.foundRoute === undefined) {
182-
throw new Error(`No route found for location ${location.href}`)
182+
return {
183+
routeId: '__notFound__',
184+
fullPath: parsedLocation.pathname,
185+
pathname: parsedLocation.pathname,
186+
params: matchedRoutes.routeParams,
187+
search: router.options.parseSearch(location.search),
188+
}
183189
}
190+
184191
return {
185192
routeId: matchedRoutes.foundRoute.id,
186193
fullPath: matchedRoutes.foundRoute.fullPath,
187194
pathname: parsedLocation.pathname,
188195
params: matchedRoutes.routeParams,
189-
search: parsedLocation.search,
196+
search: router.options.parseSearch(location.search),
190197
}
191198
}
192199

193200
const current = getLocation(blockerFnArgs.currentLocation)
194201
const next = getLocation(blockerFnArgs.nextLocation)
195202

203+
if (
204+
current.routeId === '__notFound__' &&
205+
next.routeId !== '__notFound__'
206+
) {
207+
return false
208+
}
209+
196210
const shouldBlock = await shouldBlockFn({
197211
action: blockerFnArgs.action,
198212
current,

packages/react-router/tests/useBlocker.test.tsx

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { z } from 'zod'
77
import {
88
RouterProvider,
99
createBrowserHistory,
10+
createMemoryHistory,
1011
createRootRoute,
1112
createRoute,
1213
createRouter,
@@ -440,4 +441,156 @@ describe('useBlocker', () => {
440441

441442
expect(window.location.pathname).toBe('/invoices')
442443
})
444+
445+
test('should allow navigation from 404 page when blocker is active', async () => {
446+
const rootRoute = createRootRoute({
447+
notFoundComponent: function NotFoundComponent() {
448+
const navigate = useNavigate()
449+
450+
useBlocker({ shouldBlockFn: () => true })
451+
452+
return (
453+
<>
454+
<h1>Not Found</h1>
455+
<button onClick={() => navigate({ to: '/' })}>Go Home</button>
456+
<button onClick={() => navigate({ to: '/posts' })}>
457+
Go to Posts
458+
</button>
459+
</>
460+
)
461+
},
462+
})
463+
464+
const indexRoute = createRoute({
465+
getParentRoute: () => rootRoute,
466+
path: '/',
467+
component: () => {
468+
return (
469+
<>
470+
<h1>Index</h1>
471+
</>
472+
)
473+
},
474+
})
475+
476+
const postsRoute = createRoute({
477+
getParentRoute: () => rootRoute,
478+
path: '/posts',
479+
component: () => {
480+
return (
481+
<>
482+
<h1>Posts</h1>
483+
</>
484+
)
485+
},
486+
})
487+
488+
const router = createRouter({
489+
routeTree: rootRoute.addChildren([indexRoute, postsRoute]),
490+
history,
491+
})
492+
493+
render(<RouterProvider router={router} />)
494+
495+
await router.navigate({ to: '/non-existent' as any })
496+
497+
expect(
498+
await screen.findByRole('heading', { name: 'Not Found' }),
499+
).toBeInTheDocument()
500+
501+
expect(window.location.pathname).toBe('/non-existent')
502+
503+
const homeButton = await screen.findByRole('button', { name: 'Go Home' })
504+
fireEvent.click(homeButton)
505+
506+
expect(
507+
await screen.findByRole('heading', { name: 'Index' }),
508+
).toBeInTheDocument()
509+
510+
expect(window.location.pathname).toBe('/')
511+
})
512+
513+
test('should handle blocker navigation from 404 to another 404', async () => {
514+
const rootRoute = createRootRoute({
515+
notFoundComponent: function NotFoundComponent() {
516+
const navigate = useNavigate()
517+
518+
useBlocker({ shouldBlockFn: () => true })
519+
520+
return (
521+
<>
522+
<h1>Not Found</h1>
523+
<button onClick={() => navigate({ to: '/another-404' as any })}>
524+
Go to Another 404
525+
</button>
526+
</>
527+
)
528+
},
529+
})
530+
531+
const indexRoute = createRoute({
532+
getParentRoute: () => rootRoute,
533+
path: '/',
534+
component: () => {
535+
return (
536+
<>
537+
<h1>Index</h1>
538+
</>
539+
)
540+
},
541+
})
542+
543+
const router = createRouter({
544+
routeTree: rootRoute.addChildren([indexRoute]),
545+
history,
546+
})
547+
548+
render(<RouterProvider router={router} />)
549+
550+
await router.navigate({ to: '/non-existent' })
551+
552+
expect(
553+
await screen.findByRole('heading', { name: 'Not Found' }),
554+
).toBeInTheDocument()
555+
556+
const anotherButton = await screen.findByRole('button', {
557+
name: 'Go to Another 404',
558+
})
559+
fireEvent.click(anotherButton)
560+
561+
expect(
562+
await screen.findByRole('heading', { name: 'Not Found' }),
563+
).toBeInTheDocument()
564+
565+
expect(window.location.pathname).toBe('/non-existent')
566+
})
567+
568+
test('navigate function should handle external URLs with ignoreBlocker', async () => {
569+
const rootRoute = createRootRoute()
570+
const indexRoute = createRoute({
571+
getParentRoute: () => rootRoute,
572+
path: '/',
573+
component: () => <div>Home</div>,
574+
})
575+
576+
const router = createRouter({
577+
routeTree: rootRoute.addChildren([indexRoute]),
578+
history: createMemoryHistory({
579+
initialEntries: ['/'],
580+
}),
581+
})
582+
583+
await expect(
584+
router.navigate({
585+
to: 'https://example.com',
586+
ignoreBlocker: true,
587+
}),
588+
).resolves.toBeUndefined()
589+
590+
await expect(
591+
router.navigate({
592+
to: 'https://example.com',
593+
}),
594+
).resolves.toBeUndefined()
595+
})
443596
})

packages/router-core/src/router.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1974,7 +1974,7 @@ export class RouterCore<
19741974
*
19751975
* @link https://tanstack.com/router/latest/docs/framework/react/api/router/NavigateOptionsType
19761976
*/
1977-
navigate: NavigateFn = ({ to, reloadDocument, href, ...rest }) => {
1977+
navigate: NavigateFn = async ({ to, reloadDocument, href, ...rest }) => {
19781978
if (!reloadDocument && href) {
19791979
try {
19801980
new URL(`${href}`)
@@ -1987,6 +1987,26 @@ export class RouterCore<
19871987
const location = this.buildLocation({ to, ...rest } as any)
19881988
href = location.url
19891989
}
1990+
1991+
// Check blockers for external URLs unless ignoreBlocker is true
1992+
if (!rest.ignoreBlocker) {
1993+
// Cast to access internal getBlockers method
1994+
const historyWithBlockers = this.history as any
1995+
const blockers = historyWithBlockers.getBlockers?.() ?? []
1996+
for (const blocker of blockers) {
1997+
if (blocker?.blockerFn) {
1998+
const shouldBlock = await blocker.blockerFn({
1999+
currentLocation: this.latestLocation,
2000+
nextLocation: this.latestLocation, // External URLs don't have a next location in our router
2001+
action: 'PUSH',
2002+
})
2003+
if (shouldBlock) {
2004+
return Promise.resolve()
2005+
}
2006+
}
2007+
}
2008+
}
2009+
19902010
if (rest.replace) {
19912011
window.location.replace(href)
19922012
} else {

packages/solid-router/src/useBlocker.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,13 @@ export function useBlocker(
188188
const parsedLocation = router.parseLocation(location)
189189
const matchedRoutes = router.getMatchedRoutes(parsedLocation.pathname)
190190
if (matchedRoutes.foundRoute === undefined) {
191-
throw new Error(`No route found for location ${location.href}`)
191+
return {
192+
routeId: '__notFound__',
193+
fullPath: parsedLocation.pathname,
194+
pathname: parsedLocation.pathname,
195+
params: matchedRoutes.routeParams,
196+
search: parsedLocation.search,
197+
}
192198
}
193199
return {
194200
routeId: matchedRoutes.foundRoute.id,
@@ -202,6 +208,13 @@ export function useBlocker(
202208
const current = getLocation(blockerFnArgs.currentLocation)
203209
const next = getLocation(blockerFnArgs.nextLocation)
204210

211+
if (
212+
current.routeId === '__notFound__' &&
213+
next.routeId !== '__notFound__'
214+
) {
215+
return false
216+
}
217+
205218
const shouldBlock = await props.shouldBlockFn({
206219
action: blockerFnArgs.action,
207220
current,

0 commit comments

Comments
 (0)