Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
236 changes: 142 additions & 94 deletions packages/react-on-rails-pro/src/tanstack-router/clientHydrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,19 @@ type TanStackRouterChunkPreloadInternals = TanStackRouter & {
looseRoutesById?: Record<string, unknown>;
};

// 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<void> | null;
hydrationCallbackPromise: Promise<void> | null;
didSetSsrFlag: boolean;
}
const sharedHydrationInitStates = new WeakMap<TanStackRouter, SharedHydrationInitState>();

function extractDehydratedData(dehydratedRouter: unknown): unknown {
if (!dehydratedRouter || typeof dehydratedRouter !== 'object') {
return undefined;
Expand Down Expand Up @@ -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<string, unknown>;
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<string, unknown>) => ({
...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.
Comment thread
justin808 marked this conversation as resolved.
routeChunkPreloadPromiseRef.current = cachedInit.routeChunkPreloadPromise;
hydrationCallbackPromiseRef.current = cachedInit.hydrationCallbackPromise;
didSetSsrFlagRef.current = cachedInit.didSetSsrFlag;
Comment on lines +219 to +226

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reapply temporary router.ssr flag on cached remounts

When sharedHydrationInitStates hits, this branch only restores cached refs and skips the SSR-flag setup path, so a real unmount/remount that reuses the same router can start a hydration mount with router.ssr already cleared. In this file’s own contract, that flag is what prevents Transitioner from auto-calling router.load() before post-hydration sequencing; skipping it on remount allows an early load (and then another from runPostHydrationLoad), which can race hydration state/chunk preloads and reintroduce hydration mismatch behavior for cached-router setups.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment says "discarded render (or prior mount)" which covers StrictMode, but it's worth making explicit that this path also fires on a genuine full unmount → remount of the same router instance (non-StrictMode). On that path createBrowserHistory(), __store.setState(), and options.hydrate are all skipped because they already ran during the first mount. That's safe — the router already carries the correct state — but it's a non-obvious invariant. A short note like:

// Also fires on full unmount → remount of the same router (non-StrictMode).
// Safe to skip re-init because the router already has the browser history
// and injected match state from the first mount.

would help future readers who may only see the StrictMode framing.

} 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<string, unknown>;
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<string, unknown>) => ({
...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,
});
Comment thread
justin808 marked this conversation as resolved.
Comment on lines +329 to +333

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Stale didSetSsrFlag in WeakMap after first successful mount

The WeakMap entry persists with didSetSsrFlag: true after the first mount's finally block clears router.ssr and sets didSetSsrFlagRef.current = false. In a "cached router, full unmount → remount" scenario (e.g. test suites that cache a router and re-render after cleanup), a second mount hits the WeakMap, restores didSetSsrFlagRef.current = true, then fires the dev sanity check at line 361: didSetSsrFlagRef.current && router.ssr == null — triggering the "unexpectedly cleared" warning even though router.ssr was correctly cleared by the previous mount's effect, not by a TanStack Router version change.

A minimal mitigation would be to delete the WeakMap entry in the effect's cleanup when the mount fully tears down, or to cap the cached didSetSsrFlag at the actual current value of router.ssr at hit time rather than at write time.

Comment thread
justin808 marked this conversation as resolved.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The set() call being outside (after) the try/catch block is intentionally correct: if options.hydrate or router.hydrate() throws, the catch block clears router.ssr, re-throws, and the WeakMap is never populated. The next render (StrictMode's second pass or a React retry) therefore gets a WeakMap miss and runs a full, clean init rather than reattaching state from a partially-failed mount. Worth a one-liner to preserve this intent:

// Outside the try/catch intentionally: a throw leaves the WeakMap unpopulated
// so the next render gets a full retry rather than reusing partial state.
sharedHydrationInitStates.set(router, {

}

routerRef.current = router;
Expand Down Expand Up @@ -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;
}
}
});

Expand All @@ -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 };
Expand Down
90 changes: 90 additions & 0 deletions packages/react-on-rails-pro/tests/tanstackRouter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, unknown>>;

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
Expand Down
Loading