Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(react-router): ensure fetch abort works correctly even with pendingComponent #3181

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions packages/react-router/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1959,8 +1959,11 @@ export class Router<
try {
const next = this.latestLocation
const prevLocation = this.state.resolvedLocation
const actualBrowserLocation = this.state.location
const hrefChanged = prevLocation.href !== next.href
const pathChanged = prevLocation.pathname !== next.pathname
const browserLocationChanged =
actualBrowserLocation.href !== next.href

// Cancel any pending matches
this.cancelMatches()
Expand Down Expand Up @@ -2014,6 +2017,9 @@ export class Router<
// eslint-disable-next-line @typescript-eslint/require-await
onReady: async () => {
// eslint-disable-next-line @typescript-eslint/require-await
const hasPendingFetches = this.state.pendingMatches?.some(
(match) => !!match.isFetching,
)
this.startViewTransition(async () => {
// this.viewTransitionPromise = createControlledPromise<true>()

Expand Down Expand Up @@ -2044,7 +2050,10 @@ export class Router<
isLoading: false,
loadedAt: Date.now(),
matches: newMatches,
pendingMatches: undefined,
pendingMatches:
hasPendingFetches && browserLocationChanged
? s.pendingMatches
: undefined,
cachedMatches: [
...s.cachedMatches,
...exitingMatches.filter((d) => d.status !== 'error'),
Expand Down Expand Up @@ -2214,8 +2223,14 @@ export class Router<
let rendered = false

const triggerOnReady = async () => {
const hasPendingFetches = this.state.pendingMatches?.some(
(match) => !!match.isFetching,
)

if (!rendered) {
rendered = true
if (!hasPendingFetches) {
rendered = true
}
await onReady?.()
}
}
Expand Down
83 changes: 83 additions & 0 deletions packages/react-router/tests/router.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1250,3 +1250,86 @@ describe('history: History gives correct notifcations and state', () => {
unsub()
})
})

describe('router loader abort behavior', () => {
it('should abort previous route loader even with pendingComponent present', async () => {
const abortSpy = vi.fn()
let resolveLoader: (value: unknown) => void

const rootRoute = createRootRoute({
component: () => {
const navigate = useNavigate()
return (
<div>
<nav>
<button onClick={() => navigate({ to: '/a' })}>Go to A</button>
<button onClick={() => navigate({ to: '/b' })}>Go to B</button>
</nav>
<Outlet />
</div>
)
},
})

const routeA = createRoute({
getParentRoute: () => rootRoute,
path: '/a',
component: () => <h1>Route A</h1>,
pendingComponent: () => <h1>Loading Route A...</h1>,
gcTime: 0,
staleTime: 0,
pendingMs: 0,
pendingMinMs: 1000,
})

const routeB = createRoute({
getParentRoute: () => rootRoute,
path: '/b',
component: () => <h1>Route B</h1>,
pendingComponent: () => <h1>Loading Route B...</h1>,
gcTime: 0,
staleTime: 0,
pendingMs: 0,
pendingMinMs: 1000,
loader: async ({ abortController }) => {
abortController.signal.addEventListener('abort', () => {
abortSpy()
console.log('Loader aborted!')
})

await new Promise((resolve) => {
resolveLoader = resolve
})

return null
},
})

const router = createRouter({
routeTree: rootRoute.addChildren([routeA, routeB]),
history: createMemoryHistory({
initialEntries: ['/a'],
}),
})

render(<RouterProvider router={router} />)
await act(() => router.load())

expect(screen.getByText('Route A')).toBeInTheDocument()

await act(async () => {
fireEvent.click(screen.getByText('Go to B'))
})

await screen.findByText('Loading Route B...')

await act(async () => {
fireEvent.click(screen.getByText('Go to A'))
await new Promise((resolve) => setTimeout(resolve, 0))
})

expect(abortSpy).toHaveBeenCalled()

resolveLoader!(null)
})
})