Skip to content
Merged
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
7 changes: 7 additions & 0 deletions .changeset/go-run-king.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@tanstack/react-router': patch
'@tanstack/solid-router': patch
'@tanstack/vue-router': patch
---

Fix redirected pending route transitions so lazy target routes can finish loading without stale redirected matches causing render errors.
42 changes: 28 additions & 14 deletions packages/react-router/src/Match.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,22 @@ export const MatchInner = React.memo(function MatchInnerImpl({
}): any {
const router = useRouter()

const getMatchPromise = (
match: {
id: string
_nonReactive: {
displayPendingPromise?: Promise<void>
minPendingPromise?: Promise<void>
loadPromise?: Promise<void>
}
},
key: 'displayPendingPromise' | 'minPendingPromise' | 'loadPromise',
) => {
return (
router.getMatch(match.id)?._nonReactive[key] ?? match._nonReactive[key]
)
}

if (isServer ?? router.isServer) {
const match = router.stores.activeMatchStoresById.get(matchId)?.state
if (!match) {
Expand Down Expand Up @@ -287,15 +303,15 @@ export const MatchInner = React.memo(function MatchInnerImpl({
const out = Comp ? <Comp key={key} /> : <Outlet />

if (match._displayPending) {
throw router.getMatch(match.id)?._nonReactive.displayPendingPromise
throw getMatchPromise(match, 'displayPendingPromise')
}

if (match._forcePending) {
throw router.getMatch(match.id)?._nonReactive.minPendingPromise
throw getMatchPromise(match, 'minPendingPromise')
}

if (match.status === 'pending') {
throw router.getMatch(match.id)?._nonReactive.loadPromise
throw getMatchPromise(match, 'loadPromise')
}

if (match.status === 'notFound') {
Expand All @@ -317,7 +333,7 @@ export const MatchInner = React.memo(function MatchInnerImpl({

invariant()
}
throw router.getMatch(match.id)?._nonReactive.loadPromise
throw getMatchPromise(match, 'loadPromise')
}

if (match.status === 'error') {
Expand Down Expand Up @@ -384,11 +400,11 @@ export const MatchInner = React.memo(function MatchInnerImpl({
}, [key, route.options.component, router.options.defaultComponent])

if (match._displayPending) {
throw router.getMatch(match.id)?._nonReactive.displayPendingPromise
throw getMatchPromise(match, 'displayPendingPromise')
}

if (match._forcePending) {
throw router.getMatch(match.id)?._nonReactive.minPendingPromise
throw getMatchPromise(match, 'minPendingPromise')
}

// see also hydrate() in packages/router-core/src/ssr/ssr-client.ts
Expand All @@ -413,7 +429,7 @@ export const MatchInner = React.memo(function MatchInnerImpl({
}
}
}
throw router.getMatch(match.id)?._nonReactive.loadPromise
throw getMatchPromise(match, 'loadPromise')
}

if (match.status === 'notFound') {
Expand All @@ -428,8 +444,10 @@ export const MatchInner = React.memo(function MatchInnerImpl({
}

if (match.status === 'redirected') {
// Redirects should be handled by the router transition. If we happen to
// encounter a redirect here, it's a bug. Let's warn, but render nothing.
// A match can be observed as redirected during an in-flight transition,
// especially when pending UI is already rendering. Suspend on the match's
// load promise so React can abandon this stale render and continue the
// redirect transition.
if (!isRedirect(match.error)) {
if (process.env.NODE_ENV !== 'production') {
throw new Error('Invariant failed: Expected a redirect error')
Expand All @@ -438,11 +456,7 @@ export const MatchInner = React.memo(function MatchInnerImpl({
invariant()
}

// warning(
// false,
// 'Tried to render a redirected route match! This is a weird circumstance, please file an issue!',
// )
throw router.getMatch(match.id)?._nonReactive.loadPromise
throw getMatchPromise(match, 'loadPromise')
}

if (match.status === 'error') {
Expand Down
2 changes: 1 addition & 1 deletion packages/react-router/tests/lazy/normal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { createLazyFileRoute, createLazyRoute } from '../../src'

export function Route(id: string) {
return createLazyRoute(id)({
component: () => <h1>I'm a normal route</h1>,
component: () => <h1 data-testid="lazy-route-page">I'm a normal route</h1>,
})
}

Expand Down
48 changes: 48 additions & 0 deletions packages/react-router/tests/redirect.test.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import * as React from 'react'
import {
cleanup,
configure,
Expand All @@ -10,6 +11,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'

import {
Link,
Outlet,
RouterProvider,
createBrowserHistory,
createMemoryHistory,
Expand Down Expand Up @@ -111,6 +113,52 @@ describe('redirect', () => {
expect(nestedFooLoaderMock).toHaveBeenCalled()
})

test('when root `beforeLoad` redirects while root pendingComponent is showing and the target route is lazy', async () => {
let hasRedirected = false
const consoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => {})

const rootRoute = createRootRoute({
component: () => <Outlet />,
pendingMs: 0,
pendingComponent: () => <div data-testid="pending">loading</div>,
beforeLoad: async () => {
await sleep(WAIT_TIME)
if (!hasRedirected) {
hasRedirected = true
throw redirect({ to: '/posts' })
}
},
})

const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
component: () => <div data-testid="index-page">Index page</div>,
})

const postsRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/posts',
}).lazy(() => import('./lazy/normal').then((d) => d.Route('/posts')))

const router = createRouter({
routeTree: rootRoute.addChildren([indexRoute, postsRoute]),
history,
})

render(<RouterProvider router={router} />)

// The lazy target route adds the async boundary that exposes the stale
// redirected-match render path this regression is guarding.
expect(await screen.findByTestId('lazy-route-page')).toBeInTheDocument()
expect(screen.queryByTestId('pending')).not.toBeInTheDocument()
expect(router.state.location.href).toBe('/posts')
expect(router.state.status).toBe('idle')
expect(consoleError).not.toHaveBeenCalled()
})

test('when `redirect` is thrown in `loader`', async () => {
const nestedLoaderMock = vi.fn()
const nestedFooLoaderMock = vi.fn()
Expand Down
22 changes: 20 additions & 2 deletions packages/solid-router/src/Match.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,22 @@ export const MatchInner = (): any => {
return <Outlet />
}

const getLoadPromise = (
matchId: string,
fallbackMatch:
| {
_nonReactive: {
loadPromise?: Promise<void>
}
}
| undefined,
) => {
return (
router.getMatch(matchId)?._nonReactive.loadPromise ??
fallbackMatch?._nonReactive.loadPromise
)
}

const keyedOut = () => (
<Solid.Show when={componentKey()} keyed>
{(_key) => out()}
Expand Down Expand Up @@ -375,6 +391,9 @@ export const MatchInner = (): any => {
</Solid.Match>
<Solid.Match when={currentMatch().status === 'redirected'}>
{(_) => {
const matchId = currentMatch().id
const routerMatch = router.getMatch(matchId)

if (!isRedirect(currentMatch().error)) {
if (process.env.NODE_ENV !== 'production') {
throw new Error(
Expand All @@ -387,8 +406,7 @@ export const MatchInner = (): any => {

const [loaderResult] = Solid.createResource(async () => {
await new Promise((r) => setTimeout(r, 0))
return router.getMatch(currentMatch().id)?._nonReactive
.loadPromise
return getLoadPromise(matchId, routerMatch)
})

return <>{loaderResult()}</>
Expand Down
2 changes: 1 addition & 1 deletion packages/solid-router/tests/lazy/normal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { createLazyFileRoute, createLazyRoute } from '../../src'

export function Route(id: string) {
return createLazyRoute(id)({
component: () => <h1>I'm a normal route</h1>,
component: () => <h1 data-testid="lazy-route-page">I'm a normal route</h1>,
})
}

Expand Down
47 changes: 47 additions & 0 deletions packages/solid-router/tests/redirect.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'

import {
Link,
Outlet,
RouterProvider,
createBrowserHistory,
createMemoryHistory,
Expand Down Expand Up @@ -104,6 +105,52 @@ describe('redirect', () => {
expect(nestedFooLoaderMock).toHaveBeenCalled()
})

test('when root `beforeLoad` redirects while root pendingComponent is showing and the target route is lazy', async () => {
let hasRedirected = false
const consoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => {})

const rootRoute = createRootRoute({
component: () => <Outlet />,
pendingMs: 0,
pendingComponent: () => <div data-testid="pending">loading</div>,
beforeLoad: async () => {
await sleep(WAIT_TIME)
if (!hasRedirected) {
hasRedirected = true
throw redirect({ to: '/posts' })
}
},
})

const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
component: () => <div data-testid="index-page">Index page</div>,
})

const postsRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/posts',
}).lazy(() => import('./lazy/normal').then((d) => d.Route('/posts')))

const router = createRouter({
routeTree: rootRoute.addChildren([indexRoute, postsRoute]),
history,
})

render(() => <RouterProvider router={router} />)

// The lazy target route adds the async boundary that exposes the stale
// redirected-match render path this regression is guarding.
expect(await screen.findByTestId('lazy-route-page')).toBeInTheDocument()
expect(screen.queryByTestId('pending')).not.toBeInTheDocument()
expect(router.state.location.href).toBe('/posts')
expect(router.state.status).toBe('idle')
expect(consoleError).not.toHaveBeenCalled()
})

test('when `redirect` is thrown in `loader`', async () => {
const nestedLoaderMock = vi.fn()
const nestedFooLoaderMock = vi.fn()
Expand Down
19 changes: 18 additions & 1 deletion packages/vue-router/src/Match.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,7 @@ export const MatchInner = Vue.defineComponent({
ssr: match.ssr,
_forcePending: match._forcePending,
_displayPending: match._displayPending,
_nonReactive: match._nonReactive,
},
remountKey,
}
Expand All @@ -350,6 +351,22 @@ export const MatchInner = Vue.defineComponent({
const match = Vue.computed(() => combinedState.value?.match)
const remountKey = Vue.computed(() => combinedState.value?.remountKey)

const getMatchPromise = (
match: {
id: string
_nonReactive: {
displayPendingPromise?: Promise<void>
minPendingPromise?: Promise<void>
loadPromise?: Promise<void>
}
},
key: 'displayPendingPromise' | 'minPendingPromise' | 'loadPromise',
) => {
return (
router.getMatch(match.id)?._nonReactive[key] ?? match._nonReactive[key]
)
}

return (): VNode | null => {
// If match doesn't exist, return null (component is being unmounted or not ready)
if (!combinedState.value || !match.value || !route.value) return null
Expand Down Expand Up @@ -390,7 +407,7 @@ export const MatchInner = Vue.defineComponent({

invariant()
}
throw router.getMatch(match.value.id)?._nonReactive.loadPromise
throw getMatchPromise(match.value, 'loadPromise')
}

if (match.value.status === 'error') {
Expand Down
2 changes: 1 addition & 1 deletion packages/vue-router/tests/lazy/normal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { createLazyFileRoute, createLazyRoute } from '../../src'

export function Route(id: string) {
return createLazyRoute(id)({
component: () => <h1>I'm a normal route</h1>,
component: () => <h1 data-testid="lazy-route-page">I'm a normal route</h1>,
})
}

Expand Down
Loading
Loading