diff --git a/e2e/react-start/basic/src/routes/posts.tsx b/e2e/react-start/basic/src/routes/posts.tsx index 0f69c183419..22fb7e60a85 100644 --- a/e2e/react-start/basic/src/routes/posts.tsx +++ b/e2e/react-start/basic/src/routes/posts.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react' import { Link, Outlet, createFileRoute } from '@tanstack/react-router' import { fetchPosts } from '~/utils/posts' @@ -16,9 +17,16 @@ export const Route = createFileRoute('/posts')({ function PostsComponent() { const posts = Route.useLoaderData() + const [hydrationCount, setHydrationCount] = useState(0) return (
+
@@ -56,9 +65,14 @@ function setup({ failVia }: { failVia: 'render' | 'loader' }) { }, }) - const childLoader = vi.fn(() => { + let childLoaderCalls = 0 + const childLoader = vi.fn(async () => { + childLoaderCalls++ + if (childLoaderCalls === 2) { + await secondChildLoad + } if (failVia === 'loader') { - throw new Error('test error') + throw new Error('loader error') } return 'data' }) @@ -69,7 +83,7 @@ function setup({ failVia }: { failVia: 'render' | 'loader' }) { loader: childLoader, component: function ChildComponent() { if (failVia === 'render') { - throw new Error('test error') + throw new Error('render error') } return
child content
}, @@ -85,7 +99,14 @@ function setup({ failVia }: { failVia: 'render' | 'loader' }) { }, }) - return { router, childLoader, getErrorRenders: () => errorRenders } + return { + router, + childLoader, + parentAction, + secondChildLoad, + getErrorRenders: () => errorRenders, + getInvalidation: () => invalidation, + } } test.each(['render', 'loader'] as const)( @@ -94,37 +115,71 @@ test.each(['render', 'loader'] as const)( // 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 { + router, + childLoader, + parentAction, + secondChildLoad, + getErrorRenders, + getInvalidation, + } = setup({ failVia }) const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) - const { router, childLoader, getErrorRenders } = setup({ failVia }) - render() - - 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() + try { + render() + + expect(await screen.findByTestId('error-ui')).toHaveTextContent( + `error: ${failVia} error`, + ) + const initialErrorRenders = getErrorRenders() + expect(childLoader).toHaveBeenCalledTimes(1) + consoleError.mockClear() + + fireEvent.click(screen.getByTestId('invalidate')) + + await waitFor(() => { + expect(childLoader).toHaveBeenCalledTimes(2) + expect(screen.getByTestId('invalidate')).toHaveTextContent('pending') + expect(screen.getByTestId('invalidate')).toBeDisabled() + }) + expect(secondChildLoad.status).toBe('pending') + + const invalidation = getInvalidation() + if (!invalidation) { + throw new Error('invalidate action did not return its promise') + } + + await act(async () => { + secondChildLoad.resolve() + await invalidation + }) + + await waitFor(() => { + expect(screen.getByTestId('error-ui')).toHaveTextContent( + `error: ${failVia} error`, + ) + expect(getErrorRenders()).toBeGreaterThan(initialErrorRenders) + expect(screen.getByTestId('invalidate')).toHaveTextContent('invalidate') + expect(screen.getByTestId('invalidate')).toBeEnabled() + }) + + fireEvent.click(screen.getByTestId('parent-action')) + expect(parentAction).toHaveBeenCalledTimes(1) + + const hooksCrash = consoleError.mock.calls.find((call) => + call.some((arg) => + String(arg?.message ?? arg).includes('Rendered more hooks'), + ), + ) + expect(hooksCrash).toBeUndefined() + } finally { + if (secondChildLoad.status === 'pending') { + await act(async () => { + secondChildLoad.resolve() + await getInvalidation()?.catch(() => undefined) + }) + } + consoleError.mockRestore() + } }, ) diff --git a/packages/react-router/tests/loaders.test.tsx b/packages/react-router/tests/loaders.test.tsx index d9b5968d723..869aeec76c3 100644 --- a/packages/react-router/tests/loaders.test.tsx +++ b/packages/react-router/tests/loaders.test.tsx @@ -912,30 +912,25 @@ test('reproducer for #6388 - rapid navigation between parameterized routes shoul }) render() - await act(() => router.latestLoadPromise) - - const pendingComponent = screen.findByTestId('pending-component') expect(await screen.findByTestId('home-page')).toBeInTheDocument() - const param1Link = await screen.findByTestId('link-to-param-1') fireEvent.click(param1Link) - expect(await pendingComponent).toBeInTheDocument() + expect(await screen.findByTestId('pending-component')).toBeInTheDocument() const param2Link = await screen.findByTestId('link-to-param-2') fireEvent.click(param2Link) - expect(await pendingComponent).toBeInTheDocument() + expect(await screen.findByTestId('pending-component')).toBeInTheDocument() fireEvent.click(param1Link) - expect(await pendingComponent).toBeInTheDocument() + expect(await screen.findByTestId('pending-component')).toBeInTheDocument() - await act(() => router.latestLoadPromise) + const paramPage = await screen.findByTestId('param-page') expect(onAbortMock).toHaveBeenCalled() expect(errorComponentRenderCount).not.toHaveBeenCalled() expect(screen.queryByTestId('error-component')).not.toBeInTheDocument() - expect(await pendingComponent).not.toBeInTheDocument() + expect(screen.queryByTestId('pending-component')).not.toBeInTheDocument() - const paramPage = await screen.findByTestId('param-page') expect(paramPage).toBeInTheDocument() expect(paramPage).toHaveTextContent('Param Component 1 Done') expect(loaderCompleteMock).toHaveBeenCalled() diff --git a/packages/router-core/tests/issue-3293-on-enter-after-loader.test.ts b/packages/router-core/tests/issue-3293-on-enter-after-loader.test.ts new file mode 100644 index 00000000000..a1be9103149 --- /dev/null +++ b/packages/router-core/tests/issue-3293-on-enter-after-loader.test.ts @@ -0,0 +1,108 @@ +import { expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute, createControlledPromise } from '../src' +import { createTestRouter } from './routerTestUtils' + +// Existing-behavior controls for https://github.com/TanStack/router/issues/3293 +test('#3293 existing behavior: direct load runs onEnter after beforeLoad and loader', async () => { + const beforeLoadGate = createControlledPromise<{ ready: true }>() + const loaderGate = createControlledPromise<{ someData: 42 }>() + const beforeLoad = vi.fn(() => beforeLoadGate) + const loader = vi.fn(() => loaderGate) + const onEnter = vi.fn() + + const rootRoute = new BaseRootRoute({}) + const aboutRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/about', + beforeLoad, + loader, + onEnter, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([aboutRoute]), + history: createMemoryHistory({ initialEntries: ['/about'] }), + }) + + const load = router.load() + await vi.waitFor(() => expect(beforeLoad).toHaveBeenCalledTimes(1)) + expect(loader).not.toHaveBeenCalled() + expect(onEnter).not.toHaveBeenCalled() + + beforeLoadGate.resolve({ ready: true }) + await vi.waitFor(() => expect(loader).toHaveBeenCalledTimes(1)) + expect(loader).toHaveBeenCalledWith( + expect.objectContaining({ + context: expect.objectContaining({ ready: true }), + }), + ) + expect(onEnter).not.toHaveBeenCalled() + + loaderGate.resolve({ someData: 42 }) + await load + + expect(onEnter).toHaveBeenCalledTimes(1) + expect(onEnter).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'success', + context: expect.objectContaining({ ready: true }), + loaderData: { someData: 42 }, + }), + ) +}) + +test('#3293 existing behavior: uncached SPA navigation runs onEnter after beforeLoad and loader', async () => { + const beforeLoadGate = createControlledPromise<{ ready: true }>() + const loaderGate = createControlledPromise<{ someData: 42 }>() + const beforeLoad = vi.fn(() => beforeLoadGate) + const loader = vi.fn(() => loaderGate) + const onEnter = vi.fn() + + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const aboutRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/about', + beforeLoad, + loader, + onEnter, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute, aboutRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + expect(beforeLoad).not.toHaveBeenCalled() + expect(loader).not.toHaveBeenCalled() + expect(onEnter).not.toHaveBeenCalled() + + const navigation = router.navigate({ to: '/about' }) + await vi.waitFor(() => expect(beforeLoad).toHaveBeenCalledTimes(1)) + expect(loader).not.toHaveBeenCalled() + expect(onEnter).not.toHaveBeenCalled() + + beforeLoadGate.resolve({ ready: true }) + await vi.waitFor(() => expect(loader).toHaveBeenCalledTimes(1)) + expect(loader).toHaveBeenCalledWith( + expect.objectContaining({ + context: expect.objectContaining({ ready: true }), + }), + ) + expect(onEnter).not.toHaveBeenCalled() + + loaderGate.resolve({ someData: 42 }) + await navigation + + expect(onEnter).toHaveBeenCalledTimes(1) + expect(onEnter).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'success', + context: expect.objectContaining({ ready: true }), + loaderData: { someData: 42 }, + }), + ) +}) diff --git a/packages/router-core/tests/issue-4078-loader-notfound-root-boundary.test.ts b/packages/router-core/tests/issue-4078-loader-notfound-root-boundary.test.ts new file mode 100644 index 00000000000..45085a8e1fc --- /dev/null +++ b/packages/router-core/tests/issue-4078-loader-notfound-root-boundary.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { + BaseRootRoute, + BaseRoute, + createControlledPromise, + notFound, + rootRouteId, +} from '../src' +import { createTestRouter } from './routerTestUtils' + +// Existing Core attribution coverage related to: +// https://github.com/TanStack/router/issues/4078 +// https://github.com/TanStack/router/issues/2255 +// Throwing an untargeted notFound() in a child loader used to always render the +// defaultNotFoundComponent, even when __root__ defined a notFoundComponent +// (#4078). #2255 is the same asymmetry: a path-mismatch notFound renders the +// root component + notFoundComponent, but a loader-thrown notFound did not. +// +// At Router Core's boundary, root attribution is represented by the root match +// with globalNotFound. These assertions do not distinguish the +// configured root notFoundComponent from the router default; that reported +// rendering behavior requires framework-level coverage. +describe('#4078 / #2255 existing Core root-boundary attribution', () => { + const setup = (initialEntries: Array) => { + const loaderStarted = createControlledPromise() + const loaderResponse = createControlledPromise() + const rootRoute = new BaseRootRoute({ + component: () => 'Root', + notFoundComponent: () => 'Root not found', + }) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const aboutRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/about', + loader: async () => { + loaderStarted.resolve() + await loaderResponse + throw notFound() + }, + component: () => 'About', + }) + + return { + router: createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute, aboutRoute]), + history: createMemoryHistory({ initialEntries }), + }), + loaderStarted, + loaderResponse, + } + } + + const getRootBoundaryProjection = ( + router: ReturnType['router'], + ) => { + const rootMatch = router.state.matches.find( + (match) => match.routeId === rootRouteId, + ) + + return { + routeId: rootMatch?.routeId, + globalNotFound: rootMatch?.globalNotFound, + } + } + + it('an async loader notFound navigation selects the root boundary', async () => { + const { router, loaderStarted, loaderResponse } = setup(['/']) + await router.load() + + const navigation = router.navigate({ to: '/about' }) + await loaderStarted + try { + expect(loaderResponse.status).toBe('pending') + } finally { + loaderResponse.resolve() + } + + await navigation + + expect(router.state.location.pathname).toBe('/about') + expect(getRootBoundaryProjection(router)).toEqual({ + routeId: rootRouteId, + globalNotFound: true, + }) + }) + + it('matches the root-boundary projection of an unmatched URL (#2255 parity)', async () => { + const unmatched = setup(['/missing']) + await unmatched.router.load() + const unmatchedProjection = getRootBoundaryProjection(unmatched.router) + expect(unmatchedProjection).toEqual({ + routeId: rootRouteId, + globalNotFound: true, + }) + + const loaderNotFound = setup(['/']) + await loaderNotFound.router.load() + const navigation = loaderNotFound.router.navigate({ to: '/about' }) + await loaderNotFound.loaderStarted + loaderNotFound.loaderResponse.resolve() + await navigation + + expect(getRootBoundaryProjection(loaderNotFound.router)).toEqual( + unmatchedProjection, + ) + }) +}) diff --git a/packages/router-core/tests/issue-4696-parent-context-search-normalization.test.ts b/packages/router-core/tests/issue-4696-parent-context-search-normalization.test.ts new file mode 100644 index 00000000000..20ef368318f --- /dev/null +++ b/packages/router-core/tests/issue-4696-parent-context-search-normalization.test.ts @@ -0,0 +1,102 @@ +import { expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute } from '../src' +import { createTestRouter } from './routerTestUtils' + +// Existing-behavior coverage for https://github.com/TanStack/router/issues/4696 +test('#4696 existing behavior: normalized child search keeps reused parent context', async () => { + const rootLoader = vi.fn( + ({ context }: { context: Record }) => context, + ) + const dashboardBeforeLoad = vi.fn( + ({ context }: { context: Record }) => { + if (!context.isAuthenticated) { + throw new Error('Authentication context was lost') + } + }, + ) + const rootRoute = new BaseRootRoute({ + beforeLoad: async () => { + await Promise.resolve() + return { + initialData: { + user: { email: 'mr.user@gmail.com', role: 'user' }, + }, + initializationError: undefined, + isAuthenticated: true, + isAdmin: false, + } + }, + loader: rootLoader, + }) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const dashboardRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/dashboard', + validateSearch: () => ({ page: 0 }), + beforeLoad: dashboardBeforeLoad, + }) + const history = createMemoryHistory({ initialEntries: ['/'] }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute, dashboardRoute]), + history, + }) + + await router.load() + expect(rootLoader).toHaveBeenCalledTimes(1) + expect(rootLoader.mock.calls[0]?.[0].context).toMatchObject({ + isAuthenticated: true, + }) + expect(router.state.location.pathname).toBe('/') + router.update({ + history: createMemoryHistory({ + initialEntries: ['/dashboard?page=0'], + }), + }) + await router.load() + expect(router.state.location.pathname).toBe('/dashboard') + expect(rootLoader).toHaveBeenCalledTimes(1) + expect(dashboardBeforeLoad).toHaveBeenCalledTimes(1) + expect(dashboardBeforeLoad).toHaveBeenLastCalledWith( + expect.objectContaining({ + context: expect.objectContaining({ isAuthenticated: true }), + }), + ) + expect( + router.state.matches.find((match) => match.routeId === rootRoute.id) + ?.loaderData, + ).toMatchObject({ isAuthenticated: true }) + + dashboardBeforeLoad.mockClear() + router.update({ + history: createMemoryHistory({ initialEntries: ['/dashboard'] }), + }) + await router.load() + + expect(dashboardBeforeLoad).toHaveBeenCalledTimes(1) + expect(dashboardBeforeLoad).toHaveBeenLastCalledWith( + expect.objectContaining({ + context: expect.objectContaining({ isAuthenticated: true }), + }), + ) + expect(rootLoader).toHaveBeenCalledTimes(1) + expect(router.state.location.pathname).toBe('/dashboard') + const rootMatch = router.state.matches.find( + (match) => match.routeId === rootRoute.id, + ) + const dashboardMatch = router.state.matches.find( + (match) => match.routeId === dashboardRoute.id, + ) + expect(rootMatch?.loaderData).toMatchObject({ + isAuthenticated: true, + isAdmin: false, + }) + expect(dashboardMatch).toMatchObject({ + status: 'success', + search: { page: 0 }, + searchError: undefined, + }) +}) diff --git a/packages/router-core/tests/issue-5106-hydrated-notfound-boundary.test.ts b/packages/router-core/tests/issue-5106-hydrated-notfound-boundary.test.ts new file mode 100644 index 00000000000..8a1570a3f41 --- /dev/null +++ b/packages/router-core/tests/issue-5106-hydrated-notfound-boundary.test.ts @@ -0,0 +1,151 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute, isNotFound } from '../src' +import { hydrate } from '../src/ssr/client' +import { createTestRouter } from './routerTestUtils' +import type { TsrSsrGlobal } from '../src/ssr/types' +import type { Manifest } from '../src/manifest' + +const testManifest: Manifest = { routes: {} } + +// Supplemental Core state coverage for +// https://github.com/TanStack/router/issues/5106. The reported rendered React +// hydration symptom is covered by the React Start E2E test. +describe('hydrated child-owned notFound boundary coverage', () => { + let mockWindow: { $_TSR?: TsrSsrGlobal } + + beforeEach(() => { + mockWindow = {} + ;(global as any).window = mockWindow + }) + + afterEach(() => { + delete (global as any).window + vi.restoreAllMocks() + }) + + it('#5106 existing behavior: adopts a child-owned boundary without invoking its loaders during hydration', async () => { + const postsLoader = vi.fn(() => 'posts-data') + const postLoader = vi.fn(() => 'post-data') + const history = createMemoryHistory({ + initialEntries: ['/posts/i-do-not-exist'], + }) + + const rootRoute = new BaseRootRoute({}) + const postsRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/posts', + loader: postsLoader, + component: () => 'Posts', + }) + const postRoute = new BaseRoute({ + getParentRoute: () => postsRoute, + path: '/$postId', + loader: postLoader, + component: () => 'Post', + notFoundComponent: () => 'Post not found', + }) + const safeLoader = vi.fn(() => 'safe-data') + const safeRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/safe', + loader: safeLoader, + }) + + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + postsRoute.addChildren([postRoute]), + safeRoute, + ]), + history, + isServer: false, + }) + + const matches = router.matchRoutes(router.stores.location.get()) + expect(matches.map((match) => match.routeId)).toEqual([ + rootRoute.id, + postsRoute.id, + postRoute.id, + ]) + + mockWindow.$_TSR = { + router: { + manifest: testManifest, + dehydratedData: {}, + // This synthetic payload models a child-owned boundary, so its + // terminal prefix includes the child match. + matches: [ + { + i: matches[0]!.id, + s: 'success' as const, + ssr: true, + u: Date.now(), + }, + { + i: matches[1]!.id, + s: 'success' as const, + l: 'posts-data', + ssr: true, + u: Date.now(), + }, + { + i: matches[2]!.id, + s: 'notFound' as const, + e: { isNotFound: true }, + ssr: true, + u: Date.now(), + }, + ], + }, + h: vi.fn(), + e: vi.fn(), + c: vi.fn(), + p: vi.fn(), + buffer: [], + initialized: false, + } + + await hydrate(router) + + const stateMatches = router.state.matches + expect(router.state.location.pathname).toBe('/posts/i-do-not-exist') + expect(router.state.isLoading).toBe(false) + expect(stateMatches.map((match) => match.routeId)).toEqual([ + rootRoute.id, + postsRoute.id, + postRoute.id, + ]) + + // The parent keeps the data and status supplied by the payload. + expect(stateMatches[1]!.status).toBe('success') + expect(stateMatches[1]!.loaderData).toBe('posts-data') + + // The child is adopted as the notFound boundary. + expect(stateMatches[2]!.status).toBe('notFound') + expect(isNotFound(stateMatches[2]!.error)).toBe(true) + + // Hydration does not invoke either route loader represented by the + // payload. + expect(postsLoader).not.toHaveBeenCalled() + expect(postLoader).not.toHaveBeenCalled() + expect(safeLoader).not.toHaveBeenCalled() + + // Supplemental public liveness coverage: a later client navigation still + // completes. This does not exercise React's rendered hydration boundary. + await router.navigate({ to: '/safe' }) + expect(router.state.location.pathname).toBe('/safe') + expect(router.state.isLoading).toBe(false) + expect(router.state.matches.map((match) => match.routeId)).toEqual([ + rootRoute.id, + safeRoute.id, + ]) + expect(router.state.matches.at(-1)).toMatchObject({ + routeId: safeRoute.id, + status: 'success', + loaderData: 'safe-data', + }) + expect(safeLoader).toHaveBeenCalledTimes(1) + expect(postsLoader).not.toHaveBeenCalled() + expect(postLoader).not.toHaveBeenCalled() + }) +})