Skip to content
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
6 changes: 6 additions & 0 deletions .changeset/quiet-matches-hydrate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@tanstack/react-router': patch
---

Prevent match store updates from invalidating client-only Suspense boundaries
before they hydrate.
222 changes: 121 additions & 101 deletions packages/react-router/src/Match.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,141 +66,161 @@ export const Match = React.memo(function MatchImpl({
routeId: string
}) {
const router = useRouter()

if (isServer ?? router.isServer) {
const match = router.stores.byRoute.get(routeId)!.get()!
return <MatchView router={router} match={match} />
}

const matchStore = router.stores.getMatchStore(routeId)
// eslint-disable-next-line react-hooks/rules-of-hooks
const match = useStore(matchStore, (value) => value)
return <MatchView router={router} match={match!} />
const match = router.stores.byRoute.get(routeId)!.get()!
const route: AnyRoute = router.routesById[routeId]
const ShellComponent = route.isRoot
? ((route.options as RootRouteOptions).shellComponent ?? SafeFragment)
: SafeFragment
// Keep the reactive match subtree out of dehydrated client-only boundaries.
const inner = <MatchInner routeId={routeId} />
return (
<ShellComponent>
<matchContext.Provider value={routeId}>
{match.ssr === false || match.ssr === 'data-only' ? (
<ClientOnly
fallback={renderMatchBoundaries(
router,
route,
match,
renderPending(router, route),
)}
>
{inner}
</ClientOnly>
) : (
inner
)}
</matchContext.Provider>
{(isServer ?? router.isServer) &&
route.parentRoute?.id === rootRouteId &&
router.options.scrollRestoration ? (
<ScrollRestoration />
) : null}
</ShellComponent>
)
})

function MatchView({
router,
match,
}: {
router: ReturnType<typeof useRouter>
match: AnyRouteMatch
}) {
const route: AnyRoute = router.routesById[match.routeId]

const pendingElement = renderPending(router, route)

function renderMatchBoundaries(
router: ReturnType<typeof useRouter>,
route: AnyRoute,
match: AnyRouteMatch,
children: React.ReactNode,
) {
const routeErrorComponent =
route.options.errorComponent ?? router.options.defaultErrorComponent

const routeOnCatch = route.options.onCatch ?? router.options.defaultOnCatch

const onCatch = route.options.onCatch ?? router.options.defaultOnCatch
const routeNotFoundComponent = route.isRoot
? // If it's the root route, use the _notFound option, with fallback to the notFoundRoute's component
(route.options.notFoundComponent ??
? (route.options.notFoundComponent ??
router.options.notFoundRoute?.options.component)
: route.options.notFoundComponent
const ResolvedCatchBoundary = routeErrorComponent
? CatchBoundary
: SafeFragment
const ResolvedNotFoundBoundary = routeNotFoundComponent
? CatchNotFound
: SafeFragment

const resolvedNoSsr = match.ssr === false || match.ssr === 'data-only'
return (
<ResolvedCatchBoundary
getResetKey={() => match}
errorComponent={routeErrorComponent as any}
onCatch={(error, errorInfo) => {
// Forward not found errors (we don't want to show the error component for these)
if (isNotFound(error)) {
error.routeId ??= match.routeId
throw error
}
if (process.env.NODE_ENV !== 'production') {
console.warn(`Warning: Error in route match: ${match.id}`)
}
onCatch?.(error, errorInfo)
}}
>
<ResolvedNotFoundBoundary
fallback={(error) => {
error.routeId ??= match.routeId

if (error.routeId !== match.routeId) {
throw error
}

const notFoundElement = React.createElement(
routeNotFoundComponent!,
error as any,
)
return process.env.NODE_ENV !== 'production'
? wrapInNonRouteComponentContext(
notFoundElement,
'notFoundComponent',
)
: notFoundElement
}}
>
{children}
</ResolvedNotFoundBoundary>
</ResolvedCatchBoundary>
)
}

export function MatchInner({ routeId }: { routeId: string }): any {
const router = useRouter()
let match: AnyRouteMatch

if (isServer ?? router.isServer) {
match = router.stores.byRoute.get(routeId)!.get()!
} else {
const matchStore = router.stores.getMatchStore(routeId)
// eslint-disable-next-line react-hooks/rules-of-hooks
match = useStore(matchStore, (value) => value!)
}

const route = router.routesById[routeId] as AnyRoute
const pendingElement = renderPending(router, route)
// A root component may render the document itself. Only place its Suspense
// boundary in pure CSR, inside an explicit shell, or when explicitly opted in.
const ResolvedSuspenseBoundary =
canWrapInSuspense(router, route, match.ssr) &&
(route.options.wrapInSuspense ??
pendingElement ??
((route.options.errorComponent as any)?.preload || resolvedNoSsr))
((route.options.errorComponent as any)?.preload ||
match.ssr === false ||
match.ssr === 'data-only'))
? React.Suspense
: SafeFragment

const ResolvedCatchBoundary = routeErrorComponent
? CatchBoundary
: SafeFragment

const ResolvedNotFoundBoundary = routeNotFoundComponent
? CatchNotFound
: SafeFragment

const ShellComponent = route.isRoot
? ((route.options as RootRouteOptions).shellComponent ?? SafeFragment)
: SafeFragment
return (
<ShellComponent>
<matchContext.Provider value={match.routeId}>
<ResolvedSuspenseBoundary fallback={pendingElement}>
<ResolvedCatchBoundary
getResetKey={() => match}
errorComponent={routeErrorComponent as any}
onCatch={(error, errorInfo) => {
// Forward not found errors (we don't want to show the error component for these)
if (isNotFound(error)) {
error.routeId ??= match.routeId
throw error
}
if (process.env.NODE_ENV !== 'production') {
console.warn(`Warning: Error in route match: ${match.id}`)
}
routeOnCatch?.(error, errorInfo)
}}
>
<ResolvedNotFoundBoundary
fallback={(error) => {
error.routeId ??= match.routeId

if (error.routeId !== match.routeId) {
throw error
}

const notFoundElement = React.createElement(
routeNotFoundComponent!,
error as any,
)
return process.env.NODE_ENV !== 'production'
? wrapInNonRouteComponentContext(
notFoundElement,
'notFoundComponent',
)
: notFoundElement
}}
>
{resolvedNoSsr ? (
<ClientOnly fallback={pendingElement}>
<MatchInner match={match} />
</ClientOnly>
) : (
<MatchInner match={match} />
)}
</ResolvedNotFoundBoundary>
</ResolvedCatchBoundary>
</ResolvedSuspenseBoundary>
</matchContext.Provider>
{(isServer ?? router.isServer) &&
route.parentRoute?.id === rootRouteId &&
router.options.scrollRestoration ? (
<ScrollRestoration />
) : null}
</ShellComponent>
<ResolvedSuspenseBoundary fallback={pendingElement}>
{renderMatchBoundaries(
router,
route,
match,
<MatchView router={router} route={route} match={match} />,
)}
</ResolvedSuspenseBoundary>
)
}

export const MatchInner = React.memo(function MatchInnerImpl({
function MatchView({
router,
route,
match,
}: {
router: ReturnType<typeof useRouter>
route: AnyRoute
match: AnyRouteMatch
}): any {
const router = useRouter()
const routeId = match.routeId
const route = router.routesById[routeId] as AnyRoute
const key = React.useMemo(() => {
const remountFn =
route.options.remountDeps ?? router.options.defaultRemountDeps
const remountDeps = remountFn?.({
routeId,
routeId: route.id,
loaderDeps: match.loaderDeps,
params: match._strictParams,
search: match._strictSearch,
})
return remountDeps ? JSON.stringify(remountDeps) : undefined
}, [
routeId,
route.id,
match.loaderDeps,
match._strictParams,
match._strictSearch,
Expand Down Expand Up @@ -251,7 +271,7 @@ export const MatchInner = React.memo(function MatchInnerImpl({
}

return out
})
}

/**
* Render the next child match in the route tree. Typically used inside
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as React from 'react'
import { act } from '@testing-library/react'
import { act, waitFor } from '@testing-library/react'
import { hydrateRoot } from 'react-dom/client'
import { renderToString } from 'react-dom/server'
import { afterEach, describe, expect, test, vi } from 'vitest'
Expand All @@ -9,6 +9,7 @@ import { hydrate } from '../src/ssr/client'
import {
Outlet,
RouterProvider,
createControlledPromise,
createRootRoute,
createRoute,
createRouter,
Expand All @@ -34,6 +35,90 @@ afterEach(async () => {
})

describe('hydrating a server-capped boundary lane', () => {
test('keeps the route error boundary around a client-only hydration fallback', async () => {
const loader = createControlledPromise<void>()
const hydrationError = new Error('client fallback failed')
const onCatch = vi.fn()
let serverPhase = true

const makeRouteTree = () =>
createRootRoute({
ssr: false,
loader: () => loader,
pendingComponent: () => {
if (!serverPhase) {
throw hydrationError
}
return <div data-testid="client-pending">Client pending</div>
},
errorComponent: ({ error }) => (
<div data-testid="route-error">{error.message}</div>
),
onCatch,
component: () => <div>Client content</div>,
})

const serverRouter = createRouter({
routeTree: makeRouteTree(),
history: createMemoryHistory({ initialEntries: ['/'] }),
})
serverRouter.isServer = true
await serverRouter.load()
const serverMatches = serverRouter.stores.matches.get()
const serverHtml = renderToString(<RouterProvider router={serverRouter} />)
expect(serverHtml).toContain('Client pending')

serverPhase = false
const clientRouter = createRouter({
routeTree: makeRouteTree(),
history: createMemoryHistory({ initialEntries: ['/'] }),
})
window.$_TSR = {
router: {
manifest: { routes: {} },
dehydratedData: {},
matches: serverMatches.map((match) => ({
i: dehydrateSsrMatchId(match.id),
u: match.updatedAt,
s: match.status,
l: match.loaderData,
e: match.error,
ssr: match.ssr,
})),
},
h: vi.fn(),
e: vi.fn(),
c: vi.fn(),
p: vi.fn(),
buffer: [],
initialized: false,
}
await hydrate(clientRouter)

const container = document.createElement('div')
container.innerHTML = serverHtml
document.body.appendChild(container)
vi.spyOn(console, 'error').mockImplementation(() => {})
let root!: ReturnType<typeof hydrateRoot>
await act(async () => {
root = hydrateRoot(container, <RouterProvider router={clientRouter} />, {
onRecoverableError: () => {},
})
testCleanups.push(async () => {
loader.resolve()
await act(() => root.unmount())
})
await Promise.resolve()
})

await waitFor(() => {
expect(
container.querySelector('[data-testid="route-error"]'),
).toHaveTextContent(hydrationError.message)
})
expect(onCatch).toHaveBeenCalledWith(hydrationError, expect.any(Object))
})

test('recovers a /404 payload against a missing browser URL', async () => {
function MissingPage() {
return <div data-testid="missing-page">Missing page</div>
Expand Down
Loading