Skip to content

Commit 11808c5

Browse files
justin808claude
andcommitted
Add TanStack Router SSR integration utility
Add createTanStackRouterRenderFunction() to the react-on-rails npm package, providing a render function factory for TanStack Router apps with server-side rendering support. This encapsulates the private API workarounds needed for synchronous SSR (router.__store.setState() and router.ssr flag) inside ShakaCode-maintained code, so consumer apps only use public APIs. Server-side: synchronously injects route matches and renders with renderToString. Client-side: hydrates with browser history and dehydrated router state from the server. Also includes an async path (serverRenderTanStackAppAsync) for use with React on Rails Pro NodeRenderer when rendering_returns_promises is enabled. Related: #2298, #2299 Ref: printivity/printivity#2571, shakacode/react-on-rails-demos#104 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b1b772b commit 11808c5

5 files changed

Lines changed: 466 additions & 2 deletions

File tree

packages/react-on-rails/package.json

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,11 +53,18 @@
5353
"./@internal/base/full": {
5454
"react-server": "./lib/base/full.rsc.js",
5555
"default": "./lib/base/full.js"
56-
}
56+
},
57+
"./tanstack-router": "./lib/tanstack-router/index.js"
5758
},
5859
"peerDependencies": {
5960
"react": ">= 16",
60-
"react-dom": ">= 16"
61+
"react-dom": ">= 16",
62+
"@tanstack/react-router": ">= 1.0.0"
63+
},
64+
"peerDependenciesMeta": {
65+
"@tanstack/react-router": {
66+
"optional": true
67+
}
6168
},
6269
"files": [
6370
"README.md",
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { createElement, useEffect, useRef, type ReactElement } from 'react';
2+
import type { TanStackRouter, TanStackRouterOptions, DehydratedRouterState } from './types.ts';
3+
import type { RailsContext } from '../types/index.ts';
4+
5+
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, no-underscore-dangle, import/prefer-default-export */
6+
7+
/**
8+
* Client-side hydration for a TanStack Router app.
9+
*
10+
* Flow:
11+
* 1. Create router with browser history
12+
* 2. Synchronously inject route matches (same as server) to prevent hydration mismatch
13+
* 3. Set router.ssr = true to skip auto-load on mount (Transitioner behavior)
14+
* 4. After hydration, trigger router.load() to enable client-side navigation
15+
* 5. Return a React component that renders RouterProvider
16+
*/
17+
export function clientHydrateTanStackApp(
18+
options: TanStackRouterOptions,
19+
props: Record<string, unknown>,
20+
_railsContext: RailsContext & { serverSide: false },
21+
RouterProvider: React.ComponentType<any>,
22+
createBrowserHistory: () => any,
23+
): ReactElement {
24+
const dehydratedState = props.__tanstackRouterDehydratedState as DehydratedRouterState | undefined;
25+
26+
// Create the app component that manages router lifecycle
27+
const AppComponent = () => {
28+
const routerRef = useRef<TanStackRouter | null>(null);
29+
30+
if (routerRef.current === null) {
31+
const router = options.createRouter();
32+
33+
// Set browser history for client-side navigation
34+
const browserHistory = createBrowserHistory();
35+
router.update({ history: browserHistory });
36+
37+
// Synchronously inject route matches to match server output.
38+
// This prevents hydration mismatches by ensuring the client renders
39+
// the same route tree as the server.
40+
if (typeof router.matchRoutes === 'function' && router.__store?.setState) {
41+
const matches = router.matchRoutes(router.state.location.pathname, router.state.location.search);
42+
router.__store.setState((s: Record<string, unknown>) => ({
43+
...s,
44+
status: 'idle',
45+
resolvedLocation: (s as { location: unknown }).location,
46+
matches,
47+
}));
48+
}
49+
50+
// Set SSR flag to prevent Transitioner from auto-calling router.load() on mount.
51+
// This avoids refetching data that was already loaded during SSR.
52+
(router as any).ssr = true;
53+
54+
// Hydrate router with dehydrated state from server
55+
if (dehydratedState?.dehydratedRouter && typeof router.hydrate === 'function') {
56+
router.hydrate(dehydratedState.dehydratedRouter);
57+
}
58+
59+
routerRef.current = router;
60+
}
61+
62+
const router = routerRef.current;
63+
64+
// After mount, trigger router.load() to enable client-side navigation.
65+
// The SSR flag prevented auto-loading, so we do it manually here.
66+
const isFirstRender = useRef(true);
67+
useEffect(() => {
68+
if (isFirstRender.current) {
69+
isFirstRender.current = false;
70+
71+
// If the router isn't fully loaded yet, trigger loading
72+
if (router.state.status !== 'idle') {
73+
router.load().catch((err: unknown) => {
74+
console.error('react-on-rails/tanstack-router: Error loading routes after hydration:', err);
75+
});
76+
}
77+
}
78+
}, []); // eslint-disable-line react-hooks/exhaustive-deps -- router is a ref, intentionally stable
79+
80+
let app: ReactElement = createElement(RouterProvider, { router });
81+
if (options.AppWrapper) {
82+
app = createElement(options.AppWrapper, { ...props, children: app } as any);
83+
}
84+
85+
return app;
86+
};
87+
88+
return createElement(AppComponent);
89+
}
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
/**
2+
* TanStack Router integration for React on Rails.
3+
*
4+
* This module provides utilities to use TanStack Router with React on Rails' SSR pipeline.
5+
* It encapsulates the workarounds needed for synchronous server-side rendering
6+
* (TanStack Router's route loading is async, but renderToString is sync).
7+
*
8+
* @example
9+
* ```typescript
10+
* import { createTanStackRouterRenderFunction } from 'react-on-rails/tanstack-router';
11+
* import { createRouter, RouterProvider, createMemoryHistory, createBrowserHistory } from '@tanstack/react-router';
12+
* import { routeTree } from './routeTree.gen';
13+
* import ReactOnRails from 'react-on-rails';
14+
*
15+
* const TanStackApp = createTanStackRouterRenderFunction(
16+
* {
17+
* createRouter: () => createRouter({ routeTree }),
18+
* },
19+
* { RouterProvider, createMemoryHistory, createBrowserHistory },
20+
* );
21+
*
22+
* ReactOnRails.register({ TanStackApp });
23+
* ```
24+
*
25+
* @remarks
26+
* This integration uses internal TanStack Router APIs for synchronous SSR.
27+
* ShakaCode maintains compatibility with TanStack Router versions.
28+
* If you encounter issues after upgrading @tanstack/react-router, update react-on-rails
29+
* or file an issue at https://github.com/shakacode/react_on_rails/issues
30+
*
31+
* @packageDocumentation
32+
*/
33+
34+
import type { RailsContext, RenderFunction, RenderFunctionResult } from '../types/index.ts';
35+
import type { TanStackRouterOptions } from './types.ts';
36+
import { serverRenderTanStackApp } from './serverRender.ts';
37+
import { clientHydrateTanStackApp } from './clientHydrate.ts';
38+
39+
export type { TanStackRouterOptions, DehydratedRouterState } from './types.ts';
40+
41+
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return */
42+
43+
interface TanStackRouterDeps {
44+
/**
45+
* The RouterProvider component from @tanstack/react-router.
46+
* We require this as a parameter to avoid a direct dependency on @tanstack/react-router.
47+
*/
48+
RouterProvider: React.ComponentType<any>;
49+
/**
50+
* The createMemoryHistory function from @tanstack/react-router.
51+
* Used for server-side rendering.
52+
*/
53+
createMemoryHistory: (opts: { initialEntries: string[] }) => any;
54+
/**
55+
* The createBrowserHistory function from @tanstack/react-router.
56+
* Used for client-side hydration.
57+
*/
58+
createBrowserHistory: () => any;
59+
}
60+
61+
/**
62+
* Creates a React on Rails render function for a TanStack Router application.
63+
*
64+
* This function returns a render function that can be registered with `ReactOnRails.register()`.
65+
* It handles both server-side rendering (with synchronous route matching) and client-side
66+
* hydration (with browser history).
67+
*
68+
* @param options - Configuration for the TanStack Router app
69+
* @param deps - TanStack Router dependencies (RouterProvider, createMemoryHistory, createBrowserHistory)
70+
* @returns A render function compatible with ReactOnRails.register()
71+
*
72+
* @example
73+
* ```typescript
74+
* import { createTanStackRouterRenderFunction } from 'react-on-rails/tanstack-router';
75+
* import { createRouter, RouterProvider, createMemoryHistory, createBrowserHistory } from '@tanstack/react-router';
76+
* import { routeTree } from './routeTree.gen';
77+
* import ReactOnRails from 'react-on-rails';
78+
*
79+
* const TanStackApp = createTanStackRouterRenderFunction(
80+
* {
81+
* createRouter: () => createRouter({ routeTree }),
82+
* },
83+
* { RouterProvider, createMemoryHistory, createBrowserHistory },
84+
* );
85+
*
86+
* ReactOnRails.register({ TanStackApp });
87+
* ```
88+
*/
89+
export function createTanStackRouterRenderFunction(
90+
options: TanStackRouterOptions,
91+
deps: TanStackRouterDeps,
92+
): RenderFunction {
93+
const { RouterProvider, createMemoryHistory, createBrowserHistory } = deps;
94+
95+
const renderFn = (
96+
props: Record<string, unknown> = {},
97+
railsContext?: RailsContext,
98+
): RenderFunctionResult => {
99+
if (!railsContext) {
100+
throw new Error(
101+
'react-on-rails/tanstack-router: railsContext is required. ' +
102+
'Ensure the component is rendered via react_component helper.',
103+
);
104+
}
105+
106+
if (railsContext.serverSide) {
107+
// Server-side: return a ReactElement that will be passed to renderToString.
108+
// The createReactOutput pipeline handles ReactElement results from render functions
109+
// (it calls renderToString on them).
110+
return serverRenderTanStackApp(
111+
options,
112+
props,
113+
railsContext as RailsContext & { serverSide: true },
114+
RouterProvider,
115+
createMemoryHistory,
116+
) as any;
117+
}
118+
119+
// Client-side: return a ReactElement that React on Rails will hydrate/render.
120+
return clientHydrateTanStackApp(
121+
options,
122+
props,
123+
railsContext as RailsContext & { serverSide: false },
124+
RouterProvider,
125+
createBrowserHistory,
126+
) as any;
127+
};
128+
129+
// Mark as a render function so React on Rails executes it rather than treating it
130+
// as a React component.
131+
renderFn.renderFunction = true as const;
132+
133+
return renderFn;
134+
}

0 commit comments

Comments
 (0)