Support React 17 rendering in createDevApp.

Align the dev app render path with the existing dev-utils helper, add a clearer missing-root error, and extend the tests to cover bindRoutes forwarding and env cleanup.

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
Made-with: Cursor
This commit is contained in:
Patrik Oldsberg
2026-03-16 18:54:04 +01:00
parent b1033a1fc9
commit a67b79bb21
2 changed files with 157 additions and 13 deletions
@@ -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: () => <div>Test App Root</div>,
}));
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: () => <div>Test App Root</div>,
}));
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'],
});
});
});
});
@@ -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(
<Suspense fallback={null}>
<BuiCss />
{AppRoot}
</Suspense>,
);
ReactDOMPromise.then(ReactDOM => {
const rootNode = (
<Suspense fallback={null}>
<BuiCss />
{AppRoot}
</Suspense>
);
if ('createRoot' in ReactDOM) {
ReactDOM.createRoot(rootElement).render(rootNode);
} else {
ReactDOM.render(rootNode, rootElement);
}
});
}