Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 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
5 changes: 4 additions & 1 deletion docs/oss/api-reference/javascript-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,10 @@ The best source of docs is the `interface ReactOnRails` in [types/index.ts](http
* - 2 params, or any function with `.renderFunction = true`: Render-Function — called with (props, railsContext),
* returns a React component, `{ renderedHtml }` object, or Promise (Pro Node renderer only)
* - 3 params: Renderer function — called with (props, railsContext, domNodeId),
* responsible for calling ReactDOM.render/hydrate directly (client-only)
* responsible for calling ReactDOM.render/hydrate directly (client-only).
* May optionally return a `{ teardown }` wrapper (or a promise resolving to one); React on Rails
* runs it on Turbo/Turbolinks navigation or same-id node replacement to unmount the renderer's root
* instead of leaking it. See the view-helpers renderer-function docs.
*
* Render-Functions can return:
* - A React component (function or class) — used with `react_component`
Expand Down
53 changes: 53 additions & 0 deletions docs/oss/api-reference/view-helpers-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,59 @@ Why would you want to take over mounting yourself? One use case is code splittin
> [!IMPORTANT]
> **Renderer functions are strictly client-only.** There is no DOM on the server, so a renderer function cannot produce SSR output. React on Rails detects renderer functions at registration time and will throw a descriptive error like `Detected a renderer while server rendering component 'X'. See https://reactonrails.com/docs/core-concepts/render-functions for more information.` if you attempt to use one with `react_component(... prerender: true)`, `react_component_hash` (which forces prerendering), or `stream_react_component` (which is server-streaming only). For rendering that needs to run on the server, use a regular render function instead.

#### Cleaning up on Turbo/Turbolinks navigation (optional teardown)

Because a renderer function owns the React root it creates, React on Rails cannot unmount that root for you the way it does for the components it mounts itself. With [Turbo](https://turbo.hotwired.dev/) or Turbolinks, the page swaps without a full reload, so a renderer that never unmounts leaks its root (and any subscriptions or timers it holds) on every navigation.

To opt in to cleanup, **return a teardown wrapper** — `{ teardown: () => void | Promise<void> }`, or a promise resolving to one — from the renderer. React on Rails stores it and runs it when the mount is torn down: on Turbo/Turbolinks navigation (when the framework swaps in the next page) or when the same `domNodeId` node is replaced. Returning nothing keeps the previous (leaky) behavior, so existing renderers are unaffected.

```jsx
import ReactDOMClient from 'react-dom/client';

// Renderer function: 3 params, mounts itself, returns a teardown wrapper.
const MyRenderer = (props, _railsContext, domNodeId) => {
const domNode = document.getElementById(domNodeId);
if (!domNode) {
throw new Error(`Missing DOM element with id: ${domNodeId}`);
}

// This example always creates a fresh root. See the hydration note below if your renderer
// needs to hydrate server-rendered markup.
const root = ReactDOMClient.createRoot(domNode);
root.render(<MyComponent {...props} />);

// Unmounted automatically on the next Turbo navigation (or same-id node replacement).
return { teardown: () => root.unmount() };
};
Comment thread
justin808 marked this conversation as resolved.
```

> [!NOTE]
> **Hydrating server-rendered markup?** `prerender` is not a prop React on Rails injects — the top-level `prerender:` render option only controls server rendering and is rejected for renderer functions (see the note above). If you render a component on the server with a **separate server bundle** (a server/client split) and want the client renderer to hydrate that markup, pass your own flag in the component's `props`, such as `serverRendered`, and branch on it. Remove that renderer-only flag before spreading props into your component: `const { serverRendered, ...componentProps } = props;`, then call `ReactDOMClient.hydrateRoot(domNode, <MyComponent {...componentProps} />)` when `serverRendered` is true.
Comment thread
justin808 marked this conversation as resolved.
Outdated

Under the React 16/17 legacy API there is no root handle, so unmount by container node instead:

```jsx
Comment thread
justin808 marked this conversation as resolved.
import ReactDOM from 'react-dom';

const MyLegacyRenderer = (props, _railsContext, domNodeId) => {
const { serverRendered, ...componentProps } = props;
const domNode = document.getElementById(domNodeId);
if (!domNode) {
throw new Error(`Missing DOM element with id: ${domNodeId}`);
}

if (serverRendered) {
ReactDOM.hydrate(<MyComponent {...componentProps} />, domNode);
} else {
ReactDOM.render(<MyComponent {...componentProps} />, domNode);
}
return { teardown: () => ReactDOM.unmountComponentAtNode(domNode) };
};
```

> [!NOTE]
Comment thread
justin808 marked this conversation as resolved.
> Synchronous teardowns are always honored. An **async** teardown is best-effort in the open-source package: if a navigation or node replacement happens before the renderer resolves its teardown, that still-pending teardown may be dropped. React on Rails logs a `console.error` when this happens — search for `resolved after its mount was removed` (the teardown was dropped) or `Error resolving renderer teardown` (the render promise rejected) — so the dropped teardown is diagnosable rather than silent. React on Rails Pro's client renderer awaits the renderer and handles this race reliably.

---

### React Router
Expand Down
32 changes: 20 additions & 12 deletions docs/oss/core-concepts/render-functions.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,22 @@ This guide explains how render-functions work in React on Rails and how to use t

Before diving into render-functions, it helps to know the three kinds of values you can register with `ReactOnRails.register`. React on Rails classifies each registered entry based on its shape, and the classification determines where it can run (server, client, or both) and which Ruby helpers can invoke it.

| Type | Signature | Server (SSR) | Client | Detection rule |
| --------------------- | ------------------------------------------ | --------------- | ------ | ----------------------------------------------------------------------- |
| **React Component** | `(props) => JSX` or class component | Yes | Yes | `Function.length <= 1` and no `renderFunction` flag |
| **Render Function** | `(props, railsContext) => ...` | Yes | Yes | `Function.length >= 2` **or** `fn.renderFunction === true` |
| **Renderer Function** | `(props, railsContext, domNodeId) => void` | **No — throws** | Yes | A render function (detected first) with exactly `Function.length === 3` |
| Type | Signature | Server (SSR) | Client | Detection rule |
| --------------------- | ---------------------------------------------------------- | --------------- | ------ | ----------------------------------------------------------------------- |
| **React Component** | `(props) => JSX` or class component | Yes | Yes | `Function.length <= 1` and no `renderFunction` flag |
| **Render Function** | `(props, railsContext) => ...` | Yes | Yes | `Function.length >= 2` **or** `fn.renderFunction === true` |
| **Renderer Function** | `(props, railsContext, domNodeId) => void \| { teardown }` | **No — throws** | Yes | A render function (detected first) with exactly `Function.length === 3` |

A few important points about the detection:

- **The detection is based on `Function.length`** (the number of declared parameters). Destructured parameters count as 1 — `({ name }) => ...` has length 1.
- **Render functions return** a React component, a React element, a server-render hash object, or a promise that resolves to one of those. See [Types of Render-Functions and Their Return Values](#types-of-render-functions-and-their-return-values) below.
- **Renderer functions do not return anything meaningful.** They take control of mounting/hydration themselves by calling `ReactDOM.hydrateRoot` / `createRoot` against `domNodeId`. Because there is no DOM on the server, **registering a renderer function and then server-rendering it throws a descriptive error**. Renderer functions are strictly client-side.
- **Renderer functions may optionally return a teardown wrapper** — `{ teardown: () => void | Promise<void> }`, or a promise resolving to one. They take control of mounting/hydration themselves by calling `ReactDOM.hydrateRoot` / `createRoot` against `domNodeId`. Because there is no DOM on the server, **registering a renderer function and then server-rendering it throws a descriptive error**. Renderer functions are strictly client-side.
- **`fn.renderFunction = true` is an escape hatch** for render functions that don't need `railsContext` but still want to be treated as render functions (e.g., so they can return a hash). Without the flag, a one-parameter function is classified as a regular React component.

```jsx
import ReactDOMClient from 'react-dom/client';

// Regular React Component — 0 or 1 params, renders normally
const HelloMessage = (props) => <div>Hello {props.name}</div>;

Expand All @@ -38,18 +40,24 @@ const HelloHash = (props) => {
};
HelloHash.renderFunction = true;

// Renderer Function — 3 params, handles hydration itself, CLIENT ONLY
const LazyHydrate = (props, railsContext, domNodeId) => {
// whenVisible is a hypothetical helper that resolves when the element scrolls into view
// Renderer Function — 3 params, handles hydration itself, CLIENT ONLY.
// Optionally return a { teardown } wrapper, or a promise resolving to one.
// React on Rails runs it on Turbo/Turbolinks navigation or same-id replacement.
const LazyHydrate = (props, _railsContext, domNodeId) =>
whenVisible(domNodeId).then(() => {
const root = document.getElementById(domNodeId);
ReactDOM.hydrateRoot(root, <HelloMessage {...props} />);
const domNode = document.getElementById(domNodeId);
// Navigation may remove the node before visibility resolves, so there is no mounted root to clean up.
if (!domNode) return undefined;

const root = ReactDOMClient.hydrateRoot(domNode, <HelloMessage {...props} />);
return { teardown: () => root.unmount() };
});
};

ReactOnRails.register({ HelloMessage, HelloWithContext, HelloHash, LazyHydrate });
```
Comment thread
justin808 marked this conversation as resolved.

`whenVisible` is a hypothetical helper that resolves when the element scrolls into view. The `LazyHydrate` example uses a concise-body arrow, so it returns the `whenVisible(...).then(...)` promise. If navigation removes the node before hydration runs, the callback returns nothing because there is no mounted root to clean up. If you switch the renderer to a `{ }` block body, add an explicit `return` or React on Rails will not receive the teardown wrapper.

The rest of this document focuses on **render functions** — the most flexible of the three types, with the richest set of return values. For renderer functions (client-side mounting control), see [Renderer Functions](../api-reference/view-helpers-api.md#renderer-functions-function-that-will-call-reactdomrender-or-reactdomhydrate) in the view helpers reference.

### Compatibility matrix: component types and Ruby helpers
Comment thread
justin808 marked this conversation as resolved.
Expand Down
11 changes: 11 additions & 0 deletions packages/react-on-rails/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -513,6 +513,17 @@ export interface ReactOnRailsInternal extends ReactOnRails {
* ```
* under React 18+.
*
* @remarks
* **Cleanup is the caller's responsibility.** Unlike the components React on Rails mounts itself
* (which are unmounted automatically on Turbo/Turbolinks navigation and same-id node replacement),
* a root created by this imperative API is **not** tracked internally. The returned root is handed
* back to you, and you must call `unmount()` on it yourself — e.g. on a Turbo `turbo:before-render`
* / Turbolinks `turbolinks:before-render` event, or in your framework's teardown hook — to avoid
* leaking the root (and any subscriptions or timers it holds) across navigations. If you want
* automatic cleanup instead, register a renderer function (the 3-argument render-function form) and
* return a {@link RendererTeardownResult}; React on Rails tracks those mounts and runs the teardown for
* you.
*
* @param name Name of your registered component
* @param props Props to pass to your component
* @param domNodeId HTML ID of the node the component will be rendered at
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,9 @@ export default (props, _railsContext, domNodeId) => {
} else {
ReactDOM.render(reactElement, domNode);
}

// Return a teardown wrapper so React on Rails unmounts this tree on Turbo/Turbolinks navigation
// (page unload) or same-id node replacement instead of leaking it. The React 16/17 API unmounts
// by container node rather than via a root handle.
return { teardown: () => ReactDOM.unmountComponentAtNode(domNode) };
Comment thread
justin808 marked this conversation as resolved.
};
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ export default (props, railsContext, domNodeId) => {
// eslint-disable-next-line no-param-reassign
delete props.prerender;
Comment thread
justin808 marked this conversation as resolved.

const domNode = document.getElementById(domNodeId);
Comment thread
justin808 marked this conversation as resolved.

const combinedReducer = combineReducers(reducers);
const combinedProps = composeInitialState(props, railsContext);

Expand All @@ -39,14 +41,16 @@ export default (props, railsContext, domNodeId) => {

// Provider uses this.props.children, so we're not typical React syntax.
// This allows redux to add additional props to the HelloWorldContainer.
// The React 16/17 API re-renders into the same container idempotently, so hot reload reuses the
// existing tree (no separate root to unmount first).
const renderApp = (Komponent) => {
const element = wrapElementInStrictMode(
<Provider store={store}>
<Komponent />
</Provider>,
);

render(element, document.getElementById(domNodeId));
render(element, domNode);
};

renderApp(HelloWorldContainer);
Expand All @@ -57,4 +61,9 @@ export default (props, railsContext, domNodeId) => {
renderApp(HelloWorldContainer);
});
}

// Return a teardown wrapper so React on Rails unmounts this tree on Turbo/Turbolinks navigation
// (page unload) or same-id node replacement instead of leaking it. The React 16/17 API unmounts
// by container node rather than via a root handle.
return { teardown: () => ReactDOM.unmountComponentAtNode(domNode) };
};
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ export default (props, _railsContext, domNodeId) => {
// eslint-disable-next-line no-param-reassign
delete props.prerender;

const domNode = document.getElementById(domNodeId);
Comment thread
justin808 marked this conversation as resolved.

// This is where we get the existing store.
const store = ReactOnRails.getStore('SharedReduxStore');

Expand All @@ -29,13 +31,15 @@ export default (props, _railsContext, domNodeId) => {

// Provider uses this.props.children, so we're not typical React syntax.
// This allows redux to add additional props to the HelloWorldContainer.
// The React 16/17 API re-renders into the same container idempotently, so hot reload reuses the
// existing tree (no separate root to unmount first).
const renderApp = (Component) => {
const element = wrapElementInStrictMode(
<Provider store={store}>
<Component />
</Provider>,
);
render(element, document.getElementById(domNodeId));
render(element, domNode);
};

renderApp(HelloWorldContainer);
Expand All @@ -45,4 +49,9 @@ export default (props, _railsContext, domNodeId) => {
renderApp(HelloWorldContainer);
});
}

// Return a teardown wrapper so React on Rails unmounts this tree on Turbo/Turbolinks navigation
// (page unload) or same-id node replacement instead of leaking it. The React 16/17 API unmounts
// by container node rather than via a root handle.
return { teardown: () => ReactDOM.unmountComponentAtNode(domNode) };
};
16 changes: 14 additions & 2 deletions react_on_rails/spec/dummy/client/app/startup/ManualRenderApp.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,22 @@ export default (props, _railsContext, domNodeId) => {
);

const domNode = document.getElementById(domNodeId);
Comment thread
justin808 marked this conversation as resolved.
Outdated
if (!domNode) {
const renderMode = props.prerender ? 'hydrate' : 'render';
throw new Error(
`Cannot ${renderMode} ManualRenderApp because DOM element with id "${domNodeId}" was not found.`,
);
}

let root;
if (props.prerender) {
ReactDOMClient.hydrateRoot(domNode, reactElement);
root = ReactDOMClient.hydrateRoot(domNode, reactElement);
} else {
const root = ReactDOMClient.createRoot(domNode);
root = ReactDOMClient.createRoot(domNode);
root.render(reactElement);
}

// Return a teardown wrapper so React on Rails unmounts this root on Turbo/Turbolinks navigation
// (page unload) or same-id node replacement instead of leaking it.
return { teardown: () => root.unmount() };
};
38 changes: 31 additions & 7 deletions react_on_rails/spec/dummy/client/app/startup/ReduxApp.client.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,19 @@ import { wrapElementInStrictMode } from '../strictModeSupport';
*
*/
export default (props, railsContext, domNodeId) => {
const render = props.prerender
? ReactDOMClient.hydrateRoot
: (domNode, element) => {
const root = ReactDOMClient.createRoot(domNode);
root.render(element);
};
const { prerender } = props;
// eslint-disable-next-line no-param-reassign
delete props.prerender;

const hydrateOrRender = (domNode, element) => {
if (prerender) {
return ReactDOMClient.hydrateRoot(domNode, element);
}
const newRoot = ReactDOMClient.createRoot(domNode);
newRoot.render(element);
return newRoot;
};

const combinedReducer = combineReducers(reducers);
const combinedProps = composeInitialState(props, railsContext);

Expand All @@ -40,6 +44,10 @@ export default (props, railsContext, domNodeId) => {
// renderApp is a function required for hot reloading. see
// https://github.com/retroalgic/react-on-rails-hot-minimal/blob/master/client/src/entry.js

// Track the current root so we can unmount it on Turbo navigation (via the returned teardown)
// and re-render it in place on hot reload (rather than leaking a root or re-hydrating an emptied node).
let root;

// Provider uses this.props.children, so we're not typical React syntax.
// This allows redux to add additional props to the HelloWorldContainer.
const renderApp = (Komponent) => {
Expand All @@ -49,7 +57,21 @@ export default (props, railsContext, domNodeId) => {
</Provider>,
);

render(document.getElementById(domNodeId), element);
if (root) {
// HMR: re-render in place. Re-creating the root would unmount the DOM node and then call
Comment thread
justin808 marked this conversation as resolved.
Outdated
// hydrateRoot on an emptied node (hydration mismatch) whenever prerender was true.
root.render(element);
} else {
const domNode = document.getElementById(domNodeId);
if (!domNode) {
const renderMode = prerender ? 'hydrate' : 'render';
throw new Error(
`Cannot ${renderMode} ReduxApp because DOM element with id "${domNodeId}" was not found.`,
);
}

root = hydrateOrRender(domNode, element);
}
};

renderApp(HelloWorldContainer);
Expand All @@ -60,4 +82,6 @@ export default (props, railsContext, domNodeId) => {
renderApp(HelloWorldContainer);
});
}

return { teardown: () => root.unmount() };
Comment thread
justin808 marked this conversation as resolved.
Outdated
};
Loading
Loading