Skip to content

Commit c857362

Browse files
fix(router-core): trigger abort signal when navigating away after pendingMs (#5777)
* fix(router): trigger loader abort signal when navigating away after pendingMs * ci: apply automated fixes --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent 4334f64 commit c857362

File tree

4 files changed

+195
-1
lines changed

4 files changed

+195
-1
lines changed

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

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -729,3 +729,71 @@ test('clears pendingTimeout when match resolves', async () => {
729729
expect(nestedPendingComponentOnMountMock).not.toHaveBeenCalled()
730730
expect(fooPendingComponentOnMountMock).not.toHaveBeenCalled()
731731
})
732+
733+
test('cancelMatches after pending timeout', async () => {
734+
function getPendingComponent(onMount: () => void) {
735+
const PendingComponent = () => {
736+
useEffect(() => {
737+
onMount()
738+
}, [])
739+
740+
return <div>Pending...</div>
741+
}
742+
return PendingComponent
743+
}
744+
const onAbortMock = vi.fn()
745+
const fooPendingComponentOnMountMock = vi.fn()
746+
const rootRoute = createRootRoute({
747+
component: () => (
748+
<div>
749+
<h1>Index page</h1>
750+
<Link data-testid="link-to-foo" to="/foo">
751+
link to foo
752+
</Link>
753+
<Link data-testid="link-to-bar" to="/bar">
754+
link to bar
755+
</Link>
756+
<Outlet />
757+
</div>
758+
),
759+
})
760+
const fooRoute = createRoute({
761+
getParentRoute: () => rootRoute,
762+
path: '/foo',
763+
pendingMs: WAIT_TIME * 20,
764+
loader: async ({ abortController }) => {
765+
await new Promise<void>((resolve) => {
766+
const timer = setTimeout(() => {
767+
resolve()
768+
}, WAIT_TIME * 40)
769+
abortController.signal.addEventListener('abort', () => {
770+
onAbortMock()
771+
clearTimeout(timer)
772+
resolve()
773+
})
774+
})
775+
},
776+
pendingComponent: getPendingComponent(fooPendingComponentOnMountMock),
777+
component: () => <div>Foo page</div>,
778+
})
779+
const barRoute = createRoute({
780+
getParentRoute: () => rootRoute,
781+
path: '/bar',
782+
component: () => <div>Bar page</div>,
783+
})
784+
const routeTree = rootRoute.addChildren([fooRoute, barRoute])
785+
const router = createRouter({ routeTree, history })
786+
render(<RouterProvider router={router} />)
787+
await act(() => router.latestLoadPromise)
788+
const fooLink = await screen.findByTestId('link-to-foo')
789+
fireEvent.click(fooLink)
790+
await sleep(WAIT_TIME * 30)
791+
const pendingElement = await screen.findByText('Pending...')
792+
expect(pendingElement).toBeInTheDocument()
793+
const barLink = await screen.findByTestId('link-to-bar')
794+
fireEvent.click(barLink)
795+
const barElement = await screen.findByText('Bar page')
796+
expect(barElement).toBeInTheDocument()
797+
expect(fooPendingComponentOnMountMock).toHaveBeenCalled()
798+
expect(onAbortMock).toHaveBeenCalled()
799+
})

packages/router-core/src/router.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1563,7 +1563,18 @@ export class RouterCore<
15631563
}
15641564

15651565
cancelMatches = () => {
1566-
this.state.pendingMatches?.forEach((match) => {
1566+
const currentPendingMatches = this.state.matches.filter(
1567+
(match) => match.status === 'pending',
1568+
)
1569+
const currentLoadingMatches = this.state.matches.filter(
1570+
(match) => match.isFetching === 'loader',
1571+
)
1572+
const matchesToCancelArray = new Set([
1573+
...(this.state.pendingMatches ?? []),
1574+
...currentPendingMatches,
1575+
...currentLoadingMatches,
1576+
])
1577+
matchesToCancelArray.forEach((match) => {
15671578
this.cancelMatch(match.id)
15681579
})
15691580
}

packages/router-core/tests/load.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -460,6 +460,55 @@ test('exec on stay (beforeLoad & loader)', async () => {
460460
expect(layoutBeforeLoadResolved).toBe(true)
461461
})
462462

463+
test('cancelMatches after pending timeout', async () => {
464+
const WAIT_TIME = 5
465+
const onAbortMock = vi.fn()
466+
const rootRoute = new BaseRootRoute({})
467+
const fooRoute = new BaseRoute({
468+
getParentRoute: () => rootRoute,
469+
path: '/foo',
470+
pendingMs: WAIT_TIME * 20,
471+
loader: async ({ abortController }) => {
472+
await new Promise<void>((resolve) => {
473+
const timer = setTimeout(() => {
474+
resolve()
475+
}, WAIT_TIME * 40)
476+
abortController.signal.addEventListener('abort', () => {
477+
onAbortMock()
478+
clearTimeout(timer)
479+
resolve()
480+
})
481+
})
482+
},
483+
pendingComponent: {},
484+
})
485+
const barRoute = new BaseRoute({
486+
getParentRoute: () => rootRoute,
487+
path: '/bar',
488+
})
489+
const routeTree = rootRoute.addChildren([fooRoute, barRoute])
490+
const router = new RouterCore({ routeTree, history: createMemoryHistory() })
491+
492+
await router.load()
493+
router.navigate({ to: '/foo' })
494+
await sleep(WAIT_TIME * 30)
495+
496+
// At this point, pending timeout should have triggered
497+
const fooMatch = router.getMatch('/foo')
498+
expect(fooMatch).toBeDefined()
499+
500+
// Navigate away, which should cancel the pending match
501+
await router.navigate({ to: '/bar' })
502+
await router.latestLoadPromise
503+
504+
expect(router.state.location.pathname).toBe('/bar')
505+
506+
// Verify that abort was called and pending timeout was cleared
507+
expect(onAbortMock).toHaveBeenCalled()
508+
const cancelledFooMatch = router.getMatch('/foo')
509+
expect(cancelledFooMatch?._nonReactive.pendingTimeout).toBeUndefined()
510+
})
511+
463512
function sleep(ms: number) {
464513
return new Promise<void>((resolve) => setTimeout(resolve, ms))
465514
}

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

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -713,3 +713,69 @@ test('clears pendingTimeout when match resolves', async () => {
713713
expect(nestedPendingComponentOnMountMock).not.toHaveBeenCalled()
714714
expect(fooPendingComponentOnMountMock).not.toHaveBeenCalled()
715715
})
716+
717+
test('cancelMatches after pending timeout', async () => {
718+
function getPendingComponent(onMount: () => void) {
719+
const PendingComponent = () => {
720+
onMount()
721+
return <div>Pending...</div>
722+
}
723+
return PendingComponent
724+
}
725+
const onAbortMock = vi.fn()
726+
const fooPendingComponentOnMountMock = vi.fn()
727+
const history = createMemoryHistory({ initialEntries: ['/'] })
728+
const rootRoute = createRootRoute({
729+
component: () => (
730+
<div>
731+
<h1>Index page</h1>
732+
<Link data-testid="link-to-foo" to="/foo">
733+
link to foo
734+
</Link>
735+
<Link data-testid="link-to-bar" to="/bar">
736+
link to bar
737+
</Link>
738+
<Outlet />
739+
</div>
740+
),
741+
})
742+
const fooRoute = createRoute({
743+
getParentRoute: () => rootRoute,
744+
path: '/foo',
745+
pendingMs: WAIT_TIME * 20,
746+
loader: async ({ abortController }) => {
747+
await new Promise<void>((resolve) => {
748+
const timer = setTimeout(() => {
749+
resolve()
750+
}, WAIT_TIME * 40)
751+
abortController.signal.addEventListener('abort', () => {
752+
onAbortMock()
753+
clearTimeout(timer)
754+
resolve()
755+
})
756+
})
757+
},
758+
pendingComponent: getPendingComponent(fooPendingComponentOnMountMock),
759+
component: () => <div>Foo page</div>,
760+
})
761+
const barRoute = createRoute({
762+
getParentRoute: () => rootRoute,
763+
path: '/bar',
764+
component: () => <div>Bar page</div>,
765+
})
766+
const routeTree = rootRoute.addChildren([fooRoute, barRoute])
767+
const router = createRouter({ routeTree, history })
768+
render(() => <RouterProvider router={router} />)
769+
await router.latestLoadPromise
770+
const fooLink = await screen.findByTestId('link-to-foo')
771+
fireEvent.click(fooLink)
772+
await sleep(WAIT_TIME * 30)
773+
const pendingElement = await screen.findByText('Pending...')
774+
expect(pendingElement).toBeInTheDocument()
775+
const barLink = await screen.findByTestId('link-to-bar')
776+
fireEvent.click(barLink)
777+
const barElement = await screen.findByText('Bar page')
778+
expect(barElement).toBeInTheDocument()
779+
expect(fooPendingComponentOnMountMock).toHaveBeenCalled()
780+
expect(onAbortMock).toHaveBeenCalled()
781+
})

0 commit comments

Comments
 (0)