Skip to content

Commit

Permalink
Fix unmounting in React 18 (#1466)
Browse files Browse the repository at this point in the history
* Reformat clientStartup.ts
* Unmount roots correctly in React 18+
* Avoid checking if we are in Node or in browser so much
  • Loading branch information
alexeyr-ci1 authored Jul 7, 2022
1 parent 8b708e0 commit db9c112
Show file tree
Hide file tree
Showing 4 changed files with 110 additions and 81 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ Changes since last non-beta release.

To migrate this change, remove `mini_racer` gem from your `Gemfile` and test your app for correct behaviour. You can continue using `mini_racer` and it will be still picked as the default `ExecJS` runtime, if present in your app `Gemfile`.

#### Fixed
- Correctly unmount roots under React 18. [PR 1466](https://github.com/shakacode/react_on_rails/pull/1466) by [alexeyr](https://github.com/alexeyr).

- Fixed the `You are importing hydrateRoot from "react-dom" [...] You should instead import it from "react-dom/client"` warning under React 18 ([#1441](https://github.com/shakacode/react_on_rails/issues/1441)). [PR 1460](https://github.com/shakacode/react_on_rails/pull/1460) by [alexeyr](https://github.com/alexeyr).

In exchange, you may see a warning like this when building a Webpack bundle under React 16:
Expand Down
93 changes: 56 additions & 37 deletions node_package/src/clientStartup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,25 @@ import type {
ReactOnRails as ReactOnRailsType,
RegisteredComponent,
RenderFunction,
} from './types/index';
Root,
} from './types';

import createReactOutput from './createReactOutput';
import {isServerRenderHash} from './isServerRenderResult';
import { isServerRenderHash } from './isServerRenderResult';
import reactHydrateOrRender from './reactHydrateOrRender';
import { supportsRootApi } from './reactApis';

declare global {
interface Window {
ReactOnRails: ReactOnRailsType;
__REACT_ON_RAILS_EVENT_HANDLERS_RAN_ONCE__?: boolean;
ReactOnRails: ReactOnRailsType;
__REACT_ON_RAILS_EVENT_HANDLERS_RAN_ONCE__?: boolean;
roots: Root[];
}

namespace NodeJS {
interface Global {
ReactOnRails: ReactOnRailsType;
ReactOnRails: ReactOnRailsType;
roots: Root[];
}
}
namespace Turbolinks {
Expand All @@ -32,7 +37,9 @@ declare const ReactOnRails: ReactOnRailsType;

const REACT_ON_RAILS_STORE_ATTRIBUTE = 'data-js-react-on-rails-store';

function findContext(): Window | NodeJS.Global {
type Context = Window | NodeJS.Global;

function findContext(): Context {
if (typeof window.ReactOnRails !== 'undefined') {
return window;
} else if (typeof ReactOnRails !== 'undefined') {
Expand Down Expand Up @@ -67,30 +74,22 @@ function turboInstalled() {
return false;
}

function reactOnRailsHtmlElements(): HTMLCollectionOf<Element> {
function reactOnRailsHtmlElements(): HTMLCollectionOf<Element> {
return document.getElementsByClassName('js-react-on-rails-component');
}

function forEachReactOnRailsComponentInitialize(fn: (element: Element, railsContext: RailsContext) => void, railsContext: RailsContext): void {
const els = reactOnRailsHtmlElements();
for (let i = 0; i < els.length; i += 1) {
fn(els[i], railsContext);
}
}

function initializeStore(el: Element, railsContext: RailsContext): void {
const context = findContext();
const name = el.getAttribute(REACT_ON_RAILS_STORE_ATTRIBUTE) || "";
function initializeStore(el: Element, context: Context, railsContext: RailsContext): void {
const name = el.getAttribute(REACT_ON_RAILS_STORE_ATTRIBUTE) || '';
const props = (el.textContent !== null) ? JSON.parse(el.textContent) : {};
const storeGenerator = context.ReactOnRails.getStoreGenerator(name);
const store = storeGenerator(props, railsContext);
context.ReactOnRails.setStore(name, store);
}

function forEachStore(railsContext: RailsContext): void {
function forEachStore(context: Context, railsContext: RailsContext): void {
const els = document.querySelectorAll(`[${REACT_ON_RAILS_STORE_ATTRIBUTE}]`);
for (let i = 0; i < els.length; i += 1) {
initializeStore(els[i], railsContext);
initializeStore(els[i], context, railsContext);
}
}

Expand All @@ -107,15 +106,15 @@ function delegateToRenderer(
props: Record<string, string>,
railsContext: RailsContext,
domNodeId: string,
trace: boolean
trace: boolean,
): boolean {
const { name, component, isRenderer } = componentObj;

if (isRenderer) {
if (trace) {
console.log(`\
DELEGATING TO RENDERER ${name} for dom node with id: ${domNodeId} with props, railsContext:`,
props, railsContext);
props, railsContext);
}

(component as RenderFunction)(props, railsContext, domNodeId);
Expand All @@ -126,21 +125,19 @@ DELEGATING TO RENDERER ${name} for dom node with id: ${domNodeId} with props, ra
}

function domNodeIdForEl(el: Element): string {
return el.getAttribute('data-dom-id') || "";
return el.getAttribute('data-dom-id') || '';
}

/**
* Used for client rendering by ReactOnRails. Either calls ReactDOM.hydrate, ReactDOM.render, or
* delegates to a renderer registered by the user.
* @param el
*/
function render(el: Element, railsContext: RailsContext): void {
const context = findContext();
function render(el: Element, context: Context, railsContext: RailsContext): void {
// This must match lib/react_on_rails/helper.rb
const name = el.getAttribute('data-component-name') || "";
const name = el.getAttribute('data-component-name') || '';
const domNodeId = domNodeIdForEl(el);
const props = (el.textContent !== null) ? JSON.parse(el.textContent) : {};
const trace = el.getAttribute('data-trace') === "true";
const trace = el.getAttribute('data-trace') === 'true';

try {
const domNode = document.getElementById(domNodeId);
Expand Down Expand Up @@ -168,7 +165,10 @@ function render(el: Element, railsContext: RailsContext): void {
You returned a server side type of react-router error: ${JSON.stringify(reactElementOrRouterResult)}
You should return a React.Component always for the client side entry point.`);
} else {
reactHydrateOrRender(domNode, reactElementOrRouterResult as ReactElement, shouldHydrate);
const rootOrElement = reactHydrateOrRender(domNode, reactElementOrRouterResult as ReactElement, shouldHydrate);
if (supportsRootApi) {
context.roots.push(rootOrElement as Root);
}
}
}
} catch (e: any) {
Expand All @@ -178,6 +178,13 @@ You should return a React.Component always for the client side entry point.`);
}
}

function forEachReactOnRailsComponentRender(context: Context, railsContext: RailsContext): void {
const els = reactOnRailsHtmlElements();
for (let i = 0; i < els.length; i += 1) {
render(els[i], context, railsContext);
}
}

function parseRailsContext(): RailsContext | null {
const el = document.getElementById('js-react-on-rails-context');
if (!el) {
Expand All @@ -187,7 +194,7 @@ function parseRailsContext(): RailsContext | null {
}

if (!el.textContent) {
throw new Error("The HTML element with ID 'js-react-on-rails-context' has no textContent");
throw new Error('The HTML element with ID \'js-react-on-rails-context\' has no textContent');
}

return JSON.parse(el.textContent);
Expand All @@ -201,14 +208,20 @@ export function reactOnRailsPageLoaded(): void {
// If no react on rails components
if (!railsContext) return;

forEachStore(railsContext);
forEachReactOnRailsComponentInitialize(render, railsContext);
const context = findContext();
if (supportsRootApi) {
context.roots = [];
}
forEachStore(context, railsContext);
forEachReactOnRailsComponentRender(context, railsContext);
}

function unmount(el: Element): void {
const domNodeId = domNodeIdForEl(el);
const domNode = document.getElementById(domNodeId);
if(domNode === null){return;}
if (domNode === null) {
return;
}
try {
ReactDOM.unmountComponentAtNode(domNode);
} catch (e: any) {
Expand All @@ -219,9 +232,15 @@ function unmount(el: Element): void {

function reactOnRailsPageUnloaded(): void {
debugTurbolinks('reactOnRailsPageUnloaded');
const els = reactOnRailsHtmlElements();
for (let i = 0; i < els.length; i += 1) {
unmount(els[i]);
if (supportsRootApi) {
for (const root of findContext().roots) {
root.unmount();
}
} else {
const els = reactOnRailsHtmlElements();
for (let i = 0; i < els.length; i += 1) {
unmount(els[i]);
}
}
}

Expand Down Expand Up @@ -258,11 +277,11 @@ function renderInit(): void {
}
}

function isWindow (context: Window | NodeJS.Global): context is Window {
function isWindow(context: Context): context is Window {
return (context as Window).document !== undefined;
}

export function clientStartup(context: Window | NodeJS.Global): void {
export function clientStartup(context: Context): void {
// Check if server rendering
if (!isWindow(context)) {
return;
Expand Down
8 changes: 8 additions & 0 deletions node_package/src/reactApis.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import ReactDOM from 'react-dom';

const reactMajorVersion = ReactDOM.version?.split('.')[0] || 16;

// TODO: once we require React 18, we can remove this and inline everything guarded by it.
// Not the default export because others may be added for future React versions.
// eslint-disable-next-line import/prefer-default-export
export const supportsRootApi = reactMajorVersion >= 18;
87 changes: 43 additions & 44 deletions node_package/src/reactHydrateOrRender.ts
Original file line number Diff line number Diff line change
@@ -1,44 +1,43 @@
import type { ReactElement } from 'react';
import ReactDOM from 'react-dom';
import type { RenderReturnType } from './types';

type HydrateOrRenderType = (domNode: Element, reactElement: ReactElement) => RenderReturnType;
const supportsReactCreateRoot = ReactDOM.version &&
parseInt(ReactDOM.version.split('.')[0], 10) >= 18;

// TODO: once React dependency is updated to >= 18, we can remove this and just
// import ReactDOM from 'react-dom/client';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let reactDomClient: any;
if (supportsReactCreateRoot) {
// This will never throw an exception, but it's the way to tell Webpack the dependency is optional
// https://github.com/webpack/webpack/issues/339#issuecomment-47739112
// Unfortunately, it only converts the error to a warning.
try {
// eslint-disable-next-line global-require,import/no-unresolved
reactDomClient = require('react-dom/client');
} catch (e) {
// We should never get here, but if we do, we'll just use the default ReactDOM
// and live with the warning.
reactDomClient = ReactDOM;
}
}

export const reactHydrate: HydrateOrRenderType = supportsReactCreateRoot ?
reactDomClient.hydrateRoot :
(domNode, reactElement) => ReactDOM.hydrate(reactElement, domNode);

export function reactRender(domNode: Element, reactElement: ReactElement): RenderReturnType {
if (supportsReactCreateRoot) {
const root = reactDomClient.createRoot(domNode);
root.render(reactElement);
return root;
}

// eslint-disable-next-line react/no-render-return-value
return ReactDOM.render(reactElement, domNode);
}

export default function reactHydrateOrRender(domNode: Element, reactElement: ReactElement, hydrate: boolean): RenderReturnType {
return hydrate ? reactHydrate(domNode, reactElement) : reactRender(domNode, reactElement);
}
import type { ReactElement } from 'react';
import ReactDOM from 'react-dom';
import type { RenderReturnType } from './types';
import { supportsRootApi } from './reactApis';

type HydrateOrRenderType = (domNode: Element, reactElement: ReactElement) => RenderReturnType;

// TODO: once React dependency is updated to >= 18, we can remove this and just
// import ReactDOM from 'react-dom/client';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let reactDomClient: any;
if (supportsRootApi) {
// This will never throw an exception, but it's the way to tell Webpack the dependency is optional
// https://github.com/webpack/webpack/issues/339#issuecomment-47739112
// Unfortunately, it only converts the error to a warning.
try {
// eslint-disable-next-line global-require,import/no-unresolved
reactDomClient = require('react-dom/client');
} catch (e) {
// We should never get here, but if we do, we'll just use the default ReactDOM
// and live with the warning.
reactDomClient = ReactDOM;
}
}

export const reactHydrate: HydrateOrRenderType = supportsRootApi ?
reactDomClient.hydrateRoot :
(domNode, reactElement) => ReactDOM.hydrate(reactElement, domNode);

export function reactRender(domNode: Element, reactElement: ReactElement): RenderReturnType {
if (supportsRootApi) {
const root = reactDomClient.createRoot(domNode);
root.render(reactElement);
return root;
}

// eslint-disable-next-line react/no-render-return-value
return ReactDOM.render(reactElement, domNode);
}

export default function reactHydrateOrRender(domNode: Element, reactElement: ReactElement, hydrate: boolean): RenderReturnType {
return hydrate ? reactHydrate(domNode, reactElement) : reactRender(domNode, reactElement);
}

0 comments on commit db9c112

Please sign in to comment.