From d7daa901386a6e5ab3e34d51d5735ff3f83ca498 Mon Sep 17 00:00:00 2001 From: nishant batra Date: Mon, 17 Aug 2026 17:21:31 +0530 Subject: [PATCH 1/3] feat(react-router): allow pendingComponent: false to opt out of the default pending fallback Fixes #7773. Mirrors the existing errorComponent?: false | null | undefined | ErrorRouteComponent pattern. false survives the ?? fallback chain in Match.tsx and load-client.ts (only null/undefined are nullish), so the runtime already treats it as "render nothing" -- this widens the type to allow passing it. --- packages/react-router/src/route.tsx | 2 +- ...ue-7773-pending-component-opt-out.test.tsx | 96 +++++++++++++++++++ 2 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 packages/react-router/tests/issue-7773-pending-component-opt-out.test.tsx 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..a91c078f42 --- /dev/null +++ b/packages/react-router/tests/issue-7773-pending-component-opt-out.test.tsx @@ -0,0 +1,96 @@ +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 () => { + vi.useFakeTimers() + + 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() + + await act(async () => { + await vi.advanceTimersByTimeAsync(0) + }) + expect(screen.queryByTestId('pending')).not.toBeInTheDocument() + expect(screen.queryByTestId('content')).not.toBeInTheDocument() + + loaderGate.resolve('loaded') + + await act(async () => { + await vi.advanceTimersByTimeAsync(0) + }) + 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() + + await act(async () => { + await vi.advanceTimersByTimeAsync(0) + }) + expect(screen.getByTestId('pending')).toBeInTheDocument() + expect(screen.queryByTestId('content')).not.toBeInTheDocument() + + loaderGate.resolve('loaded') + + await act(async () => { + await vi.advanceTimersByTimeAsync(100) + }) + expect(screen.queryByTestId('pending')).not.toBeInTheDocument() + expect(screen.getByTestId('content')).toHaveTextContent('loaded') +}) From 8faea26f4f5c55b6b798a2998a6058cc2bbb2716 Mon Sep 17 00:00:00 2001 From: nishant batra Date: Mon, 17 Aug 2026 21:10:17 +0530 Subject: [PATCH 2/3] docs(router): document pendingComponent: false opt-out Updates the RouteOptions API reference for #7773 -- false now opts a route out of routerOptions.defaultPendingComponent entirely. --- docs/router/api/router/RouteOptionsType.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 From 5c44c9af90b7be50f4e151676a67ff15258ef1d2 Mon Sep 17 00:00:00 2001 From: nishant batra Date: Tue, 18 Aug 2026 21:21:34 +0530 Subject: [PATCH 3/3] test(react-router): drop non-load-bearing timer advances in pendingComponent test The opt-out case never schedules a pending timer (pendingComponent: false short-circuits before pendingMs/pendingMinMs come into play), so it doesn't need fake timers at all. The default-inheritance case only needs fake timers to cross the pendingMinMs boundary, tightened to a 99ms/+1ms check. --- ...ue-7773-pending-component-opt-out.test.tsx | 24 ++++++++----------- 1 file changed, 10 insertions(+), 14 deletions(-) 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 index a91c078f42..8d99d55849 100644 --- 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 @@ -17,8 +17,6 @@ afterEach(() => { // https://github.com/TanStack/router/issues/7773 test('pendingComponent: false suppresses the router-wide default pending fallback', async () => { - vi.useFakeTimers() - const loaderGate = createControlledPromise() const rootRoute = createRootRoute({ component: Outlet }) const optedOutRoute = createRoute({ @@ -39,18 +37,14 @@ test('pendingComponent: false suppresses the router-wide default pending fallbac }) render() - - await act(async () => { - await vi.advanceTimersByTimeAsync(0) - }) expect(screen.queryByTestId('pending')).not.toBeInTheDocument() expect(screen.queryByTestId('content')).not.toBeInTheDocument() - loaderGate.resolve('loaded') - await act(async () => { - await vi.advanceTimersByTimeAsync(0) + loaderGate.resolve('loaded') + await loaderGate }) + expect(screen.queryByTestId('pending')).not.toBeInTheDocument() expect(screen.getByTestId('content')).toHaveTextContent('loaded') }) @@ -79,17 +73,19 @@ test('a sibling route without pendingComponent: false still uses the router-wide }) render() - - await act(async () => { - await vi.advanceTimersByTimeAsync(0) - }) 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(100) + await vi.advanceTimersByTimeAsync(1) }) expect(screen.queryByTestId('pending')).not.toBeInTheDocument() expect(screen.getByTestId('content')).toHaveTextContent('loaded')