Skip to content

Commit 0b7771b

Browse files
justin808claude
andcommitted
feat(client): adopt renderer-function teardown in dummy apps + docs (#3578)
Follow-up to #3209 / #3576, which shipped the renderer-function teardown contract. This adopts the contract across the in-tree dummy renderer functions and documents the optional teardown return value. None of these change the public contract. In-tree dummy renderers (each now captures its root and returns a teardown so React on Rails unmounts it on Turbo/Turbolinks navigation or same-id node replacement instead of leaking it): - OSS modern (spec/dummy/client/app/startup): ManualRenderApp, ReduxApp, ReduxSharedStoreApp - OSS legacy React 16 (spec/dummy/client/app-react16/startup): ManualRenderApp, ReduxApp, ReduxSharedStoreApp (unmount via ReactDOM.unmountComponentAtNode) - Pro auto-load (ror-auto-load-components): ManualRenderApp, ReduxApp, ReduxSharedStoreApp, ApolloGraphQLApp, LazyApolloGraphQLApp - Pro loadable: loadable-client.imports-loadable (returns a promise resolving to the teardown, since the root is created inside loadableReady) The React 18 createRoot renderers that have a module.hot path now unmount the previous root before re-mounting, so hot reload no longer leaks a root or calls createRoot twice on one node. The React 16/17 renderers re-render into the same container idempotently, so their HMR path is unchanged. Docs: - core-concepts/render-functions.md: the LazyHydrate example captures the root and returns a teardown - api-reference/view-helpers-api.md: new "Cleaning up on Turbo/Turbolinks navigation" subsection with a Turbo-navigation example (React 18 + legacy) and the async best-effort caveat - api-reference/javascript-api.md: short reference note on the teardown return Imperative ReactOnRails.render(...) (item 3, separately scoped in #3209): documented the caller's cleanup responsibility in the render() JSDoc rather than auto-tracking the root. Auto-tracking would risk double-unmount for callers that already manage unmount() and was explicitly deferred as out of scope, so this is a documentation decision only with no behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 8dae7f8 commit 0b7771b

16 files changed

Lines changed: 173 additions & 33 deletions

File tree

docs/oss/api-reference/javascript-api.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,10 @@ The best source of docs is the `interface ReactOnRails` in [types/index.ts](http
3030
* - 2 params, or any function with `.renderFunction = true`: Render-Function — called with (props, railsContext),
3131
* returns a React component, `{ renderedHtml }` object, or Promise (Pro Node renderer only)
3232
* - 3 params: Renderer function — called with (props, railsContext, domNodeId),
33-
* responsible for calling ReactDOM.render/hydrate directly (client-only)
33+
* responsible for calling ReactDOM.render/hydrate directly (client-only).
34+
* May optionally return a teardown callback `() => void | Promise<void>` (or a promise resolving
35+
* to one); React on Rails runs it on Turbo/Turbolinks navigation or same-id node replacement to
36+
* unmount the renderer's root instead of leaking it. See the view-helpers renderer-function docs.
3437
*
3538
* Render-Functions can return:
3639
* - A React component (function or class) — used with `react_component`

docs/oss/api-reference/view-helpers-api.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,45 @@ Why would you want to take over mounting yourself? One use case is code splittin
106106
> [!IMPORTANT]
107107
> **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.
108108
109+
#### Cleaning up on Turbo/Turbolinks navigation (optional teardown)
110+
111+
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.
112+
113+
To opt in to cleanup, **return a teardown callback**`() => 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 (page unload) or when the same `domNodeId` node is replaced. Returning nothing keeps the previous (leaky) behavior, so existing renderers are unaffected.
114+
115+
```jsx
116+
import ReactDOMClient from 'react-dom/client';
117+
118+
// Renderer function: 3 params, mounts itself, returns a teardown.
119+
const MyRenderer = (props, _railsContext, domNodeId) => {
120+
const domNode = document.getElementById(domNodeId);
121+
const root = domNode.innerHTML
122+
? ReactDOMClient.hydrateRoot(domNode, <MyComponent {...props} />)
123+
: ReactDOMClient.createRoot(domNode);
124+
if (!domNode.innerHTML) {
125+
root.render(<MyComponent {...props} />);
126+
}
127+
128+
// Unmounted automatically on the next Turbo navigation (or same-id node replacement).
129+
return () => root.unmount();
130+
};
131+
```
132+
133+
Under the React 16/17 legacy API there is no root handle, so unmount by container node instead:
134+
135+
```jsx
136+
import ReactDOM from 'react-dom';
137+
138+
const MyLegacyRenderer = (props, _railsContext, domNodeId) => {
139+
const domNode = document.getElementById(domNodeId);
140+
ReactDOM.render(<MyComponent {...props} />, domNode);
141+
return () => ReactDOM.unmountComponentAtNode(domNode);
142+
};
143+
```
144+
145+
> [!NOTE]
146+
> 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 Pro's client renderer awaits the renderer and handles this race reliably.
147+
109148
---
110149

111150
### React Router

docs/oss/core-concepts/render-functions.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,14 +38,16 @@ const HelloHash = (props) => {
3838
};
3939
HelloHash.renderFunction = true;
4040

41-
// Renderer Function — 3 params, handles hydration itself, CLIENT ONLY
42-
const LazyHydrate = (props, railsContext, domNodeId) => {
41+
// Renderer Function — 3 params, handles hydration itself, CLIENT ONLY.
42+
// Optionally return a teardown (or a promise resolving to one); React on Rails runs it on
43+
// Turbo/Turbolinks navigation (or same-id node replacement) so the root is unmounted, not leaked.
44+
const LazyHydrate = (props, railsContext, domNodeId) =>
4345
// whenVisible is a hypothetical helper that resolves when the element scrolls into view
4446
whenVisible(domNodeId).then(() => {
45-
const root = document.getElementById(domNodeId);
46-
ReactDOM.hydrateRoot(root, <HelloMessage {...props} />);
47+
const domNode = document.getElementById(domNodeId);
48+
const root = ReactDOM.hydrateRoot(domNode, <HelloMessage {...props} />);
49+
return () => root.unmount();
4750
});
48-
};
4951

5052
ReactOnRails.register({ HelloMessage, HelloWithContext, HelloHash, LazyHydrate });
5153
```

packages/react-on-rails/src/types/index.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -465,6 +465,17 @@ export interface ReactOnRailsInternal extends ReactOnRails {
465465
* ```
466466
* under React 18+.
467467
*
468+
* @remarks
469+
* **Cleanup is the caller's responsibility.** Unlike the components React on Rails mounts itself
470+
* (which are unmounted automatically on Turbo/Turbolinks navigation and same-id node replacement),
471+
* a root created by this imperative API is **not** tracked internally. The returned root is handed
472+
* back to you, and you must call `unmount()` on it yourself — e.g. on a Turbo `turbo:before-render`
473+
* / Turbolinks `turbolinks:before-render` event, or in your framework's teardown hook — to avoid
474+
* leaking the root (and any subscriptions or timers it holds) across navigations. If you want
475+
* automatic cleanup instead, register a renderer function (the 3-argument render-function form) and
476+
* return a {@link RendererTeardown}; React on Rails tracks those mounts and runs the teardown for
477+
* you.
478+
*
468479
* @param name Name of your registered component
469480
* @param props Props to pass to your component
470481
* @param domNodeId HTML ID of the node the component will be rendered at

react_on_rails/spec/dummy/client/app-react16/startup/ManualRenderApp.jsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,9 @@ export default (props, _railsContext, domNodeId) => {
1818
} else {
1919
ReactDOM.render(reactElement, domNode);
2020
}
21+
22+
// Return a teardown so React on Rails unmounts this tree on Turbo/Turbolinks navigation
23+
// (page unload) or same-id node replacement instead of leaking it. The React 16/17 API unmounts
24+
// by container node rather than via a root handle.
25+
return () => ReactDOM.unmountComponentAtNode(domNode);
2126
};

react_on_rails/spec/dummy/client/app-react16/startup/ReduxApp.client.jsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ export default (props, railsContext, domNodeId) => {
2727
// eslint-disable-next-line no-param-reassign
2828
delete props.prerender;
2929

30+
const domNode = document.getElementById(domNodeId);
31+
3032
const combinedReducer = combineReducers(reducers);
3133
const combinedProps = composeInitialState(props, railsContext);
3234

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

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

49-
render(element, document.getElementById(domNodeId));
53+
render(element, domNode);
5054
};
5155

5256
renderApp(HelloWorldContainer);
@@ -57,4 +61,9 @@ export default (props, railsContext, domNodeId) => {
5761
renderApp(HelloWorldContainer);
5862
});
5963
}
64+
65+
// Return a teardown so React on Rails unmounts this tree on Turbo/Turbolinks navigation
66+
// (page unload) or same-id node replacement instead of leaking it. The React 16/17 API unmounts
67+
// by container node rather than via a root handle.
68+
return () => ReactDOM.unmountComponentAtNode(domNode);
6069
};

react_on_rails/spec/dummy/client/app-react16/startup/ReduxSharedStoreApp.client.jsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ export default (props, _railsContext, domNodeId) => {
2121
// eslint-disable-next-line no-param-reassign
2222
delete props.prerender;
2323

24+
const domNode = document.getElementById(domNodeId);
25+
2426
// This is where we get the existing store.
2527
const store = ReactOnRails.getStore('SharedReduxStore');
2628

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

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

4145
renderApp(HelloWorldContainer);
@@ -45,4 +49,9 @@ export default (props, _railsContext, domNodeId) => {
4549
renderApp(HelloWorldContainer);
4650
});
4751
}
52+
53+
// Return a teardown so React on Rails unmounts this tree on Turbo/Turbolinks navigation
54+
// (page unload) or same-id node replacement instead of leaking it. The React 16/17 API unmounts
55+
// by container node rather than via a root handle.
56+
return () => ReactDOM.unmountComponentAtNode(domNode);
4857
};

react_on_rails/spec/dummy/client/app/startup/ManualRenderApp.jsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,15 @@ export default (props, _railsContext, domNodeId) => {
1111
);
1212

1313
const domNode = document.getElementById(domNodeId);
14+
let root;
1415
if (props.prerender) {
15-
ReactDOMClient.hydrateRoot(domNode, reactElement);
16+
root = ReactDOMClient.hydrateRoot(domNode, reactElement);
1617
} else {
17-
const root = ReactDOMClient.createRoot(domNode);
18+
root = ReactDOMClient.createRoot(domNode);
1819
root.render(reactElement);
1920
}
21+
22+
// Return a teardown so React on Rails unmounts this root on Turbo/Turbolinks navigation
23+
// (page unload) or same-id node replacement instead of leaking it.
24+
return () => root.unmount();
2025
};

react_on_rails/spec/dummy/client/app/startup/ReduxApp.client.jsx

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,19 @@ import { wrapElementInStrictMode } from '../strictModeSupport';
2121
*
2222
*/
2323
export default (props, railsContext, domNodeId) => {
24-
const render = props.prerender
25-
? ReactDOMClient.hydrateRoot
26-
: (domNode, element) => {
27-
const root = ReactDOMClient.createRoot(domNode);
28-
root.render(element);
29-
};
24+
const { prerender } = props;
3025
// eslint-disable-next-line no-param-reassign
3126
delete props.prerender;
3227

28+
const hydrateOrRender = (domNode, element) => {
29+
if (prerender) {
30+
return ReactDOMClient.hydrateRoot(domNode, element);
31+
}
32+
const newRoot = ReactDOMClient.createRoot(domNode);
33+
newRoot.render(element);
34+
return newRoot;
35+
};
36+
3337
const combinedReducer = combineReducers(reducers);
3438
const combinedProps = composeInitialState(props, railsContext);
3539

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

47+
// Track the current root so we can unmount it on Turbo navigation (via the returned teardown) and
48+
// before re-mounting on hot reload (so we never leak a root or call createRoot twice on one node).
49+
let root;
50+
4351
// Provider uses this.props.children, so we're not typical React syntax.
4452
// This allows redux to add additional props to the HelloWorldContainer.
4553
const renderApp = (Komponent) => {
@@ -49,7 +57,10 @@ export default (props, railsContext, domNodeId) => {
4957
</Provider>,
5058
);
5159

52-
render(document.getElementById(domNodeId), element);
60+
if (root) {
61+
root.unmount();
62+
}
63+
root = hydrateOrRender(document.getElementById(domNodeId), element);
5364
};
5465

5566
renderApp(HelloWorldContainer);
@@ -60,4 +71,10 @@ export default (props, railsContext, domNodeId) => {
6071
renderApp(HelloWorldContainer);
6172
});
6273
}
74+
75+
return () => {
76+
if (root) {
77+
root.unmount();
78+
}
79+
};
6380
};

react_on_rails/spec/dummy/client/app/startup/ReduxSharedStoreApp.client.jsx

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,21 +15,29 @@ import { wrapElementInStrictMode } from '../strictModeSupport';
1515
* React will see that the state is the same and not do anything.
1616
*/
1717
export default (props, _railsContext, domNodeId) => {
18-
const render = props.prerender
19-
? ReactDOMClient.hydrateRoot
20-
: (domNode, element) => {
21-
const root = ReactDOMClient.createRoot(domNode);
22-
root.render(element);
23-
};
18+
const { prerender } = props;
2419
// eslint-disable-next-line no-param-reassign
2520
delete props.prerender;
2621

22+
const hydrateOrRender = (domNode, element) => {
23+
if (prerender) {
24+
return ReactDOMClient.hydrateRoot(domNode, element);
25+
}
26+
const newRoot = ReactDOMClient.createRoot(domNode);
27+
newRoot.render(element);
28+
return newRoot;
29+
};
30+
2731
// This is where we get the existing store.
2832
const store = ReactOnRails.getStore('SharedReduxStore');
2933

3034
// renderApp is a function required for hot reloading. see
3135
// https://github.com/retroalgic/react-on-rails-hot-minimal/blob/master/client/src/entry.js
3236

37+
// Track the current root so we can unmount it on Turbo navigation (via the returned teardown) and
38+
// before re-mounting on hot reload (so we never leak a root or call createRoot twice on one node).
39+
let root;
40+
3341
// Provider uses this.props.children, so we're not typical React syntax.
3442
// This allows redux to add additional props to the HelloWorldContainer.
3543
const renderApp = (Component) => {
@@ -38,7 +46,10 @@ export default (props, _railsContext, domNodeId) => {
3846
<Component />
3947
</Provider>,
4048
);
41-
render(document.getElementById(domNodeId), element);
49+
if (root) {
50+
root.unmount();
51+
}
52+
root = hydrateOrRender(document.getElementById(domNodeId), element);
4253
};
4354

4455
renderApp(HelloWorldContainer);
@@ -48,4 +59,10 @@ export default (props, _railsContext, domNodeId) => {
4859
renderApp(HelloWorldContainer);
4960
});
5061
}
62+
63+
return () => {
64+
if (root) {
65+
root.unmount();
66+
}
67+
};
5168
};

0 commit comments

Comments
 (0)