diff --git a/packages/frontend-dev-utils/src/createDevApp.test.tsx b/packages/frontend-dev-utils/src/createDevApp.test.tsx index 2dc4a69101..b5beb79c1c 100644 --- a/packages/frontend-dev-utils/src/createDevApp.test.tsx +++ b/packages/frontend-dev-utils/src/createDevApp.test.tsx @@ -18,15 +18,25 @@ import { PageBlueprint, createFrontendPlugin, } from '@backstage/frontend-plugin-api'; -import { within } from '@testing-library/react'; +import { waitFor, within } from '@testing-library/react'; import { createDevApp } from './createDevApp'; -const anyEnv = (process.env = { ...process.env }) as any; +jest.setTimeout(15000); + +const originalEnv = process.env; describe('createDevApp', () => { + beforeEach(() => { + process.env = { ...originalEnv }; + }); + afterEach(() => { - delete anyEnv.APP_CONFIG; - document.getElementById('root')?.remove(); + document.body.innerHTML = ''; + jest.resetAllMocks(); + }); + + afterAll(() => { + process.env = originalEnv; }); it('should render a dev app with a plugin', async () => { @@ -46,7 +56,7 @@ describe('createDevApp', () => { ], }); - anyEnv.APP_CONFIG = [ + (process.env as any).APP_CONFIG = [ { context: 'test', data: { @@ -62,5 +72,109 @@ describe('createDevApp', () => { const body = within(document.body); await body.findByText('Test Plugin Page', {}, { timeout: 10000 }); - }, 15000); + }); + + it('should forward bindRoutes to createApp', async () => { + jest.resetModules(); + + const bindRoutes = jest.fn(); + const createApp = jest.fn(() => ({ + createRoot: () =>
Test App Root
, + })); + const render = jest.fn(); + const createRoot = jest.fn(() => ({ render })); + + jest.doMock('@backstage/frontend-defaults', () => ({ + createApp, + })); + jest.doMock('@backstage/plugin-app', () => ({ + __esModule: true, + default: { + withOverrides: jest.fn(() => 'app-plugin-override'), + getExtension: jest.fn(() => ({ + override: jest.fn(() => 'disabled-sign-in-page'), + })), + }, + })); + jest.doMock('react-dom/client', () => ({ + __esModule: true, + createRoot, + })); + + const root = document.createElement('div'); + root.id = 'root'; + document.body.appendChild(root); + + let isolatedCreateDevApp: typeof import('./createDevApp').createDevApp; + jest.isolateModules(() => { + ({ createDevApp: isolatedCreateDevApp } = require('./createDevApp')); + }); + + isolatedCreateDevApp({ + bindRoutes, + features: ['plugin-feature'] as any, + }); + + await waitFor(() => { + expect(createApp).toHaveBeenCalledWith({ + bindRoutes, + features: ['app-plugin-override', 'plugin-feature'], + }); + expect(createRoot).toHaveBeenCalledWith(root); + }); + }); + + it('should throw a clear error when the root element is missing', () => { + expect(() => createDevApp({ features: [] })).toThrow( + "Could not find the dev app root element '#root'; make sure your dev entry HTML contains a root element with that id.", + ); + }); + + it('should fall back to legacy react-dom rendering when createRoot is unavailable', async () => { + jest.resetModules(); + delete process.env.HAS_REACT_DOM_CLIENT; + + const createApp = jest.fn(() => ({ + createRoot: () =>
Test App Root
, + })); + const render = jest.fn(); + + jest.doMock('@backstage/frontend-defaults', () => ({ + createApp, + })); + jest.doMock('@backstage/plugin-app', () => ({ + __esModule: true, + default: { + withOverrides: jest.fn(() => 'app-plugin-override'), + getExtension: jest.fn(() => ({ + override: jest.fn(() => 'disabled-sign-in-page'), + })), + }, + })); + jest.doMock('react-dom', () => ({ + __esModule: true, + render, + })); + + const root = document.createElement('div'); + root.id = 'root'; + document.body.appendChild(root); + + let isolatedCreateDevApp: typeof import('./createDevApp').createDevApp; + jest.isolateModules(() => { + ({ createDevApp: isolatedCreateDevApp } = require('./createDevApp')); + }); + + isolatedCreateDevApp({ + features: ['plugin-feature'] as any, + }); + + await waitFor(() => { + expect(render).toHaveBeenCalled(); + expect(createApp).toHaveBeenCalledWith({ + bindRoutes: undefined, + features: ['app-plugin-override', 'plugin-feature'], + }); + }); + }); }); diff --git a/packages/frontend-dev-utils/src/createDevApp.tsx b/packages/frontend-dev-utils/src/createDevApp.tsx index 1f75e8d090..112901ed52 100644 --- a/packages/frontend-dev-utils/src/createDevApp.tsx +++ b/packages/frontend-dev-utils/src/createDevApp.tsx @@ -20,8 +20,8 @@ import { } from '@backstage/frontend-plugin-api'; import { createApp, CreateAppOptions } from '@backstage/frontend-defaults'; import appPlugin from '@backstage/plugin-app'; -import ReactDOM from 'react-dom/client'; import { Suspense, lazy } from 'react'; +import 'react-dom'; type AppPluginWithSimpleOverrides = { withOverrides(options: { extensions: unknown[] }): FrontendFeature; @@ -40,6 +40,15 @@ const appPluginOverride = ( const BuiCss = lazy(() => import('./BuiCss')); +let ReactDOMPromise: Promise< + typeof import('react-dom') | typeof import('react-dom/client') +>; +if (process.env.HAS_REACT_DOM_CLIENT) { + ReactDOMPromise = import('react-dom/client'); +} else { + ReactDOMPromise = import('react-dom'); +} + /** * Options for {@link createDevApp}. * @@ -57,6 +66,18 @@ export interface CreateDevAppOptions { bindRoutes?: CreateAppOptions['bindRoutes']; } +function getRootElement(): HTMLElement { + const rootElement = document.getElementById('root'); + + if (!rootElement) { + throw new Error( + "Could not find the dev app root element '#root'; make sure your dev entry HTML contains a root element with that id.", + ); + } + + return rootElement; +} + /** * Creates and renders a minimal development app for the new frontend system. * @@ -72,6 +93,7 @@ export interface CreateDevAppOptions { * @public */ export function createDevApp(options: CreateDevAppOptions): void { + const rootElement = getRootElement(); const { features, bindRoutes } = options; const devFeatures: CreateAppOptions['features'] = [ appPluginOverride, @@ -84,10 +106,18 @@ export function createDevApp(options: CreateDevAppOptions): void { const app = createApp(appOptions); const AppRoot = app.createRoot(); - ReactDOM.createRoot(document.getElementById('root')!).render( - - - {AppRoot} - , - ); + ReactDOMPromise.then(ReactDOM => { + const rootNode = ( + + + {AppRoot} + + ); + + if ('createRoot' in ReactDOM) { + ReactDOM.createRoot(rootElement).render(rootNode); + } else { + ReactDOM.render(rootNode, rootElement); + } + }); }