Skip to content

Commit ffb1fb4

Browse files
fix: clear pendingTimeout when match resolves (#4579)
1 parent eb21cc7 commit ffb1fb4

File tree

2 files changed

+129
-35
lines changed

2 files changed

+129
-35
lines changed

packages/react-router/tests/loaders.test.tsx

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
1111

1212
import { z } from 'zod'
13+
import { useEffect } from 'react'
1314
import {
1415
Link,
1516
Outlet,
@@ -645,3 +646,86 @@ test('reproducer #4546', async () => {
645646
expect(loaderData).toHaveTextContent('5')
646647
}
647648
})
649+
650+
test('clears pendingTimeout when match resolves', async () => {
651+
const defaultPendingComponentOnMountMock = vi.fn()
652+
const nestedPendingComponentOnMountMock = vi.fn()
653+
const fooPendingComponentOnMountMock = vi.fn()
654+
655+
function getPendingComponent(onMount: () => void) {
656+
const PendingComponent = () => {
657+
useEffect(() => {
658+
onMount()
659+
}, [])
660+
661+
return <div>Pending...</div>
662+
}
663+
return PendingComponent
664+
}
665+
666+
const rootRoute = createRootRoute({})
667+
const indexRoute = createRoute({
668+
getParentRoute: () => rootRoute,
669+
path: '/',
670+
component: () => {
671+
return (
672+
<div>
673+
<h1>Index page</h1>
674+
<Link data-testid="link-to-foo" to="/nested/foo">
675+
link to foo
676+
</Link>
677+
</div>
678+
)
679+
},
680+
})
681+
const nestedRoute = createRoute({
682+
getParentRoute: () => rootRoute,
683+
path: '/nested',
684+
// this route does not specify pendingMinMs, so it will use the defaultPendingMs from the router
685+
// which is set to WAIT_TIME * 2
686+
// since the loader immediately resolves, the pending component must NOT be shown
687+
pendingComponent: getPendingComponent(nestedPendingComponentOnMountMock),
688+
loader: () => {
689+
return 'nested'
690+
},
691+
})
692+
const fooRoute = createRoute({
693+
getParentRoute: () => nestedRoute,
694+
path: '/foo',
695+
// this route's loader takes WAIT_TIME * 5, so it will take longer than the defaultPendingMs
696+
// however, this route specifies pendingMs as WAIT_TIME * 10,
697+
// so this route's pending component must also NOT be shown
698+
pendingComponent: getPendingComponent(fooPendingComponentOnMountMock),
699+
pendingMs: WAIT_TIME * 10,
700+
loader: async () => {
701+
await sleep(WAIT_TIME * 5)
702+
},
703+
component: () => <div>Nested Foo page</div>,
704+
})
705+
const routeTree = rootRoute.addChildren([
706+
nestedRoute.addChildren([fooRoute]),
707+
indexRoute,
708+
])
709+
const router = createRouter({
710+
routeTree,
711+
history,
712+
defaultPendingMs: WAIT_TIME * 2,
713+
defaultPendingComponent: getPendingComponent(
714+
defaultPendingComponentOnMountMock,
715+
),
716+
})
717+
718+
render(<RouterProvider router={router} />)
719+
const linkToFoo = await screen.findByTestId('link-to-foo')
720+
fireEvent.click(linkToFoo)
721+
await router.latestLoadPromise
722+
const fooElement = await screen.findByText('Nested Foo page')
723+
expect(fooElement).toBeInTheDocument()
724+
725+
expect(router.state.location.href).toBe('/nested/foo')
726+
727+
// none of the pending components should have been called
728+
expect(defaultPendingComponentOnMountMock).not.toHaveBeenCalled()
729+
expect(nestedPendingComponentOnMountMock).not.toHaveBeenCalled()
730+
expect(fooPendingComponentOnMountMock).not.toHaveBeenCalled()
731+
})

packages/router-core/src/router.ts

Lines changed: 45 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1391,7 +1391,13 @@ export class RouterCore<
13911391
if (!match) return
13921392

13931393
match.abortController.abort()
1394-
clearTimeout(match.pendingTimeout)
1394+
this.updateMatch(id, (prev) => {
1395+
clearTimeout(prev.pendingTimeout)
1396+
return {
1397+
...prev,
1398+
pendingTimeout: undefined,
1399+
}
1400+
})
13951401
}
13961402

13971403
cancelMatches = () => {
@@ -2312,21 +2318,31 @@ export class RouterCore<
23122318
)
23132319

23142320
let executeBeforeLoad = true
2315-
if (
2316-
// If we are in the middle of a load, either of these will be present
2317-
// (not to be confused with `loadPromise`, which is always defined)
2318-
existingMatch.beforeLoadPromise ||
2319-
existingMatch.loaderPromise
2320-
) {
2321-
if (shouldPending) {
2322-
setTimeout(() => {
2321+
const setupPendingTimeout = () => {
2322+
if (
2323+
shouldPending &&
2324+
this.getMatch(matchId)!.pendingTimeout === undefined
2325+
) {
2326+
const pendingTimeout = setTimeout(() => {
23232327
try {
23242328
// Update the match and prematurely resolve the loadMatches promise so that
23252329
// the pending component can start rendering
23262330
triggerOnReady()
23272331
} catch {}
23282332
}, pendingMs)
2333+
updateMatch(matchId, (prev) => ({
2334+
...prev,
2335+
pendingTimeout,
2336+
}))
23292337
}
2338+
}
2339+
if (
2340+
// If we are in the middle of a load, either of these will be present
2341+
// (not to be confused with `loadPromise`, which is always defined)
2342+
existingMatch.beforeLoadPromise ||
2343+
existingMatch.loaderPromise
2344+
) {
2345+
setupPendingTimeout()
23302346

23312347
// Wait for the beforeLoad to resolve before we continue
23322348
await existingMatch.beforeLoadPromise
@@ -2354,21 +2370,6 @@ export class RouterCore<
23542370
beforeLoadPromise: createControlledPromise<void>(),
23552371
}
23562372
})
2357-
const abortController = new AbortController()
2358-
2359-
let pendingTimeout: ReturnType<typeof setTimeout>
2360-
2361-
if (shouldPending) {
2362-
// If we might show a pending component, we need to wait for the
2363-
// pending promise to resolve before we start showing that state
2364-
pendingTimeout = setTimeout(() => {
2365-
try {
2366-
// Update the match and prematurely resolve the loadMatches promise so that
2367-
// the pending component can start rendering
2368-
triggerOnReady()
2369-
} catch {}
2370-
}, pendingMs)
2371-
}
23722373

23732374
const { paramsError, searchError } = this.getMatch(matchId)!
23742375

@@ -2380,6 +2381,10 @@ export class RouterCore<
23802381
handleSerialError(index, searchError, 'VALIDATE_SEARCH')
23812382
}
23822383

2384+
setupPendingTimeout()
2385+
2386+
const abortController = new AbortController()
2387+
23832388
const parentMatchContext =
23842389
parentMatch?.context ?? this.options.context ?? {}
23852390

@@ -2388,7 +2393,6 @@ export class RouterCore<
23882393
isFetching: 'beforeLoad',
23892394
fetchCount: prev.fetchCount + 1,
23902395
abortController,
2391-
pendingTimeout,
23922396
context: {
23932397
...parentMatchContext,
23942398
...prev.__routeContext,
@@ -2758,16 +2762,22 @@ export class RouterCore<
27582762
loadPromise?.resolve()
27592763
}
27602764

2761-
updateMatch(matchId, (prev) => ({
2762-
...prev,
2763-
isFetching: loaderIsRunningAsync ? prev.isFetching : false,
2764-
loaderPromise: loaderIsRunningAsync
2765-
? prev.loaderPromise
2766-
: undefined,
2767-
invalid: false,
2768-
_dehydrated: undefined,
2769-
_forcePending: undefined,
2770-
}))
2765+
updateMatch(matchId, (prev) => {
2766+
clearTimeout(prev.pendingTimeout)
2767+
return {
2768+
...prev,
2769+
isFetching: loaderIsRunningAsync
2770+
? prev.isFetching
2771+
: false,
2772+
loaderPromise: loaderIsRunningAsync
2773+
? prev.loaderPromise
2774+
: undefined,
2775+
invalid: false,
2776+
pendingTimeout: undefined,
2777+
_dehydrated: undefined,
2778+
_forcePending: undefined,
2779+
}
2780+
})
27712781
return this.getMatch(matchId)!
27722782
})(),
27732783
)

0 commit comments

Comments
 (0)