Skip to content
Merged
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
55 changes: 55 additions & 0 deletions packages/react-router/tests/issue-2182-root-pending.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import * as React from 'react'
import { act, cleanup, render, screen } from '@testing-library/react'
import { afterEach, expect, test, vi } from 'vitest'
import {
RouterProvider,
createControlledPromise,
createMemoryHistory,
createRootRoute,
createRouter,
} from '../src'

afterEach(() => {
cleanup()
vi.useRealTimers()
})

// https://github.com/TanStack/router/issues/2182
test('root pending fallback remains visible through pendingMinMs', async () => {
vi.useFakeTimers()

const loaderGate = createControlledPromise<string>()
const rootRoute = createRootRoute({
pendingMs: 0,
pendingMinMs: 100,
pendingComponent: () => <div data-testid="root-pending">Pending</div>,
loader: () => loaderGate,
component: () => <div data-testid="root-content">Loaded</div>,
})
const router = createRouter({
routeTree: rootRoute,
history: createMemoryHistory({ initialEntries: ['/'] }),
})

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

await act(async () => {
await vi.advanceTimersByTimeAsync(0)
})
expect(screen.getByTestId('root-pending')).toBeInTheDocument()
expect(screen.queryByTestId('root-content')).not.toBeInTheDocument()

loaderGate.resolve('loaded')

await act(async () => {
await vi.advanceTimersByTimeAsync(99)
})
expect(screen.getByTestId('root-pending')).toBeInTheDocument()
expect(screen.queryByTestId('root-content')).not.toBeInTheDocument()

await act(async () => {
await vi.advanceTimersByTimeAsync(1)
})
expect(screen.queryByTestId('root-pending')).not.toBeInTheDocument()
expect(screen.getByTestId('root-content')).toBeInTheDocument()
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import * as React from 'react'
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, expect, test, vi } from 'vitest'

import {
Outlet,
RouterProvider,
createMemoryHistory,
createRootRoute,
createRoute,
createRouter,
redirect,
} from '../src'
import { sleep } from './utils'

afterEach(() => {
vi.restoreAllMocks()
cleanup()
})

// https://github.com/TanStack/router/issues/7367
// Root route shows a spinner immediately (pendingMs: 0) while beforeLoad
// decides where to send the user, keeps it up for pendingMinMs, and then
// redirects. This used to crash in MatchInnerImpl (white screen) because the
// redirected match was rendered/thrown after its loadPromise was cleared.
test('immediate pending spinner (pendingMs: 0 + pendingMinMs) with root beforeLoad redirect renders the target without render errors', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
let hasRedirected = false

const rootRoute = createRootRoute({
component: () => <Outlet />,
pendingMs: 0,
pendingMinMs: 100,
pendingComponent: () => <div data-testid="pending">loading</div>,
errorComponent: ({ error }) => (
<pre data-testid="root-error">{String(error)}</pre>
),
beforeLoad: async () => {
await sleep(50)
if (!hasRedirected) {
hasRedirected = true
throw redirect({ to: '/welcome', replace: true })
}
},
})

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

const welcomeRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/welcome',
component: () => <div data-testid="welcome-page">Welcome</div>,
})

const router = createRouter({
routeTree: rootRoute.addChildren([indexRoute, welcomeRoute]),
history: createMemoryHistory({ initialEntries: ['/'] }),
})

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

// pendingMs: 0 — the spinner must show right away.
expect(await screen.findByTestId('pending')).toBeInTheDocument()

// The redirect must complete: the target renders, no error boundary output
// and no render crash.
expect(
await screen.findByTestId('welcome-page', undefined, { timeout: 5_000 }),
).toBeInTheDocument()
expect(screen.queryByTestId('pending')).not.toBeInTheDocument()
expect(screen.queryByTestId('root-error')).not.toBeInTheDocument()
expect(router.state.location.pathname).toBe('/welcome')
expect(consoleError).not.toHaveBeenCalled()
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import * as React from 'react'
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, expect, test, vi } from 'vitest'

import {
Outlet,
RouterProvider,
createMemoryHistory,
createRootRoute,
createRoute,
createRouter,
redirect,
} from '../src'
import { sleep } from './utils'

afterEach(() => {
vi.restoreAllMocks()
cleanup()
})

// https://github.com/TanStack/router/issues/7457
// A chain of async layout beforeLoad redirects during the very first load
// (search-stripping self-redirect -> layout redirect -> child redirect) used
// to leave a match rendering with a nulled loadPromise, crashing
// MatchInnerImpl with an uncaught `undefined`. Pending UI is enabled for
// every match (defaultPendingMs: 0) to force pending publication mid-chain.
test('chained layout beforeLoad redirects on first load render the final target without throwing from MatchInner', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})

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

const userLayout = createRoute({
id: 'user',
getParentRoute: () => rootRoute,
validateSearch: (search: Record<string, unknown>): { flag?: boolean } => ({
flag: search.flag === true || search.flag === 'true' ? true : undefined,
}),
beforeLoad: async ({ search }) => {
if (search.flag) {
await sleep(20)
throw redirect({
to: '.',
replace: true,
search: (prev: any) => ({ ...prev, flag: undefined }),
})
}
},
component: () => <Outlet />,
})

const dashboardLayout = createRoute({
id: 'dashboard',
getParentRoute: () => userLayout,
beforeLoad: async () => {
await sleep(30)
throw redirect({ to: '/intro', replace: true })
},
component: () => <Outlet />,
})

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

const introLayout = createRoute({
getParentRoute: () => userLayout,
path: '/intro',
beforeLoad: ({ location }) => {
if (location.pathname !== '/intro/step') {
throw redirect({ to: '/intro/step', replace: true })
}
},
component: () => <Outlet />,
})

const introIndexRoute = createRoute({
getParentRoute: () => introLayout,
path: '/',
component: () => <div data-testid="intro-index">Intro index</div>,
})

const introStepRoute = createRoute({
getParentRoute: () => introLayout,
path: 'step',
component: () => <div data-testid="intro-step">Intro step</div>,
})

const router = createRouter({
routeTree: rootRoute.addChildren([
userLayout.addChildren([
dashboardLayout.addChildren([homeRoute]),
introLayout.addChildren([introIndexRoute, introStepRoute]),
]),
]),
defaultPendingMs: 0,
defaultPendingComponent: () => <div data-testid="pending">loading</div>,
history: createMemoryHistory({ initialEntries: ['/home?flag=true'] }),
})

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

expect(
await screen.findByTestId('intro-step', undefined, { timeout: 5_000 }),
).toBeInTheDocument()
expect(router.state.location.pathname).toBe('/intro/step')
expect(consoleError).not.toHaveBeenCalled()
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import * as React from 'react'
import { afterEach, expect, test, vi } from 'vitest'
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from '@testing-library/react'
import {
Outlet,
RouterProvider,
createMemoryHistory,
createRootRoute,
createRoute,
createRouter,
useRouter,
} from '../src'
import type { ErrorComponentProps } from '../src'

afterEach(() => {
vi.restoreAllMocks()
cleanup()
})

// https://github.com/TanStack/router/issues/7638
// router.invalidate() called inside React.startTransition while a nested
// route is showing its errorComponent must complete the reload and land back
// on the error UI without crashing React with
// "Rendered more hooks than during the previous render."
function setup({ failVia }: { failVia: 'render' | 'loader' }) {
const rootRoute = createRootRoute({ component: () => <Outlet /> })

const testRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/test',
component: function TestComponent() {
const router = useRouter()
const [isPending, startTransition] = React.useTransition()
return (
<div>
<button
data-testid="invalidate"
disabled={isPending}
onClick={() =>
startTransition(() => {
router.invalidate()
})
}
>
invalidate
</button>
<Outlet />
</div>
)
},
})

const childLoader = vi.fn(() => {
if (failVia === 'loader') {
throw new Error('test error')
}
return 'data'
})

const testIndexRoute = createRoute({
getParentRoute: () => testRoute,
path: '/',
loader: childLoader,
component: function ChildComponent() {
if (failVia === 'render') {
throw new Error('test error')
}
return <div>child content</div>
},
})

let errorRenders = 0
const router = createRouter({
routeTree: rootRoute.addChildren([testRoute.addChildren([testIndexRoute])]),
history: createMemoryHistory({ initialEntries: ['/test'] }),
defaultErrorComponent: (props: ErrorComponentProps) => {
errorRenders++
return <div data-testid="error-ui">error: {props.error.message}</div>
},
})

return { router, childLoader, getErrorRenders: () => errorRenders }
}

test.each(['render', 'loader'] as const)(
'invalidate() inside startTransition through a nested %s-error route does not crash',
async (failVia) => {
// Error boundaries log caught errors through console.error, and so does a
// hooks-order crash. Capture instead of polluting the test output, then
// inspect the captured calls for the crash signature.
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})

const { router, childLoader, getErrorRenders } = setup({ failVia })
render(<RouterProvider router={router} />)

expect(await screen.findByTestId('error-ui')).toBeInTheDocument()
const initialErrorRenders = getErrorRenders()
const initialLoaderCalls = childLoader.mock.calls.length

fireEvent.click(screen.getByTestId('invalidate'))

// The invalidated reload must actually complete: the loader re-ran ...
await waitFor(() => {
expect(childLoader.mock.calls.length).toBeGreaterThan(initialLoaderCalls)
})

// ... the error UI is rendered again after the reload ...
await waitFor(() => {
expect(screen.getByTestId('error-ui')).toBeInTheDocument()
expect(getErrorRenders()).toBeGreaterThan(initialErrorRenders)
})

// React must not have torn the tree down with a hooks-order violation.
const hooksCrash = consoleError.mock.calls.find((call) =>
call.some((arg) =>
String(arg?.message ?? arg).includes('Rendered more hooks'),
),
)
expect(hooksCrash).toBeUndefined()
// The surrounding route (the issue's "frozen" parent) is still mounted
// and interactive.
expect(screen.getByTestId('invalidate')).toBeInTheDocument()
},
)
Loading
Loading