Skip to content

Commit 05348a7

Browse files
fix: correctly handle pending vs. cached matches (#4550)
1 parent 46448a4 commit 05348a7

File tree

2 files changed

+263
-21
lines changed

2 files changed

+263
-21
lines changed

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

Lines changed: 237 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {
2+
act,
23
cleanup,
34
configure,
45
fireEvent,
@@ -17,6 +18,7 @@ import {
1718
createRootRoute,
1819
createRoute,
1920
createRouter,
21+
useRouter,
2022
} from '../src'
2123

2224
import { sleep } from './utils'
@@ -380,7 +382,7 @@ test('reproducer #4245', async () => {
380382

381383
render(<RouterProvider router={router} />)
382384
// We wait for the initial loader to complete
383-
await router.load()
385+
await act(() => router.load())
384386
const fooLink = await screen.findByTestId('link-to-foo')
385387

386388
expect(fooLink).toBeInTheDocument()
@@ -389,27 +391,257 @@ test('reproducer #4245', async () => {
389391
fireEvent.click(fooLink)
390392

391393
// We immediately see the content of the foo route
392-
const indexLink = await screen.findByTestId('link-to-index')
394+
const indexLink = await screen.findByTestId('link-to-index', undefined, {
395+
timeout: WAIT_TIME,
396+
})
393397
expect(indexLink).toBeInTheDocument()
394398

395399
// We navigate to the index route
396400
fireEvent.click(indexLink)
397401

398402
// We immediately see the content of the index route because the stale data is still available
399-
const fooLink2 = await screen.findByTestId('link-to-foo')
403+
const fooLink2 = await screen.findByTestId('link-to-foo', undefined, {
404+
timeout: WAIT_TIME,
405+
})
400406
expect(fooLink2).toBeInTheDocument()
401407

402408
// We navigate to the foo route again
403409
fireEvent.click(fooLink2)
404410

405411
// We immediately see the content of the foo route
406-
const indexLink2 = await screen.findByTestId('link-to-index')
412+
const indexLink2 = await screen.findByTestId('link-to-index', undefined, {
413+
timeout: WAIT_TIME,
414+
})
407415
expect(indexLink2).toBeInTheDocument()
408416

409417
// We navigate to the index route again
410418
fireEvent.click(indexLink2)
411419

412420
// We now should see the content of the index route immediately because the stale data is still available
413-
const fooLink3 = await screen.findByTestId('link-to-foo')
421+
const fooLink3 = await screen.findByTestId('link-to-foo', undefined, {
422+
timeout: WAIT_TIME,
423+
})
414424
expect(fooLink3).toBeInTheDocument()
415425
})
426+
427+
test('reproducer #4546', async () => {
428+
const rootRoute = createRootRoute({
429+
component: () => {
430+
return (
431+
<>
432+
<div className="p-2 flex gap-2 text-lg">
433+
<Link
434+
data-testid="link-to-index"
435+
to="/"
436+
activeProps={{
437+
className: 'font-bold',
438+
}}
439+
activeOptions={{ exact: true }}
440+
>
441+
Home
442+
</Link>{' '}
443+
<Link
444+
data-testid="link-to-id"
445+
to="$id"
446+
params={{
447+
id: '1',
448+
}}
449+
activeProps={{
450+
className: 'font-bold',
451+
}}
452+
>
453+
/1
454+
</Link>
455+
</div>
456+
<hr />
457+
<Outlet />
458+
</>
459+
)
460+
},
461+
})
462+
463+
let counter = 0
464+
const appRoute = createRoute({
465+
getParentRoute: () => rootRoute,
466+
id: '_app',
467+
beforeLoad: () => {
468+
counter += 1
469+
return {
470+
counter,
471+
}
472+
},
473+
component: () => {
474+
return (
475+
<div>
476+
<Header />
477+
<Outlet />
478+
</div>
479+
)
480+
},
481+
})
482+
483+
function Header() {
484+
const router = useRouter()
485+
const { counter } = appRoute.useRouteContext()
486+
487+
return (
488+
<div>
489+
Header Counter: <p data-testid="header-counter">{counter}</p>
490+
<button
491+
onClick={() => {
492+
router.invalidate()
493+
}}
494+
data-testid="invalidate-router"
495+
style={{
496+
border: '1px solid blue',
497+
}}
498+
>
499+
Invalidate router
500+
</button>
501+
</div>
502+
)
503+
}
504+
505+
const indexRoute = createRoute({
506+
getParentRoute: () => appRoute,
507+
path: '/',
508+
loader: ({ context }) => {
509+
return {
510+
counter: context.counter,
511+
}
512+
},
513+
514+
component: () => {
515+
const data = indexRoute.useLoaderData()
516+
const ctx = indexRoute.useRouteContext()
517+
518+
return (
519+
<div
520+
style={{
521+
display: 'flex',
522+
flexDirection: 'column',
523+
}}
524+
>
525+
<div>Index route</div>
526+
<div>
527+
route context:{' '}
528+
<p data-testid="index-route-context">{ctx.counter}</p>
529+
</div>
530+
<div>
531+
loader data: <p data-testid="index-loader-data">{data.counter}</p>
532+
</div>
533+
</div>
534+
)
535+
},
536+
})
537+
const idRoute = createRoute({
538+
getParentRoute: () => appRoute,
539+
path: '$id',
540+
loader: ({ context }) => {
541+
return {
542+
counter: context.counter,
543+
}
544+
},
545+
546+
component: () => {
547+
const data = idRoute.useLoaderData()
548+
const ctx = idRoute.useRouteContext()
549+
550+
return (
551+
<div
552+
style={{
553+
display: 'flex',
554+
flexDirection: 'column',
555+
}}
556+
>
557+
<div>$id route</div>
558+
<div>
559+
route context: <p data-testid="id-route-context">{ctx.counter}</p>
560+
</div>
561+
<div>
562+
loader data: <p data-testid="id-loader-data">{data.counter}</p>
563+
</div>
564+
</div>
565+
)
566+
},
567+
})
568+
569+
const routeTree = rootRoute.addChildren([
570+
appRoute.addChildren([indexRoute, idRoute]),
571+
])
572+
const router = createRouter({ routeTree })
573+
574+
render(<RouterProvider router={router} />)
575+
576+
const indexLink = await screen.findByTestId('link-to-index')
577+
expect(indexLink).toBeInTheDocument()
578+
579+
const idLink = await screen.findByTestId('link-to-id')
580+
expect(idLink).toBeInTheDocument()
581+
582+
const invalidateRouterButton = await screen.findByTestId('invalidate-router')
583+
expect(invalidateRouterButton).toBeInTheDocument()
584+
585+
{
586+
const headerCounter = await screen.findByTestId('header-counter')
587+
expect(headerCounter).toHaveTextContent('1')
588+
589+
const routeContext = await screen.findByTestId('index-route-context')
590+
expect(routeContext).toHaveTextContent('1')
591+
592+
const loaderData = await screen.findByTestId('index-loader-data')
593+
expect(loaderData).toHaveTextContent('1')
594+
}
595+
596+
fireEvent.click(idLink)
597+
598+
{
599+
const headerCounter = await screen.findByTestId('header-counter')
600+
expect(headerCounter).toHaveTextContent('2')
601+
602+
const routeContext = await screen.findByTestId('id-route-context')
603+
expect(routeContext).toHaveTextContent('2')
604+
605+
const loaderData = await screen.findByTestId('id-loader-data')
606+
expect(loaderData).toHaveTextContent('2')
607+
}
608+
609+
fireEvent.click(indexLink)
610+
611+
{
612+
const headerCounter = await screen.findByTestId('header-counter')
613+
expect(headerCounter).toHaveTextContent('3')
614+
615+
const routeContext = await screen.findByTestId('index-route-context')
616+
expect(routeContext).toHaveTextContent('3')
617+
618+
const loaderData = await screen.findByTestId('index-loader-data')
619+
expect(loaderData).toHaveTextContent('3')
620+
}
621+
622+
fireEvent.click(invalidateRouterButton)
623+
624+
{
625+
const headerCounter = await screen.findByTestId('header-counter')
626+
expect(headerCounter).toHaveTextContent('4')
627+
628+
const routeContext = await screen.findByTestId('index-route-context')
629+
expect(routeContext).toHaveTextContent('4')
630+
631+
const loaderData = await screen.findByTestId('index-loader-data')
632+
expect(loaderData).toHaveTextContent('4')
633+
}
634+
635+
fireEvent.click(idLink)
636+
637+
{
638+
const headerCounter = await screen.findByTestId('header-counter')
639+
expect(headerCounter).toHaveTextContent('5')
640+
641+
const routeContext = await screen.findByTestId('id-route-context')
642+
expect(routeContext).toHaveTextContent('5')
643+
644+
const loaderData = await screen.findByTestId('id-loader-data')
645+
expect(loaderData).toHaveTextContent('5')
646+
}
647+
})

packages/router-core/src/router.ts

Lines changed: 26 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1791,16 +1791,9 @@ export class RouterCore<
17911791
location: this.latestLocation,
17921792
pendingMatches,
17931793
// If a cached moved to pendingMatches, remove it from cachedMatches
1794-
cachedMatches: s.cachedMatches.filter((cachedMatch) => {
1795-
const pendingMatch = pendingMatches.find((e) => e.id === cachedMatch.id)
1796-
1797-
if (!pendingMatch) return true
1798-
1799-
return (
1800-
cachedMatch.status === 'success' &&
1801-
(cachedMatch.isFetching || cachedMatch.loaderData !== undefined)
1802-
)
1803-
}),
1794+
cachedMatches: s.cachedMatches.filter(
1795+
(d) => !pendingMatches.find((e) => e.id === d.id),
1796+
),
18041797
}))
18051798
}
18061799

@@ -2208,7 +2201,15 @@ export class RouterCore<
22082201

22092202
// Wait for the beforeLoad to resolve before we continue
22102203
await existingMatch.beforeLoadPromise
2211-
executeBeforeLoad = this.getMatch(matchId)!.status === 'error'
2204+
const match = this.getMatch(matchId)!
2205+
if (match.status === 'error') {
2206+
executeBeforeLoad = true
2207+
} else if (
2208+
match.preload &&
2209+
(match.status === 'redirected' || match.status === 'notFound')
2210+
) {
2211+
handleRedirectAndNotFound(match, match.error)
2212+
}
22122213
}
22132214
if (executeBeforeLoad) {
22142215
// If we are not in the middle of a load OR the previous load failed, start it
@@ -2337,14 +2338,23 @@ export class RouterCore<
23372338
validResolvedMatches.forEach(({ id: matchId, routeId }, index) => {
23382339
matchPromises.push(
23392340
(async () => {
2340-
const { loaderPromise: prevLoaderPromise } =
2341-
this.getMatch(matchId)!
2342-
23432341
let loaderShouldRunAsync = false
23442342
let loaderIsRunningAsync = false
23452343

2346-
if (prevLoaderPromise) {
2347-
await prevLoaderPromise
2344+
const prevMatch = this.getMatch(matchId)!
2345+
// there is a loaderPromise, so we are in the middle of a load
2346+
if (prevMatch.loaderPromise) {
2347+
// do not block if we already have stale data we can show
2348+
// but only if the ongoing load is not a preload since error handling is different for preloads
2349+
// and we don't want to swallow errors
2350+
if (
2351+
prevMatch.status === 'success' &&
2352+
!sync &&
2353+
!prevMatch.preload
2354+
) {
2355+
return this.getMatch(matchId)!
2356+
}
2357+
await prevMatch.loaderPromise
23482358
const match = this.getMatch(matchId)!
23492359
if (match.error) {
23502360
handleRedirectAndNotFound(match, match.error)

0 commit comments

Comments
 (0)