diff --git a/packages/react-router/src/router.ts b/packages/react-router/src/router.ts index cb07523e983..627d82bc058 100644 --- a/packages/react-router/src/router.ts +++ b/packages/react-router/src/router.ts @@ -1975,12 +1975,38 @@ export class Router< const existingMatch = this.getMatch(matchId)! const parentMatchId = matches[index - 1]?.id + const route = this.looseRoutesById[routeId]! + + const pendingMs = + route.options.pendingMs ?? this.options.defaultPendingMs + + const shouldPending = !!( + onReady && + !this.isServer && + !preload && + (route.options.loader || route.options.beforeLoad) && + typeof pendingMs === 'number' && + pendingMs !== Infinity && + (route.options.pendingComponent ?? + this.options.defaultPendingComponent) + ) + if ( // If we are in the middle of a load, either of these will be present // (not to be confused with `loadPromise`, which is always defined) existingMatch.beforeLoadPromise || existingMatch.loaderPromise ) { + if (shouldPending) { + setTimeout(() => { + try { + // Update the match and prematurely resolve the loadMatches promise so that + // the pending component can start rendering + triggerOnReady() + } catch {} + }, pendingMs) + } + // Wait for the beforeLoad to resolve before we continue await existingMatch.beforeLoadPromise } else { @@ -1994,23 +2020,8 @@ export class Router< beforeLoadPromise: createControlledPromise(), })) - const route = this.looseRoutesById[routeId]! const abortController = new AbortController() - const pendingMs = - route.options.pendingMs ?? this.options.defaultPendingMs - - const shouldPending = !!( - onReady && - !this.isServer && - !preload && - (route.options.loader || route.options.beforeLoad) && - typeof pendingMs === 'number' && - pendingMs !== Infinity && - (route.options.pendingComponent ?? - this.options.defaultPendingComponent) - ) - let pendingTimeout: ReturnType if (shouldPending) { diff --git a/packages/react-router/tests/link.test.tsx b/packages/react-router/tests/link.test.tsx index 9bf54202b02..e2a5e0e1831 100644 --- a/packages/react-router/tests/link.test.tsx +++ b/packages/react-router/tests/link.test.tsx @@ -28,7 +28,7 @@ import { useRouterState, useSearch, } from '../src' -import { getIntersectionObserverMock } from './utils' +import { getIntersectionObserverMock, sleep } from './utils' const ioObserveMock = vi.fn() const ioDisconnectMock = vi.fn() @@ -47,6 +47,8 @@ afterEach(() => { cleanup() }) +const WAIT_TIME = 300 + describe('Link', () => { test('when using renderHook it returns a hook with same content to prove rerender works', async () => { /** @@ -3528,6 +3530,57 @@ describe('Link', () => { expect(ioObserveMock).not.toBeCalled() }) + + test('Router.preload="intent", pendingComponent renders during unresolved route loader', async () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + return ( +
+

Index page

+ + link to posts + +
+ ) + }, + }) + + const postRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/posts', + loader: () => sleep(WAIT_TIME), + component: () =>
Posts page
, + }) + + const routeTree = rootRoute.addChildren([postRoute, indexRoute]) + const router = createRouter({ + routeTree, + defaultPreload: 'intent', + defaultPendingMs: 200, + defaultPendingComponent: () =>

Loading...

, + }) + + render() + + const linkToPosts = await screen.findByRole('link', { + name: 'link to posts', + }) + expect(linkToPosts).toBeInTheDocument() + + fireEvent.focus(linkToPosts) + fireEvent.click(linkToPosts) + + const loadingElement = await screen.findByText('Loading...') + expect(loadingElement).toBeInTheDocument() + + const postsElement = await screen.findByText('Posts page') + expect(postsElement).toBeInTheDocument() + + expect(window.location.pathname).toBe('/posts') + }) }) describe('createLink', () => {