Skip to content

Commit d56f71f

Browse files
committed
Address TanStack hydration review follow-ups
1 parent 27c7ea1 commit d56f71f

3 files changed

Lines changed: 29 additions & 14 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ After a release, run `/update-changelog` in Claude Code to analyze commits, writ
4646

4747
#### Fixed
4848

49-
- **[Pro]** **TanStack Router hydration no longer bails to a full client re-render**: Chunk-preload sequencing for post-hydration navigation is preserved by awaiting the preload promise in `runPostHydrationLoad` before `router.load()`. `TanStackHydrationApp` previously wrapped `RouterProvider` in a `Suspense` + `RouteChunkPreloadGate` chain, but `serverRender.ts`'s `buildAppElement` emits `AppWrapper > RouterProvider` directly with no Suspense boundary. The extra client-side boundary produced a tree-shape mismatch during hydration, causing React to discard the SSR HTML and re-render from scratch. `RouterProvider` is now rendered directly so the client tree mirrors the server output exactly. [PR 3213](https://github.com/shakacode/react_on_rails/pull/3213) by [Seifeldin7](https://github.com/Seifeldin7).
49+
- **[Pro]** **TanStack Router hydration no longer bails to a full client re-render**: TanStack Router SSR pages no longer discard server-rendered HTML during hydration because the client tree now renders `RouterProvider` with the same shape as the server output. Post-hydration navigation still waits for matched lazy route chunks before `router.load()`. [PR 3213](https://github.com/shakacode/react_on_rails/pull/3213) by [Seifeldin7](https://github.com/Seifeldin7).
5050
- **[Pro]** **Allow patched ruby-jwt releases**: React on Rails Pro now requires `jwt >= 3.2.0`, removing the previous `~> 2.7` cap so applications can resolve the patched ruby-jwt release for the empty-key HMAC advisory. [PR 3322](https://github.com/shakacode/react_on_rails/pull/3322) by [ihabadham](https://github.com/ihabadham).
5151
- **[Pro]** **Pro migration generator rewrites all base-package references and preserves Gemfile pins**: `rails generate react_on_rails:pro` now rewrites Jest/Vitest mock helpers (`jest.mock`, `vi.mock`, `requireActual`/`importActual`, and the rest) and TypeScript `declare module 'react-on-rails'` blocks alongside its existing `import`/`require`/dynamic-import handling, and the Gemfile swap now preserves the user's existing version pin (and other gem options) instead of overwriting them with the running gem's version. `react_on_rails:doctor` is widened to match: it also flags stale side-effect imports (`import 'react-on-rails';`), Jest/Vitest mock helpers, and `declare module` blocks, and the new side-effect-import pattern keeps the doctor a superset of the rewriter so anything the rewriter doesn't reach gets surfaced. Closes [Issue 3104](https://github.com/shakacode/react_on_rails/issues/3104). [PR 3232](https://github.com/shakacode/react_on_rails/pull/3232) by [justin808](https://github.com/justin808).
5252
- **[Pro]** **Pro migration scans TypeScript 4.7 `.mts` and `.cts` modules**: `react_on_rails:doctor` and the Pro migration rewriter now include `.mts`/`.cts` source files (and their `.d.mts`/`.d.cts` declaration counterparts) when looking for stale `react-on-rails` references, matching the existing `.mjs`/`.cjs` coverage. Fixes [Issue 3250](https://github.com/shakacode/react_on_rails/issues/3250). [PR 3334](https://github.com/shakacode/react_on_rails/pull/3334) by [justin808](https://github.com/justin808).

packages/react-on-rails-pro/src/tanstack-router/clientHydrate.ts

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,15 @@ type ReactWithOptionalInsertionEffect = typeof React & {
1616
useInsertionEffect?: typeof useEffect;
1717
};
1818

19+
// Read from the React namespace instead of using a named import because the Pro
20+
// package supports React >= 16, where useInsertionEffect does not exist as an
21+
// export. In React 18+, useInsertionEffect is currently excluded from the
22+
// StrictMode passive/layout effect replay cycle relied on by CSS-in-JS
23+
// libraries; that makes it the narrowest hook for "real unmount only" cleanup.
24+
// React versions without this hook also do not have that StrictMode replay
25+
// behavior, so useEffect is an adequate fallback there.
1926
const { useInsertionEffect } = React as ReactWithOptionalInsertionEffect;
20-
const useRealUnmountEffect: typeof useEffect =
27+
const useSynchronousRealUnmountEffect: typeof useEffect =
2128
typeof useInsertionEffect === 'function' ? useInsertionEffect : useEffect;
2229

2330
type TanStackRouterHydrationInternals = TanStackRouter & {
@@ -167,7 +174,7 @@ function TanStackHydrationApp({
167174
const routerRef = useRef<TanStackRouter | null>(null);
168175
const didTriggerPostHydrationLoadRef = useRef(false);
169176
const didSetSsrFlagRef = useRef(false);
170-
const latestEffectRunIdRef = useRef(0);
177+
const latestEffectRunIdRef = useRef(0); // 0 = no post-hydration effect run yet.
171178
// Set during render-phase SSR init; awaited in runPostHydrationLoad before
172179
// router.load() so post-hydration navigation waits for matched lazy chunks.
173180
const routeChunkPreloadPromiseRef = useRef<Promise<void> | null>(null);
@@ -310,12 +317,10 @@ function TanStackHydrationApp({
310317

311318
const router = routerRef.current;
312319

313-
// Clear the temporary router.ssr flag synchronously on real unmount. In React
314-
// 18+, useInsertionEffect is not replayed by the StrictMode passive-effect
315-
// remount cycle, so this cleanup does not race the second useEffect setup.
316-
// React versions without useInsertionEffect also do not have that StrictMode
317-
// passive-effect remount behavior, so useEffect is an adequate fallback.
318-
useRealUnmountEffect(() => {
320+
// Clear the temporary router.ssr flag synchronously on real unmount. This is
321+
// the unmount path; the async finally() below is the normal settled/cancelled
322+
// path. didSetSsrFlagRef is the shared latch so exactly one path clears.
323+
useSynchronousRealUnmountEffect(() => {
319324
if (!router || !hasSsrPayload) {
320325
return undefined;
321326
}
@@ -372,6 +377,10 @@ function TanStackHydrationApp({
372377
return;
373378
}
374379
}
380+
// No final cancellation check is needed for the no-await fast path:
381+
// without pending hydration or preload promises, cleanup cannot run between
382+
// the checks above and this call. If unmount happens after router.load()
383+
// starts, the cleanup's cancelLoad() call handles the in-flight load.
375384
await router.load();
376385
};
377386

@@ -382,11 +391,14 @@ function TanStackHydrationApp({
382391
}
383392
})
384393
.finally(() => {
385-
// Always clear temporary router.ssr set by this module, regardless of
386-
// cancellation state, unless a StrictMode effect re-mount already
387-
// re-armed a newer post-hydration load run.
388-
// The didSetSsrFlagRef guard ensures we only clear values this module
389-
// created, preserving user-provided router.ssr from createRouter().
394+
// Invariant: temporary router.ssr is cleared by exactly one path:
395+
// 1. this finally block after the post-hydration load settles/cancels;
396+
// 2. the synchronous real-unmount cleanup above.
397+
// didSetSsrFlagRef is the shared latch, preserving user-provided
398+
// router.ssr from createRouter(). latestEffectRunIdRef prevents stale
399+
// StrictMode passive-effect finally blocks from racing a remount; React
400+
// runs passive cleanup/setup back-to-back before queued promise
401+
// continuations drain.
390402
if (latestEffectRunIdRef.current === effectRunId && didSetSsrFlagRef.current) {
391403
router.ssr = undefined;
392404
didSetSsrFlagRef.current = false;

packages/react-on-rails-pro/tests/tanstackRouter.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1120,6 +1120,8 @@ describe('tanstack-router integration (Pro)', () => {
11201120
};
11211121
const deps = {
11221122
RouterProvider: ({ router: providerRouter }: { router: TanStackRouter }) => {
1123+
// Intentional render-phase mutation for test isolation: simulate TanStack
1124+
// Router clearing the private flag before our post-hydration effect.
11231125
providerRouter.ssr = undefined;
11241126
return React.createElement('div');
11251127
},
@@ -1670,6 +1672,7 @@ describe('tanstack-router integration (Pro)', () => {
16701672
expect(router.ssr).toEqual({ manifest: undefined });
16711673

16721674
await compatAct(async () => {
1675+
expect(resolveChunks).toHaveLength(1);
16731676
resolveChunks.forEach((resolve) => resolve());
16741677
await new Promise((r) => {
16751678
setTimeout(r, 0);

0 commit comments

Comments
 (0)