diff --git a/.changeset/quiet-matches-hydrate.md b/.changeset/quiet-matches-hydrate.md
new file mode 100644
index 00000000000..6c6990e32b6
--- /dev/null
+++ b/.changeset/quiet-matches-hydrate.md
@@ -0,0 +1,6 @@
+---
+'@tanstack/react-router': patch
+---
+
+Prevent match store updates from invalidating client-only Suspense boundaries
+before they hydrate.
diff --git a/packages/react-router/src/Match.tsx b/packages/react-router/src/Match.tsx
index a0bfa89d552..98126064982 100644
--- a/packages/react-router/src/Match.tsx
+++ b/packages/react-router/src/Match.tsx
@@ -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
- }
-
- const matchStore = router.stores.getMatchStore(routeId)
- // eslint-disable-next-line react-hooks/rules-of-hooks
- const match = useStore(matchStore, (value) => value)
- return
+ 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 =
+ return (
+
+
+ {match.ssr === false || match.ssr === 'data-only' ? (
+
+ {inner}
+
+ ) : (
+ inner
+ )}
+
+ {(isServer ?? router.isServer) &&
+ route.parentRoute?.id === rootRouteId &&
+ router.options.scrollRestoration ? (
+
+ ) : null}
+
+ )
})
-function MatchView({
- router,
- match,
-}: {
- router: ReturnType
- match: AnyRouteMatch
-}) {
- const route: AnyRoute = router.routesById[match.routeId]
-
- const pendingElement = renderPending(router, route)
-
+function renderMatchBoundaries(
+ router: ReturnType,
+ 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 (
+ 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)
+ }}
+ >
+ {
+ 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}
+
+
+ )
+}
+
+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 (
-
-
-
- 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)
- }}
- >
- {
- 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 ? (
-
-
-
- ) : (
-
- )}
-
-
-
-
- {(isServer ?? router.isServer) &&
- route.parentRoute?.id === rootRouteId &&
- router.options.scrollRestoration ? (
-
- ) : null}
-
+
+ {renderMatchBoundaries(
+ router,
+ route,
+ match,
+ ,
+ )}
+
)
}
-export const MatchInner = React.memo(function MatchInnerImpl({
+function MatchView({
+ router,
+ route,
match,
}: {
+ router: ReturnType
+ 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,
@@ -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
diff --git a/packages/react-router/tests/hydration-capped-boundary-pending.test.tsx b/packages/react-router/tests/hydration-capped-boundary-pending.test.tsx
index 57d99c8af58..1229c69bd10 100644
--- a/packages/react-router/tests/hydration-capped-boundary-pending.test.tsx
+++ b/packages/react-router/tests/hydration-capped-boundary-pending.test.tsx
@@ -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'
@@ -9,6 +9,7 @@ import { hydrate } from '../src/ssr/client'
import {
Outlet,
RouterProvider,
+ createControlledPromise,
createRootRoute,
createRoute,
createRouter,
@@ -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()
+ 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 Client pending
+ },
+ errorComponent: ({ error }) => (
+ {error.message}
+ ),
+ onCatch,
+ component: () => Client content
,
+ })
+
+ const serverRouter = createRouter({
+ routeTree: makeRouteTree(),
+ history: createMemoryHistory({ initialEntries: ['/'] }),
+ })
+ serverRouter.isServer = true
+ await serverRouter.load()
+ const serverMatches = serverRouter.stores.matches.get()
+ const serverHtml = renderToString()
+ 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
+ await act(async () => {
+ root = hydrateRoot(container, , {
+ 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 Missing page