Skip to content
Open
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
2 changes: 2 additions & 0 deletions .lychee.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,10 @@ exclude = [
'^https://careersatdoordash\.com', # Cloudflare bot challenge returns 403
'^https://www\.linkedin\.com',
'^https://reactrails\.slack\.com',
'^https://invite\.reactrails\.com', # Slack invite endpoint returns 403 for automated requests
'^https://docs\.google\.com', # Private docs require auth
'^https://claude\.ai', # Returns 403 for automated requests
'^https://(www\.)?110grill\.com', # Popmenu/Cloudflare bot challenge returns 403

# ============================================================================
# KNOWN DEAD OR PROBLEMATIC URLS
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ After a release, run `/update-changelog` in Claude Code to analyze commits, writ

#### Fixed

- **[Pro]** **TanStack Router hydration no longer bails to a full client re-render**: `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. Chunk-preload sequencing for post-hydration navigation is preserved by awaiting the preload promise in `runPostHydrationLoad` before `router.load()`. [PR 3213](https://github.com/shakacode/react_on_rails/pull/3213) by [Seifeldin7](https://github.com/Seifeldin7).
- **[Pro]** **Node renderer now exposes `performance` when `supportModules: true`**: React 19's development build of `React.lazy` calls `performance.now()`, which previously threw `ReferenceError: performance is not defined` inside the node renderer's VM context unless users manually added `performance` via `additionalContext`. `performance` is now included in the default globals alongside `Buffer`, `process`, etc. Fixes [Issue 3154](https://github.com/shakacode/react_on_rails/issues/3154). [PR 3158](https://github.com/shakacode/react_on_rails/pull/3158) by [justin808](https://github.com/justin808).
- **Client startup now recovers if initialization begins during `interactive` after `DOMContentLoaded` already fired**: React on Rails now still initializes the page when the client bundle starts in the browser timing window after `DOMContentLoaded` but before the document reaches `complete`. Fixes [Issue 3150](https://github.com/shakacode/react_on_rails/issues/3150). [PR 3151](https://github.com/shakacode/react_on_rails/pull/3151) by [ihabadham](https://github.com/ihabadham).
- **Doctor accepts TypeScript server bundle entrypoints**: `react_on_rails:doctor` now resolves common source entrypoint suffixes (`.js`, `.jsx`, `.ts`, `.tsx`, `.mjs`, `.cjs`) before warning that the server bundle is missing, preventing false positives when apps use `server-bundle.ts`. [PR 3111](https://github.com/shakacode/react_on_rails/pull/3111) by [justin808](https://github.com/justin808).
Expand Down
67 changes: 8 additions & 59 deletions packages/react-on-rails-pro/src/tanstack-router/clientHydrate.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Suspense, createElement, useEffect, useRef, type ReactElement } from 'react';
import { createElement, useEffect, useRef, type ReactElement } from 'react';
import type {
DehydratedRouterState,
TanStackHistory,
Expand All @@ -22,32 +22,6 @@ type TanStackRouterChunkPreloadInternals = TanStackRouter & {
looseRoutesById?: Record<string, unknown>;
};

interface RouteChunkPreloadGateProps {
preloadPromise: Promise<void> | 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;
Expand Down Expand Up @@ -183,8 +157,9 @@ function TanStackHydrationApp({
const routerRef = useRef<TanStackRouter | null>(null);
const didTriggerPostHydrationLoadRef = useRef(false);
const didSetSsrFlagRef = useRef(false);
// Set during render-phase SSR init; awaited in runPostHydrationLoad before
// router.load() so post-hydration navigation waits for matched lazy chunks.
const routeChunkPreloadPromiseRef = useRef<Promise<void> | null>(null);
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.

This ref is still used correctly — it is populated at line ~231 and awaited in runPostHydrationLoad before router.load(). Consider a short inline comment here (or a JSDoc on runPostHydrationLoad) clarifying that this is the only remaining consumer, so a future reader doesn't assume it is dead code after the gate removal.

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 explains the history of what was removed rather than explaining the current invariant. Since future readers won't know what was removed, the backward-looking framing ("since the Suspense/gate wrapper was removed") is confusing. Consider simplifying to just the forward-looking contract:

Suggested change
const routeChunkPreloadPromiseRef = useRef<Promise<void> | null>(null);
// Set during render-phase SSR init; awaited in runPostHydrationLoad before
// router.load() so post-hydration navigation waits for matched lazy chunks.
const routeChunkPreloadPromiseRef = useRef<Promise<void> | null>(null);

const routeChunkPreloadSettledRef = useRef(true);
const hydrationCallbackPromiseRef = useRef<Promise<void> | null>(null);
const didWarnPrivateInternalsRef = useRef(false);
const warnedMissingSsrMatchIdsRef = useRef<Set<string>>(new Set());
Expand Down Expand Up @@ -259,14 +234,6 @@ function TanStackHydrationApp({
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)
Expand Down Expand Up @@ -397,29 +364,11 @@ function TanStackHydrationApp({
};
}, [hasSsrPayload, router]);

// Always use RouterProvider directly — matching the server-rendered tree.
// RouterClient is NOT used because it wraps RouterProvider in <Await> 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 }),
),
);
// Render RouterProvider directly — matching the server-rendered tree
// (AppWrapper > RouterProvider). Any extra Suspense boundary here produces
// a shape mismatch during hydration and React bails to full client-side
// rendering.
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 old RouteChunkPreloadGate also suspended post-hydration re-renders while chunk promises were in-flight (the gate only skipped suspension on the initial hydration render). That protection is intentionally dropped here — the router.ssr flag prevents Transitioner-initiated navigation during this window, and the window itself is very short.

Worth leaving a note in the comment so a future reader does not assume the gate needs to be re-introduced:

Suggested change
// rendering.
// Render RouterProvider directly — matching the server-rendered tree
// (AppWrapper > RouterProvider). Any extra Suspense boundary here produces
// a shape mismatch during hydration and React bails to full client-side
// rendering.
//
// Note: the old RouteChunkPreloadGate also suspended post-hydration
// re-renders while chunk promises were in-flight. That protection is
// intentionally absent here: router.ssr prevents Transitioner navigation
// during the window, and the window is short (mount effect → router.load()
// completion). Do not re-introduce a Suspense wrapper to restore it.
let app: ReactElement = createElement(RouterProvider, { router });

let app: ReactElement = createElement(RouterProvider, { router });
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.

This is the right fix. Even with the old isHydrating guard skipping the throw, the Suspense boundary itself was still present in the component tree, so React's reconciler saw Suspense > RouteChunkPreloadGate > RouterProvider on the client vs. RouterProvider on the server — a structural type-mismatch that causes a hydration bail-out regardless of whether the boundary ever suspends.

One thing worth adding to the comment: routeChunkPreloadPromiseRef is still awaited in runPostHydrationLoad (line ~321) before router.load(), so chunk-preload timing for post-hydration navigation is unchanged. The only semantic difference is that React can no longer suspend intermediate re-renders during the preload window — which is acceptable given how brief that window is in practice.

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.

This is the correct fix. One follow-up worth considering: there is no test that asserts this path never suspends. If a future change re-adds a Suspense wrapper here (e.g. thinking it only affects post-hydration), the hydration regression will be silent in tests but loud in production. A renderToString-based test that sets loadRouteChunk to a pending promise and verifies the component renders without suspending would lock this in.

Suggested change
let app: ReactElement = createElement(RouterProvider, { router });
let app: ReactElement = createElement(RouterProvider, { router });

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.

This is the right fix. Worth noting the behavioral shift: the old RouteChunkPreloadGate would re-render-block via Suspense (null fallback) after hydration if lazy chunks were still loading. With that gone, the deferred await routeChunkPreloadPromiseRef.current inside runPostHydrationLoad is now the only thing preventing router.load() from firing early. The test suite covers that loadRouteChunk is called, but there's no async test verifying the ordering (router.load is not called until the preload promise settles). A test similar to waits for async router.options.hydrate before triggering post-hydration router.load but for the chunk-preload path would lock in that invariant against future refactors.

Comment thread
Seifeldin7 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 comment explains the hydration-mismatch side of this decision well. Worth adding one sentence about the re-render behavior change:

Suggested change
let app: ReactElement = createElement(RouterProvider, { router });
// Render RouterProvider directly — matching the server-rendered tree
// (AppWrapper > RouterProvider). Any extra Suspense boundary here produces
// a shape mismatch during hydration and React bails to full client-side
// rendering. Note: the former RouteChunkPreloadGate that suspended re-renders
// during chunk preload is intentionally not replicated here — chunk-preload
// sequencing is now enforced by awaiting routeChunkPreloadPromiseRef in
// runPostHydrationLoad before router.load(), so free re-renders during that
// window are safe and avoid the null-fallback flash the gate caused.

if (options.AppWrapper) {
const wrapperProps = { ...incomingProps } as Record<string, unknown>;
delete wrapperProps.__tanstackRouterDehydratedState;
Expand Down
137 changes: 137 additions & 0 deletions packages/react-on-rails-pro/tests/tanstackRouter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1364,4 +1364,141 @@ describe('tanstack-router integration (Pro)', () => {
},
});
});

it('waits for the matched route chunk preload promise before triggering post-hydration router.load', async () => {
// Regression test for the Suspense-gate removal: with the gate gone,
// RouterProvider renders directly during hydration. The chunk-preload
// behavior must still be preserved by awaiting routeChunkPreloadPromiseRef
// inside runPostHydrationLoad before calling router.load(); otherwise
// post-hydration navigation could race ahead of matched lazy chunks.
const router = buildRouter();
const productsRoute = { id: '/products' };
let resolveChunk: (() => void) | undefined;
const loadRouteChunk = jest.fn().mockImplementation(
() =>
new Promise<void>((resolve) => {
resolveChunk = resolve;
}),
);
(router.matchRoutes as jest.Mock).mockReturnValue([
{ id: '/products', routeId: '/products', status: 'pending', updatedAt: 0, loaderData: undefined },
]);
router.looseRoutesById = { '/products': productsRoute };
router.loadRouteChunk = loadRouteChunk;
// No user-defined hydrate callback — isolate the chunk-preload await path.
router.options = {};
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.

This silently replaces the entire options object (including the hydrate: jest.fn() set by buildRouter()). The intent is clear from the comment, but being explicit about what's being suppressed reads better:

Suggested change
router.options = {};
// No user-defined hydrate callback — isolate the chunk-preload await path.
router.options = { hydrate: undefined };


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 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<Record<string, unknown>>, props));
await Promise.resolve();
});

// Chunk preload kicked off during render-phase init, but the post-hydration
// load must remain blocked until the preload promise settles.
expect(loadRouteChunk).toHaveBeenCalledTimes(1);
expect(loadRouteChunk).toHaveBeenCalledWith(productsRoute);
expect(router.load).not.toHaveBeenCalled();

if (!resolveChunk) {
throw new Error('Expected loadRouteChunk to be invoked during render-phase init.');
}
const settleChunk = resolveChunk;

await compatAct(async () => {
settleChunk();
// Use a macrotask boundary so all queued microtasks (the preload
// promise's .then plus every await hop in runPostHydrationLoad) fully
// drain before we assert. Counting individual ticks is fragile:
// adding or removing a single await in runPostHydrationLoad would
// silently break the assertion below.
await new Promise((r) => {
setTimeout(r, 0);
});
});

expect(router.load).toHaveBeenCalledTimes(1);

await compatAct(async () => {
root.unmount();
});
});

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
// boundary. Any extra Suspense in the client tree produces a shape
// mismatch and React bails to a full client-side re-render.
const router = buildRouter();
const renderFn = createTanStackRouterRenderFunction(
{ createRouter: () => router },
{
RouterProvider: (_: { router: TanStackRouter }) =>
React.createElement('div', { 'data-testid': 'provider' }),
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 Client = renderFn(props, {
serverSide: false,
pathname: '/products',
search: '?category=tools',
} as unknown as RailsContext) as React.ComponentType<Record<string, unknown>>;

// The fix's strongest guarantee: the rendered HTML must be exactly the
// mock RouterProvider's output. With a Suspense wrapper (the bug),
// react-dom/server emits <!--$-->/<!--/$--> markers around the children
// even when the boundary does not actually suspend, so this equality
// assertion catches any wrapping Suspense boundary.
const html = renderToString(React.createElement(Client, props));
expect(html).toBe('<div data-testid="provider"></div>');
});
});
Loading