Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix unmounting in React 18 #1466

Merged
merged 5 commits into from
Jul 7, 2022
Merged
Show file tree
Hide file tree
Changes from all 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
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);
}