Skip to content

Commit 0ebdff0

Browse files
justin808claude
andauthored
Adopt renderer-function teardown in dummy apps + docs (#3578) (#3585)
## Summary Follow-up to #3209, after **PR #3576** (the renderer-function teardown contract) merged into `main` as `55fcdcf4af0629f32969dfa55f6fd71794ce200f`. This adopts the explicit `{ teardown }` wrapper contract across the in-tree dummy renderer functions and documents the optional teardown wrapper return value. **None of these change the public runtime contract** — they adopt it in-tree and document it, exactly the checklist deferred in #3578. > ✅ **Retargeted to main.** #3576 has landed, so #3585 is now based on `main` and the head branch has been rebased onto `origin/main`. Closes #3578. ## 1. In-tree dummy renderers → return `{ teardown }` Each renderer function (3-arg `(props, railsContext, domNodeId)` form) now captures its React root and returns `{ teardown: () => ... }` so React on Rails unmounts it on Turbo/Turbolinks navigation (page unload) or same-id node replacement instead of leaking it. **Decision:** use the explicit `{ teardown }` wrapper, not a bare callback. #3576 made the runtime/type contract explicit so legacy 3-argument render functions that happened to return a function are not misclassified as cleanup callbacks. **OSS modern** (`react_on_rails/spec/dummy/client/app/startup/`): - `ManualRenderApp.jsx` - `ReduxApp.client.jsx` — `module.hot` path now reuses the existing root instead of creating a second root - `ReduxSharedStoreApp.client.jsx` — `module.hot` path now reuses the existing root instead of creating a second root **OSS legacy React 16** (`react_on_rails/spec/dummy/client/app-react16/startup/`): - `ManualRenderApp.jsx`, `ReduxApp.client.jsx`, `ReduxSharedStoreApp.client.jsx` — teardown unmounts via `ReactDOM.unmountComponentAtNode(domNode)` (no root handle in the React 16/17 API) **Pro auto-load** (`react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/`): - `ManualRenderApp.jsx`, `ReduxApp.client.jsx`, `ReduxSharedStoreApp.client.jsx`, `ApolloGraphQLApp.client.jsx`, `LazyApolloGraphQLApp.client.tsx` **Pro loadable:** - `loadable/loadable-client.imports-loadable.jsx` — returns a **promise resolving to** `{ teardown }`, since the root is created inside `loadableReady()` - `@loadable/component` version confirmation: the Pro dummy app declares `^5.16.3` and the lockfile resolves `5.16.7`, above the Promise API floor (`5.12.0`). ### A note on the `module.hot` paths The React 18 `createRoot` renderers (OSS modern Redux apps) now re-render on the same root during hot reload, so HMR no longer leaks a root or calls `createRoot` twice on the same node. The React 16/17 renderers re-render into the same container idempotently (`ReactDOM.render`/`hydrate`), so there is no separate root to unmount first — their HMR path is unchanged, and the teardown handles unload/replacement cleanup. ## 2. Docs - `docs/oss/core-concepts/render-functions.md` — the `LazyHydrate` example captures the root and resolves to `{ teardown }`. - `docs/oss/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. - `docs/oss/api-reference/javascript-api.md` — short reference note on the `{ teardown }` return value. ## 3. Imperative `ReactOnRails.render(...)` — decision recorded Flagged as out of scope in #3209 ("same family of problem; different surface; deferred"). **Decision: document the caller's responsibility; do not auto-track.** I added a `@remarks` block to the `render()` JSDoc in `packages/react-on-rails/src/types/index.ts` stating that a root created by this imperative API is **not** tracked internally and the caller must `unmount()` it themselves (e.g. on `turbo:before-render`), or use a renderer function for automatic cleanup. Rationale: auto-tracking these roots in `renderedRoots` would be a behavior change that risks **double-unmount** for callers that already manage `unmount()` themselves, and #3209 explicitly deferred this surface. Documentation-only, no behavior change. If we later want tracked imperative renders, that can be a separate, opt-in API. ## Retarget/rebase status - #3576 merged into `main` as `55fcdcf4af0629f32969dfa55f6fd71794ce200f` on 2026-06-05. - #3585 auto-closed when the old stacked base branch was deleted, then was reopened and retargeted to `main`. - The #3585 head branch was rebased onto latest `origin/main` and force-pushed with lease; current rebased top commit is `667d1954`. ## Follow-up Non-blocking review nits from the final green #3585 pass are tracked in #3689 instead of delaying this PR. The `@loadable/component` Promise API floor is recorded above, and the legacy React 16 dummy `props` mutation cleanup is intentionally deferred because #3585 preserves existing fixture behavior while adding teardown. The intentionally deferred surface remains the imperative `ReactOnRails.render(...)` API documented below; if tracked imperative roots are desired later, that should be a separate opt-in API issue/PR. ## Acceptance criteria - [x] All in-tree dummy renderer functions return `{ teardown }` that unmounts their root (and the `module.hot` createRoot path reuses the previous root instead of creating a new one). - [x] Renderer-function docs show the optional `{ teardown }` return value with a Turbo-navigation example. - [x] A decision (documentation) is recorded for the imperative `ReactOnRails.render(...)` path. ## Verification - Rebased #3585 branch onto latest `origin/main` after #3576 merged; no conflicts. - `pnpm exec prettier --check ...changed files...` — clean - `pnpm exec eslint --report-unused-disable-directives ...changed JS/TS files...` — clean - `pnpm --filter react-on-rails run type-check` — clean - `pnpm --filter react-on-rails-pro run type-check` — clean - `pnpm --dir react_on_rails_pro/spec/dummy exec tsc -p tsconfig.json --noEmit --pretty false` — no new errors; still reports the 4 pre-existing `tests/strict-mode-support.test.tsx` unknown-prop errors documented before - `git diff --check origin/main...HEAD` — clean - `pnpm run lint` — currently blocked by unrelated `test/shakaperf/rsc-fouc` artifact lint errors on main; those files are not touched by this PR. No CHANGELOG entry: per the project changelog guidelines these are test fixtures + docs + a JSDoc comment (not user-visible behavior), and #3576 already documents the user-facing contract. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Changes are limited to test fixtures, documentation, and JSDoc; public teardown behavior was already introduced in the prior contract PR. > > **Overview** > Adopts the **optional `{ teardown }` wrapper** from the merged renderer teardown contract across in-tree dummy **3-argument renderer functions**, plus documentation and a JSDoc note—**no new runtime behavior** in this PR. > > **Dummy apps:** OSS React 16 and Pro auto-load/loadable client startups now resolve the mount DOM node up front (throw if missing), capture the React root (or legacy container), and **return `{ teardown: () => root.unmount() }`** (or a **promise** that resolves to it for `loadableReady`). That lets React on Rails unmount renderer-owned trees on Turbo/Turbolinks navigation and same-id node replacement instead of leaking roots. > > **Docs:** `render-functions.md` and `view-helpers-api.md` document optional teardown (React 18 + legacy examples, hydrate-vs-render notes, async teardown best-effort in OSS). `javascript-api.md` briefly mentions the return shape on 3-param renderers. > > **Types:** `ReactOnRails.render()` JSDoc now states that **imperative mounts are not auto-tracked**—callers must `unmount()` themselves or use a renderer function with `{ teardown }` for automatic cleanup. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 200865b. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Renderer functions and client entrypoints may return optional teardown handlers so mounted React roots are unmounted cleanly during Turbo/Turbolinks navigation or same-id node replacement. * Client startups now validate target DOM nodes before hydrating. * **Documentation** * Expanded API and core-concepts docs with examples for teardown return values, hydration patterns, and sync/async teardown behavior. * **Bug Fixes** * Prevents leaked mounted React roots across navigations and hot-reloads. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent af4d304 commit 0ebdff0

13 files changed

Lines changed: 202 additions & 27 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 }` wrapper (or a promise resolving to one); React on Rails
35+
* runs it on Turbo/Turbolinks navigation or same-id node replacement to unmount the renderer's root
36+
* 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: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,59 @@ 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 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.
114+
115+
```jsx
116+
import ReactDOMClient from 'react-dom/client';
117+
118+
// Renderer function: 3 params, mounts itself, returns a teardown wrapper.
119+
const MyRenderer = (props, _railsContext, domNodeId) => {
120+
const domNode = document.getElementById(domNodeId);
121+
if (!domNode) {
122+
throw new Error(`Missing DOM element with id: ${domNodeId}`);
123+
}
124+
125+
// This example always creates a fresh root. See the hydration note below if your renderer
126+
// needs to hydrate server-rendered markup.
127+
const root = ReactDOMClient.createRoot(domNode);
128+
root.render(<MyComponent {...props} />);
129+
130+
// Unmounted automatically on the next Turbo navigation (or same-id node replacement).
131+
return { teardown: () => root.unmount() };
132+
};
133+
```
134+
135+
> [!NOTE]
136+
> **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 your client renderer also serves components that were rendered on the server through a separate server bundle (a server/client split), pass an application-level signal in the component's `props`, such as `serverRendered`, and branch on it. The in-repo dummy apps use their own fixture props for this decision; the custom flag here is just an example for renderers that need an explicit hydrate-vs-render signal. 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.
137+
138+
Under the React 16/17 legacy API there is no root handle, so unmount by container node instead:
139+
140+
```jsx
141+
import ReactDOM from 'react-dom';
142+
143+
const MyLegacyRenderer = (props, _railsContext, domNodeId) => {
144+
const { serverRendered, ...componentProps } = props;
145+
const domNode = document.getElementById(domNodeId);
146+
if (!domNode) {
147+
throw new Error(`Missing DOM element with id: ${domNodeId}`);
148+
}
149+
150+
if (serverRendered) {
151+
ReactDOM.hydrate(<MyComponent {...componentProps} />, domNode);
152+
} else {
153+
ReactDOM.render(<MyComponent {...componentProps} />, domNode);
154+
}
155+
return { teardown: () => ReactDOM.unmountComponentAtNode(domNode) };
156+
};
157+
```
158+
159+
> [!NOTE]
160+
> 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.
161+
109162
---
110163

111164
### React Router

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

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,22 @@ This guide explains how render-functions work in React on Rails and how to use t
66

77
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.
88

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

1515
A few important points about the detection:
1616

1717
- **The detection is based on `Function.length`** (the number of declared parameters). Destructured parameters count as 1 — `({ name }) => ...` has length 1.
1818
- **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.
19-
- **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.
19+
- **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.
2020
- **`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.
2121

2222
```jsx
23+
import ReactDOMClient from 'react-dom/client';
24+
2325
// Regular React Component — 0 or 1 params, renders normally
2426
const HelloMessage = (props) => <div>Hello {props.name}</div>;
2527

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

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

5056
ReactOnRails.register({ HelloMessage, HelloWithContext, HelloHash, LazyHydrate });
5157
```
5258

59+
`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.
60+
5361
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.
5462

5563
### Compatibility matrix: component types and Ruby helpers

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -524,6 +524,17 @@ export interface ReactOnRailsInternal extends ReactOnRails {
524524
* ```
525525
* under React 18+.
526526
*
527+
* @remarks
528+
* **Cleanup is the caller's responsibility.** Unlike the components React on Rails mounts itself
529+
* (which are unmounted automatically on Turbo/Turbolinks navigation and same-id node replacement),
530+
* a root created by this imperative API is **not** tracked internally. The returned root is handed
531+
* back to you, and you must call `unmount()` on it yourself — e.g. on a Turbo `turbo:before-render`
532+
* / Turbolinks `turbolinks:before-render` event, or in your framework's teardown hook — to avoid
533+
* leaking the root (and any subscriptions or timers it holds) across navigations. If you want
534+
* automatic cleanup instead, register a renderer function (the 3-argument render-function form) and
535+
* return a {@link RendererTeardownResult}; React on Rails tracks those mounts and runs the teardown for
536+
* you.
537+
*
527538
* @param name Name of your registered component
528539
* @param props Props to pass to your component
529540
* @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: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,21 @@ export default (props, _railsContext, domNodeId) => {
1313
);
1414

1515
const domNode = document.getElementById(domNodeId);
16+
if (!domNode) {
17+
const renderMode = props.prerender ? 'hydrate' : 'render';
18+
throw new Error(
19+
`Cannot ${renderMode} ManualRenderApp because DOM element with id "${domNodeId}" was not found.`,
20+
);
21+
}
22+
1623
if (props.prerender) {
1724
ReactDOM.hydrate(reactElement, domNode);
1825
} else {
1926
ReactDOM.render(reactElement, domNode);
2027
}
28+
29+
// Return a teardown wrapper so React on Rails unmounts this tree on Turbo/Turbolinks navigation
30+
// (page unload) or same-id node replacement instead of leaking it. The React 16/17 API unmounts
31+
// by container node rather than via a root handle.
32+
return { teardown: () => ReactDOM.unmountComponentAtNode(domNode) };
2133
};

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

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,19 @@ import { wrapElementInStrictMode } from '../../app/strictModeSupport';
2323
*
2424
*/
2525
export default (props, railsContext, domNodeId) => {
26-
const render = props.prerender ? ReactDOM.hydrate : ReactDOM.render;
26+
const { prerender } = props;
27+
const render = prerender ? ReactDOM.hydrate : ReactDOM.render;
2728
// eslint-disable-next-line no-param-reassign
2829
delete props.prerender;
2930

31+
const domNode = document.getElementById(domNodeId);
32+
if (!domNode) {
33+
const renderMode = prerender ? 'hydrate' : 'render';
34+
throw new Error(
35+
`Cannot ${renderMode} ReduxApp because DOM element with id "${domNodeId}" was not found.`,
36+
);
37+
}
38+
3039
const combinedReducer = combineReducers(reducers);
3140
const combinedProps = composeInitialState(props, railsContext);
3241

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

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

49-
render(element, document.getElementById(domNodeId));
60+
render(element, domNode);
5061
};
5162

5263
renderApp(HelloWorldContainer);
@@ -57,4 +68,9 @@ export default (props, railsContext, domNodeId) => {
5768
renderApp(HelloWorldContainer);
5869
});
5970
}
71+
72+
// Return a teardown wrapper so React on Rails unmounts this tree on Turbo/Turbolinks navigation
73+
// (page unload) or same-id node replacement instead of leaking it. The React 16/17 API unmounts
74+
// by container node rather than via a root handle.
75+
return { teardown: () => ReactDOM.unmountComponentAtNode(domNode) };
6076
};

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

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,19 @@ import { wrapElementInStrictMode } from '../../app/strictModeSupport';
1717
* React will see that the state is the same and not do anything.
1818
*/
1919
export default (props, _railsContext, domNodeId) => {
20-
const render = props.prerender ? ReactDOM.hydrate : ReactDOM.render;
20+
const { prerender } = props;
21+
const render = prerender ? ReactDOM.hydrate : ReactDOM.render;
2122
// eslint-disable-next-line no-param-reassign
2223
delete props.prerender;
2324

25+
const domNode = document.getElementById(domNodeId);
26+
if (!domNode) {
27+
const renderMode = prerender ? 'hydrate' : 'render';
28+
throw new Error(
29+
`Cannot ${renderMode} ReduxSharedStoreApp because DOM element with id "${domNodeId}" was not found.`,
30+
);
31+
}
32+
2433
// This is where we get the existing store.
2534
const store = ReactOnRails.getStore('SharedReduxStore');
2635

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

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

4152
renderApp(HelloWorldContainer);
@@ -45,4 +56,9 @@ export default (props, _railsContext, domNodeId) => {
4556
renderApp(HelloWorldContainer);
4657
});
4758
}
59+
60+
// Return a teardown wrapper so React on Rails unmounts this tree on Turbo/Turbolinks navigation
61+
// (page unload) or same-id node replacement instead of leaking it. The React 16/17 API unmounts
62+
// by container node rather than via a root handle.
63+
return { teardown: () => ReactDOM.unmountComponentAtNode(domNode) };
4864
};

react_on_rails_pro/spec/dummy/client/app/loadable/loadable-client.imports-loadable.jsx

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,23 @@ import { HelmetProvider } from '@dr.pogodin/react-helmet';
77
import ClientApp from './LoadableApp';
88
import { wrapElementInStrictMode } from '../strictModeSupport';
99

10-
const App = (props, railsContext, domNodeId) => {
11-
loadableReady(() => {
10+
const App = (props, railsContext, domNodeId) =>
11+
// loadableReady resolves once the split chunks are present, then we hydrate. Returning the promise
12+
// (which resolves to a teardown wrapper) lets React on Rails unmount this root on Turbo/Turbolinks
13+
// navigation or same-id node replacement instead of leaking it. The callback form would discard it.
14+
loadableReady().then(() => {
1215
const el = document.getElementById(domNodeId);
16+
// Navigation may remove the node before chunks resolve; no root was mounted,
17+
// so React on Rails treats undefined as no teardown.
18+
if (!el) return undefined;
19+
1320
const reactElement = wrapElementInStrictMode(
1421
<HelmetProvider>
1522
{React.createElement(ClientApp, { ...props, path: railsContext.pathname })}
1623
</HelmetProvider>,
1724
);
18-
hydrateRoot(el, reactElement);
25+
const root = hydrateRoot(el, reactElement);
26+
return { teardown: () => root.unmount() };
1927
});
20-
};
2128

2229
export default App;

react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/ApolloGraphQLApp.client.jsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,20 @@ export default (_props, _railsContext, domNodeId) => {
2323
ssrForceFetchDelay: 100,
2424
});
2525
const el = document.getElementById(domNodeId);
26+
if (!el) {
27+
throw new Error(
28+
`Cannot hydrate ApolloGraphQLApp because DOM element with id "${domNodeId}" was not found.`,
29+
);
30+
}
31+
2632
const App = wrapElementInStrictMode(
2733
<ApolloProvider client={client}>
2834
<ApolloGraphQL />
2935
</ApolloProvider>,
3036
);
31-
hydrateRoot(el, App);
37+
const root = hydrateRoot(el, App);
38+
39+
// Return a teardown wrapper so React on Rails unmounts this root on Turbo/Turbolinks navigation
40+
// (page unload) or same-id node replacement instead of leaking it.
41+
return { teardown: () => root.unmount() };
3242
};

react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/LazyApolloGraphQLApp.client.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,12 @@ export default (_props: unknown, _railsContext: RailsContext, domNodeId: string)
1616
throw new Error(`Missing DOM element with id: ${domNodeId}`);
1717
}
1818

19-
console.log('window.__SSR_COMPUTATION_CACHE', window.__SSR_COMPUTATION_CACHE);
2019
const ssrComputationCache = window.__SSR_COMPUTATION_CACHE;
2120
setSSRCache(ssrComputationCache);
2221
const App = wrapElementInStrictMode(<ApolloGraphQL />);
23-
hydrateRoot(el, App);
22+
const root = hydrateRoot(el, App);
23+
24+
// Return a teardown wrapper so React on Rails unmounts this root on Turbo/Turbolinks navigation
25+
// (page unload) or same-id node replacement instead of leaking it.
26+
return { teardown: () => root.unmount() };
2427
};

0 commit comments

Comments
 (0)