From 836a97bfc4fd752aa32f99f680e09fc6cab1750f Mon Sep 17 00:00:00 2001 From: Sheraff Date: Fri, 28 Aug 2026 09:51:19 +0200 Subject: [PATCH] fix(react-router): isolate client-only match hydration --- .changeset/quiet-matches-hydrate.md | 6 + packages/react-router/src/Match.tsx | 222 ++++++++++-------- ...hydration-capped-boundary-pending.test.tsx | 87 ++++++- 3 files changed, 213 insertions(+), 102 deletions(-) create mode 100644 .changeset/quiet-matches-hydrate.md 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