From 9468a67b92f47964044ff3516e119799db695097 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 13 Oct 2023 15:31:05 +0200 Subject: [PATCH] dev-utils,techdocs: add conditional support for React 18 Signed-off-by: Patrik Oldsberg --- .changeset/eleven-hounds-invent.md | 5 +++ .changeset/shaggy-beers-collect.md | 6 ++++ packages/cli/config/jest.js | 7 ++++ packages/cli/src/lib/bundler/config.ts | 12 +++++++ packages/dev-utils/package.json | 4 +-- packages/dev-utils/src/devApp/render.tsx | 18 ++++++++-- plugins/techdocs/package.json | 4 +-- .../reader/transformers/addGitFeedbackLink.ts | 4 +-- .../reader/transformers/addSidebarToggle.ts | 4 +-- .../transformers/copyToClipboard.test.ts | 28 ++++++++++----- .../reader/transformers/copyToClipboard.tsx | 4 +-- .../reader/transformers/renderReactElement.ts | 34 +++++++++++++++++++ yarn.lock | 8 ++--- 13 files changed, 113 insertions(+), 25 deletions(-) create mode 100644 .changeset/eleven-hounds-invent.md create mode 100644 .changeset/shaggy-beers-collect.md create mode 100644 plugins/techdocs/src/reader/transformers/renderReactElement.ts 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