diff --git a/docs/router/api/router/RouteOptionsType.md b/docs/router/api/router/RouteOptionsType.md index ac35cdc845..66e2c80d90 100644 --- a/docs/router/api/router/RouteOptionsType.md +++ b/docs/router/api/router/RouteOptionsType.md @@ -41,9 +41,10 @@ The `RouteOptions` type accepts an object with the following properties: ### `pendingComponent` property -- Type: `RouteComponent` or `LazyRouteComponent` +- Type: `false`, `RouteComponent`, or `LazyRouteComponent` - Optional - Defaults to `routerOptions.defaultPendingComponent` - The content to be rendered if and when the route is pending and has reached its pendingMs threshold. +- Set to `false` to opt out of `routerOptions.defaultPendingComponent` for this route and render nothing instead. ### `notFoundComponent` property diff --git a/packages/react-router/src/route.tsx b/packages/react-router/src/route.tsx index 6f6961807b..48a11e30e5 100644 --- a/packages/react-router/src/route.tsx +++ b/packages/react-router/src/route.tsx @@ -53,7 +53,7 @@ declare module '@tanstack/router-core' { component?: RouteComponent errorComponent?: false | null | undefined | ErrorRouteComponent notFoundComponent?: NotFoundRouteComponent - pendingComponent?: RouteComponent + pendingComponent?: false | null | undefined | RouteComponent } export interface RootRouteOptionsExtensions { diff --git a/packages/react-router/tests/issue-7773-pending-component-opt-out.test.tsx b/packages/react-router/tests/issue-7773-pending-component-opt-out.test.tsx new file mode 100644 index 0000000000..8d99d55849 --- /dev/null +++ b/packages/react-router/tests/issue-7773-pending-component-opt-out.test.tsx @@ -0,0 +1,92 @@ +import { act, cleanup, render, screen } from '@testing-library/react' +import { afterEach, expect, test, vi } from 'vitest' +import { + Outlet, + RouterProvider, + createControlledPromise, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from '../src' + +afterEach(() => { + cleanup() + vi.useRealTimers() +}) + +// https://github.com/TanStack/router/issues/7773 +test('pendingComponent: false suppresses the router-wide default pending fallback', async () => { + const loaderGate = createControlledPromise() + const rootRoute = createRootRoute({ component: Outlet }) + const optedOutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/opted-out', + pendingComponent: false, + loader: () => loaderGate, + component: () => ( +
{optedOutRoute.useLoaderData()}
+ ), + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([optedOutRoute]), + history: createMemoryHistory({ initialEntries: ['/opted-out'] }), + defaultPendingComponent: () =>
Pending
, + defaultPendingMs: 0, + defaultPendingMinMs: 100, + }) + + render() + expect(screen.queryByTestId('pending')).not.toBeInTheDocument() + expect(screen.queryByTestId('content')).not.toBeInTheDocument() + + await act(async () => { + loaderGate.resolve('loaded') + await loaderGate + }) + + expect(screen.queryByTestId('pending')).not.toBeInTheDocument() + expect(screen.getByTestId('content')).toHaveTextContent('loaded') +}) + +// Contrast case: confirms the type widening didn't disturb default inheritance +// for routes that don't opt out. +test('a sibling route without pendingComponent: false still uses the router-wide default', async () => { + vi.useFakeTimers() + + const loaderGate = createControlledPromise() + const rootRoute = createRootRoute({ component: Outlet }) + const defaultRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/default', + loader: () => loaderGate, + component: () => ( +
{defaultRoute.useLoaderData()}
+ ), + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([defaultRoute]), + history: createMemoryHistory({ initialEntries: ['/default'] }), + defaultPendingComponent: () =>
Pending
, + defaultPendingMs: 0, + defaultPendingMinMs: 100, + }) + + render() + expect(screen.getByTestId('pending')).toBeInTheDocument() + expect(screen.queryByTestId('content')).not.toBeInTheDocument() + + loaderGate.resolve('loaded') + + // pendingMinMs (100) hasn't elapsed yet -- the default fallback must stay up. + await act(async () => { + await vi.advanceTimersByTimeAsync(99) + }) + expect(screen.getByTestId('pending')).toBeInTheDocument() + + await act(async () => { + await vi.advanceTimersByTimeAsync(1) + }) + expect(screen.queryByTestId('pending')).not.toBeInTheDocument() + expect(screen.getByTestId('content')).toHaveTextContent('loaded') +})