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
8 changes: 8 additions & 0 deletions e2e/react-start/basic/src/routes/posts.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useState } from 'react'
import { Link, Outlet, createFileRoute } from '@tanstack/react-router'

import { fetchPosts } from '~/utils/posts'
Expand All @@ -16,9 +17,16 @@ export const Route = createFileRoute('/posts')({

function PostsComponent() {
const posts = Route.useLoaderData()
const [hydrationCount, setHydrationCount] = useState(0)

return (
<div className="p-2 flex gap-2">
<button
data-testid="posts-parent-hydration-counter"
onClick={() => setHydrationCount((count) => count + 1)}
>
Parent hydration count: {hydrationCount}
</button>
<ul className="list-disc pl-4">
{[...posts, { id: 'i-do-not-exist', title: 'Non-existent Post' }].map(
(post) => {
Expand Down
18 changes: 18 additions & 0 deletions e2e/react-start/basic/tests/not-found.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,24 @@ test.use({
],
})
test.describe('not-found', () => {
test('direct nested not-found hydrates its already-rendered parent route', async ({
page,
}) => {
test.skip(isSpaMode, 'issue #5106 requires a server-rendered direct visit')

const response = await page.goto('/posts/i-do-not-exist')
await page.waitForLoadState('networkidle')

const serverHtml = await response?.text()
expect(serverHtml).toContain('posts-parent-hydration-counter')
expect(serverHtml).toContain('Post not found')
await expect(page.getByText('Post not found')).toBeInViewport()
const counter = page.getByTestId('posts-parent-hydration-counter')
await expect(counter).toHaveText('Parent hydration count: 0')
await counter.click()
await expect(counter).toHaveText('Parent hydration count: 1')
})

test(`global not found`, async ({ page }) => {
const response = await page.goto(`/this-page-does-not-exist/foo/bar`)

Expand Down
73 changes: 73 additions & 0 deletions packages/react-router/tests/errorComponent.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import ReactDOMServer from 'react-dom/server'

import {
HeadContent,
Link,
Outlet,
RouterProvider,
Expand All @@ -14,6 +15,11 @@ import {
createRouter,
notFound,
} from '../src'
import {
RouterServer,
createRequestHandler,
renderRouterToString,
} from '../src/ssr/server'
import type { ErrorComponentProps, RouterHistory } from '../src'

function MyErrorComponent(props: ErrorComponentProps) {
Expand Down Expand Up @@ -344,6 +350,73 @@ test('SSR errorComponent receives primitive errors thrown from beforeLoad', asyn
expect(html).toContain('primitive error thrown')
})

// https://github.com/TanStack/router/issues/4684
test('#4684: SSR renders head content when beforeLoad throws', async () => {
const rootRoute = createRootRoute({
head: () => ({
links: [{ rel: 'stylesheet', href: '/global.css' }],
}),
shellComponent: function RootDocument({ children }) {
return (
<html>
<head>
<HeadContent />
</head>
<body>{children}</body>
</html>
)
},
component: Outlet,
})
const failingRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/fail',
beforeLoad: () => {
throw new Error('beforeLoad failed')
},
head: ({ match }) => ({
meta: [{ title: match.error ? 'Error title' : 'Success title' }],
}),
component: function FailingRoute() {
return <div>Route content</div>
},
errorComponent: ({ error }) => <div>Error UI: {error.message}</div>,
})

const handler = createRequestHandler({
request: new Request('http://localhost/fail'),
createRouter: () =>
createRouter({
routeTree: rootRoute.addChildren([failingRoute]),
isServer: true,
}),
})

const response = await handler(({ router, responseHeaders }) =>
renderRouterToString({
router,
responseHeaders,
children: <RouterServer router={router} />,
}),
)

expect(response.status).toBe(500)
const html = await response.text()
const serverDocument = new DOMParser().parseFromString(html, 'text/html')

expect(serverDocument.body.textContent).toContain(
'Error UI: beforeLoad failed',
)
expect(serverDocument.head.querySelector('title')?.textContent).toBe(
'Error title',
)
expect(
serverDocument.head.querySelector(
'link[rel="stylesheet"][href="/global.css"]',
),
).not.toBeNull()
})

describe('notFoundComponent is rendered when an error is thrown in params.parse', () => {
test('displays notFoundComponent when error is thrown in params.parse', async () => {
const history = createMemoryHistory({ initialEntries: ['/'] })
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import * as React from 'react'
import { act, cleanup, render, screen } from '@testing-library/react'
import { afterEach, expect, test, vi } from 'vitest'
import {
RouterProvider,
createMemoryHistory,
createRootRoute,
createRouter,
} from '../src'

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

// https://github.com/TanStack/router/issues/2905
test('#2905: a root pendingComponent renders while root beforeLoad is pending', async () => {
vi.useFakeTimers()

const rootRoute = createRootRoute({
beforeLoad: async () => {
await new Promise((resolve) => setTimeout(resolve, 3_000))
},
pendingComponent: () => <div data-testid="root-pending">Root pending</div>,
component: () => <div data-testid="root-content">Root</div>,
})
const router = createRouter({
routeTree: rootRoute,
history: createMemoryHistory({ initialEntries: ['/'] }),
})

render(<RouterProvider router={router} />)
await act(async () => {
await vi.advanceTimersByTimeAsync(0)
})

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

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

await act(async () => {
await vi.advanceTimersByTimeAsync(1_999)
})
expect(screen.getByTestId('root-pending')).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,113 @@
import * as React from 'react'
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from '@testing-library/react'
import { afterEach, expect, test } from 'vitest'
import {
Link,
Outlet,
RouterProvider,
createMemoryHistory,
createRootRouteWithContext,
createRoute,
createRouter,
} from '../src'

afterEach(cleanup)

// https://github.com/TanStack/router/issues/5778
test('#5778: intent preload sees a RouterProvider context update before the first navigation', async () => {
const seen: Array<{
route: 'auth' | 'foo'
foo: string
cause: string
preload: boolean
}> = []
const rootRoute = createRootRouteWithContext<{ foo: string }>()({
component: () => (
<>
<Link to="/foo" preload="intent">
Foo
</Link>
<Outlet />
</>
),
})
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
component: () => <div>Home</div>,
})
const authRoute = createRoute({
getParentRoute: () => rootRoute,
id: '_authenticated',
beforeLoad: ({ context, cause, preload }) => {
seen.push({ route: 'auth', foo: context.foo, cause, preload })
},
component: () => <Outlet />,
})
const fooRoute = createRoute({
getParentRoute: () => authRoute,
path: '/foo',
beforeLoad: ({ context, cause, preload }) => {
seen.push({ route: 'foo', foo: context.foo, cause, preload })
},
component: () => <div>Foo page</div>,
})
const router = createRouter({
routeTree: rootRoute.addChildren([
indexRoute,
authRoute.addChildren([fooRoute]),
]),
history: createMemoryHistory({ initialEntries: ['/'] }),
context: { foo: null! },
defaultPreload: 'intent',
defaultPreloadDelay: 0,
})

function App() {
const [foo, setFoo] = React.useState('foo')
return (
<>
<output aria-label="Current context">{foo}</output>
<button
onClick={() => {
setFoo('baz')
}}
>
Update context
</button>
<RouterProvider router={router} context={{ foo }} />
</>
)
}

render(<App />)
expect(await screen.findByText('Home')).toBeInTheDocument()
expect(screen.getByLabelText('Current context')).toHaveTextContent('foo')

fireEvent.click(screen.getByRole('button', { name: 'Update context' }))
await waitFor(() =>
expect(screen.getByLabelText('Current context')).toHaveTextContent('baz'),
)

expect(seen).toEqual([])
const link = screen.getByRole('link', { name: 'Foo' })
fireEvent.mouseEnter(link)
await waitFor(() => expect(seen).toHaveLength(2))
expect(seen).toEqual([
{ route: 'auth', foo: 'baz', cause: 'preload', preload: true },
{ route: 'foo', foo: 'baz', cause: 'preload', preload: true },
])
expect(screen.getByText('Home')).toBeInTheDocument()
expect(screen.queryByText('Foo page')).not.toBeInTheDocument()
expect(router.state.location.pathname).toBe('/')

fireEvent.click(link)
expect(await screen.findByText('Foo page')).toBeInTheDocument()
expect(router.state.location.pathname).toBe('/foo')
})
Loading
Loading