Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/react-router/eslint.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
},
]
29 changes: 21 additions & 8 deletions packages/react-router/src/useBlocker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
153 changes: 153 additions & 0 deletions packages/react-router/tests/useBlocker.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { z } from 'zod'
import {
RouterProvider,
createBrowserHistory,
createMemoryHistory,
createRootRoute,
createRoute,
createRouter,
Expand Down Expand Up @@ -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 (
<>
<h1>Not Found</h1>
<button onClick={() => navigate({ to: '/' })}>Go Home</button>
<button onClick={() => navigate({ to: '/posts' })}>
Go to Posts
</button>
</>
)
},
})

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

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

const router = createRouter({
routeTree: rootRoute.addChildren([indexRoute, postsRoute]),
history,
})

render(<RouterProvider router={router} />)

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 (
<>
<h1>Not Found</h1>
<button onClick={() => navigate({ to: '/another-404' as any })}>
Go to Another 404
</button>
</>
)
},
})

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

const router = createRouter({
routeTree: rootRoute.addChildren([indexRoute]),
history,
})

render(<RouterProvider router={router} />)

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: () => <div>Home</div>,
})

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()
})
})
22 changes: 21 additions & 1 deletion packages/router-core/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`)
Expand All @@ -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 {
Expand Down