Skip to content

Add TanStack Router SSR integration utility#2516

Open
justin808 wants to merge 8 commits intomasterfrom
jg/tanstack-query-support
Open

Add TanStack Router SSR integration utility#2516
justin808 wants to merge 8 commits intomasterfrom
jg/tanstack-query-support

Conversation

@justin808
Copy link
Member

@justin808 justin808 commented Mar 4, 2026

Summary

Adds createTanStackRouterRenderFunction() to the react-on-rails npm package, providing a render function factory for TanStack Router apps with server-side rendering support.

This addresses the need identified in printivity/printivity#2571 and react_on_rails-demos#104 where TanStack Router SSR requires private API workarounds (router.__store.setState() and router.ssr = true).

What this does

  • Encapsulates private API workarounds inside ShakaCode-maintained code, so consumer apps only use public APIs
  • Server-side: Synchronously injects route matches via __store.setState() and renders with renderToString
  • Client-side: Hydrates with browser history, dehydrated router state from server, and deferred router.load() after mount
  • Async path: serverRenderTanStackAppAsync() for Pro NodeRenderer with rendering_returns_promises (uses public await router.load() — no hacks needed)
  • Version safety: Validates internal APIs at runtime and throws clear errors if TanStack Router changes its internals

Usage

import { createTanStackRouterRenderFunction } from 'react-on-rails/tanstack-router';
import { createRouter, RouterProvider, createMemoryHistory, createBrowserHistory } from '@tanstack/react-router';
import { routeTree } from './routeTree.gen';
import ReactOnRails from 'react-on-rails';

const TanStackApp = createTanStackRouterRenderFunction(
  { createRouter: () => createRouter({ routeTree }) },
  { RouterProvider, createMemoryHistory, createBrowserHistory },
);

ReactOnRails.register({ TanStackApp });

Files changed

  • New: packages/react-on-rails/src/tanstack-router/ — 4 files (index, types, serverRender, clientHydrate)
  • Modified: packages/react-on-rails/package.json — new export + optional peer dependency

Future work (not in this PR)

Closes #2298

Test plan

  • TypeScript build passes: pnpm --filter react-on-rails run build
  • All 112 existing tests pass: pnpm --filter react-on-rails run test
  • ESLint passes: pnpm run lint
  • Prettier passes
  • Pre-commit hooks pass (trailing newlines, prettier, eslint)
  • Integration test with react_on_rails-demos TanStack Router demo app

🤖 Generated with Claude Code


Note

Cursor Bugbot is generating a summary for commit 5226885. Configure here.

Summary by CodeRabbit

  • New Features
    • TanStack Router integration with server rendering, dehydration, and client hydration; demo apps and routes added.
  • API Changes
    • Render results can include optional clientProps and routeError; renderedHtml may be a React element or server-side hash.
  • Behavior Changes
    • Server-rendered clientProps are validated and merged into hydration props (with JSON/string handling and error reporting).
  • Documentation
    • New TanStack Router guide and updated render-function/API docs.
  • Tests
    • Added tests for TanStack Router flows and clientProps merging.

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>
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 4, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds TanStack Router integration and wiring: package export and optional peer dependency; TypeScript types; server sync/async render helpers; client hydration utility; threading of server-returned clientProps into Rails helper merge; docs, tests, demo apps, and example routes.

Changes

Cohort / File(s) Summary
Package manifest
packages/react-on-rails/package.json
Add public export ./tanstack-router and optional peerDependency @tanstack/react-router.
TanStack Router entry & types
packages/react-on-rails/src/tanstack-router/index.ts, packages/react-on-rails/src/tanstack-router/types.ts
New public entry exposing createTanStackRouterRenderFunction, exported types, and a lightweight router abstraction.
Server rendering
packages/react-on-rails/src/tanstack-router/serverRender.ts
Add serverRenderTanStackApp and serverRenderTanStackAppAsync with sync injection, internals validation, SSR flagging, and dehydrated-state construction.
Client hydration
packages/react-on-rails/src/tanstack-router/clientHydrate.ts
Add clientHydrateTanStackApp to create browser-history router, apply dehydrated state or inject matches, preserve router ref, and run post-hydration load.
TanStack utils
packages/react-on-rails/src/tanstack-router/utils.ts
Add search normalization helpers normalizeSearch and locationSearch.
Type & render plumbing
packages/react-on-rails/src/types/index.ts, packages/react-on-rails/src/serverRenderReactComponent.ts, packages/react-on-rails/src/serverRenderUtils.ts, packages/react-on-rails/src/isServerRenderResult.ts
Introduce optional clientProps into render types and propagate through server-render processing; update server hash detection and async render handling.
Render-function wrapper
packages/react-on-rails/src/tanstack-router/index.ts
Create render-function factory that delegates to server/client helpers and returns a ReactOnRails-compatible renderFunction.
Docs & guides
docs/*, docs/building-features/tanstack-router.md, docs/core-concepts/render-functions.md, docs/api-reference/*
Add TanStack Router guide, update render-function docs and API docs to document clientProps/dehydration and new return shapes.
Rails helper & merge logic
react_on_rails/lib/react_on_rails/helper.rb
Add merge_server_rendered_client_props! and call site to validate/merge result["clientProps"] into render_options.props before generating client script; JSON/Hash validation and errors.
Ruby tests for helper
react_on_rails/spec/dummy/spec/helpers/react_on_rails_helper_spec.rb
Add tests covering merging clientProps into props, JSON-string props handling, and error cases.
Demo apps, routes & views
react_on_rails/spec/dummy/..., react_on_rails_pro/spec/dummy/..., react_on_rails/spec/dummy/package.json, react_on_rails_pro/spec/dummy/package.json
Add example TanStack Router components, controllers, views, routes, system tests, and add @tanstack/react-router to demo package manifests.
Client/demo components
react_on_rails/spec/dummy/client/app/startup/TanStackRouterApp.jsx, react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/TanStackRouterAppAsync.jsx
Add synchronous and async demo entries; async variant returns Promise-based SSR result and marks as renderFunction.
Tests
packages/react-on-rails/tests/tanstackRouter.test.ts, packages/react-on-rails/tests/serverRenderReactComponent.test.ts
Add comprehensive tests for TanStack Router renderFunction (server & client), hydration with dehydrated payloads, and clientProps propagation.
Server render result detection
packages/react-on-rails/src/isServerRenderResult.ts
Make isServerRenderHash accept unknown and use runtime property checks for robust detection.

Sequence Diagram(s)

sequenceDiagram
    participant Controller as Rails Controller
    participant Server as SSR Process
    participant Router as TanStack Router
    participant Store as Router.__store
    participant Renderer as React Renderer

    Controller->>Server: call renderFunction(props with url, railsContext.serverSide=true)
    Server->>Router: createRouter(createMemoryHistory(initialEntries=[url]))
    Server->>Router: validate internals / matchRoutes(path)
    Server->>Store: inject matches sync (__store setState)
    Server->>Router: router.ssr = true
    Server->>Router: dehydrated = router.dehydrate()
    Server->>Renderer: build appElement (RouterProvider + AppWrapper)
    Renderer-->>Server: appElement
    Server-->>Controller: return { renderedHtml: appElement, clientProps: { __tanstackRouterDehydratedState } }
Loading
sequenceDiagram
    participant Browser as Client Browser
    participant Hydrator as clientHydrateTanStackApp
    participant History as createBrowserHistory
    participant Router as TanStack Router
    participant Effect as post-hydration effect

    Browser->>Hydrator: init(props may include dehydratedState)
    Hydrator->>History: createBrowserHistory()
    Hydrator->>Router: createRouter(browserHistory)
    alt dehydratedState present
        Hydrator->>Router: router.hydrate(dehydratedState)
        Hydrator->>Router: router.ssr = true
    else no dehydratedRouter
        Hydrator->>Router: router.matchRoutes(path) -> inject matches into __store
    end
    Browser->>Effect: useEffect -> if dehydratedState then router.load()
    Router-->>Browser: RouterProvider renders hydrated app
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 I hopped through code with tiny paws and cheer,

Wound routes on server, left breadcrumbs for here,
Rails and TanStack clasped hands for a while,
Hydration snugly finished with a twitch and a smile —🥕✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 42.11% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title accurately describes the main change: adding a TanStack Router SSR integration utility (createTanStackRouterRenderFunction) to the react-on-rails package.
Linked Issues check ✅ Passed The PR fulfills the primary coding objectives from issue #2298: it provides documented, working code that integrates TanStack Router with React on Rails SSR through the new createTanStackRouterRenderFunction utility, including both server-side and client-side rendering paths with router hydration support.
Out of Scope Changes check ✅ Passed All changes align with the PR objectives. The addition of TanStack Router support extends the framework, and supporting type and infrastructure changes (types/index.ts, serverRenderReactComponent.ts, helper.rb) are necessary to enable clientProps hydration and server render result handling.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch jg/tanstack-query-support

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Bugbot Autofix is ON, but it could not run because Privacy Mode (Legacy) is turned on. To enable Bugbot Autofix, switch your privacy mode in the Cursor dashboard.

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5226885a84

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@greptile-apps
Copy link

greptile-apps bot commented Mar 4, 2026

Greptile Summary

This PR adds a new react-on-rails/tanstack-router sub-entry to the react-on-rails npm package, providing createTanStackRouterRenderFunction() — a factory that produces a React on Rails render function handling both synchronous SSR and client-side hydration for TanStack Router apps.

The implementation is well-structured with clear comments explaining private-API workarounds. However, three issues need addressing:

  • Missing serverRenderTanStackAppAsync export (index.ts:39): The async render path described in the PR for Pro NodeRenderer (rendering_returns_promises) is implemented in serverRender.ts but never re-exported from index.ts, making it completely inaccessible via the public API react-on-rails/tanstack-router.

  • router.load() is unreachable dead code (clientHydrate.ts:70–77): The guard if (router.state.status !== 'idle') can never be true when the sync injection path succeeds, because that path explicitly sets status: 'idle'. This means router.load() is never called post-hydration, which could silently skip deferred loaders and navigation hooks.

  • Internal __tanstackRouterDehydratedState prop leaks into AppWrapper (serverRender.ts:60–64, clientHydrate.ts:82): The dehydrated state prop is spread into the user's AppWrapper component on both server and client, which is an internal implementation detail that should be filtered out before passing to user code.

Confidence Score: 2/5

  • Not safe to merge as-is — the async render path is completely inaccessible via the public API, and client-side hydration is missing a critical router.load() call post-hydration.
  • Two concrete logic-level issues need resolution before merging: (1) the missing serverRenderTanStackAppAsync export makes an advertised feature inaccessible to Pro NodeRenderer users, and (2) the always-false router.state.status !== 'idle' guard prevents router.load() from ever executing post-hydration, which could silently break deferred data loading and navigation hooks. A third style issue (internal prop leaking to AppWrapper) should also be addressed to prevent runtime warnings. The code is otherwise well-structured, but these three issues compound to make the PR unsafe to merge.
  • packages/react-on-rails/src/tanstack-router/index.ts (missing export) and packages/react-on-rails/src/tanstack-router/clientHydrate.ts (dead-code guard) need the most critical attention; packages/react-on-rails/src/tanstack-router/serverRender.ts should also be updated to filter internal props before passing to AppWrapper.

Sequence Diagram

sequenceDiagram
    participant RoR as React on Rails
    participant RenderFn as createTanStackRouterRenderFunction
    participant Server as serverRenderTanStackApp
    participant Client as clientHydrateTanStackApp
    participant Router as TanStack Router

    Note over RoR,Router: Server-Side Rendering (sync)
    RoR->>RenderFn: renderFn(props, railsContext{serverSide:true})
    RenderFn->>Server: serverRenderTanStackApp(options, props, railsContext)
    Server->>Router: options.createRouter()
    Server->>Router: router.update({history: memoryHistory})
    Server->>Router: validateRouterInternals(router)
    Server->>Router: router.matchRoutes(pathname, search)
    Server->>Router: router.__store.setState({status:'idle', matches})
    Server->>Router: router.ssr = true
    Server->>Router: router.dehydrate()
    Server-->>RenderFn: ReactElement (with __tanstackRouterDehydratedState in props)
    RenderFn-->>RoR: ReactElement → renderToString()

    Note over RoR,Router: Client-Side Hydration
    RoR->>RenderFn: renderFn(props, railsContext{serverSide:false})
    RenderFn->>Client: clientHydrateTanStackApp(options, props, railsContext)
    Client->>Router: options.createRouter()
    Client->>Router: router.update({history: browserHistory})
    Client->>Router: router.__store.setState({status:'idle', matches})
    Client->>Router: router.ssr = true
    Client->>Router: router.hydrate(dehydratedState.dehydratedRouter)
    Client-->>RenderFn: ReactElement (AppComponent)
    RenderFn-->>RoR: ReactElement → hydrateRoot()
    Note over Client,Router: useEffect (post-mount): router.load() [currently unreachable]
Loading

Last reviewed commit: 5226885

@claude
Copy link

claude bot commented Mar 4, 2026

Review of PR #2516 — TanStack Router SSR integration

Good overall architecture — encapsulating the private API workarounds in a library-maintained module is the right call. A few issues worth addressing before merging:

🔴 serverRenderTanStackAppAsync is dead code

serverRenderTanStackAppAsync is exported from serverRender.ts but is not re-exported from index.ts and is never called from createTanStackRouterRenderFunction. Users have no way to opt into the async path (for Pro NodeRenderer with rendering_returns_promises). The PR description says this is the "async path for Pro NodeRenderer" — but that path is currently unreachable through the public API.

Options:

  • Export it from index.ts and document how to use it with Pro
  • Or wire it into createTanStackRouterRenderFunction based on a flag/option

🔴 __tanstackRouterDehydratedState leaks into AppWrapper props

In serverRender.ts, buildAppElement is called with propsWithState which includes __tanstackRouterDehydratedState. This spreads into AppWrapper via createElement(AppWrapper, { ...extraProps, children: app }).

On the client, clientHydrate.ts similarly spreads the full props (which will include __tanstackRouterDehydratedState deserialized from the <script> tag) into AppWrapper.

User-defined AppWrapper components will receive this unexpected internal prop, causing TypeScript errors in strictly-typed wrappers and unexpected prop warnings.

Fix: strip __tanstackRouterDehydratedState before passing props to AppWrapper.

🟡 Inconsistent internal API failure handling between server and client

serverRender.ts calls validateRouterInternals() which throws a clear, actionable error if __store.setState or matchRoutes are missing.

clientHydrate.ts wraps the equivalent logic in a silent if guard (line 40) — if internal APIs have changed, the client silently produces a hydration mismatch with no hint of what went wrong.

Client-side should throw (or at minimum console.error) with the same quality of message as the server-side validator.

🟡 AppComponent is a new function reference on every clientHydrateTanStackApp call

A new anonymous AppComponent function is created on every call. If React on Rails ever calls this render function more than once for the same mount point (e.g., hot-module replacement, StrictMode double-invoking), React will treat it as a different component type and fully unmount/remount the tree, losing state.

Consider defining AppComponent at module scope or memoizing it, since options, RouterProvider, and createBrowserHistory are already closed over in the outer factory function.

🟡 No unit tests for the new module

The test plan has "Integration test with react_on_rails-demos TanStack Router demo app" unchecked. The new tanstack-router/ module has no unit tests at all. Given the complexity of the SSR hydration logic and the reliance on private APIs, unit tests with mocked router objects would be valuable for catching regressions when TanStack Router updates.

Minor

  • matchRoutes in types.ts types locationSearch as Record<string, string>, but TanStack Router's actual search params can contain non-string values (arrays, objects from parseSearch). This may cause silent incorrect behavior for routes with complex search params.
  • The eslint-disable banner at the top of each file disables 5 rules wholesale. Inline suppressions at the specific lines using private APIs would be more surgical.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (1)
packages/react-on-rails/src/tanstack-router/types.ts (1)

11-21: Widen search value typing to avoid rejecting valid typed routers.

Lines [13] and [20] use Record<string, string>, which is stricter than many TanStack Router search schemas (often non-string values). This can make createRouter return types harder to assign.

🔧 Proposed change
   matchRoutes: (
     pathname: string,
-    locationSearch: Record<string, string>,
+    locationSearch: Record<string, unknown>,
     opts?: { throwOnError?: boolean },
   ) => unknown[];
@@
     location: {
       pathname: string;
-      search: Record<string, string>;
+      search: Record<string, unknown>;
       searchStr: string;
       hash: string;
       href: string;
     };

Also applies to: 25-27

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/react-on-rails/src/tanstack-router/types.ts` around lines 11 - 21,
The type for search is too narrow (Record<string, string>) and rejects valid
TanStack Router schemas; update the search typing used in matchRoutes signature
and in state.location (and the other occurrences around the same area) to a
wider type such as Record<string, unknown> (or Record<string, any>) so
non-string search values are accepted; locate the matchRoutes declaration and
the state { location { search } } type blocks and replace the Record<string,
string> occurrences with the wider type.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/react-on-rails/package.json`:
- Around line 62-67: The peer dependency range for "@tanstack/react-router" is
too loose (">= 1.0.0") and can pull in breaking v2+ changes; update the
peerDependencies entry for "@tanstack/react-router" to constrain to v1.x (for
example ">=1.0.0 <2.0.0") so consumers cannot install incompatible v2+ releases
that break uses of internal APIs like router.__store.setState and router.ssr.

In `@packages/react-on-rails/src/tanstack-router/clientHydrate.ts`:
- Around line 44-47: The initialization forcibly setting status to 'idle'
prevents the later condition that calls router.load() from ever running; remove
the status override so the initial state uses the router's real status and then
unconditionally call router.load() on first client render (respecting router.ssr
= true behavior) to enable client-side navigation — update the clientHydrate
initialization that assigns status and resolvedLocation and ensure router.load()
is invoked (call router.load() directly or remove the status check gating it) so
that Router.load() executes after the first render.

In `@packages/react-on-rails/src/tanstack-router/index.ts`:
- Line 39: The public entrypoint currently only re-exports types
(TanStackRouterOptions, DehydratedRouterState) and the sync factory but omits
the async SSR helper; add an explicit export for serverRenderTanStackAppAsync so
consumers can import it from the package surface (e.g. export {
serverRenderTanStackAppAsync } from './serverRenderTanStackAppAsync' or from the
module where it's implemented) and similarly ensure the same export is added in
the other export block referenced (lines ~106-117) so the async helper is
available from both public entrypoints; reference the symbol
serverRenderTanStackAppAsync when adding the export.

---

Nitpick comments:
In `@packages/react-on-rails/src/tanstack-router/types.ts`:
- Around line 11-21: The type for search is too narrow (Record<string, string>)
and rejects valid TanStack Router schemas; update the search typing used in
matchRoutes signature and in state.location (and the other occurrences around
the same area) to a wider type such as Record<string, unknown> (or
Record<string, any>) so non-string search values are accepted; locate the
matchRoutes declaration and the state { location { search } } type blocks and
replace the Record<string, string> occurrences with the wider type.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 60276abb-c2aa-4f10-860f-3231180d4b00

📥 Commits

Reviewing files that changed from the base of the PR and between 2e5dbbc and 5226885.

📒 Files selected for processing (5)
  • packages/react-on-rails/package.json
  • packages/react-on-rails/src/tanstack-router/clientHydrate.ts
  • packages/react-on-rails/src/tanstack-router/index.ts
  • packages/react-on-rails/src/tanstack-router/serverRender.ts
  • packages/react-on-rails/src/tanstack-router/types.ts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@claude
Copy link

claude bot commented Mar 4, 2026

PR Review: Add TanStack Router SSR integration utility

Overall this is a well-structured PR that thoughtfully encapsulates private API workarounds. The documentation is clear and the intent is solid. Here are my findings across the files:

Critical / Bugs

1. @tanstack/react-router landed in dependencies (not devDependencies) in the lockfile

The pnpm-lock.yaml shows @tanstack/react-router under the dependencies section for packages/react-on-rails:

packages/react-on-rails:
  dependencies:
    '@tanstack/react-router':
      specifier: '>= 1.0.0'
      version: 1.163.3(...)

...but package.json only lists it under peerDependencies. The lockfile indicates it was installed as a real (production) dependency. For consumers who don't use TanStack Router this means the library and its transitive deps (isbot, seroval, cookie-es, etc.) would be installed unnecessarily. It should be added to devDependencies in package.json so pnpm records it correctly for local development, while consumers receive it only via the optional peer dependency declaration.


2. AppComponent is re-created on every call to clientHydrateTanStackApp

In clientHydrate.ts, AppComponent is a function defined inside clientHydrateTanStackApp. Every time that outer function is called a new component type is produced. React uses referential equality to decide whether to remount; if the render function is invoked again (e.g. by a parent re-render, hot reload, or any future React on Rails internals change) React will see a brand-new component type, tear down the existing tree, and remount — destroying the router ref and forcing a full re-init. The component definition needs to be stable (created once, outside the render function body).


3. Client-side validation silently skips injection; server-side throws

In serverRender.ts, validateRouterInternals throws a clear error when router.__store.setState or router.matchRoutes are missing. In clientHydrate.ts the equivalent check is:

if (typeof router.matchRoutes === 'function' && router.__store?.setState) {
  // inject matches
}

If the APIs are absent on the client, the injection is silently skipped — the server has injected matches but the client hasn't, guaranteed hydration mismatch with no indication of the root cause. The client should apply the same error throwing that the server does.


Design / API Issues

4. serverRenderTanStackAppAsync is not exported from the public entry point

The function is exported from serverRender.ts but never re-exported through index.ts. Users who need async SSR (with rendering_returns_promises = true) can't reach it via the public react-on-rails/tanstack-router import path — they'd have to reach into internal paths that aren't in the exports map and aren't part of the public API contract.


5. __tanstackRouterDehydratedState leaks into AppWrapper props

In clientHydrate.ts line 82:

app = createElement(options.AppWrapper, { ...props, children: app } as any);

props at this point still contains __tanstackRouterDehydratedState (the internal serialization detail). The AppWrapper component the user supplies will receive this unexpected prop. Same pattern in buildAppElement in serverRender.ts. The internal prop should be stripped before forwarding to AppWrapper.


6. AppWrapper type doesn't match how it's actually called

TanStackRouterOptions.AppWrapper is typed as ComponentType<{ children: ReactNode }> but the code passes additional user props alongside children. The type should either be ComponentType<{ children: ReactNode } & Record<string, unknown>> or the props should genuinely be filtered before passing.


Minor / Nits

7. Pinned-version comment is stale

serverRender.ts line 38:

// Pinned behavior from @tanstack/react-router@1.139.0

The installed version is 1.163.3. The comment should be updated (or removed and replaced with a link to the relevant TanStack Router source).

8. No tests for the new module

There are no unit tests covering serverRenderTanStackApp, clientHydrateTanStackApp, or the main createTanStackRouterRenderFunction factory. Given the complexity of the workarounds (especially the private-API path), at minimum a test asserting that the validation/error paths work correctly would help prevent regressions.

@github-actions
Copy link
Contributor

github-actions bot commented Mar 4, 2026

size-limit report 📦

Path Size
react-on-rails/client bundled (gzip) 62.6 KB (+0.03% 🔺)
react-on-rails/client bundled (gzip) (time) 62.6 KB (+0.03% 🔺)
react-on-rails/client bundled (brotli) 53.65 KB (-0.1% 🔽)
react-on-rails/client bundled (brotli) (time) 53.65 KB (-0.1% 🔽)
react-on-rails-pro/client bundled (gzip) 63.6 KB (+0.02% 🔺)
react-on-rails-pro/client bundled (gzip) (time) 63.6 KB (+0.02% 🔺)
react-on-rails-pro/client bundled (brotli) 54.62 KB (-0.1% 🔽)
react-on-rails-pro/client bundled (brotli) (time) 54.62 KB (-0.1% 🔽)
registerServerComponent/client bundled (gzip) 127.37 KB (+0.01% 🔺)
registerServerComponent/client bundled (gzip) (time) 127.37 KB (+0.01% 🔺)
registerServerComponent/client bundled (brotli) 61.61 KB (+0.1% 🔺)
registerServerComponent/client bundled (brotli) (time) 61.61 KB (+0.1% 🔺)
wrapServerComponentRenderer/client bundled (gzip) 121.94 KB (0%)
wrapServerComponentRenderer/client bundled (gzip) (time) 121.94 KB (0%)
wrapServerComponentRenderer/client bundled (brotli) 56.65 KB (0%)
wrapServerComponentRenderer/client bundled (brotli) (time) 56.65 KB (0%)

@justin808 justin808 added the P2 Backlog priority label Mar 5, 2026
@claude
Copy link

claude bot commented Mar 5, 2026

Review: TanStack Router SSR Integration - Good overall structure. See inline comments for specific issues.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@docs/building-features/tanstack-router.md`:
- Around line 79-84: Update the "Compatibility Notes" section to explicitly
state that sync SSR (renderToString) does not await TanStack Router async route
loaders, and add recommended alternatives: provide data from the
server/controller before rendering, fetch data on the client after hydration, or
use the async render-function SSR path (Pro) that supports awaiting loaders;
reference the terms renderToString, async route loaders, and async
render-function path in the text so readers can locate relevant implementation
points.

In `@packages/react-on-rails/src/tanstack-router/clientHydrate.ts`:
- Around line 55-83: The guard incorrectly checks router.__store?.setState by
truthiness which can allow non-function values; modify the conditional that
currently reads "typeof router.matchRoutes === 'function' &&
router.__store?.setState" to instead assert the setter is a function e.g.
"typeof router.matchRoutes === 'function' && typeof router.__store?.setState ===
'function'" so that router.matchRoutes(...), router.__store.setState(...) block
only runs when both are functions (same pattern used in serverRender.ts),
ensuring the later hasSsrPayload branch throws the intended compatibility error
when internals are missing or invalid.

In `@react_on_rails/lib/react_on_rails/helper.rb`:
- Around line 606-614: The merge of existing_props and client_props in
ReactOnRails::Helper (where render_options.set_option(:props,
existing_props.merge(client_props)) is called) can leave duplicate keys when one
hash uses symbol keys and the other uses string keys; normalize key types before
merging (e.g., convert existing_props keys to strings or client_props keys to
symbols) so client_props reliably override server props, then set the normalized
merged hash via render_options.set_option(:props, ...); also add a unit test
that supplies existing props using a symbol key (e.g., :name) and client_props
using the string key "name" to verify the client value overrides the server
value.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 547c2fcd-6ad9-4cab-b7d7-7b9dd22659d6

📥 Commits

Reviewing files that changed from the base of the PR and between d362f2e and 8356ffb.

📒 Files selected for processing (12)
  • docs/api-reference/view-helpers-api.md
  • docs/building-features/tanstack-router.md
  • packages/react-on-rails/package.json
  • packages/react-on-rails/src/tanstack-router/clientHydrate.ts
  • packages/react-on-rails/src/tanstack-router/serverRender.ts
  • packages/react-on-rails/tests/tanstackRouter.test.ts
  • react_on_rails/lib/react_on_rails/helper.rb
  • react_on_rails/spec/dummy/spec/helpers/react_on_rails_helper_spec.rb
  • react_on_rails/spec/dummy/spec/system/integration_spec.rb
  • react_on_rails_pro/spec/dummy/app/controllers/tanstack_router_controller.rb
  • react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/TanStackRouterAppAsync.jsx
  • react_on_rails_pro/spec/dummy/spec/system/integration_spec.rb
🚧 Files skipped from review as they are similar to previous changes (5)
  • react_on_rails/spec/dummy/spec/helpers/react_on_rails_helper_spec.rb
  • react_on_rails_pro/spec/dummy/spec/system/integration_spec.rb
  • packages/react-on-rails/package.json
  • docs/api-reference/view-helpers-api.md
  • react_on_rails_pro/spec/dummy/app/controllers/tanstack_router_controller.rb

@claude
Copy link

claude bot commented Mar 5, 2026

Code Review

Overall this is a well-structured PR that addresses a real need. The runtime validation of internal TanStack Router APIs is a particularly good defensive pattern, and the test coverage is solid. A few issues to address before merging:


Bugs / Correctness

renderToString import (inline comment retracted): I initially flagged a missing renderToString import in serverRenderReactComponent.ts, but it is already imported from ./ReactDOMServer.cts at line 8. No action needed.

validateRouterInternals vs injectRouteMatchesSync inconsistency (inline comment on serverRender.ts:58): Validation accepts search OR searchStr, but the actual implementation only reads location.search. If TanStack Router ever switches to searchStr, the validation would pass but the wrong search string would be used silently. Either tighten the validation to require search, or teach injectRouteMatchesSync to fall back to searchStr.

merge_server_rendered_client_props! — symbol/string key collision (inline comment on helper.rb:613): { name: "Alice" }.merge({ "name" => "override" }) in Ruby produces a hash with both :name and "name" keys. When serialized to JSON both become "name", resulting in a duplicate key (undefined behavior). Since Rails props commonly use symbol keys and JS-originated clientProps always have string keys, this is a realistic scenario. Fix by normalizing all keys to strings before merging:

existing_props.transform_keys(&:to_s).merge(client_props.transform_keys(&:to_s))

router.ssr = true missing from async path (inline comment on serverRender.ts:136): The sync SSR path sets router.ssr = true to prevent Suspense boundary issues, but serverRenderTanStackAppAsync does not. Verify whether this flag is needed for the async path too.


React 18 Strict Mode issue

isFirstRender guard in useEffect (inline comment on clientHydrate.ts:100): In React 18 Strict Mode (development), effects run twice (mount → unmount → mount). The routerRef persists across remounts, so isFirstRender.current is already false on the second mount — meaning router.load() is never called after the Strict Mode remount. This likely means the router doesn't fully initialize in local development, which can hide hydration bugs. Either document this as intentional or use a different guard.


Code quality

normalizeSearch duplicated (inline comment on serverRender.ts:12): The function is identical in serverRender.ts and clientHydrate.ts. Extract to a shared utils.ts in the same directory.


Demo app patterns

Unnecessary wrapper in TanStackRouterApp.jsx (inline comment on the OSS dummy file): createTanStackRouterRenderFunction already returns a ready-to-register render function. The extra wrapper that conditionally returns () => result for client-side is unnecessary and will confuse users following this as a reference. The simplest correct pattern is:

const TanStackRouterApp = createTanStackRouterRenderFunction(options, deps);
ReactOnRails.register({ TanStackRouterApp });

If there's a known reason this can't work directly, please document it.

Pro controller pre-seeds __tanstackRouterDehydratedState as a Rails prop (inline comment on Pro controller): This appears to be a workaround for the async path not going through clientProps merging. If async render functions' clientProps aren't merged by the Ruby helper (because the result is a Promise), that limitation should be explicitly documented and ideally fixed before this ships as the reference implementation.


Minor / Nitpick

  • The index action on TanstackRouterController is implicit (Rails implicit rendering) — this is fine but inconsistent with the explicit index action in ReactRouterController. A brief comment or explicit no-op action would clarify intent.
  • The package.json export for ./tanstack-router is missing a "types" field alongside a "import" condition (only has "default"), which may cause issues for consumers using bundlers that distinguish ESM entrypoints.

@claude
Copy link

claude bot commented Mar 5, 2026

PR Review: Add TanStack Router SSR Integration Utility - see inline comments for details

@claude
Copy link

claude bot commented Mar 5, 2026

Overall this is a well-structured and clearly documented addition. The approach of encapsulating private-API workarounds behind a stable public interface is sound, and the test coverage for the main happy-path flows is good.

Key issues worth addressing:

  1. React component identity instability in clientHydrate.ts (Bug) - clientHydrateTanStackApp creates an AppComponent function inside itself on every call. React uses referential equality to decide whether to unmount/remount a subtree. If the parent re-renders and calls clientHydrateTanStackApp again, a new AppComponent reference is produced and React tears down and recreates the entire router, losing navigation state. The earlier TanStackHydrationApp component pattern (where props were passed to a stable named component) avoids this. Fix: hoist AppComponent out of the function body.

  2. normalizeSearch duplicated between serverRender.ts and clientHydrate.ts - The function is identical in both files. Extract it to a shared utils.ts.

  3. Peer dependency version constraint is misleading - >= 1.0.0 for @tanstack/react-router is too broad. The code is pinned to internal API shape from v1.163.3 (router.__store.setState, router.ssr). Consider tightening to >= 1.163.0 < 2 and documenting the validated range.

  4. serverRenderTanStackAppAsync does not set router.ssr = true - The sync path sets this flag to prevent Suspense boundary issues. The async path skips it. This asymmetry could cause subtle issues in the Pro/async rendering path.

  5. Confusing error message in merge_server_rendered_client_props! - When raw_existing_props is a String that fails JSON.parse, the error says "Pass props as a Hash, not String". But the user did pass a string (valid JSON strings are accepted). The message should clarify that the string failed to parse or is not a JSON object.

  6. Minor: buildAppElement removes __tanstackRouterDehydratedState unnecessarily on the server - During SSR, props is the original Rails props hash and this key will not be present. The delete is harmless but misleading.

  7. No tests for the post-mount router.load() effect - The useEffect path in clientHydrate.ts is untested. Consider adding tests using act() to verify router.load() is called after mount when hasSsrPayload is true, and not called when it is false.

@claude
Copy link

claude bot commented Mar 5, 2026

PR Review: TanStack Router SSR Integration. This is a well-structured addition. The architecture of injecting TanStack Router deps as parameters is the right call for an optional peer dependency. The clientProps pass-through is clean and generic enough for future integrations. Issues to address: (1) Bug - React Strict Mode: isFirstRender ref in clientHydrate.ts causes router.load() to run on the discarded render but not the real mount in React 18 Strict Mode, breaking navigation in development. Fix: remove the guard and call router.load() directly in the effect. (2) Bug - searchStr mismatch: validateRouterInternals accepts either search OR searchStr on router.state.location, but injectRouteMatchesSync only reads location.search. If only searchStr exists, the validator passes but injected routes use the wrong search value. (3) Missing router.ssr in async path: serverRenderTanStackAppAsync never sets router.ssr = true. The sync path sets this to prevent TanStack Router Suspense boundaries from interfering with renderToString. The Pro NodeRenderer calls renderToString on the returned element so the same flag is needed. (4) Duplicated normalizeSearch: identical function defined in both serverRender.ts and clientHydrate.ts. Extract to a shared utils.ts. (5) Minor dead code: the if (!store) early return in injectRouteMatchesSync is unreachable since validateRouterInternals already threw. The docs, Ruby merge_server_rendered_client_props!, and test coverage are all solid.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (3)
packages/react-on-rails/tests/serverRenderReactComponent.test.ts (1)

236-259: Consider asserting hasErrors is false in async clientProps path.

This test validates html and clientProps; adding hasErrors keeps parity with adjacent async tests and catches partial-failure regressions.

Suggested test hardening
   const result = await renderResult;
   expect(result.html).toEqual('<div>Hello with async hash client props</div>');
   expect(result.clientProps).toEqual({
     __tanstackRouterDehydratedState: { url: '/products?category=tools' },
   });
+  expect(result.hasErrors).toBeFalsy();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/react-on-rails/tests/serverRenderReactComponent.test.ts` around
lines 236 - 259, The async test "serverRenderReactComponent processes async
serverRenderHash with clientProps" currently asserts html and clientProps but
not the error flag; update the test that registers X6WithClientProps and awaits
serverRenderReactComponent (the renderResult/result flow) to also assert that
result.hasErrors is false to match adjacent async tests and detect partial
failures. Locate the test block referencing X6WithClientProps,
serverRenderReactComponent, and the result variable and add an assertion that
result.hasErrors === false (e.g., expect(result.hasErrors).toBe(false)).
packages/react-on-rails/src/tanstack-router/clientHydrate.ts (1)

34-35: Replace duplicated dehydration-prop literal with a constant.

Line 34 and Line 107 use the same hardcoded key; centralizing it reduces typo risk.

🧹 Proposed refactor
+const TANSTACK_DEHYDRATED_STATE_PROP = '__tanstackRouterDehydratedState' as const;
+
 function TanStackHydrationApp({
@@
-  const dehydratedState = incomingProps.__tanstackRouterDehydratedState as DehydratedRouterState | undefined;
+  const dehydratedState = incomingProps[
+    TANSTACK_DEHYDRATED_STATE_PROP
+  ] as DehydratedRouterState | undefined;
@@
-    delete wrapperProps.__tanstackRouterDehydratedState;
+    delete wrapperProps[TANSTACK_DEHYDRATED_STATE_PROP];

Also applies to: 106-108

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/react-on-rails/src/tanstack-router/clientHydrate.ts` around lines 34
- 35, The code repeats the literal "__tanstackRouterDehydratedState" in
clientHydrate.ts (e.g., the dehydratedState assignment and the later lookup
around lines 106-108); replace the duplicated string with a single
exported/const symbol (e.g., DEHYDRATION_PROP =
"__tanstackRouterDehydratedState") and use that constant wherever the literal
appears (including the dehydratedState = incomingProps[DEHYDRATION_PROP] cast
and the later hasSsrPayload/lookup), so all occurrences reference the same
identifier to avoid typos and centralize the key.
packages/react-on-rails/src/tanstack-router/serverRender.ts (1)

137-140: Deduplicate dehydrated-state construction in sync/async paths.

Line 137-Line 140 and Line 173-Line 176 repeat identical logic; extracting one helper reduces drift.

♻️ Proposed refactor
+function buildDehydratedState(router: TanStackRouter, url: string): DehydratedRouterState {
+  return {
+    url,
+    dehydratedRouter: typeof router.dehydrate === 'function' ? router.dehydrate() : null,
+  };
+}
+
 export function serverRenderTanStackApp(
@@
-  const dehydratedState: DehydratedRouterState = {
-    url,
-    dehydratedRouter: typeof router.dehydrate === 'function' ? router.dehydrate() : null,
-  };
+  const dehydratedState = buildDehydratedState(router, url);
@@
 export async function serverRenderTanStackAppAsync(
@@
-  const dehydratedState: DehydratedRouterState = {
-    url,
-    dehydratedRouter: typeof router.dehydrate === 'function' ? router.dehydrate() : null,
-  };
+  const dehydratedState = buildDehydratedState(router, url);

Also applies to: 173-176

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/react-on-rails/src/tanstack-router/serverRender.ts` around lines 137
- 140, The construction of the DehydratedRouterState is duplicated; extract a
small helper (e.g., getDehydratedState or buildDehydratedState) that accepts url
and router and returns a DehydratedRouterState with url and dehydratedRouter:
typeof router.dehydrate === 'function' ? router.dehydrate() : null, then replace
both inline creations (the dehydratedState assignments around dehydrated-state
construction) to call that helper to remove duplication and keep sync/async
paths consistent.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/react-on-rails/src/isServerRenderResult.ts`:
- Around line 13-17: The type-guard is missing the 'clientProps' property check
so isServerRenderResult incorrectly returns false for objects with only
clientProps; update the guard in isServerRenderResult (the function
isServerRenderResult) to include "'clientProps' in testValue" alongside the
existing checks ('renderedHtml', 'redirectLocation', 'routeError', 'error') so
any ServerRenderResult with clientProps is recognized as such
(ServerRenderResult is defined in types/index.ts).

---

Nitpick comments:
In `@packages/react-on-rails/src/tanstack-router/clientHydrate.ts`:
- Around line 34-35: The code repeats the literal
"__tanstackRouterDehydratedState" in clientHydrate.ts (e.g., the dehydratedState
assignment and the later lookup around lines 106-108); replace the duplicated
string with a single exported/const symbol (e.g., DEHYDRATION_PROP =
"__tanstackRouterDehydratedState") and use that constant wherever the literal
appears (including the dehydratedState = incomingProps[DEHYDRATION_PROP] cast
and the later hasSsrPayload/lookup), so all occurrences reference the same
identifier to avoid typos and centralize the key.

In `@packages/react-on-rails/src/tanstack-router/serverRender.ts`:
- Around line 137-140: The construction of the DehydratedRouterState is
duplicated; extract a small helper (e.g., getDehydratedState or
buildDehydratedState) that accepts url and router and returns a
DehydratedRouterState with url and dehydratedRouter: typeof router.dehydrate ===
'function' ? router.dehydrate() : null, then replace both inline creations (the
dehydratedState assignments around dehydrated-state construction) to call that
helper to remove duplication and keep sync/async paths consistent.

In `@packages/react-on-rails/tests/serverRenderReactComponent.test.ts`:
- Around line 236-259: The async test "serverRenderReactComponent processes
async serverRenderHash with clientProps" currently asserts html and clientProps
but not the error flag; update the test that registers X6WithClientProps and
awaits serverRenderReactComponent (the renderResult/result flow) to also assert
that result.hasErrors is false to match adjacent async tests and detect partial
failures. Locate the test block referencing X6WithClientProps,
serverRenderReactComponent, and the result variable and add an assertion that
result.hasErrors === false (e.g., expect(result.hasErrors).toBe(false)).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 119f685d-2083-48ed-a095-8b643f0ca778

📥 Commits

Reviewing files that changed from the base of the PR and between 8356ffb and cb26900.

📒 Files selected for processing (12)
  • docs/building-features/tanstack-router.md
  • packages/react-on-rails/src/isServerRenderResult.ts
  • packages/react-on-rails/src/serverRenderReactComponent.ts
  • packages/react-on-rails/src/tanstack-router/clientHydrate.ts
  • packages/react-on-rails/src/tanstack-router/serverRender.ts
  • packages/react-on-rails/src/tanstack-router/utils.ts
  • packages/react-on-rails/src/types/index.ts
  • packages/react-on-rails/tests/serverRenderReactComponent.test.ts
  • react_on_rails/lib/react_on_rails/helper.rb
  • react_on_rails/spec/dummy/client/app/startup/TanStackRouterApp.jsx
  • react_on_rails/spec/dummy/spec/helpers/react_on_rails_helper_spec.rb
  • react_on_rails_pro/spec/dummy/app/controllers/tanstack_router_controller.rb
🚧 Files skipped from review as they are similar to previous changes (2)
  • react_on_rails/spec/dummy/client/app/startup/TanStackRouterApp.jsx
  • docs/building-features/tanstack-router.md

Comment on lines +13 to +17
return (
'renderedHtml' in testValue ||
'redirectLocation' in testValue ||
'routeError' in testValue ||
'error' in testValue
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Missing clientProps check in guard.

The ServerRenderResult interface (see types/index.ts:122-128) defines five optional properties: renderedHtml, clientProps, redirectLocation, routeError, and error. This guard only checks for four of them, omitting clientProps.

A ServerRenderResult with only clientProps populated (e.g., { clientProps: { foo: 'bar' } }) would incorrectly return false, causing createReactOutput to treat it as a React element instead of a server render hash.

Proposed fix
  return (
    'renderedHtml' in testValue ||
+   'clientProps' in testValue ||
    'redirectLocation' in testValue ||
    'routeError' in testValue ||
    'error' in testValue
  );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
return (
'renderedHtml' in testValue ||
'redirectLocation' in testValue ||
'routeError' in testValue ||
'error' in testValue
return (
'renderedHtml' in testValue ||
'clientProps' in testValue ||
'redirectLocation' in testValue ||
'routeError' in testValue ||
'error' in testValue
);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/react-on-rails/src/isServerRenderResult.ts` around lines 13 - 17,
The type-guard is missing the 'clientProps' property check so
isServerRenderResult incorrectly returns false for objects with only
clientProps; update the guard in isServerRenderResult (the function
isServerRenderResult) to include "'clientProps' in testValue" alongside the
existing checks ('renderedHtml', 'redirectLocation', 'routeError', 'error') so
any ServerRenderResult with clientProps is recognized as such
(ServerRenderResult is defined in types/index.ts).

"Pass props as a Hash, not #{class_name}."
end

normalized_existing_props = existing_props.deep_stringify_keys
Copy link

Choose a reason for hiding this comment

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

Nil props produces a confusing error message.

When react_component("TanStackApp", prerender: true) is called without a props: keyword, render_options.props is nil. The code reaches existing_props = nil, then the unless existing_props.is_a?(Hash) guard raises:

"Cannot merge result['clientProps'] into non-Hash props. Pass props as a Hash, not NilClass."

A user seeing this error from a nil props call won't understand what to fix. Consider treating nil as an empty hash:

Suggested change
normalized_existing_props = existing_props.deep_stringify_keys
normalized_existing_props = (existing_props || {}).deep_stringify_keys

(router as any).ssr = true;
}

routerRef.current = router;
Copy link

Choose a reason for hiding this comment

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

Render-time side effects with no cleanup path.

The router.update(), router.__store.setState(), and router.ssr = true calls on lines 46–87 are mutating external state during the render phase. While the routerRef.current === null guard makes this safe from double-initialization in practice (React preserves ref values across StrictMode double invocations in React 18+), there is no cleanup path if the component unmounts and remounts (e.g. in React 18 StrictMode or with transitions). In StrictMode, React intentionally unmounts and remounts the component once to detect side effects — the useEffect on line 100 runs the router.load() call, but there's no corresponding teardown if the effect fires, is cleaned up, and fires again.

Consider whether router.load() should be called in the useEffect even when hasSsrPayload is false (so the router loads when navigating to an unrendered page on the client), and whether a return () => { /* cleanup */ } teardown is needed.

}

const result = syncRenderFn(propsOrDefault, railsContext);
return () => result;
Copy link

Choose a reason for hiding this comment

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

Suspicious () => result wrapper on client path.

syncRenderFn already returns a ReactElement on the client side (since TanStackHydrationApp is an element). Wrapping it in () => result here returns a function from this render function rather than a ReactElement.

React on Rails will see a function return value and treat it as a "renderer function" (a special kind that takes a DOM node as a third argument), which is not what's intended here.

If the intent is to work around React on Rails' handling of async render functions on the client, a comment explaining why this wrapper is needed would help. Otherwise, consider returning result directly:

Suggested change
return () => result;
return result;

// promiseResult is string | ServerRenderHashRenderedHtml (both are FinalHtmlResult)
return promiseResult as FinalHtmlResult;
// promiseResult can be a FinalHtmlResult or a ServerRenderResult hash.
return promiseResult;
Copy link

Choose a reason for hiding this comment

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

processPromise now returns an untyped value.

The old code had an explicit cast return promiseResult as FinalHtmlResult which was at least type-checked at the call site. The new code returns promiseResult without any cast or type annotation, which widens the inferred return type and loses the type safety that downstream consumers relied on.

The result is immediately passed into isServerRenderHash(asyncResult) in createPromiseResult (line ~129), so the behavior is correct at runtime. But explicitly typing the return would make the intent clear:

Suggested change
return promiseResult;
// promiseResult can be a FinalHtmlResult or a ServerRenderResult hash.
return promiseResult as FinalHtmlResult | ServerRenderResult;

* - Without pre-populated matches, SSR output would be empty
*
* Uses private API: router.__store.setState()
* Pinned behavior from @tanstack/react-router@1.163.3
Copy link

Choose a reason for hiding this comment

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

Version pin conflicts with the broad >= 1.0.0 peer dependency range.

The comment says the behavior was pinned against @tanstack/react-router@1.163.3, but the peerDependencies range in package.json accepts >= 1.0.0, which includes all of v1.x and any future major versions. The validateRouterInternals function does catch breakage at runtime, but a user upgrading @tanstack/react-router from e.g. 1.50 to 2.0 could hit confusing runtime failures in production.

Consider:

  1. Tightening the optional peer dependency to >=1.0.0 <2.0.0 (or similar), and
  2. Including the compatible version range in the validateRouterInternals error message so users know which versions are known-good.

(testValue as ServerRenderResult).redirectLocation ||
(testValue as ServerRenderResult).routeError ||
(testValue as ServerRenderResult).error
export function isServerRenderHash(testValue: unknown): testValue is ServerRenderResult {
Copy link

Choose a reason for hiding this comment

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

Unused imports after the signature change.

CreateReactOutputResult and RenderFunctionResult are no longer used in this file — the signature changed to testValue: unknown. The import on lines 2–4 is dead code and will cause a TypeScript/lint warning. The RenderStateHtml import on line 5 is also not used here.

Suggested change
export function isServerRenderHash(testValue: unknown): testValue is ServerRenderResult {
import type { ServerRenderResult } from './types/index.ts';

console.error('react-on-rails/tanstack-router: Error loading routes after hydration:', err);
});
}
}, [hasSsrPayload]); // eslint-disable-line react-hooks/exhaustive-deps -- router is a ref, intentionally stable
Copy link

Choose a reason for hiding this comment

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

router is missing from the dependency array, and the reason given is questionable.

The comment says "router is a ref, intentionally stable" — but router here is routerRef.current (a value snapshot), not routerRef (the ref object). Accessing .current inside a useEffect is generally fine since refs are stable, but eslint-plugin-react-hooks flags routerRef.current reads inside effects as potentially stale. A cleaner pattern is to include routerRef in the deps (always stable) instead of suppressing the warning:

Suggested change
}, [hasSsrPayload]); // eslint-disable-line react-hooks/exhaustive-deps -- router is a ref, intentionally stable
}, []); // router is derived from routerRef which is stable; hasSsrPayload is derived from props and won't change after mount

Or restructure to use routerRef directly:

useEffect(() => {
  if (hasSsrPayload && routerRef.current) {
    routerRef.current.load().catch(...);
  }
}, []); // eslint-disable-line react-hooks/exhaustive-deps

@claude
Copy link

claude bot commented Mar 5, 2026

Review: TanStack Router SSR Integration

Overall this is a well-structured addition. The encapsulation of TanStack Router's private API workarounds in one place, runtime validation via validateRouterInternals, and the clean createTanStackRouterRenderFunction API are all good design decisions. The test coverage for the new JS code and the Ruby clientProps merge path is solid.

Issues Found

Bugs / Correctness

  1. merge_server_rendered_client_props! with nil props (Ruby) — See inline comment at helper.rb:612. When react_component("TanStackApp", prerender: true) is called without a props: key, render_options.props is nil, which falls through to the unless existing_props.is_a?(Hash) guard and raises an unhelpful "NilClass" error. Treating nil as {} (empty hash) would be the natural behavior.

  2. return () => result in TanStackRouterAppAsync.jsx — See inline comment. On the client path, syncRenderFn already returns a ReactElement. Wrapping it in a () => result function returns a function from the render function, which React on Rails interprets as a "renderer function" (requiring a DOM node as a 3rd argument). This looks like a bug unless there's intentional behavior being relied on here — a clear comment explaining the pattern is needed at minimum.

  3. Unused imports in isServerRenderResult.tsCreateReactOutputResult, RenderFunctionResult, and RenderStateHtml are imported but no longer used after the signature change to testValue: unknown. These will cause lint warnings.

Code Quality / Type Safety

  1. processPromise return type — The previous return promiseResult as FinalHtmlResult made the return type explicit. The new untyped return promiseResult widens the inferred type, weakening downstream type-checking. An explicit cast to FinalHtmlResult | ServerRenderResult would restore type safety.

  2. useEffect dependency arrayrouter (which is routerRef.current) is used inside the effect but excluded via eslint-disable. Since hasSsrPayload is stable after mount, an empty dependency array [] with a comment is cleaner than suppressing react-hooks/exhaustive-deps on [hasSsrPayload].

Architecture / Compatibility Risk

  1. Version pin vs. broad peer dependency range — The private API usage is pinned in a comment to @tanstack/react-router@1.163.3, but peerDependencies accepts >= 1.0.0 (all of v1 and beyond). The validateRouterInternals guard catches runtime breakage, but consider tightening to >=1.0.0 <2.0.0 until v2 compatibility is validated, and including a "tested with v1.x" note in the compatibility error messages.

  2. Render-time side effects in TanStackHydrationApprouter.update(), router.__store.setState(), and router.ssr = true execute during the render phase. This works today because routerRef.current prevents double execution, but there is no teardown path if the component unmounts/remounts under React 18 StrictMode transitions. Consider documenting this assumption or adding a cleanup.

Minor Notes

  • The documentation is comprehensive and well-organized. The "Compatibility Notes" section in tanstack-router.md appropriately sets user expectations about private API reliance.
  • The isServerRenderHash refactor from truthy checks (!!result.renderedHtml) to property existence checks ('renderedHtml' in testValue) is a correctness improvement — the old code would return false when renderedHtml was an empty string.
  • The Ruby merge_server_rendered_client_props! correctly moves before generate_component_script so merged props land in the client hydration script. ✓
  • The test for merge_server_rendered_client_props! with symbol/string key normalization (normalized_existing_props = existing_props.deep_stringify_keys) is a good edge case to cover.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

P2 Backlog priority

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Documentation: Add TanStack Router Integration Guide

1 participant