diff --git a/CHANGELOG.md b/CHANGELOG.md index e103703220..e6d8270c28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ After a release, run `/update-changelog` in Claude Code to analyze commits, writ #### Fixed - **[Pro]** Streaming server-render responses now raise `ReactOnRailsPro::Error` when the stream response status is unavailable or the renderer delivers a readable HTTP error status as a streaming body, instead of silently returning no chunks. This is a user-visible behavior change for callers that do not already rescue `ReactOnRailsPro::Error` from `each_chunk`. [PR 3383](https://github.com/shakacode/react_on_rails/pull/3383). +- **[Pro]** **TanStack Router hydration no longer double-calls `loadRouteChunk` under React 18 StrictMode**: React 18's StrictMode double-renders components with fresh hook state on each pass, so the `routerRef.current === null` guard in `clientHydrate.ts` fired twice when `options.createRouter` returned the same router instance, re-running `loadRouteChunk`, `__store.setState`, and the user-defined `hydrate` callback. The render-phase init is now memoized via a module-level `WeakMap` keyed on the router instance, dedup'ing per-router side effects across mount cycles. Production behavior is unchanged because each mount creates a fresh router. Fixes [Issue 3405](https://github.com/shakacode/react_on_rails/issues/3405). [PR 3410](https://github.com/shakacode/react_on_rails/pull/3410) by [justin808](https://github.com/justin808). - **[Pro]** **Benchmark CI starts the production dummy app on the expected port**: `react_on_rails_pro/spec/dummy/bin/prod` now sets `PORT=3001` by default before launching Foreman, preventing Foreman's default `PORT=5000` from making the Rails server miss the benchmark workflow readiness check on `localhost:3001`. Both `react_on_rails/spec/dummy/bin/prod` and `react_on_rails_pro/spec/dummy/bin/prod` respect `PORT` when it's set. [PR 3403](https://github.com/shakacode/react_on_rails/pull/3403) by [alexeyr-ci2](https://github.com/alexeyr-ci2). ### [16.7.0.rc.2] - 2026-05-24 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 9de03dde2e..ed3389f0a3 100644 --- a/packages/react-on-rails-pro/src/tanstack-router/clientHydrate.ts +++ b/packages/react-on-rails-pro/src/tanstack-router/clientHydrate.ts @@ -24,6 +24,19 @@ type TanStackRouterChunkPreloadInternals = TanStackRouter & { looseRoutesById?: Record; }; +// Shared across mounts of the same router instance. React 18's StrictMode +// double-renders the component with fresh hook state on each pass, so a +// useRef guard alone does not prevent the render-phase init from running +// twice when options.createRouter returns the same router (e.g. user-cached). +// Keying on the router instance via WeakMap leaves production behavior +// unchanged (each mount creates a fresh router and gets a fresh init). +interface SharedHydrationInitState { + routeChunkPreloadPromise: Promise | null; + hydrationCallbackPromise: Promise | null; + didSetSsrFlag: boolean; +} +const sharedHydrationInitStates = new WeakMap(); + function extractDehydratedData(dehydratedRouter: unknown): unknown { if (!dehydratedRouter || typeof dehydratedRouter !== 'object') { return undefined; @@ -194,107 +207,130 @@ function TanStackHydrationApp({ // 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. + // + // Cross-mount invariant: sharedHydrationInitStates dedupes per-router + // side effects (loadRouteChunk, __store.setState, options.hydrate) across + // React 18 StrictMode's double-render-with-fresh-hooks behavior — which + // resets useRef on each pass, so this `routerRef.current === null` guard + // alone fires twice when options.createRouter returns the same instance. const router = options.createRouter(); - - // Set browser history for client-side navigation - const browserHistory = createBrowserHistory(); - router.update({ history: browserHistory }); - - // 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, - ); - 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; - }); - - // 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), + const cachedInit = sharedHydrationInitStates.get(router); + + if (cachedInit) { + // Same router instance was already initialized by a discarded render + // (or prior mount). Reattach the pending preload/hydrate promises to + // this mount's refs so the post-hydration effect awaits the original + // work; restore didSetSsrFlag so cleanup correctly clears router.ssr. + routeChunkPreloadPromiseRef.current = cachedInit.routeChunkPreloadPromise; + hydrationCallbackPromiseRef.current = cachedInit.hydrationCallbackPromise; + didSetSsrFlagRef.current = cachedInit.didSetSsrFlag; + } else { + // Set browser history for client-side navigation + const browserHistory = createBrowserHistory(); + router.update({ history: browserHistory }); + + // 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.', ); - // 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); + // 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.', + ); } - } 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; + 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, + ); + 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; + }); + + // 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; } - throw error; } + + sharedHydrationInitStates.set(router, { + routeChunkPreloadPromise: routeChunkPreloadPromiseRef.current, + hydrationCallbackPromise: hydrationCallbackPromiseRef.current, + didSetSsrFlag: didSetSsrFlagRef.current, + }); } routerRef.current = router; @@ -373,6 +409,14 @@ function TanStackHydrationApp({ if (latestEffectRunIdRef.current === effectRunId && didSetSsrFlagRef.current) { router.ssr = undefined; didSetSsrFlagRef.current = false; + // Keep sharedHydrationInitStates in sync so a later mount of the + // same cached router doesn't restore a stale didSetSsrFlag=true and + // trigger the dev sanity-check warning below on a router whose ssr + // flag was already cleared correctly. + const cached = sharedHydrationInitStates.get(router); + if (cached) { + cached.didSetSsrFlag = false; + } } }); @@ -385,6 +429,10 @@ function TanStackHydrationApp({ if (latestEffectRunIdRef.current === effectRunId && didSetSsrFlagRef.current) { router.ssr = undefined; didSetSsrFlagRef.current = false; + const cached = sharedHydrationInitStates.get(router); + if (cached) { + cached.didSetSsrFlag = false; + } } }); const cancellableRouter = router as TanStackRouter & { cancelLoad?: () => void }; diff --git a/packages/react-on-rails-pro/tests/tanstackRouter.test.ts b/packages/react-on-rails-pro/tests/tanstackRouter.test.ts index 4d16ec1902..42144acf51 100644 --- a/packages/react-on-rails-pro/tests/tanstackRouter.test.ts +++ b/packages/react-on-rails-pro/tests/tanstackRouter.test.ts @@ -1687,6 +1687,96 @@ describe('tanstack-router integration (Pro)', () => { }); }); + it('does not warn about cleared router.ssr after full remount of a cached router', async () => { + // Regression test for the WeakMap cache: after a full unmount+remount of + // the same router instance, the cached didSetSsrFlag must reflect the + // post-hydration clear — otherwise the dev sanity-check below at + // clientHydrate.ts:361 would falsely warn that router.ssr "was unexpectedly + // cleared" on the second mount even though we cleared it correctly. + const router = buildRouter(); + const productsRoute = { id: '/products' }; + const loadRouteChunk = jest.fn().mockResolvedValue(undefined); + (router.matchRoutes as jest.Mock).mockReturnValue([ + { id: '/products', routeId: '/products', status: 'pending', updatedAt: 0, loaderData: undefined }, + ]); + router.looseRoutesById = { '/products': productsRoute }; + router.loadRouteChunk = loadRouteChunk; + router.options = { hydrate: undefined }; + + const originalNodeEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'development'; + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + const renderFn = createTanStackRouterRenderFunction( + { createRouter: () => router }, + { + RouterProvider: (_: { 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 props = { + __tanstackRouterDehydratedState: { + url: '/products?category=tools', + dehydratedRouter: { matches: [{ id: 'products' }] }, + }, + }; + const Client = renderFn(props, { + serverSide: false, + pathname: '/products', + search: '?category=tools', + } as unknown as RailsContext) as React.ComponentType>; + + try { + // First mount: render-phase init runs, post-hydration effect clears router.ssr. + const container1 = document.createElement('div'); + const root1 = createRoot(container1); + await compatAct(async () => { + root1.render(React.createElement(Client, props)); + await new Promise((r) => { + setTimeout(r, 0); + }); + }); + expect(router.ssr).toBeUndefined(); + await compatAct(async () => { + root1.unmount(); + }); + + // Second mount of the same cached router. The render-phase guard hits + // the WeakMap and restores didSetSsrFlag from the cache. If the cache + // had been left stale at didSetSsrFlag=true, the dev sanity check would + // fire here because router.ssr is already null on the cached router. + warnSpy.mockClear(); + const container2 = document.createElement('div'); + const root2 = createRoot(container2); + await compatAct(async () => { + root2.render(React.createElement(Client, props)); + await new Promise((r) => { + setTimeout(r, 0); + }); + }); + + expect(warnSpy).not.toHaveBeenCalledWith( + expect.stringContaining('router.ssr was unexpectedly cleared'), + ); + + await compatAct(async () => { + root2.unmount(); + }); + } finally { + process.env.NODE_ENV = originalNodeEnv; + warnSpy.mockRestore(); + } + }); + it('renders RouterProvider directly without an enclosing Suspense boundary during hydration', () => { // Regression test for the Suspense-gate removal: serverRender.ts's // buildAppElement emits AppWrapper > RouterProvider with no Suspense