Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
6 changes: 6 additions & 0 deletions .changeset/fancy-camels-rhyme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@tanstack/react-router': patch
'@tanstack/router-core': patch
---

fix throw undefined on immediate redirect during route load
96 changes: 96 additions & 0 deletions packages/react-router/tests/loaders.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
fireEvent,
render,
screen,
waitFor,
} from '@testing-library/react'

import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
Expand Down Expand Up @@ -940,3 +941,98 @@ test('reproducer for #6388 - rapid navigation between parameterized routes shoul
expect(paramPage).toHaveTextContent('Param Component 1 Done')
expect(loaderCompleteMock).toHaveBeenCalled()
})

test('keeps rendering the current pending route when a regular navigation aborts it', async () => {
const firstLoaderAborted = vi.fn()
const firstErrorComponentRenderCount = vi.fn()
let resolveSecondLoader: (() => void) | undefined

const rootRoute = createRootRoute({
component: () => <Outlet />,
})

const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
component: () => <div data-testid="home-page">Home page</div>,
})

const firstRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/first',
pendingMs: 0,
loader: async ({ abortController }) => {
await new Promise<void>((_resolve, reject) => {
abortController.signal.addEventListener('abort', () => {
firstLoaderAborted()
reject(new DOMException('Aborted', 'AbortError'))
})
})

return 'first'
},
component: () => (
<div data-testid="first-page">{firstRoute.useLoaderData()}</div>
),
pendingComponent: () => (
<div data-testid="first-pending">Pending first route</div>
),
errorComponent: ({ error }) => {
firstErrorComponentRenderCount(error)
return <div data-testid="first-error">{String(error)}</div>
},
})

const secondRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/second',
loader: async () => {
await new Promise<void>((resolve) => {
resolveSecondLoader = resolve
})

return 'second'
},
component: () => (
<div data-testid="second-page">{secondRoute.useLoaderData()}</div>
),
})

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

render(<RouterProvider router={router} />)
await act(() => router.latestLoadPromise)

expect(await screen.findByTestId('home-page')).toBeInTheDocument()

act(() => {
void router.navigate({ to: '/first' })
})
expect(await screen.findByTestId('first-pending')).toBeInTheDocument()

act(() => {
void router.navigate({ to: '/second' })
})
await act(() => sleep(0))

await waitFor(() => {
expect(resolveSecondLoader).toBeDefined()
})

expect(firstLoaderAborted).toHaveBeenCalled()
expect(screen.getByTestId('first-pending')).toBeInTheDocument()
expect(firstErrorComponentRenderCount).not.toHaveBeenCalled()
expect(screen.queryByTestId('first-error')).not.toBeInTheDocument()

act(() => {
resolveSecondLoader?.()
})

await act(() => router.latestLoadPromise)
expect(await screen.findByTestId('second-page')).toHaveTextContent('second')
})
73 changes: 73 additions & 0 deletions packages/react-router/tests/router.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1423,6 +1423,79 @@ describe('invalidate', () => {
})
})

it('keeps rendering a suspense fallback when invalidate({ forcePending: true }) reloads a route', async () => {
const history = createMemoryHistory({
initialEntries: ['/force-pending'],
})
const errorComponentRenderCount = vi.fn()
let shouldSuspendReload = false
let resolveReload: (() => void) | undefined

const rootRoute = createRootRoute({
component: () => <Outlet />,
})

const forcePendingRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/force-pending',
pendingMs: 0,
pendingMinMs: 10,
loader: async () => {
if (shouldSuspendReload) {
await new Promise<void>((resolve) => {
resolveReload = resolve
})
}

return 'done'
},
component: () => (
<div data-testid="force-pending-route">
{forcePendingRoute.useLoaderData()}
</div>
),
pendingComponent: () => (
<div data-testid="force-pending-fallback">Pending...</div>
),
errorComponent: ({ error }) => {
errorComponentRenderCount(error)
return <div data-testid="force-pending-error">{String(error)}</div>
},
})

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

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

await act(() => router.load())
expect(await screen.findByTestId('force-pending-route')).toHaveTextContent(
'done',
)

shouldSuspendReload = true
act(() => {
void router.invalidate({ forcePending: true })
})

expect(
await screen.findByTestId('force-pending-fallback'),
).toBeInTheDocument()
expect(errorComponentRenderCount).not.toHaveBeenCalled()
expect(screen.queryByTestId('force-pending-error')).not.toBeInTheDocument()

act(() => {
resolveReload?.()
})

await act(() => router.latestLoadPromise)
expect(await screen.findByTestId('force-pending-route')).toHaveTextContent(
'done',
)
})

/**
* Regression test:
* - When a route loader throws `notFound()`, the match enters a `'notFound'` status.
Expand Down
10 changes: 6 additions & 4 deletions packages/router-core/src/load-matches.ts
Original file line number Diff line number Diff line change
Expand Up @@ -839,9 +839,10 @@ const loadRouteMatch = async (
await runLoader(inner, matchPromises, matchId, index, route)
const match = inner.router.getMatch(matchId)!
match._nonReactive.loaderPromise?.resolve()
match._nonReactive.loadPromise?.resolve()
if (match.status !== 'pending') {
match._nonReactive.loadPromise?.resolve()
}
match._nonReactive.loaderPromise = undefined
match._nonReactive.loadPromise = undefined
} catch (err) {
if (isRedirect(err)) {
await inner.router.navigate(err.options)
Expand Down Expand Up @@ -939,8 +940,9 @@ const loadRouteMatch = async (
const match = inner.router.getMatch(matchId)!
if (!loaderIsRunningAsync) {
match._nonReactive.loaderPromise?.resolve()
match._nonReactive.loadPromise?.resolve()
match._nonReactive.loadPromise = undefined
if (match.status !== 'pending') {
match._nonReactive.loadPromise?.resolve()
}
}

clearTimeout(match._nonReactive.pendingTimeout)
Expand Down
Loading