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);
+ }
+ });
}