diff --git a/CHANGELOG.md b/CHANGELOG.md index e1557e117f..340a4c9be7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,10 @@ After a release, run `/update-changelog` in Claude Code to analyze commits, writ - **[Pro]** **Auto-resolve renderer password from ENV**: `setup_renderer_password` now falls back to `ENV["RENDERER_PASSWORD"]` when neither `config.renderer_password` nor a URL-embedded password is set, aligning Rails-side behavior with the Node Renderer defaults. Blank values (`nil` or `""`) are treated identically and fall through the full resolution chain: config → URL → ENV. [PR 2921](https://github.com/shakacode/react_on_rails/pull/2921) by [justin808](https://github.com/justin808). +#### Fixed + +- **[Pro]** **Fixed TanStack Router SSR hydration mismatches in the async path**: Client hydration now restores server match data before first render, uses `RouterProvider` directly to match the server-rendered tree, and stops the post-hydration load when a custom `router.options.hydrate` callback fails instead of continuing with partially hydrated client state. [PR 2932](https://github.com/shakacode/react_on_rails/pull/2932) by [justin808](https://github.com/justin808). + ### [16.6.0.rc.0] - 2026-04-01 #### Added diff --git a/packages/react-on-rails-pro/src/tanstack-router/clientHydrate.ts b/packages/react-on-rails-pro/src/tanstack-router/clientHydrate.ts index b0e1d9b407..acd54daa09 100644 --- a/packages/react-on-rails-pro/src/tanstack-router/clientHydrate.ts +++ b/packages/react-on-rails-pro/src/tanstack-router/clientHydrate.ts @@ -1,24 +1,109 @@ -import { createElement, useEffect, useRef, type ComponentType, type ReactElement } from 'react'; +import { Suspense, createElement, useEffect, useRef, type ReactElement } from 'react'; import type { DehydratedRouterState, TanStackHistory, TanStackRouter, TanStackRouterOptions, - TanStackSsrRouterState, + TanStackSsrMatch, } from './types.ts'; import type { RailsContext } from 'react-on-rails/types'; -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import/prefer-default-export, no-underscore-dangle */ + +type TanStackRouterHydrationInternals = TanStackRouter & { + matchRoutes: (location: unknown) => unknown[]; + __store: { + setState: (updater: (s: Record) => Record) => void; + }; +}; + +type TanStackRouterChunkPreloadInternals = TanStackRouter & { + loadRouteChunk?: (route: unknown) => Promise; + looseRoutesById?: Record; +}; + +interface RouteChunkPreloadGateProps { + preloadPromise: Promise | null; + preloadSettledRef: { current: boolean }; + isHydrating?: boolean; + children?: ReactElement; +} + +function RouteChunkPreloadGate({ + preloadPromise, + preloadSettledRef, + isHydrating, + children, +}: RouteChunkPreloadGateProps): ReactElement { + // During SSR hydration (first render), skip the suspension gate to avoid a + // hydration mismatch: the server rendered RouterProvider content directly, + // so throwing a promise here would cause Suspense to render the null fallback + // instead of matching the server HTML. After hydration completes (the + // post-mount effect sets didTriggerPostHydrationLoadRef), the gate activates + // normally for any subsequent re-renders. + if (!isHydrating && preloadPromise && !preloadSettledRef.current) { + // eslint-disable-next-line @typescript-eslint/only-throw-error -- Suspense boundaries intentionally suspend on thrown Promise. + throw preloadPromise; + } + return children as ReactElement; +} + +function extractDehydratedData(dehydratedRouter: unknown): unknown { + if (!dehydratedRouter || typeof dehydratedRouter !== 'object') { + return undefined; + } + return (dehydratedRouter as { dehydratedData?: unknown }).dehydratedData; +} + +function preloadMatchedRouteChunks( + router: TanStackRouterChunkPreloadInternals, + matches: unknown[], +): Promise | null { + const { loadRouteChunk, looseRoutesById } = router; + if (typeof loadRouteChunk !== 'function' || !looseRoutesById) { + return null; + } + + const routeChunkPromises: Array> = []; + matches.forEach((match) => { + const { routeId } = match as { routeId?: unknown }; + if (typeof routeId !== 'string') { + return; + } + + const route = looseRoutesById[routeId]; + if (!route) { + return; + } + + routeChunkPromises.push(loadRouteChunk(route)); + }); + + if (!routeChunkPromises.length) { + return null; + } + + return Promise.all(routeChunkPromises) + .then(() => undefined) + .catch((error: unknown) => { + console.error('react-on-rails-pro/tanstack-router: Error preloading matched route chunks:', error); + }); +} /** * Client-side hydration for a TanStack Router app. * + * Uses RouterProvider directly (not RouterClient) to match the server-rendered + * component tree. RouterClient wraps RouterProvider in which always + * suspends on first render via defer(), causing a hydration mismatch because + * the server renders RouterProvider directly without an wrapper. + * * Flow: * 1. Create router with browser history - * 2. Hydrate from dehydrated state provided by serverRenderTanStackAppAsync - * 3. Set router.ssr to skip auto-load on mount (Transitioner behavior, legacy path only) - * 4. After hydration, trigger router.load() to enable client-side navigation - * 5. Return a React component that renders RouterProvider + * 2. If SSR payload exists, synchronously inject route matches to match server output + * 3. Set router.ssr to prevent Transitioner auto-load during hydration + * 4. Hydrate with dehydrated router state if available + * 5. After mount, trigger router.load() and clear SSR flag once settled */ interface TanStackHydrationAppProps { @@ -26,48 +111,57 @@ interface TanStackHydrationAppProps { incomingProps: Record; railsContext: RailsContext & { serverSide: false }; RouterProvider: React.ComponentType<{ router: TanStackRouter }>; - RouterClient?: ComponentType<{ router: TanStackRouter }>; createBrowserHistory: () => TanStackHistory; } -interface TanStackSsrGlobal { - router?: TanStackSsrRouterState; - buffer: Array<() => void>; - initialized?: boolean; - hydrated?: boolean; - streamEnded?: boolean; - // These short keys are TanStack Router's upstream $_TSR bootstrap contract: - // h = hydrated, e = stream ended, c = cleanup, p = run-or-buffer script. - h: () => void; - e: () => void; - c: () => void; - p: (script: () => void) => void; +/** + * Converts a dehydrated match ID (using \0 separator) back to the standard + * route ID format (using / separator) used by matchRoutes(). + * + * Inverse of dehydrateSsrMatchId() in serverRender.ts, which replaces '/' + * with '\0' to match the $_TSR bootstrap wire format used by TanStack Router's + * DehydrateRouter component (see @tanstack/react-router/src/DehydrateRouter.tsx). + */ +function rehydrateMatchId(dehydratedId: string): string { + return dehydratedId.split('\0').join('/'); } -function setTanStackSsrGlobal(ssrRouter: TanStackSsrRouterState): void { - const globalState = window as unknown as Record; - const existing = globalState.$_TSR as TanStackSsrGlobal | undefined; - const tsrGlobal: TanStackSsrGlobal = existing ?? { - buffer: [], - h() { - this.hydrated = true; - }, - e() { - this.streamEnded = true; - }, - c() {}, - p(script: () => void) { - if (!this.initialized) { - this.buffer.push(script); - return; - } +/** + * Applies server-rendered match data (loaderData, beforeLoadContext, status, etc.) + * from the dehydrated SSR payload to fresh client matches. This ensures the first + * client render can access the same data the server used, preventing mismatches + * for routes that render from loader results. + */ +function applyDehydratedMatchData( + matches: unknown[], + ssrMatches: TanStackSsrMatch[], + onMissingSsrMatch?: (match: Record) => void, +): unknown[] { + return matches.map((match) => { + const m = match as Record; + const ssrMatch = ssrMatches.find((sm) => rehydrateMatchId(sm.i) === m.id); - script(); - }, - }; + if (ssrMatch) { + return { + ...m, + status: ssrMatch.s, + updatedAt: ssrMatch.u, + ...(ssrMatch.l !== undefined ? { loaderData: ssrMatch.l } : {}), + ...(ssrMatch.b !== undefined ? { __beforeLoadContext: ssrMatch.b } : {}), + ...(ssrMatch.e !== undefined ? { error: ssrMatch.e } : {}), + ...(ssrMatch.ssr !== undefined ? { ssr: ssrMatch.ssr } : {}), + }; + } - tsrGlobal.router = ssrRouter; - globalState.$_TSR = tsrGlobal; + // No server match — override pending to success to prevent MatchInner + // from throwing loadPromise (which would cause Suspense suspension). + if (m.status === 'pending') { + onMissingSsrMatch?.(m); + return { ...m, status: 'success' }; + } + + return m; + }); } function TanStackHydrationApp({ @@ -76,56 +170,161 @@ function TanStackHydrationApp({ // eslint-disable-next-line @typescript-eslint/no-unused-vars -- Required by TanStackHydrationAppProps interface. railsContext: _railsContext, RouterProvider, - RouterClient, createBrowserHistory, }: TanStackHydrationAppProps): ReactElement { - // eslint-disable-next-line no-underscore-dangle -- Internal hydration payload key injected by server-side render. const dehydratedState = incomingProps.__tanstackRouterDehydratedState as | DehydratedRouterState | null | undefined; - const ssrRouter = dehydratedState?.ssrRouter; const hasSsrPayload = dehydratedState != null; - const hasSsrRouter = ssrRouter !== undefined; const hasDehydratedRouter = dehydratedState?.dehydratedRouter !== undefined && dehydratedState.dehydratedRouter !== null; const routerRef = useRef(null); - // This only deduplicates the manual load within one mounted hydration instance. - // A full remount creates a fresh router and may legitimately issue another load. const didTriggerPostHydrationLoadRef = useRef(false); - const didInitializeSsrGlobalRef = useRef(false); - const didSetLegacySsrFlagRef = useRef(false); + const didSetSsrFlagRef = useRef(false); + const routeChunkPreloadPromiseRef = useRef | null>(null); + const routeChunkPreloadSettledRef = useRef(true); + const hydrationCallbackPromiseRef = useRef | null>(null); + const didWarnPrivateInternalsRef = useRef(false); + const warnedMissingSsrMatchIdsRef = useRef>(new Set()); + + const warnMissingSsrMatch = (match: Record): void => { + if (process.env.NODE_ENV !== 'development') { + return; + } + const routeId = + typeof match.id === 'string' || typeof match.id === 'number' ? String(match.id) : ''; + if (warnedMissingSsrMatchIdsRef.current.has(routeId)) { + return; + } + warnedMissingSsrMatchIdsRef.current.add(routeId); + console.warn( + `react-on-rails-pro/tanstack-router: No server match found for route "${routeId}". ` + + 'Overriding match.status from "pending" to "success" to prevent hydration suspension.', + ); + }; if (routerRef.current === null) { + // Intentionally initialize the hydration router during render so the very + // first RouterProvider render sees SSR-injected matches and the temporary + // router.ssr flag. Moving this to an effect causes a first-render mismatch + // (the provider would render before hydration state is injected). + // + // Safety invariant: all mutations in this block target only the router + // instance created here and routerRef.current is assigned only after the + // block completes. If React discards this render (StrictMode/concurrency), + // the discarded router instance is dropped and a fresh instance is created + // and initialized on the next render. const router = options.createRouter(); // Set browser history for client-side navigation const browserHistory = createBrowserHistory(); router.update({ history: browserHistory }); - // Hydrate router with dehydrated state from server. - if (hasSsrRouter) { - // RouterClient will hydrate the route matches from window.$_TSR. - } else if (hasDehydratedRouter && typeof router.hydrate === 'function') { - router.hydrate(dehydratedState.dehydratedRouter); - } else if (hasDehydratedRouter) { - throw new Error( - 'react-on-rails-pro/tanstack-router: Cannot hydrate SSR payload. ' + - 'router.hydrate() is required but not available on your TanStack Router version. ' + - 'Ensure @tanstack/react-router >=1.139.0 <2.0.0 is installed and provides router.hydrate().', + // Only apply SSR hydration when a server payload exists. + // Client-only renders (prerender: false) must not set router.ssr or + // inject matches — the Transitioner handles initial loading for those. + if (hasSsrPayload) { + if (process.env.NODE_ENV === 'development' && !didWarnPrivateInternalsRef.current) { + didWarnPrivateInternalsRef.current = true; + console.warn( + 'react-on-rails-pro/tanstack-router: Hydration uses TanStack Router private internals ' + + '(matchRoutes, __store, loadRouteChunk, looseRoutesById). Keep @tanstack/react-router ' + + 'within the supported range (>=1.139.0 <2.0.0) and run integration tests when upgrading.', + ); + } + + // Validate internal APIs before using them. + if (typeof router.matchRoutes !== 'function' || !router.__store?.setState) { + throw new Error( + 'react-on-rails-pro/tanstack-router: router.matchRoutes() and router.__store are required ' + + 'but not available. Ensure @tanstack/react-router >=1.139.0 <2.0.0 is installed.', + ); + } + const hydrationRouter = router as TanStackRouterHydrationInternals; + + // Synchronously inject route matches to match server-rendered output. + // The server fully loads routes (via router.load()) before rendering, so + // all matches are resolved. We replicate this on the client so the initial + // render produces the same component tree as the server HTML. + // + // When ssrRouter match data is available (from serverRenderTanStackAppAsync), + // we apply loaderData, beforeLoadContext, status, etc. from the server payload + // so routes that render from loader results can hydrate correctly. + // Otherwise we override 'pending' to 'success' to prevent MatchInner from + // throwing loadPromise (which would cause Suspense suspension). + const rawMatches = hydrationRouter.matchRoutes(hydrationRouter.state.location); + routeChunkPreloadPromiseRef.current = preloadMatchedRouteChunks( + router as TanStackRouterChunkPreloadInternals, + rawMatches, ); - } + if (routeChunkPreloadPromiseRef.current) { + routeChunkPreloadSettledRef.current = false; + void routeChunkPreloadPromiseRef.current.finally(() => { + routeChunkPreloadSettledRef.current = true; + }); + } else { + routeChunkPreloadSettledRef.current = true; + } + const ssrMatches = dehydratedState?.ssrRouter?.matches; + const matches = ssrMatches?.length + ? applyDehydratedMatchData(rawMatches, ssrMatches, warnMissingSsrMatch) + : rawMatches.map((match) => { + const m = match as Record; + if (m.status === 'pending') { + warnMissingSsrMatch(m); + return { ...m, status: 'success' }; + } + return m; + }); - // Legacy hydration path only: signal SSR mode so the Transitioner skips its - // initial router.load() call, preventing a hydration mismatch. The object - // shape matches TanStack Router's internal $_TSR hydration contract (the - // Transitioner only checks truthiness). The new ssrRouter/RouterClient path - // does not need this — RouterClient sets router.ssr internally via its own - // hydrate() function. - if (!hasSsrRouter && hasSsrPayload && !router.ssr) { - router.ssr = { manifest: undefined }; - didSetLegacySsrFlagRef.current = true; + // Render-phase store injection is required for hydration parity: this + // must happen before the first RouterProvider render. + hydrationRouter.__store.setState((s: Record) => ({ + ...s, + status: 'idle', + resolvedLocation: (s as { location: unknown }).location, + matches, + })); + + // Set SSR flag so the Transitioner skips its initial router.load() call, + // preventing a state update during hydration that would cause a mismatch. + // The shape matches TanStack Router's internal $_TSR hydration contract + // (the Transitioner only checks truthiness). + // Preserve user-set values from createRouter() (e.g. TanStack Start). + if (!router.ssr) { + router.ssr = { manifest: undefined }; + didSetSsrFlagRef.current = true; + } + + try { + // Run user-defined hydration callback for custom dehydratedData + // (for example external query/cache payloads), matching TanStack + // Router's ssr-client behavior. + if (typeof router.options?.hydrate === 'function') { + const hydrationResult = router.options.hydrate( + extractDehydratedData(dehydratedState?.dehydratedRouter), + ); + // Let async hydration failures reject so we do not continue into + // router.load() with partially hydrated client state. + hydrationCallbackPromiseRef.current = Promise.resolve(hydrationResult).then(() => undefined); + } + + // Backward-compatibility hook: if user router exposes router.hydrate(), + // invoke it with the full dehydrated router payload. + if (hasDehydratedRouter && typeof router.hydrate === 'function') { + router.hydrate(dehydratedState.dehydratedRouter); + } + } catch (error) { + // If render-phase hydration throws, clear only the temporary SSR flag + // created by this module so retries are not blocked. + if (didSetSsrFlagRef.current) { + router.ssr = undefined; + didSetSsrFlagRef.current = false; + } + throw error; + } } routerRef.current = router; @@ -133,25 +332,10 @@ function TanStackHydrationApp({ const router = routerRef.current; - if (hasSsrRouter && typeof window !== 'undefined' && !didInitializeSsrGlobalRef.current) { - setTanStackSsrGlobal(ssrRouter); - didInitializeSsrGlobalRef.current = true; - } - // After mount, trigger router.load() to enable client-side navigation. // The SSR flag prevented auto-loading, so we do it manually here. useEffect(() => { - if (!router) { - return undefined; - } - - if (hasSsrRouter) { - return undefined; - } - - // Only SSR hydration needs a manual load call. - // For client-only renders, Transitioner handles initial loading. - if (!hasSsrPayload) { + if (!router || !hasSsrPayload) { return undefined; } @@ -161,42 +345,83 @@ function TanStackHydrationApp({ didTriggerPostHydrationLoadRef.current = true; let cancelled = false; - // `cancelled` only suppresses logging for a discarded mount. The in-flight load still - // completes unless the router exposes a best-effort cancelLoad() hook. - router - .load() + const runPostHydrationLoad = async (): Promise => { + if (hydrationCallbackPromiseRef.current) { + await hydrationCallbackPromiseRef.current; + if (cancelled) { + return; + } + } + if (routeChunkPreloadPromiseRef.current) { + await routeChunkPreloadPromiseRef.current; + if (cancelled) { + return; + } + } + if (cancelled) { + return; + } + await router.load(); + }; + + void runPostHydrationLoad() .catch((err: unknown) => { if (!cancelled) { console.error('react-on-rails-pro/tanstack-router: Error loading routes after hydration:', err); } }) .finally(() => { - // Legacy hydration only: clear the temporary SSR hint after the first - // client load has completed so it cannot influence later navigations. - // Only clear when this module set it, so pre-existing router.ssr state - // from user code or upstream router internals is preserved. - if (didSetLegacySsrFlagRef.current) { + // Always clear temporary router.ssr set by this module, regardless of + // cancellation state. In React 18 StrictMode, the effect cleanup sets + // cancelled=true and didTriggerPostHydrationLoadRef prevents re-trigger + // on re-mount — if we skip cleanup here the SSR flag stays set + // permanently, blocking the Transitioner from ever calling router.load(). + // The didSetSsrFlagRef guard ensures we only clear values this module + // created, preserving user-provided router.ssr from createRouter(). + if (didSetSsrFlagRef.current) { router.ssr = undefined; - didSetLegacySsrFlagRef.current = false; + didSetSsrFlagRef.current = false; } }); return () => { cancelled = true; + if (didSetSsrFlagRef.current) { + router.ssr = undefined; + didSetSsrFlagRef.current = false; + } const cancellableRouter = router as TanStackRouter & { cancelLoad?: () => void }; if (typeof cancellableRouter.cancelLoad === 'function') { cancellableRouter.cancelLoad(); } }; - }, [hasSsrPayload, hasSsrRouter, router]); - - const RouterRoot = - hasSsrRouter && typeof window !== 'undefined' && RouterClient ? RouterClient : RouterProvider; + }, [hasSsrPayload, router]); - let app: ReactElement = createElement(RouterRoot, { router }); + // Always use RouterProvider directly — matching the server-rendered tree. + // RouterClient is NOT used because it wraps RouterProvider in which + // introduces a Suspense boundary that doesn't exist in the server HTML, + // causing React hydration mismatch errors. + // + // RouteChunkPreloadGate blocks re-renders until matched lazy chunks finish + // preloading (when preload support is available). During the initial SSR + // hydration render, the gate is skipped to match the server-rendered tree + // and avoid a hydration mismatch. After the post-mount effect runs + // (didTriggerPostHydrationLoadRef becomes true), the gate activates normally. + let app: ReactElement = createElement( + Suspense, + { fallback: null }, + createElement( + RouteChunkPreloadGate, + { + preloadPromise: routeChunkPreloadPromiseRef.current, + preloadSettledRef: routeChunkPreloadSettledRef, + isHydrating: hasSsrPayload && !didTriggerPostHydrationLoadRef.current, + }, + createElement(RouterProvider, { router }), + ), + ); if (options.AppWrapper) { const wrapperProps = { ...incomingProps } as Record; - // eslint-disable-next-line no-underscore-dangle -- Internal hydration payload key should not reach user AppWrapper props. delete wrapperProps.__tanstackRouterDehydratedState; app = createElement(options.AppWrapper, wrapperProps, app); } @@ -209,7 +434,6 @@ export function clientHydrateTanStackApp( props: Record, railsContext: RailsContext & { serverSide: false }, RouterProvider: React.ComponentType<{ router: TanStackRouter }>, - RouterClient: ComponentType<{ router: TanStackRouter }> | undefined, createBrowserHistory: () => TanStackHistory, ): ReactElement { return createElement(TanStackHydrationApp, { @@ -217,7 +441,6 @@ export function clientHydrateTanStackApp( incomingProps: props, railsContext, RouterProvider, - RouterClient, createBrowserHistory, }); } diff --git a/packages/react-on-rails-pro/src/tanstack-router/index.ts b/packages/react-on-rails-pro/src/tanstack-router/index.ts index 09c995293b..477a84f1d6 100644 --- a/packages/react-on-rails-pro/src/tanstack-router/index.ts +++ b/packages/react-on-rails-pro/src/tanstack-router/index.ts @@ -46,9 +46,10 @@ interface TanStackRouterDeps { */ RouterProvider: React.ComponentType<{ router: TanStackRouter }>; /** - * Optional RouterClient component from @tanstack/react-router/ssr/client. - * When provided, it enables TanStack Router's SSR client hydration path for - * router versions that do not expose router.dehydrate()/router.hydrate(). + * @deprecated No longer used for hydration. RouterProvider is always used + * directly to match the server-rendered tree. RouterClient caused hydration + * mismatches because it wraps RouterProvider in which suspends. + * Kept for backward compatibility only. */ RouterClient?: React.ComponentType<{ router: TanStackRouter }>; /** @@ -81,6 +82,7 @@ export function createTanStackRouterRenderFunction( deps: TanStackRouterDeps, ): RenderFunction { const { RouterProvider, RouterClient, createMemoryHistory, createBrowserHistory } = deps; + let didWarnRouterClientDeprecated = false; const renderFn = ( props: Record = {}, @@ -113,12 +115,20 @@ export function createTanStackRouterRenderFunction( // This intentionally creates a fresh closure per renderFn call so the client component // captures the current railsContext and TanStack Router dependencies for that mount. return function TanStackRouterClientApp(clientProps: Record = {}) { + if (RouterClient && !didWarnRouterClientDeprecated) { + didWarnRouterClientDeprecated = true; + console.warn( + 'react-on-rails-pro/tanstack-router: The RouterClient parameter is deprecated and ignored. ' + + 'RouterProvider is now used directly to avoid SSR hydration mismatches. ' + + 'You can safely remove the RouterClient import from your createTanStackRouterRenderFunction call.', + ); + } + return clientHydrateTanStackApp( options, clientProps, railsContext as RailsContext & { serverSide: false }, RouterProvider, - RouterClient, createBrowserHistory, ); }; diff --git a/packages/react-on-rails-pro/src/tanstack-router/serverRender.ts b/packages/react-on-rails-pro/src/tanstack-router/serverRender.ts index 64d70e34a9..c6bd695ca1 100644 --- a/packages/react-on-rails-pro/src/tanstack-router/serverRender.ts +++ b/packages/react-on-rails-pro/src/tanstack-router/serverRender.ts @@ -116,6 +116,8 @@ export async function serverRenderTanStackAppAsync( const dehydratedState: DehydratedRouterState = { url, dehydratedRouter: typeof router.dehydrate === 'function' ? router.dehydrate() : null, + // Keep ssrRouter payload for compatibility and to restore server match data + // before first client render. ssrRouter: buildSsrRouterState(router), }; diff --git a/packages/react-on-rails-pro/src/tanstack-router/types.ts b/packages/react-on-rails-pro/src/tanstack-router/types.ts index 6876527118..121aa0a10d 100644 --- a/packages/react-on-rails-pro/src/tanstack-router/types.ts +++ b/packages/react-on-rails-pro/src/tanstack-router/types.ts @@ -8,6 +8,15 @@ import type { ComponentType, ReactNode } from 'react'; export interface TanStackRouter { update: (opts: { history: TanStackHistory }) => void; load: () => Promise; + // Internal TanStack Router APIs used only by the hydration workaround. + // Kept optional in the public type so consumers/mocks are not forced + // to model private internals. + matchRoutes?: (location: unknown) => unknown[]; + __store?: { + setState: (updater: (s: Record) => Record) => void; + }; + looseRoutesById?: Record; + loadRouteChunk?: (route: unknown) => Promise; state: { status: string; location: { @@ -22,11 +31,13 @@ export interface TanStackRouter { }; dehydrate?: () => unknown; hydrate?: (data: unknown) => void; + options?: { + hydrate?: (dehydratedData: unknown) => Promise | unknown; + }; // TanStack Router's Transitioner checks this field (truthiness only) to skip - // auto-loading on mount. The canonical shape is { manifest?: unknown }, set - // internally by TanStack's hydrate() during RouterClient hydration. We set - // it on the legacy (non-RouterClient) hydration path to prevent a duplicate - // initial load that causes hydration mismatch. + // auto-loading on mount. The canonical shape is { manifest?: unknown }. + // Set during client hydration to prevent a duplicate initial load that + // causes hydration mismatch. ssr?: { manifest?: unknown }; } @@ -99,6 +110,6 @@ export interface DehydratedRouterState { url: string; /** Router dehydrated state from router.dehydrate() */ dehydratedRouter: unknown; - /** TanStack Router SSR match payload used by RouterClient hydration */ + /** Legacy TanStack SSR match payload used for compatibility and match-data restoration during hydration */ ssrRouter?: TanStackSsrRouterState; } diff --git a/packages/react-on-rails-pro/tests/tanstackRouter.test.ts b/packages/react-on-rails-pro/tests/tanstackRouter.test.ts index 66ab797e32..c83e02f572 100644 --- a/packages/react-on-rails-pro/tests/tanstackRouter.test.ts +++ b/packages/react-on-rails-pro/tests/tanstackRouter.test.ts @@ -9,6 +9,7 @@ import type { RailsContext, ServerRenderResult } from 'react-on-rails/types'; import type { TanStackRouter } from '../src/tanstack-router/types.ts'; function buildRouter(): TanStackRouter { + const productsRoute = { id: '/products' }; const state = { status: 'pending', location: { @@ -30,7 +31,25 @@ function buildRouter(): TanStackRouter { return { update: jest.fn(), load: jest.fn().mockResolvedValue(undefined), + matchRoutes: jest + .fn() + .mockReturnValue([ + { id: '/products', routeId: '/products', status: 'pending', updatedAt: 0, loaderData: undefined }, + ]), + __store: { + setState: jest.fn((updater) => { + const newState = updater(state as unknown as Record); + Object.assign(state, newState); + }), + }, + looseRoutesById: { + '/products': productsRoute, + }, + loadRouteChunk: undefined, state, + options: { + hydrate: jest.fn(), + }, dehydrate: jest.fn().mockReturnValue({ matches: [{ id: 'products' }] }), hydrate: jest.fn(), }; @@ -189,6 +208,246 @@ describe('tanstack-router integration (Pro)', () => { expect(typeof result).toBe('function'); }); + it('renders RouterProvider (not RouterClient) on client hydration with SSR match data', () => { + // Regression test: the old code used RouterClient when ssrRouter data existed. + // RouterClient wraps RouterProvider in which suspends via defer(), + // producing a component tree that differs from the server-rendered tree + // (which uses RouterProvider directly), causing a React hydration mismatch. + const router = buildRouter(); + + const providerCalls: unknown[] = []; + const clientCalls: unknown[] = []; + const MockRouterProvider = (p: { router: TanStackRouter }) => { + providerCalls.push(p); + return React.createElement('div', { 'data-testid': 'provider' }, 'RouterProvider'); + }; + const MockRouterClient = (p: { router: TanStackRouter }) => { + clientCalls.push(p); + return React.createElement('div', { 'data-testid': 'client' }, 'RouterClient'); + }; + + const renderFn = createTanStackRouterRenderFunction( + { createRouter: () => router }, + { + RouterProvider: MockRouterProvider, + RouterClient: MockRouterClient, + createMemoryHistory: jest.fn(), + createBrowserHistory: jest.fn().mockReturnValue({ + location: { + pathname: '/products', + search: '?category=tools', + hash: '', + href: '/products?category=tools', + state: null, + }, + }), + }, + ); + + const props = { + __tanstackRouterDehydratedState: { + url: '/products?category=tools', + dehydratedRouter: { matches: [{ id: 'products' }] }, + ssrRouter: { + manifest: undefined, + lastMatchId: '\u0000products', + matches: [{ i: '\u0000products', l: { products: ['hammer'] }, s: 'success', ssr: true, u: 123 }], + }, + }, + }; + const result = renderFn(props, { + serverSide: false, + pathname: '/products', + search: '?category=tools', + } as unknown as RailsContext); + + const html = renderToString( + React.createElement(result as React.ComponentType>, props), + ); + + // The fix: RouterProvider is always used, matching the server-rendered tree. + expect(html).toContain('RouterProvider'); + expect(html).not.toContain('RouterClient'); + expect(providerCalls.length).toBeGreaterThan(0); + expect(clientCalls).toHaveLength(0); + }); + + it('warns once per render function when RouterClient is provided', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const RouterClientA = (_: { router: TanStackRouter }) => React.createElement('div'); + const RouterClientB = (_: { router: TanStackRouter }) => React.createElement('div'); + + const createDeps = (routerClient: (p: { router: TanStackRouter }) => React.ReactElement) => ({ + RouterProvider: (_props: { router: TanStackRouter }) => React.createElement('div'), + RouterClient: routerClient, + createMemoryHistory: jest.fn(), + createBrowserHistory: jest.fn().mockReturnValue({ + location: { + pathname: '/products', + search: '', + hash: '', + href: '/products', + state: null, + }, + }), + }); + + const renderFnA = createTanStackRouterRenderFunction( + { createRouter: () => buildRouter() }, + createDeps(RouterClientA), + ); + const appA = renderFnA({}, { + serverSide: false, + pathname: '/products', + search: '', + } as unknown as RailsContext) as (props?: Record) => React.ReactElement; + appA({}); + appA({}); + + const renderFnB = createTanStackRouterRenderFunction( + { createRouter: () => buildRouter() }, + createDeps(RouterClientB), + ); + const appB = renderFnB({}, { + serverSide: false, + pathname: '/products', + search: '', + } as unknown as RailsContext) as (props?: Record) => React.ReactElement; + appB({}); + + const deprecationWarnings = warnSpy.mock.calls.filter((call) => + String(call[0]).includes('RouterClient parameter is deprecated and ignored'), + ); + expect(deprecationWarnings).toHaveLength(2); + + warnSpy.mockRestore(); + }); + + it('injects SSR match data into router store to prevent Suspense suspension during hydration', () => { + // Without synchronous match injection, the router's initial matches have + // status='pending' and a loadPromise, causing MatchInner to throw (suspend). + // The fix calls matchRoutes() + __store.setState() to make matches 'success' + // before the first render, matching the fully-loaded server output. + const setState = jest.fn(); + const router = buildRouter(); + router.matchRoutes = jest.fn().mockReturnValue([{ id: '/products', status: 'pending', updatedAt: 0 }]); + router.__store = { setState }; + + const renderFn = createTanStackRouterRenderFunction( + { createRouter: () => router }, + { + RouterProvider: (_: { router: TanStackRouter }) => React.createElement('div'), + createMemoryHistory: jest.fn(), + createBrowserHistory: jest.fn().mockReturnValue({ + location: { pathname: '/products', search: '', hash: '', href: '/products', state: null }, + }), + }, + ); + + const props = { + __tanstackRouterDehydratedState: { + url: '/products', + dehydratedRouter: null, + ssrRouter: { + manifest: undefined, + lastMatchId: '\u0000products', + matches: [{ i: '\u0000products', l: { products: ['hammer'] }, s: 'success', u: 456 }], + }, + }, + }; + const result = renderFn(props, { + serverSide: false, + pathname: '/products', + search: '', + } as unknown as RailsContext); + + // Trigger the render which initializes the router + renderToString(React.createElement(result as React.ComponentType>, props)); + + expect(router.matchRoutes).toHaveBeenCalledWith(router.state.location); + expect(setState).toHaveBeenCalledTimes(1); + + // Verify the state updater applies SSR match data correctly + const updater = setState.mock.calls[0][0] as (s: Record) => Record; + const newState = updater({ + status: 'pending', + location: '/products', + matches: [{ id: '/products', status: 'pending' }], + }); + expect(newState.status).toBe('idle'); + expect(newState.resolvedLocation).toBe('/products'); + expect((newState.matches as Array>)[0].status).toBe('success'); + expect((newState.matches as Array>)[0].loaderData).toEqual({ + products: ['hammer'], + }); + }); + + it('rehydrates nested route match IDs from \\0-separated to /-separated format', () => { + // Pin the assumption that dehydrateSsrMatchId (serverRender.ts) uses \0 as + // the segment separator and rehydrateMatchId (clientHydrate.ts) reverses it. + // If TanStack Router's $_TSR wire format changes, this test will catch it. + const setState = jest.fn(); + const router = buildRouter(); + router.state.location.pathname = '/products/42'; + router.matchRoutes = jest.fn().mockReturnValue([ + { id: '/products', status: 'pending', updatedAt: 0 }, + { id: '/products/$id', status: 'pending', updatedAt: 0 }, + ]); + router.__store = { setState }; + + const renderFn = createTanStackRouterRenderFunction( + { createRouter: () => router }, + { + RouterProvider: (_: { router: TanStackRouter }) => React.createElement('div'), + createMemoryHistory: jest.fn(), + createBrowserHistory: jest.fn().mockReturnValue({ + location: { pathname: '/products/42', search: '', hash: '', href: '/products/42', state: null }, + }), + }, + ); + + const props = { + __tanstackRouterDehydratedState: { + url: '/products/42', + dehydratedRouter: null, + ssrRouter: { + manifest: undefined, + lastMatchId: '\u0000products\u0000$id', + matches: [ + { i: '\u0000products', l: { list: true }, s: 'success', u: 100 }, + { i: '\u0000products\u0000$id', l: { product: { id: 42 } }, s: 'success', u: 200 }, + ], + }, + }, + }; + const result = renderFn(props, { + serverSide: false, + pathname: '/products/42', + search: '', + } as unknown as RailsContext); + + renderToString(React.createElement(result as React.ComponentType>, props)); + + expect(setState).toHaveBeenCalledTimes(1); + + const updater = setState.mock.calls[0][0] as (s: Record) => Record; + const newState = updater({ + status: 'pending', + location: '/products/42', + matches: [], + }); + + const matches = newState.matches as Array>; + expect(matches).toHaveLength(2); + // Verify nested route IDs were correctly rehydrated (\0 → /) + expect(matches[0].id).toBe('/products'); + expect(matches[0].loaderData).toEqual({ list: true }); + expect(matches[0].status).toBe('success'); + expect(matches[1].id).toBe('/products/$id'); + expect(matches[1].loaderData).toEqual({ product: { id: 42 } }); + expect(matches[1].status).toBe('success'); + }); + it('does not pass __tanstackRouterDehydratedState through AppWrapper props', () => { const router = buildRouter(); const observedProps: Array> = []; @@ -280,15 +539,178 @@ describe('tanstack-router integration (Pro)', () => { expect(router.ssr).toEqual({ manifest: undefined }); }); - it('clears router.ssr after the post-hydration legacy load settles', async () => { + it('preloads matched lazy route chunks before the first hydration render', () => { const router = buildRouter(); - let resolveLoad: (() => void) | undefined; - router.load = jest.fn().mockImplementation( + const rootRoute = { id: '__root__' }; + const productsRoute = { id: '/products' }; + const loadRouteChunk = jest.fn().mockResolvedValue([]); + + (router.matchRoutes as jest.Mock).mockReturnValue([ + { id: '__root__', routeId: '__root__', status: 'pending', updatedAt: 0, loaderData: undefined }, + { id: '/products', routeId: '/products', status: 'pending', updatedAt: 0, loaderData: undefined }, + ]); + router.looseRoutesById = { + __root__: rootRoute, + '/products': productsRoute, + }; + router.loadRouteChunk = loadRouteChunk; + + const options = { + createRouter: () => router, + }; + const deps = { + RouterProvider: (_props: { router: TanStackRouter }) => React.createElement('div'), + createMemoryHistory: jest.fn(), + createBrowserHistory: jest.fn().mockReturnValue({ + location: { + pathname: '/products', + search: '?category=tools', + hash: '', + href: '/products?category=tools', + state: null, + }, + }), + }; + + const renderFn = createTanStackRouterRenderFunction(options, deps); + const result = renderFn( + { + __tanstackRouterDehydratedState: { + url: '/products?category=tools', + dehydratedRouter: { matches: [{ id: 'products' }] }, + }, + }, + { + serverSide: false, + pathname: '/products', + search: '?category=tools', + } as unknown as RailsContext, + ); + + renderToString( + React.createElement(result as React.ComponentType>, { + __tanstackRouterDehydratedState: { + url: '/products?category=tools', + dehydratedRouter: { matches: [{ id: 'products' }] }, + }, + }), + ); + + expect(loadRouteChunk).toHaveBeenCalledTimes(2); + expect(loadRouteChunk).toHaveBeenNthCalledWith(1, rootRoute); + expect(loadRouteChunk).toHaveBeenNthCalledWith(2, productsRoute); + }); + + it('throws a clear error when required hydration internals are unavailable', () => { + const router = buildRouter(); + delete router.matchRoutes; + delete router.__store; + + const options = { + createRouter: () => router, + }; + const deps = { + RouterProvider: (_props: { router: TanStackRouter }) => React.createElement('div'), + createMemoryHistory: jest.fn(), + createBrowserHistory: jest.fn().mockReturnValue({ + location: { + pathname: '/products', + search: '', + hash: '', + href: '/products', + state: null, + }, + }), + }; + + const renderFn = createTanStackRouterRenderFunction(options, deps); + const props = { + __tanstackRouterDehydratedState: { + url: '/products', + dehydratedRouter: { matches: [{ id: 'products' }] }, + }, + }; + const result = renderFn(props, { + serverSide: false, + pathname: '/products', + search: '', + } as unknown as RailsContext); + + expect(() => + renderToString(React.createElement(result as React.ComponentType>, props)), + ).toThrow(/router\.matchRoutes\(\) and router\.__store are required/); + }); + + it('runs router.options.hydrate callback with dehydratedData on the SSR hydration path', () => { + const router = buildRouter(); + const hydrateCallback = jest.fn(); + router.options = { + hydrate: hydrateCallback, + }; + + const options = { + createRouter: () => router, + }; + const deps = { + RouterProvider: (_props: { router: TanStackRouter }) => React.createElement('div'), + createMemoryHistory: jest.fn(), + createBrowserHistory: jest.fn().mockReturnValue({ + location: { + pathname: '/products', + search: '?category=tools', + hash: '', + href: '/products?category=tools', + state: null, + }, + }), + }; + + const renderFn = createTanStackRouterRenderFunction(options, deps); + const dehydratedData = { queryCache: { products: ['hammer'] } }; + const result = renderFn( + { + __tanstackRouterDehydratedState: { + url: '/products?category=tools', + dehydratedRouter: { + matches: [{ id: 'products' }], + dehydratedData, + }, + }, + }, + { + serverSide: false, + pathname: '/products', + search: '?category=tools', + } as unknown as RailsContext, + ); + + renderToString( + React.createElement(result as React.ComponentType>, { + __tanstackRouterDehydratedState: { + url: '/products?category=tools', + dehydratedRouter: { + matches: [{ id: 'products' }], + dehydratedData, + }, + }, + }), + ); + + expect(hydrateCallback).toHaveBeenCalledTimes(1); + expect(hydrateCallback).toHaveBeenCalledWith(dehydratedData); + }); + + it('waits for async router.options.hydrate before triggering post-hydration router.load', async () => { + const router = buildRouter(); + let resolveHydrate: (() => void) | undefined; + const hydrateCallback = jest.fn().mockImplementation( () => new Promise((resolve) => { - resolveLoad = resolve; + resolveHydrate = resolve; }), ); + router.options = { hydrate: hydrateCallback }; + router.load = jest.fn().mockResolvedValue(undefined); const options = { createRouter: () => router, @@ -311,7 +733,10 @@ describe('tanstack-router integration (Pro)', () => { const props = { __tanstackRouterDehydratedState: { url: '/products?category=tools', - dehydratedRouter: { matches: [{ id: 'products' }] }, + dehydratedRouter: { + matches: [{ id: 'products' }], + dehydratedData: { queryCache: { products: ['hammer'] } }, + }, }, }; const clientApp = renderFn(props, { @@ -324,14 +749,294 @@ describe('tanstack-router integration (Pro)', () => { await compatAct(async () => { root.render(React.createElement(clientApp as React.ComponentType>, props)); + await Promise.resolve(); }); - expect(router.load).toHaveBeenCalledTimes(1); - expect(router.ssr).toEqual({ manifest: undefined }); + expect(hydrateCallback).toHaveBeenCalledTimes(1); + expect(router.load).not.toHaveBeenCalled(); - if (!resolveLoad) { - throw new Error('Expected router.load to be invoked during hydration.'); - } + await compatAct(async () => { + resolveHydrate?.(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(router.load).toHaveBeenCalledTimes(1); + + await compatAct(async () => { + root.unmount(); + }); + }); + + it('does not call router.load when async router.options.hydrate rejects', async () => { + const router = buildRouter(); + let rejectHydrate: ((reason?: unknown) => void) | undefined; + const hydrationError = new Error('hydrate failed'); + const hydrateCallback = jest.fn().mockImplementation( + () => + new Promise((_resolve, reject) => { + rejectHydrate = reject; + }), + ); + router.options = { hydrate: hydrateCallback }; + router.load = jest.fn().mockResolvedValue(undefined); + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + const options = { + createRouter: () => router, + }; + const deps = { + RouterProvider: (_props: { router: TanStackRouter }) => React.createElement('div'), + createMemoryHistory: jest.fn(), + createBrowserHistory: jest.fn().mockReturnValue({ + location: { + pathname: '/products', + search: '?category=tools', + hash: '', + href: '/products?category=tools', + state: null, + }, + }), + }; + + const renderFn = createTanStackRouterRenderFunction(options, deps); + const props = { + __tanstackRouterDehydratedState: { + url: '/products?category=tools', + dehydratedRouter: { + matches: [{ id: 'products' }], + dehydratedData: { queryCache: { products: ['hammer'] } }, + }, + }, + }; + const clientApp = renderFn(props, { + serverSide: false, + pathname: '/products', + search: '?category=tools', + } as unknown as RailsContext); + const container = document.createElement('div'); + const root = createRoot(container); + + try { + await compatAct(async () => { + root.render(React.createElement(clientApp as React.ComponentType>, props)); + await Promise.resolve(); + }); + + expect(hydrateCallback).toHaveBeenCalledTimes(1); + expect(router.load).not.toHaveBeenCalled(); + expect(router.ssr).toEqual({ manifest: undefined }); + + if (!rejectHydrate) { + throw new Error('Expected router.options.hydrate to be invoked during hydration.'); + } + + await compatAct(async () => { + rejectHydrate?.(hydrationError); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(router.load).not.toHaveBeenCalled(); + expect(router.ssr).toBeUndefined(); + expect(errorSpy).toHaveBeenCalledWith( + 'react-on-rails-pro/tanstack-router: Error loading routes after hydration:', + hydrationError, + ); + + await compatAct(async () => { + root.unmount(); + }); + } finally { + errorSpy.mockRestore(); + } + }); + + it('does not call router.load after unmount if async hydration resolves later', async () => { + const router = buildRouter(); + let resolveHydrate: (() => void) | undefined; + const hydrateCallback = jest.fn().mockImplementation( + () => + new Promise((resolve) => { + resolveHydrate = resolve; + }), + ); + router.options = { hydrate: hydrateCallback }; + router.load = jest.fn().mockResolvedValue(undefined); + + const options = { + createRouter: () => router, + }; + const deps = { + RouterProvider: (_props: { router: TanStackRouter }) => React.createElement('div'), + createMemoryHistory: jest.fn(), + createBrowserHistory: jest.fn().mockReturnValue({ + location: { + pathname: '/products', + search: '?category=tools', + hash: '', + href: '/products?category=tools', + state: null, + }, + }), + }; + + const renderFn = createTanStackRouterRenderFunction(options, deps); + const props = { + __tanstackRouterDehydratedState: { + url: '/products?category=tools', + dehydratedRouter: { + matches: [{ id: 'products' }], + dehydratedData: { queryCache: { products: ['hammer'] } }, + }, + }, + }; + const clientApp = renderFn(props, { + serverSide: false, + pathname: '/products', + search: '?category=tools', + } as unknown as RailsContext); + const container = document.createElement('div'); + const root = createRoot(container); + + await compatAct(async () => { + root.render(React.createElement(clientApp as React.ComponentType>, props)); + await Promise.resolve(); + }); + + expect(hydrateCallback).toHaveBeenCalledTimes(1); + expect(router.load).not.toHaveBeenCalled(); + + await compatAct(async () => { + root.unmount(); + }); + + if (!resolveHydrate) { + throw new Error('Expected router.options.hydrate to be invoked during hydration.'); + } + + await compatAct(async () => { + resolveHydrate?.(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(router.load).not.toHaveBeenCalled(); + }); + + it('initializes hydration store injection once per created router in StrictMode', async () => { + const setStateCalls: jest.Mock[] = []; + const options = { + createRouter: jest.fn().mockImplementation(() => { + const router = buildRouter(); + const setState = jest.fn(); + router.__store = { setState }; + setStateCalls.push(setState); + return router; + }), + }; + const deps = { + RouterProvider: (_props: { router: TanStackRouter }) => React.createElement('div'), + createMemoryHistory: jest.fn(), + createBrowserHistory: jest.fn().mockReturnValue({ + location: { + pathname: '/products', + search: '?category=tools', + hash: '', + href: '/products?category=tools', + state: null, + }, + }), + }; + + const renderFn = createTanStackRouterRenderFunction(options, deps); + const props = { + __tanstackRouterDehydratedState: { + url: '/products?category=tools', + dehydratedRouter: { matches: [{ id: 'products' }] }, + }, + }; + const clientApp = renderFn(props, { + serverSide: false, + pathname: '/products', + search: '?category=tools', + } as unknown as RailsContext); + const container = document.createElement('div'); + const root = createRoot(container); + + await compatAct(async () => { + root.render( + React.createElement( + React.StrictMode, + null, + React.createElement(clientApp as React.ComponentType>, props), + ), + ); + await Promise.resolve(); + }); + + expect(setStateCalls.length).toBeGreaterThan(0); + setStateCalls.forEach((setState) => { + expect(setState).toHaveBeenCalledTimes(1); + }); + + await compatAct(async () => { + root.unmount(); + }); + }); + + it('clears router.ssr after the post-hydration legacy load settles', async () => { + const router = buildRouter(); + let resolveLoad: (() => void) | undefined; + router.load = jest.fn().mockImplementation( + () => + new Promise((resolve) => { + resolveLoad = resolve; + }), + ); + + const options = { + createRouter: () => router, + }; + const deps = { + RouterProvider: (_props: { router: TanStackRouter }) => React.createElement('div'), + createMemoryHistory: jest.fn(), + createBrowserHistory: jest.fn().mockReturnValue({ + location: { + pathname: '/products', + search: '?category=tools', + hash: '', + href: '/products?category=tools', + state: null, + }, + }), + }; + + const renderFn = createTanStackRouterRenderFunction(options, deps); + const props = { + __tanstackRouterDehydratedState: { + url: '/products?category=tools', + dehydratedRouter: { matches: [{ id: 'products' }] }, + }, + }; + const clientApp = renderFn(props, { + serverSide: false, + pathname: '/products', + search: '?category=tools', + } as unknown as RailsContext); + const container = document.createElement('div'); + const root = createRoot(container); + + await compatAct(async () => { + root.render(React.createElement(clientApp as React.ComponentType>, props)); + }); + + expect(router.load).toHaveBeenCalledTimes(1); + expect(router.ssr).toEqual({ manifest: undefined }); + + if (!resolveLoad) { + throw new Error('Expected router.load to be invoked during hydration.'); + } await compatAct(async () => { resolveLoad?.(); @@ -347,6 +1052,127 @@ describe('tanstack-router integration (Pro)', () => { }); }); + it('clears router.ssr in cleanup when post-hydration load does not settle', async () => { + const router = buildRouter(); + router.load = jest.fn().mockImplementation(() => new Promise(() => {})); + const cancelLoad = jest.fn(); + (router as TanStackRouter & { cancelLoad?: () => void }).cancelLoad = cancelLoad; + + const options = { + createRouter: () => router, + }; + const deps = { + RouterProvider: (_props: { router: TanStackRouter }) => React.createElement('div'), + createMemoryHistory: jest.fn(), + createBrowserHistory: jest.fn().mockReturnValue({ + location: { + pathname: '/products', + search: '?category=tools', + hash: '', + href: '/products?category=tools', + state: null, + }, + }), + }; + + const renderFn = createTanStackRouterRenderFunction(options, deps); + const props = { + __tanstackRouterDehydratedState: { + url: '/products?category=tools', + dehydratedRouter: { matches: [{ id: 'products' }] }, + }, + }; + const clientApp = renderFn(props, { + serverSide: false, + pathname: '/products', + search: '?category=tools', + } as unknown as RailsContext); + const container = document.createElement('div'); + const root = createRoot(container); + + await compatAct(async () => { + root.render(React.createElement(clientApp as React.ComponentType>, props)); + }); + + expect(router.ssr).toEqual({ manifest: undefined }); + + await compatAct(async () => { + root.unmount(); + }); + + expect(router.ssr).toBeUndefined(); + expect(cancelLoad).toHaveBeenCalledTimes(1); + }); + + it('preserves user-provided router.ssr after post-hydration load settles', async () => { + const preconfiguredSsr = { manifest: { source: 'user' } }; + const router = buildRouter(); + router.ssr = preconfiguredSsr; + + let resolveLoad: (() => void) | undefined; + router.load = jest.fn().mockImplementation( + () => + new Promise((resolve) => { + resolveLoad = resolve; + }), + ); + + const options = { + createRouter: () => router, + }; + const deps = { + RouterProvider: (_props: { router: TanStackRouter }) => React.createElement('div'), + createMemoryHistory: jest.fn(), + createBrowserHistory: jest.fn().mockReturnValue({ + location: { + pathname: '/products', + search: '?category=tools', + hash: '', + href: '/products?category=tools', + state: null, + }, + }), + }; + + const renderFn = createTanStackRouterRenderFunction(options, deps); + const props = { + __tanstackRouterDehydratedState: { + url: '/products?category=tools', + dehydratedRouter: { matches: [{ id: 'products' }] }, + }, + }; + const clientApp = renderFn(props, { + serverSide: false, + pathname: '/products', + search: '?category=tools', + } as unknown as RailsContext); + const container = document.createElement('div'); + const root = createRoot(container); + + await compatAct(async () => { + root.render(React.createElement(clientApp as React.ComponentType>, props)); + }); + + expect(router.load).toHaveBeenCalledTimes(1); + expect(router.ssr).toBe(preconfiguredSsr); + + if (!resolveLoad) { + throw new Error('Expected router.load to be invoked during hydration.'); + } + + await compatAct(async () => { + resolveLoad?.(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(router.ssr).toBe(preconfiguredSsr); + + await compatAct(async () => { + root.unmount(); + }); + }); + it('does not throw on client hydration when the SSR payload has no dehydrated router data', () => { const router = buildRouter(); router.dehydrate = jest.fn().mockReturnValue(null);