diff --git a/.changeset/eleven-hounds-invent.md b/.changeset/eleven-hounds-invent.md
new file mode 100644
index 0000000000..ba863ebc28
--- /dev/null
+++ b/.changeset/eleven-hounds-invent.md
@@ -0,0 +1,5 @@
+---
+'@backstage/cli': patch
+---
+
+In frontend builds and tests `process.env.HAS_REACT_DOM_CLIENT` will now be defined if `react-dom/client` is present, i.e. if using React 18. This allows for conditional imports of `react-dom/client`.
diff --git a/.changeset/shaggy-beers-collect.md b/.changeset/shaggy-beers-collect.md
new file mode 100644
index 0000000000..471368d6f2
--- /dev/null
+++ b/.changeset/shaggy-beers-collect.md
@@ -0,0 +1,6 @@
+---
+'@backstage/dev-utils': patch
+'@backstage/plugin-techdocs': patch
+---
+
+Added support for React 18. The new `createRoot` API from `react-dom/client` will now be used if present.
diff --git a/packages/cli/config/jest.js b/packages/cli/config/jest.js
index 1756b37db2..4a8bf9694f 100644
--- a/packages/cli/config/jest.js
+++ b/packages/cli/config/jest.js
@@ -24,6 +24,13 @@ const envOptions = {
oldTests: Boolean(process.env.BACKSTAGE_OLD_TESTS),
};
+try {
+ require.resolve('react-dom/client');
+ process.env.HAS_REACT_DOM_CLIENT = true;
+} catch {
+ /* ignored */
+}
+
const transformIgnorePattern = [
'@material-ui',
'ajv',
diff --git a/packages/cli/src/lib/bundler/config.ts b/packages/cli/src/lib/bundler/config.ts
index ed20fe385a..534ffe8c58 100644
--- a/packages/cli/src/lib/bundler/config.ts
+++ b/packages/cli/src/lib/bundler/config.ts
@@ -81,6 +81,15 @@ async function readBuildInfo() {
};
}
+function hasReactDomClient() {
+ try {
+ require.resolve('react-dom/client');
+ return true;
+ } catch {
+ return false;
+ }
+}
+
export async function createConfig(
paths: BundlingPaths,
options: BundlingOptions,
@@ -136,6 +145,9 @@ export async function createConfig(
() => JSON.stringify(options.getFrontendAppConfigs()),
true,
),
+ // This allows for conditional imports of react-dom/client, since there's no way
+ // to check for presence of it in source code without module resolution errors.
+ 'process.env.HAS_REACT_DOM_CLIENT': JSON.stringify(hasReactDomClient()),
}),
);
diff --git a/packages/dev-utils/package.json b/packages/dev-utils/package.json
index 5ec49ded70..bbb55d9b14 100644
--- a/packages/dev-utils/package.json
+++ b/packages/dev-utils/package.json
@@ -47,8 +47,8 @@
"react-use": "^17.2.4"
},
"peerDependencies": {
- "react": "^16.13.1 || ^17.0.0",
- "react-dom": "^16.13.1 || ^17.0.0",
+ "react": "^16.13.1 || ^17.0.0 || ^18.0.0",
+ "react-dom": "^16.13.1 || ^17.0.0 || ^18.0.0",
"react-router-dom": "6.0.0-beta.0 || ^6.3.0"
},
"devDependencies": {
diff --git a/packages/dev-utils/src/devApp/render.tsx b/packages/dev-utils/src/devApp/render.tsx
index 5f2b21b8d2..335308ff5e 100644
--- a/packages/dev-utils/src/devApp/render.tsx
+++ b/packages/dev-utils/src/devApp/render.tsx
@@ -45,9 +45,19 @@ import {
import { Box } from '@material-ui/core';
import BookmarkIcon from '@material-ui/icons/Bookmark';
import React, { ComponentType, ReactNode, PropsWithChildren } from 'react';
-import ReactDOM from 'react-dom';
import { createRoutesFromChildren, Route } from 'react-router-dom';
import { SidebarThemeSwitcher } from './SidebarThemeSwitcher';
+import 'react-dom';
+
+let ReactDOM:
+ | typeof import('react-dom')
+ // TODO: replace with import('react-dom/client') when repo is migrated to 18
+ | { createRoot(el: HTMLElement): { render(el: JSX.Element): void } };
+if (process.env.HAS_REACT_DOM_CLIENT) {
+ ReactDOM = require('react-dom/client');
+} else {
+ ReactDOM = require('react-dom');
+}
export function isReactRouterBeta(): boolean {
const [obj] = createRoutesFromChildren(} />);
@@ -235,7 +245,11 @@ export class DevAppBuilder {
window.location.pathname = this.defaultPage;
}
- ReactDOM.render(, document.getElementById('root'));
+ if ('createRoot' in ReactDOM) {
+ ReactDOM.createRoot(document.getElementById('root')!).render();
+ } else {
+ ReactDOM.render(, document.getElementById('root'));
+ }
}
}
diff --git a/plugins/techdocs/package.json b/plugins/techdocs/package.json
index a0fde4fd56..578535c351 100644
--- a/plugins/techdocs/package.json
+++ b/plugins/techdocs/package.json
@@ -74,8 +74,8 @@
"react-use": "^17.2.4"
},
"peerDependencies": {
- "react": "^16.13.1 || ^17.0.0",
- "react-dom": "^16.13.1 || ^17.0.0",
+ "react": "^16.13.1 || ^17.0.0 || ^18.0.0",
+ "react-dom": "^16.13.1 || ^17.0.0 || ^18.0.0",
"react-router-dom": "6.0.0-beta.0 || ^6.3.0"
},
"devDependencies": {
diff --git a/plugins/techdocs/src/reader/transformers/addGitFeedbackLink.ts b/plugins/techdocs/src/reader/transformers/addGitFeedbackLink.ts
index 9239573bd1..58876cd806 100644
--- a/plugins/techdocs/src/reader/transformers/addGitFeedbackLink.ts
+++ b/plugins/techdocs/src/reader/transformers/addGitFeedbackLink.ts
@@ -21,8 +21,8 @@ import {
} from '@backstage/integration';
import FeedbackOutlinedIcon from '@material-ui/icons/FeedbackOutlined';
import React from 'react';
-import ReactDOM from 'react-dom';
import parseGitUrl from 'git-url-parse';
+import { renderReactElement } from './renderReactElement';
// requires repo
export const addGitFeedbackLink = (
@@ -75,7 +75,7 @@ export const addGitFeedbackLink = (
default:
return dom;
}
- ReactDOM.render(React.createElement(FeedbackOutlinedIcon), feedbackLink);
+ renderReactElement(React.createElement(FeedbackOutlinedIcon), feedbackLink);
feedbackLink.style.paddingLeft = '5px';
feedbackLink.title = 'Leave feedback for this page';
feedbackLink.id = 'git-feedback-link';
diff --git a/plugins/techdocs/src/reader/transformers/addSidebarToggle.ts b/plugins/techdocs/src/reader/transformers/addSidebarToggle.ts
index 789c9179d5..352e396abf 100644
--- a/plugins/techdocs/src/reader/transformers/addSidebarToggle.ts
+++ b/plugins/techdocs/src/reader/transformers/addSidebarToggle.ts
@@ -17,7 +17,7 @@
import type { Transformer } from './transformer';
import MenuIcon from '@material-ui/icons/Menu';
import React from 'react';
-import ReactDOM from 'react-dom';
+import { renderReactElement } from './renderReactElement';
export const addSidebarToggle = (): Transformer => {
return dom => {
@@ -33,7 +33,7 @@ export const addSidebarToggle = (): Transformer => {
}
const toggleSidebar = mkdocsToggleSidebar.cloneNode() as HTMLLabelElement;
- ReactDOM.render(React.createElement(MenuIcon), toggleSidebar);
+ renderReactElement(React.createElement(MenuIcon), toggleSidebar);
toggleSidebar.id = 'toggle-sidebar';
toggleSidebar.title = 'Toggle Sidebar';
toggleSidebar.classList.add('md-content__button');
diff --git a/plugins/techdocs/src/reader/transformers/copyToClipboard.test.ts b/plugins/techdocs/src/reader/transformers/copyToClipboard.test.ts
index cac7f76a0e..604957d5cd 100644
--- a/plugins/techdocs/src/reader/transformers/copyToClipboard.test.ts
+++ b/plugins/techdocs/src/reader/transformers/copyToClipboard.test.ts
@@ -17,7 +17,7 @@
import { createTestShadowDom } from '../../test-utils';
import { copyToClipboard } from './copyToClipboard';
import { lightTheme } from '@backstage/theme';
-import { waitFor } from '@testing-library/react';
+import { act, waitFor } from '@testing-library/react';
import useCopyToClipboard from 'react-use/lib/useCopyToClipboard';
const clipboardSpy = jest.fn();
@@ -43,8 +43,11 @@ describe('copyToClipboard', () => {
spy.mockReturnValue([{}, copy]);
const expectedClipboard = 'function foo() {return "bar";}';
- const shadowDom = await createTestShadowDom(
- `
+
+ let shadowDom: ShadowRoot;
+ await act(async () => {
+ shadowDom = await createTestShadowDom(
+ `
@@ -52,13 +55,20 @@ describe('copyToClipboard', () => {
`,
- {
- preTransformers: [],
- postTransformers: [copyToClipboard(lightTheme)],
- },
- );
+ {
+ preTransformers: [],
+ postTransformers: [copyToClipboard(lightTheme)],
+ },
+ );
+ });
- shadowDom.querySelector('button')?.click();
+ await waitFor(() => {
+ expect(shadowDom.querySelector('button')).not.toBe(null);
+ });
+
+ await act(async () => {
+ shadowDom.querySelector('button')!.click();
+ });
await waitFor(() => {
const tooltip = document.querySelector('[role="tooltip"]');
diff --git a/plugins/techdocs/src/reader/transformers/copyToClipboard.tsx b/plugins/techdocs/src/reader/transformers/copyToClipboard.tsx
index 28a6c789b6..1fd9d179a3 100644
--- a/plugins/techdocs/src/reader/transformers/copyToClipboard.tsx
+++ b/plugins/techdocs/src/reader/transformers/copyToClipboard.tsx
@@ -15,7 +15,7 @@
*/
import React, { useState, useCallback } from 'react';
-import ReactDom from 'react-dom';
+import { renderReactElement } from './renderReactElement';
import {
withStyles,
Theme,
@@ -91,7 +91,7 @@ export const copyToClipboard = (theme: Theme): Transformer => {
const text = code.textContent || '';
const container = document.createElement('div');
code?.parentElement?.prepend(container);
- ReactDom.render(
+ renderReactElement(
,
diff --git a/plugins/techdocs/src/reader/transformers/renderReactElement.ts b/plugins/techdocs/src/reader/transformers/renderReactElement.ts
new file mode 100644
index 0000000000..86d44532c5
--- /dev/null
+++ b/plugins/techdocs/src/reader/transformers/renderReactElement.ts
@@ -0,0 +1,34 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+let ReactDOM:
+ | typeof import('react-dom')
+ // TODO: replace with import('react-dom/client') when repo is migrated to 18
+ | { createRoot(el: HTMLElement): { render(el: JSX.Element): void } };
+if (process.env.HAS_REACT_DOM_CLIENT) {
+ ReactDOM = require('react-dom/client');
+} else {
+ ReactDOM = require('react-dom');
+}
+
+/** @internal */
+export function renderReactElement(element: JSX.Element, root: HTMLElement) {
+ if ('createRoot' in ReactDOM) {
+ ReactDOM.createRoot(root).render(element);
+ } else {
+ ReactDOM.render(element, root);
+ }
+}
diff --git a/yarn.lock b/yarn.lock
index fd3b9da734..5197f04c2b 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -4267,8 +4267,8 @@ __metadata:
react-use: ^17.2.4
zen-observable: ^0.10.0
peerDependencies:
- react: ^16.13.1 || ^17.0.0
- react-dom: ^16.13.1 || ^17.0.0
+ react: ^16.13.1 || ^17.0.0 || ^18.0.0
+ react-dom: ^16.13.1 || ^17.0.0 || ^18.0.0
react-router-dom: 6.0.0-beta.0 || ^6.3.0
languageName: unknown
linkType: soft
@@ -9876,8 +9876,8 @@ __metadata:
react-helmet: 6.1.0
react-use: ^17.2.4
peerDependencies:
- react: ^16.13.1 || ^17.0.0
- react-dom: ^16.13.1 || ^17.0.0
+ react: ^16.13.1 || ^17.0.0 || ^18.0.0
+ react-dom: ^16.13.1 || ^17.0.0 || ^18.0.0
react-router-dom: 6.0.0-beta.0 || ^6.3.0
languageName: unknown
linkType: soft