Skip to content

Commit eafccca

Browse files
committed
Unmount roots correctly in React 18+
1 parent a1cc54c commit eafccca

File tree

3 files changed

+72
-49
lines changed

3 files changed

+72
-49
lines changed

node_package/src/clientStartup.ts

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,21 +5,25 @@ import type {
55
ReactOnRails as ReactOnRailsType,
66
RegisteredComponent,
77
RenderFunction,
8-
} from './types/index';
8+
Root,
9+
} from './types';
910

1011
import createReactOutput from './createReactOutput';
1112
import { isServerRenderHash } from './isServerRenderResult';
1213
import reactHydrateOrRender from './reactHydrateOrRender';
14+
import { supportsRootApi } from './reactApis';
1315

1416
declare global {
1517
interface Window {
1618
ReactOnRails: ReactOnRailsType;
1719
__REACT_ON_RAILS_EVENT_HANDLERS_RAN_ONCE__?: boolean;
20+
roots: Root[];
1821
}
1922

2023
namespace NodeJS {
2124
interface Global {
2225
ReactOnRails: ReactOnRailsType;
26+
roots: Root[];
2327
}
2428
}
2529
namespace Turbolinks {
@@ -169,7 +173,10 @@ function render(el: Element, railsContext: RailsContext): void {
169173
You returned a server side type of react-router error: ${JSON.stringify(reactElementOrRouterResult)}
170174
You should return a React.Component always for the client side entry point.`);
171175
} else {
172-
reactHydrateOrRender(domNode, reactElementOrRouterResult as ReactElement, shouldHydrate);
176+
const rootOrElement = reactHydrateOrRender(domNode, reactElementOrRouterResult as ReactElement, shouldHydrate);
177+
if (supportsRootApi) {
178+
context.roots.push(rootOrElement as Root);
179+
}
173180
}
174181
}
175182
} catch (e: any) {
@@ -203,6 +210,9 @@ export function reactOnRailsPageLoaded(): void {
203210
if (!railsContext) return;
204211

205212
forEachStore(railsContext);
213+
if (supportsRootApi) {
214+
findContext().roots = [];
215+
}
206216
forEachReactOnRailsComponentInitialize(render, railsContext);
207217
}
208218

@@ -222,9 +232,15 @@ function unmount(el: Element): void {
222232

223233
function reactOnRailsPageUnloaded(): void {
224234
debugTurbolinks('reactOnRailsPageUnloaded');
225-
const els = reactOnRailsHtmlElements();
226-
for (let i = 0; i < els.length; i += 1) {
227-
unmount(els[i]);
235+
if (supportsRootApi) {
236+
for (const root of findContext().roots) {
237+
root.unmount();
238+
}
239+
} else {
240+
const els = reactOnRailsHtmlElements();
241+
for (let i = 0; i < els.length; i += 1) {
242+
unmount(els[i]);
243+
}
228244
}
229245
}
230246

node_package/src/reactApis.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import ReactDOM from 'react-dom';
2+
3+
const reactMajorVersion = ReactDOM.version?.split('.')[0] || 16;
4+
5+
// TODO: once we require React 18, we can remove this and inline everything guarded by it.
6+
// Not the default export because others may be added for future React versions.
7+
// eslint-disable-next-line import/prefer-default-export
8+
export const supportsRootApi = reactMajorVersion >= 18;
Lines changed: 43 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,43 @@
1-
import type { ReactElement } from 'react';
2-
import ReactDOM from 'react-dom';
3-
import type { RenderReturnType } from './types';
4-
5-
type HydrateOrRenderType = (domNode: Element, reactElement: ReactElement) => RenderReturnType;
6-
const supportsReactCreateRoot = ReactDOM.version &&
7-
parseInt(ReactDOM.version.split('.')[0], 10) >= 18;
8-
9-
// TODO: once React dependency is updated to >= 18, we can remove this and just
10-
// import ReactDOM from 'react-dom/client';
11-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
12-
let reactDomClient: any;
13-
if (supportsReactCreateRoot) {
14-
// This will never throw an exception, but it's the way to tell Webpack the dependency is optional
15-
// https://github.com/webpack/webpack/issues/339#issuecomment-47739112
16-
// Unfortunately, it only converts the error to a warning.
17-
try {
18-
// eslint-disable-next-line global-require,import/no-unresolved
19-
reactDomClient = require('react-dom/client');
20-
} catch (e) {
21-
// We should never get here, but if we do, we'll just use the default ReactDOM
22-
// and live with the warning.
23-
reactDomClient = ReactDOM;
24-
}
25-
}
26-
27-
export const reactHydrate: HydrateOrRenderType = supportsReactCreateRoot ?
28-
reactDomClient.hydrateRoot :
29-
(domNode, reactElement) => ReactDOM.hydrate(reactElement, domNode);
30-
31-
export function reactRender(domNode: Element, reactElement: ReactElement): RenderReturnType {
32-
if (supportsReactCreateRoot) {
33-
const root = reactDomClient.createRoot(domNode);
34-
root.render(reactElement);
35-
return root;
36-
}
37-
38-
// eslint-disable-next-line react/no-render-return-value
39-
return ReactDOM.render(reactElement, domNode);
40-
}
41-
42-
export default function reactHydrateOrRender(domNode: Element, reactElement: ReactElement, hydrate: boolean): RenderReturnType {
43-
return hydrate ? reactHydrate(domNode, reactElement) : reactRender(domNode, reactElement);
44-
}
1+
import type { ReactElement } from 'react';
2+
import ReactDOM from 'react-dom';
3+
import type { RenderReturnType } from './types';
4+
import { supportsRootApi } from './reactApis';
5+
6+
type HydrateOrRenderType = (domNode: Element, reactElement: ReactElement) => RenderReturnType;
7+
8+
// TODO: once React dependency is updated to >= 18, we can remove this and just
9+
// import ReactDOM from 'react-dom/client';
10+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
11+
let reactDomClient: any;
12+
if (supportsRootApi) {
13+
// This will never throw an exception, but it's the way to tell Webpack the dependency is optional
14+
// https://github.com/webpack/webpack/issues/339#issuecomment-47739112
15+
// Unfortunately, it only converts the error to a warning.
16+
try {
17+
// eslint-disable-next-line global-require,import/no-unresolved
18+
reactDomClient = require('react-dom/client');
19+
} catch (e) {
20+
// We should never get here, but if we do, we'll just use the default ReactDOM
21+
// and live with the warning.
22+
reactDomClient = ReactDOM;
23+
}
24+
}
25+
26+
export const reactHydrate: HydrateOrRenderType = supportsRootApi ?
27+
reactDomClient.hydrateRoot :
28+
(domNode, reactElement) => ReactDOM.hydrate(reactElement, domNode);
29+
30+
export function reactRender(domNode: Element, reactElement: ReactElement): RenderReturnType {
31+
if (supportsRootApi) {
32+
const root = reactDomClient.createRoot(domNode);
33+
root.render(reactElement);
34+
return root;
35+
}
36+
37+
// eslint-disable-next-line react/no-render-return-value
38+
return ReactDOM.render(reactElement, domNode);
39+
}
40+
41+
export default function reactHydrateOrRender(domNode: Element, reactElement: ReactElement, hydrate: boolean): RenderReturnType {
42+
return hydrate ? reactHydrate(domNode, reactElement) : reactRender(domNode, reactElement);
43+
}

0 commit comments

Comments
 (0)