From c25532a7a1376995d234dab10a6bed1ba3efc4f4 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 16 Mar 2026 11:31:49 +0100 Subject: [PATCH 01/14] Add @backstage/frontend-dev-utils package Adds a new `@backstage/frontend-dev-utils` package for the new frontend system. It provides a minimal `createDevApp` helper for wiring up a development app from a `dev/` entry point. The app-visualizer plugin is updated to use this new package as the initial testing ground. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .changeset/add-frontend-dev-utils.md | 5 ++ .../app-visualizer-use-frontend-dev-utils.md | 5 ++ packages/frontend-dev-utils/.eslintrc.js | 1 + packages/frontend-dev-utils/README.md | 19 +++++ packages/frontend-dev-utils/catalog-info.yaml | 10 +++ packages/frontend-dev-utils/knip-report.md | 2 + packages/frontend-dev-utils/package.json | 64 ++++++++++++++++ packages/frontend-dev-utils/report.api.md | 18 +++++ .../src/createDevApp.test.tsx | 74 +++++++++++++++++++ .../frontend-dev-utils/src/createDevApp.tsx | 64 ++++++++++++++++ packages/frontend-dev-utils/src/index.ts | 23 ++++++ plugins/app-visualizer/dev/index.ts | 9 +-- plugins/app-visualizer/package.json | 2 +- yarn.lock | 28 ++++++- 14 files changed, 315 insertions(+), 9 deletions(-) create mode 100644 .changeset/add-frontend-dev-utils.md create mode 100644 .changeset/app-visualizer-use-frontend-dev-utils.md create mode 100644 packages/frontend-dev-utils/.eslintrc.js create mode 100644 packages/frontend-dev-utils/README.md create mode 100644 packages/frontend-dev-utils/catalog-info.yaml create mode 100644 packages/frontend-dev-utils/knip-report.md create mode 100644 packages/frontend-dev-utils/package.json create mode 100644 packages/frontend-dev-utils/report.api.md create mode 100644 packages/frontend-dev-utils/src/createDevApp.test.tsx create mode 100644 packages/frontend-dev-utils/src/createDevApp.tsx create mode 100644 packages/frontend-dev-utils/src/index.ts diff --git a/.changeset/add-frontend-dev-utils.md b/.changeset/add-frontend-dev-utils.md new file mode 100644 index 0000000000..9b4299734d --- /dev/null +++ b/.changeset/add-frontend-dev-utils.md @@ -0,0 +1,5 @@ +--- +'@backstage/frontend-dev-utils': minor +--- + +Added `@backstage/frontend-dev-utils`, a new package that provides a minimal helper for wiring up a development app for frontend plugins using the new frontend system. It exports a `createDevApp` function that handles creating and rendering a development app from a `dev/` entry point. diff --git a/.changeset/app-visualizer-use-frontend-dev-utils.md b/.changeset/app-visualizer-use-frontend-dev-utils.md new file mode 100644 index 0000000000..f047fe8f7e --- /dev/null +++ b/.changeset/app-visualizer-use-frontend-dev-utils.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-app-visualizer': patch +--- + +Switched dev entry point to use `createDevApp` from `@backstage/frontend-dev-utils`. diff --git a/packages/frontend-dev-utils/.eslintrc.js b/packages/frontend-dev-utils/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/packages/frontend-dev-utils/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/packages/frontend-dev-utils/README.md b/packages/frontend-dev-utils/README.md new file mode 100644 index 0000000000..5a590c1d58 --- /dev/null +++ b/packages/frontend-dev-utils/README.md @@ -0,0 +1,19 @@ +# @backstage/frontend-dev-utils + +Utilities for developing Backstage frontend plugins using the new frontend system. + +This package provides a minimal helper for wiring up a development app from a `dev/` entry point, making it easy to run and test frontend plugins in isolation. + +## Installation + +Install the package via Yarn: + +```sh +cd plugins/ # if within a monorepo +yarn add -D @backstage/frontend-dev-utils +``` + +## Documentation + +- [Backstage Readme](https://github.com/backstage/backstage/blob/master/README.md) +- [Backstage Documentation](https://backstage.io/docs) diff --git a/packages/frontend-dev-utils/catalog-info.yaml b/packages/frontend-dev-utils/catalog-info.yaml new file mode 100644 index 0000000000..b05c739594 --- /dev/null +++ b/packages/frontend-dev-utils/catalog-info.yaml @@ -0,0 +1,10 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: backstage-frontend-dev-utils + title: '@backstage/frontend-dev-utils' + description: Utilities for developing Backstage frontend plugins using the new frontend system. +spec: + lifecycle: experimental + type: backstage-web-library + owner: framework-maintainers diff --git a/packages/frontend-dev-utils/knip-report.md b/packages/frontend-dev-utils/knip-report.md new file mode 100644 index 0000000000..2661c35327 --- /dev/null +++ b/packages/frontend-dev-utils/knip-report.md @@ -0,0 +1,2 @@ +# Knip report + diff --git a/packages/frontend-dev-utils/package.json b/packages/frontend-dev-utils/package.json new file mode 100644 index 0000000000..be76ebdbe9 --- /dev/null +++ b/packages/frontend-dev-utils/package.json @@ -0,0 +1,64 @@ +{ + "name": "@backstage/frontend-dev-utils", + "version": "0.0.0", + "description": "Utilities for developing Backstage frontend plugins using the new frontend system.", + "backstage": { + "role": "web-library" + }, + "publishConfig": { + "access": "public", + "main": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "keywords": [ + "backstage" + ], + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "packages/frontend-dev-utils" + }, + "license": "Apache-2.0", + "sideEffects": false, + "main": "src/index.ts", + "types": "src/index.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "backstage-cli package build", + "clean": "backstage-cli package clean", + "lint": "backstage-cli package lint", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack", + "start": "backstage-cli package start", + "test": "backstage-cli package test" + }, + "dependencies": { + "@backstage/frontend-defaults": "workspace:^", + "@backstage/frontend-plugin-api": "workspace:^" + }, + "devDependencies": { + "@backstage/cli": "workspace:^", + "@backstage/plugin-app": "workspace:^", + "@backstage/test-utils": "workspace:^", + "@testing-library/jest-dom": "^6.0.0", + "@testing-library/react": "^16.0.0", + "@types/react": "^18.0.0", + "react": "^18.0.2", + "react-dom": "^18.0.2", + "react-router-dom": "^6.30.2" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0", + "react": "^17.0.0 || ^18.0.0", + "react-dom": "^17.0.0 || ^18.0.0", + "react-router-dom": "^6.30.2" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } +} diff --git a/packages/frontend-dev-utils/report.api.md b/packages/frontend-dev-utils/report.api.md new file mode 100644 index 0000000000..2dd3b38467 --- /dev/null +++ b/packages/frontend-dev-utils/report.api.md @@ -0,0 +1,18 @@ +## API Report File for "@backstage/frontend-dev-utils" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { CreateAppOptions } from '@backstage/frontend-defaults'; +import { FrontendFeature } from '@backstage/frontend-plugin-api'; +import { FrontendFeatureLoader } from '@backstage/frontend-plugin-api'; + +// @public +export function createDevApp(options: CreateDevAppOptions): void; + +// @public +export interface CreateDevAppOptions { + createAppOptions?: Omit; + features: (FrontendFeature | FrontendFeatureLoader)[]; +} +``` diff --git a/packages/frontend-dev-utils/src/createDevApp.test.tsx b/packages/frontend-dev-utils/src/createDevApp.test.tsx new file mode 100644 index 0000000000..fd54ee26f3 --- /dev/null +++ b/packages/frontend-dev-utils/src/createDevApp.test.tsx @@ -0,0 +1,74 @@ +/* + * Copyright 2026 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. + */ + +import { + PageBlueprint, + createFrontendPlugin, +} from '@backstage/frontend-plugin-api'; +import { within, waitFor } from '@testing-library/react'; +import { mockApis } from '@backstage/test-utils'; +import { createDevApp } from './createDevApp'; +import { default as appPlugin } from '@backstage/plugin-app'; + +describe('createDevApp', () => { + afterEach(() => { + document.getElementById('root')?.remove(); + }); + + it('should render a dev app with a plugin', async () => { + const root = document.createElement('div'); + root.id = 'root'; + document.body.appendChild(root); + + const testPlugin = createFrontendPlugin({ + pluginId: 'test', + extensions: [ + PageBlueprint.make({ + params: { + path: '/', + loader: async () =>
Test Plugin Page
, + }, + }), + ], + }); + + createDevApp({ + features: [ + appPlugin.withOverrides({ + extensions: [ + appPlugin + .getExtension('sign-in-page:app') + .override({ disabled: true }), + ], + }), + testPlugin, + ], + createAppOptions: { + advanced: { + configLoader: async () => ({ config: mockApis.config() }), + }, + }, + }); + + const body = within(document.body); + await waitFor( + () => { + expect(body.getByText('Test Plugin Page')).toBeDefined(); + }, + { timeout: 10000 }, + ); + }, 15000); +}); diff --git a/packages/frontend-dev-utils/src/createDevApp.tsx b/packages/frontend-dev-utils/src/createDevApp.tsx new file mode 100644 index 0000000000..dd5d7efc59 --- /dev/null +++ b/packages/frontend-dev-utils/src/createDevApp.tsx @@ -0,0 +1,64 @@ +/* + * Copyright 2026 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. + */ + +import { + FrontendFeature, + FrontendFeatureLoader, +} from '@backstage/frontend-plugin-api'; +import { createApp, CreateAppOptions } from '@backstage/frontend-defaults'; +import ReactDOM from 'react-dom/client'; + +/** + * Options for {@link createDevApp}. + * + * @public + */ +export interface CreateDevAppOptions { + /** + * The list of features to load in the dev app. + */ + features: (FrontendFeature | FrontendFeatureLoader)[]; + + /** + * Additional options to pass through to `createApp`. + */ + createAppOptions?: Omit; +} + +/** + * Creates and renders a minimal development app for the new frontend system. + * + * @example + * ```tsx + * // dev/index.ts + * import { createDevApp } from '@backstage/frontend-dev-utils'; + * import myPlugin from '../src'; + * + * createDevApp({ features: [myPlugin] }); + * ``` + * + * @public + */ +export function createDevApp(options: CreateDevAppOptions): void { + const app = createApp({ + ...options.createAppOptions, + features: options.features, + }); + + ReactDOM.createRoot(document.getElementById('root')!).render( + app.createRoot(), + ); +} diff --git a/packages/frontend-dev-utils/src/index.ts b/packages/frontend-dev-utils/src/index.ts new file mode 100644 index 0000000000..d93fa3d473 --- /dev/null +++ b/packages/frontend-dev-utils/src/index.ts @@ -0,0 +1,23 @@ +/* + * Copyright 2026 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. + */ + +/** + * Utilities for developing Backstage frontend plugins using the new frontend system. + * + * @packageDocumentation + */ + +export { createDevApp, type CreateDevAppOptions } from './createDevApp'; diff --git a/plugins/app-visualizer/dev/index.ts b/plugins/app-visualizer/dev/index.ts index fa49e3360b..5a5222ffbf 100644 --- a/plugins/app-visualizer/dev/index.ts +++ b/plugins/app-visualizer/dev/index.ts @@ -14,12 +14,7 @@ * limitations under the License. */ -import ReactDOM from 'react-dom/client'; -import { createApp } from '@backstage/frontend-defaults'; +import { createDevApp } from '@backstage/frontend-dev-utils'; import { default as plugin } from '../src'; -const app = createApp({ - features: [plugin], -}); - -ReactDOM.createRoot(document.getElementById('root')!).render(app.createRoot()); +createDevApp({ features: [plugin] }); diff --git a/plugins/app-visualizer/package.json b/plugins/app-visualizer/package.json index 4c2a456f5b..bf71701775 100644 --- a/plugins/app-visualizer/package.json +++ b/plugins/app-visualizer/package.json @@ -43,7 +43,7 @@ }, "devDependencies": { "@backstage/cli": "workspace:^", - "@backstage/frontend-defaults": "workspace:^", + "@backstage/frontend-dev-utils": "workspace:^", "@types/react": "^18.0.0", "react": "^18.0.2", "react-dom": "^18.0.2", diff --git a/yarn.lock b/yarn.lock index 90ac7fa1c1..b54ad39bba 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3671,6 +3671,32 @@ __metadata: languageName: unknown linkType: soft +"@backstage/frontend-dev-utils@workspace:^, @backstage/frontend-dev-utils@workspace:packages/frontend-dev-utils": + version: 0.0.0-use.local + resolution: "@backstage/frontend-dev-utils@workspace:packages/frontend-dev-utils" + dependencies: + "@backstage/cli": "workspace:^" + "@backstage/frontend-defaults": "workspace:^" + "@backstage/frontend-plugin-api": "workspace:^" + "@backstage/plugin-app": "workspace:^" + "@backstage/test-utils": "workspace:^" + "@testing-library/jest-dom": "npm:^6.0.0" + "@testing-library/react": "npm:^16.0.0" + "@types/react": "npm:^18.0.0" + react: "npm:^18.0.2" + react-dom: "npm:^18.0.2" + react-router-dom: "npm:^6.30.2" + peerDependencies: + "@types/react": ^17.0.0 || ^18.0.0 + react: ^17.0.0 || ^18.0.0 + react-dom: ^17.0.0 || ^18.0.0 + react-router-dom: ^6.30.2 + peerDependenciesMeta: + "@types/react": + optional: true + languageName: unknown + linkType: soft + "@backstage/frontend-dynamic-feature-loader@workspace:packages/frontend-dynamic-feature-loader": version: 0.0.0-use.local resolution: "@backstage/frontend-dynamic-feature-loader@workspace:packages/frontend-dynamic-feature-loader" @@ -4026,7 +4052,7 @@ __metadata: "@backstage/cli": "workspace:^" "@backstage/core-components": "workspace:^" "@backstage/core-plugin-api": "workspace:^" - "@backstage/frontend-defaults": "workspace:^" + "@backstage/frontend-dev-utils": "workspace:^" "@backstage/frontend-plugin-api": "workspace:^" "@backstage/ui": "workspace:^" "@remixicon/react": "npm:^4.6.0" From 0b4ed8550d8f9b3c433917550cbcbba74648f788 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 16 Mar 2026 11:50:57 +0100 Subject: [PATCH 02/14] Document createDevApp pattern for frontend plugin development Add documentation for the new `@backstage/frontend-dev-utils` package across three docs pages: the frontend system building plugins guide, the CLI build system reference, and the project structure overview. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- docs/contribute/project-structure.md | 5 ++++- docs/frontend-system/building-plugins/01-index.md | 15 +++++++++++++++ docs/tooling/cli/02-build-system.md | 14 ++++++++++++++ 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/docs/contribute/project-structure.md b/docs/contribute/project-structure.md index 372fb79844..f1be29c312 100644 --- a/docs/contribute/project-structure.md +++ b/docs/contribute/project-structure.md @@ -144,7 +144,10 @@ are separated out into their own folder, see further down. - [`dev-utils/`](https://github.com/backstage/backstage/tree/master/packages/dev-utils) - Helps you setup a plugin for isolated development so that it can be served - separately. + separately. This is for plugins using the legacy frontend system. + +- [`frontend-dev-utils/`](https://github.com/backstage/backstage/tree/master/packages/frontend-dev-utils) - + Utilities for developing frontend plugins using the new frontend system. Provides the `createDevApp` helper for setting up a minimal development app in a plugin's `dev/` entry point. - [`e2e-test/`](https://github.com/backstage/backstage/tree/master/packages/e2e-test) - Another CLI that can be run to try out what would happen if you build all the diff --git a/docs/frontend-system/building-plugins/01-index.md b/docs/frontend-system/building-plugins/01-index.md index d9acf76e15..12558d5413 100644 --- a/docs/frontend-system/building-plugins/01-index.md +++ b/docs/frontend-system/building-plugins/01-index.md @@ -110,6 +110,21 @@ What we've built here is a very common type of plugin. It's a top-level tool tha We have also provided external access to our route reference by passing it to the plugin `routes` option. This makes it possible for app integrators to bind an external link from a different plugin to our plugin page. You can read more about how this works in the [External Route References](../architecture/36-routes.md#external-route-references) section. +## Running a dev server + +Each frontend plugin package has a `dev/` folder that is used as the entry point when you run `yarn start`. This is a convenient way to run your plugin in isolation during development. The `@backstage/frontend-dev-utils` package provides a `createDevApp` helper that sets up a minimal app for this purpose: + +```tsx title="in dev/index.ts" +import { createDevApp } from '@backstage/frontend-dev-utils'; +import myPlugin from '../src'; + +createDevApp({ features: [myPlugin] }); +``` + +This will create and render a Backstage app with only your plugin installed. If you need to include additional features that your plugin depends on, pass them along in the `features` array. You can also forward additional options to `createApp` from `@backstage/frontend-defaults` using the `createAppOptions` option. + +The dev setup is started by running `yarn start` in the plugin directory, which uses the `backstage-cli package start` command. It sets up a local development server with hot reloading, just like a full app. + ## Utility APIs Another type of extensions that is commonly used are [Utility APIs](../utility-apis/01-index.md). They can encapsulate shared pieces of functionality of your plugin, for example an API client for a backend service. You can optionally export your Utility API for other plugins to use, or allow integrators to replace the implementation of your Utility API with their own. For details on how to define and provide your own Utility API in your plugin, see the section on [creating Utility APIs](../utility-apis/02-creating.md). diff --git a/docs/tooling/cli/02-build-system.md b/docs/tooling/cli/02-build-system.md index 2c7826a149..c7bc468632 100644 --- a/docs/tooling/cli/02-build-system.md +++ b/docs/tooling/cli/02-build-system.md @@ -294,6 +294,20 @@ will be set up that listens to the protocol, host and port set by `app.baseUrl` in the configuration. If needed it is also possible to override the listening options through the `app.listen` configuration. +For frontend plugin packages using the new frontend system, the recommended way to +set up the `dev/index` entry point is to use the `createDevApp` helper from +`@backstage/frontend-dev-utils`. It creates and renders a minimal Backstage app +with your plugin loaded: + +```tsx title="in dev/index.ts" +import { createDevApp } from '@backstage/frontend-dev-utils'; +import myPlugin from '../src'; + +createDevApp({ features: [myPlugin] }); +``` + +For the legacy frontend system, the `@backstage/dev-utils` package provides equivalent helpers. + The frontend development bundling is currently based on [Webpack](https://webpack.js.org/) and [Webpack Dev Server](https://webpack.js.org/configuration/dev-server/). The From 63248c824e7179de215760b0b0c2b09556497d84 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 16 Mar 2026 12:05:00 +0100 Subject: [PATCH 03/14] Bypass sign-in page in createDevApp The dev app now automatically disables the sign-in page extension, so plugins under development don't require authentication. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .changeset/add-frontend-dev-utils.md | 2 +- packages/frontend-dev-utils/package.json | 4 ++-- .../frontend-dev-utils/src/createDevApp.test.tsx | 12 +----------- packages/frontend-dev-utils/src/createDevApp.tsx | 12 +++++++++++- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/.changeset/add-frontend-dev-utils.md b/.changeset/add-frontend-dev-utils.md index 9b4299734d..2ba6c3e5b6 100644 --- a/.changeset/add-frontend-dev-utils.md +++ b/.changeset/add-frontend-dev-utils.md @@ -2,4 +2,4 @@ '@backstage/frontend-dev-utils': minor --- -Added `@backstage/frontend-dev-utils`, a new package that provides a minimal helper for wiring up a development app for frontend plugins using the new frontend system. It exports a `createDevApp` function that handles creating and rendering a development app from a `dev/` entry point. +Added `@backstage/frontend-dev-utils`, a new package that provides a minimal helper for wiring up a development app for frontend plugins using the new frontend system. It exports a `createDevApp` function that handles creating and rendering a development app from a `dev/` entry point. The dev app automatically bypasses the sign-in page. diff --git a/packages/frontend-dev-utils/package.json b/packages/frontend-dev-utils/package.json index be76ebdbe9..84e515cee1 100644 --- a/packages/frontend-dev-utils/package.json +++ b/packages/frontend-dev-utils/package.json @@ -37,11 +37,11 @@ }, "dependencies": { "@backstage/frontend-defaults": "workspace:^", - "@backstage/frontend-plugin-api": "workspace:^" + "@backstage/frontend-plugin-api": "workspace:^", + "@backstage/plugin-app": "workspace:^" }, "devDependencies": { "@backstage/cli": "workspace:^", - "@backstage/plugin-app": "workspace:^", "@backstage/test-utils": "workspace:^", "@testing-library/jest-dom": "^6.0.0", "@testing-library/react": "^16.0.0", diff --git a/packages/frontend-dev-utils/src/createDevApp.test.tsx b/packages/frontend-dev-utils/src/createDevApp.test.tsx index fd54ee26f3..7f94c5543e 100644 --- a/packages/frontend-dev-utils/src/createDevApp.test.tsx +++ b/packages/frontend-dev-utils/src/createDevApp.test.tsx @@ -21,7 +21,6 @@ import { import { within, waitFor } from '@testing-library/react'; import { mockApis } from '@backstage/test-utils'; import { createDevApp } from './createDevApp'; -import { default as appPlugin } from '@backstage/plugin-app'; describe('createDevApp', () => { afterEach(() => { @@ -46,16 +45,7 @@ describe('createDevApp', () => { }); createDevApp({ - features: [ - appPlugin.withOverrides({ - extensions: [ - appPlugin - .getExtension('sign-in-page:app') - .override({ disabled: true }), - ], - }), - testPlugin, - ], + features: [testPlugin], createAppOptions: { advanced: { configLoader: async () => ({ config: mockApis.config() }), diff --git a/packages/frontend-dev-utils/src/createDevApp.tsx b/packages/frontend-dev-utils/src/createDevApp.tsx index dd5d7efc59..078a9208cf 100644 --- a/packages/frontend-dev-utils/src/createDevApp.tsx +++ b/packages/frontend-dev-utils/src/createDevApp.tsx @@ -19,6 +19,7 @@ import { FrontendFeatureLoader, } from '@backstage/frontend-plugin-api'; import { createApp, CreateAppOptions } from '@backstage/frontend-defaults'; +import appPlugin from '@backstage/plugin-app'; import ReactDOM from 'react-dom/client'; /** @@ -55,7 +56,16 @@ export interface CreateDevAppOptions { export function createDevApp(options: CreateDevAppOptions): void { const app = createApp({ ...options.createAppOptions, - features: options.features, + features: [ + appPlugin.withOverrides({ + extensions: [ + appPlugin + .getExtension('sign-in-page:app') + .override({ disabled: true }), + ], + }), + ...options.features, + ], }); ReactDOM.createRoot(document.getElementById('root')!).render( From db612a84ab3645965cdd75d5e40f46841c3a80c4 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 16 Mar 2026 12:14:59 +0100 Subject: [PATCH 04/14] Allow frontend-dev-utils in dependency verification exceptions Add @backstage/frontend-dev-utils to the except list for the rule that prevents web-library packages from depending on frontend-plugin packages, since it legitimately wraps @backstage/plugin-app for development environments. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- scripts/verify-local-dependencies.js | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/verify-local-dependencies.js b/scripts/verify-local-dependencies.js index de7e2908f6..baf9564faf 100755 --- a/scripts/verify-local-dependencies.js +++ b/scripts/verify-local-dependencies.js @@ -62,6 +62,7 @@ const roleRules = [ // TODO(freben): Address these '@backstage/frontend-defaults', '@backstage/frontend-app-api', + '@backstage/frontend-dev-utils', '@backstage/frontend-test-utils', '@backstage/plugin-api-docs', '@backstage/plugin-techdocs-addons-test-utils', From 8ea3899944311d498554bdf19f891f5fdd9ec262 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 16 Mar 2026 12:49:20 +0100 Subject: [PATCH 05/14] Add BUI CSS to createDevApp Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .changeset/add-frontend-dev-utils.md | 2 +- packages/frontend-dev-utils/package.json | 3 ++- packages/frontend-dev-utils/src/createDevApp.tsx | 3 +++ yarn.lock | 1 + 4 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.changeset/add-frontend-dev-utils.md b/.changeset/add-frontend-dev-utils.md index 2ba6c3e5b6..310ab204a0 100644 --- a/.changeset/add-frontend-dev-utils.md +++ b/.changeset/add-frontend-dev-utils.md @@ -2,4 +2,4 @@ '@backstage/frontend-dev-utils': minor --- -Added `@backstage/frontend-dev-utils`, a new package that provides a minimal helper for wiring up a development app for frontend plugins using the new frontend system. It exports a `createDevApp` function that handles creating and rendering a development app from a `dev/` entry point. The dev app automatically bypasses the sign-in page. +Added `@backstage/frontend-dev-utils`, a new package that provides a minimal helper for wiring up a development app for frontend plugins using the new frontend system. It exports a `createDevApp` function that handles creating and rendering a development app from a `dev/` entry point. The dev app automatically bypasses the sign-in page and loads the `@backstage/ui` CSS. diff --git a/packages/frontend-dev-utils/package.json b/packages/frontend-dev-utils/package.json index 84e515cee1..33eadabbe2 100644 --- a/packages/frontend-dev-utils/package.json +++ b/packages/frontend-dev-utils/package.json @@ -38,7 +38,8 @@ "dependencies": { "@backstage/frontend-defaults": "workspace:^", "@backstage/frontend-plugin-api": "workspace:^", - "@backstage/plugin-app": "workspace:^" + "@backstage/plugin-app": "workspace:^", + "@backstage/ui": "workspace:^" }, "devDependencies": { "@backstage/cli": "workspace:^", diff --git a/packages/frontend-dev-utils/src/createDevApp.tsx b/packages/frontend-dev-utils/src/createDevApp.tsx index 078a9208cf..54eea25d06 100644 --- a/packages/frontend-dev-utils/src/createDevApp.tsx +++ b/packages/frontend-dev-utils/src/createDevApp.tsx @@ -14,6 +14,9 @@ * limitations under the License. */ +// eslint-disable-next-line @backstage/no-ui-css-imports-in-non-frontend +import '@backstage/ui/css/styles.css'; + import { FrontendFeature, FrontendFeatureLoader, diff --git a/yarn.lock b/yarn.lock index b54ad39bba..771285702f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3680,6 +3680,7 @@ __metadata: "@backstage/frontend-plugin-api": "workspace:^" "@backstage/plugin-app": "workspace:^" "@backstage/test-utils": "workspace:^" + "@backstage/ui": "workspace:^" "@testing-library/jest-dom": "npm:^6.0.0" "@testing-library/react": "npm:^16.0.0" "@types/react": "npm:^18.0.0" From 2e5c65120a76b6093c64ae7f8a0654a4b362752a Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 16 Mar 2026 15:00:02 +0100 Subject: [PATCH 06/14] Fix PluginHeader ResizeObserver loop Defer header height updates to avoid ResizeObserver loop warnings in FullPage layouts and normalize header actions before rendering. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .../fix-plugin-header-resizeobserver-loop.md | 7 +++ .../components/PluginHeader/PluginHeader.tsx | 51 ++++++++++++++++--- 2 files changed, 50 insertions(+), 8 deletions(-) create mode 100644 .changeset/fix-plugin-header-resizeobserver-loop.md diff --git a/.changeset/fix-plugin-header-resizeobserver-loop.md b/.changeset/fix-plugin-header-resizeobserver-loop.md new file mode 100644 index 0000000000..b91620d00c --- /dev/null +++ b/.changeset/fix-plugin-header-resizeobserver-loop.md @@ -0,0 +1,7 @@ +--- +'@backstage/ui': patch +--- + +Fixed `PluginHeader` to avoid triggering `ResizeObserver loop completed with undelivered notifications` warnings when used in layouts that react to the header height, such as pages that use `FullPage`. + +**Affected components:** PluginHeader diff --git a/packages/ui/src/components/PluginHeader/PluginHeader.tsx b/packages/ui/src/components/PluginHeader/PluginHeader.tsx index ac5f04a011..d153a3af18 100644 --- a/packages/ui/src/components/PluginHeader/PluginHeader.tsx +++ b/packages/ui/src/components/PluginHeader/PluginHeader.tsx @@ -19,7 +19,7 @@ import { Tabs, TabList, Tab } from '../Tabs'; import { useDefinition } from '../../hooks/useDefinition'; import { PluginHeaderDefinition } from './definition'; import { type NavigateOptions } from 'react-router-dom'; -import { useRef } from 'react'; +import { Children, useMemo, useRef } from 'react'; import { useIsomorphicLayoutEffect } from '../../hooks/useIsomorphicLayoutEffect'; import { Box } from '../Box'; import { Link } from 'react-aria-components'; @@ -55,35 +55,70 @@ export const PluginHeader = (props: PluginHeaderProps) => { const toolbarWrapperRef = useRef(null); const toolbarContentRef = useRef(null); const toolbarControlsRef = useRef(null); + const animationFrameRef = useRef(undefined); + const lastAppliedHeightRef = useRef(undefined); + + const actionChildren = useMemo(() => { + return Children.toArray(customActions); + }, [customActions]); useIsomorphicLayoutEffect(() => { const el = headerRef.current; - if (!el) return undefined; + if (!el) { + return undefined; + } - const updateHeight = () => { - const height = el.offsetHeight; + const cancelScheduledUpdate = () => { + if (animationFrameRef.current === undefined) { + return; + } + + cancelAnimationFrame(animationFrameRef.current); + animationFrameRef.current = undefined; + }; + + const applyHeight = (height: number) => { + if (lastAppliedHeightRef.current === height) { + return; + } + + lastAppliedHeightRef.current = height; document.documentElement.style.setProperty( '--bui-header-height', `${height}px`, ); }; - // Set height once immediately - updateHeight(); + const scheduleHeightUpdate = () => { + cancelScheduledUpdate(); + animationFrameRef.current = requestAnimationFrame(() => { + animationFrameRef.current = undefined; + applyHeight(el.offsetHeight); + }); + }; + + // Set height once immediately so the initial layout is correct. + applyHeight(el.offsetHeight); // Observe for resize changes if ResizeObserver is available // (not present in Jest/jsdom by default) if (typeof ResizeObserver === 'undefined') { return () => { + cancelScheduledUpdate(); + lastAppliedHeightRef.current = undefined; document.documentElement.style.removeProperty('--bui-header-height'); }; } - const observer = new ResizeObserver(updateHeight); + const observer = new ResizeObserver(() => { + scheduleHeightUpdate(); + }); observer.observe(el); return () => { observer.disconnect(); + cancelScheduledUpdate(); + lastAppliedHeightRef.current = undefined; document.documentElement.style.removeProperty('--bui-header-height'); }; }, []); @@ -111,7 +146,7 @@ export const PluginHeader = (props: PluginHeaderProps) => {
- {customActions} + {actionChildren}
From 1594749d8eddd8f19641a1bf31ca8a7386a4dc13 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 16 Mar 2026 15:06:05 +0100 Subject: [PATCH 07/14] Simplify createDevApp options Accept createApp options at the top level in createDevApp and update the tests, docs, API report, and changeset to match the new shape. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .changeset/add-frontend-dev-utils.md | 2 +- .../building-plugins/01-index.md | 2 +- packages/frontend-dev-utils/report.api.md | 4 +- .../src/createDevApp.test.tsx | 15 ++---- .../frontend-dev-utils/src/createDevApp.tsx | 46 +++++++++++-------- 5 files changed, 35 insertions(+), 34 deletions(-) diff --git a/.changeset/add-frontend-dev-utils.md b/.changeset/add-frontend-dev-utils.md index 310ab204a0..902dc8019a 100644 --- a/.changeset/add-frontend-dev-utils.md +++ b/.changeset/add-frontend-dev-utils.md @@ -2,4 +2,4 @@ '@backstage/frontend-dev-utils': minor --- -Added `@backstage/frontend-dev-utils`, a new package that provides a minimal helper for wiring up a development app for frontend plugins using the new frontend system. It exports a `createDevApp` function that handles creating and rendering a development app from a `dev/` entry point. The dev app automatically bypasses the sign-in page and loads the `@backstage/ui` CSS. +Added `@backstage/frontend-dev-utils`, a new package that provides a minimal helper for wiring up a development app for frontend plugins using the new frontend system. It exports a `createDevApp` function that handles creating and rendering a development app from a `dev/` entry point. The dev app automatically bypasses the sign-in page and loads the `@backstage/ui` CSS. The options interface extends all `createApp` options from `@backstage/frontend-defaults` (except `features`), such as `bindRoutes` and `advanced`. diff --git a/docs/frontend-system/building-plugins/01-index.md b/docs/frontend-system/building-plugins/01-index.md index 12558d5413..b8e1346bee 100644 --- a/docs/frontend-system/building-plugins/01-index.md +++ b/docs/frontend-system/building-plugins/01-index.md @@ -121,7 +121,7 @@ import myPlugin from '../src'; createDevApp({ features: [myPlugin] }); ``` -This will create and render a Backstage app with only your plugin installed. If you need to include additional features that your plugin depends on, pass them along in the `features` array. You can also forward additional options to `createApp` from `@backstage/frontend-defaults` using the `createAppOptions` option. +This will create and render a Backstage app with only your plugin installed. If you need to include additional features that your plugin depends on, pass them along in the `features` array. The options also accept all other `createApp` options from `@backstage/frontend-defaults`, such as `bindRoutes` and `advanced`. The dev setup is started by running `yarn start` in the plugin directory, which uses the `backstage-cli package start` command. It sets up a local development server with hot reloading, just like a full app. diff --git a/packages/frontend-dev-utils/report.api.md b/packages/frontend-dev-utils/report.api.md index 2dd3b38467..42261680ab 100644 --- a/packages/frontend-dev-utils/report.api.md +++ b/packages/frontend-dev-utils/report.api.md @@ -11,8 +11,8 @@ import { FrontendFeatureLoader } from '@backstage/frontend-plugin-api'; export function createDevApp(options: CreateDevAppOptions): void; // @public -export interface CreateDevAppOptions { - createAppOptions?: Omit; +export interface CreateDevAppOptions + extends Omit { features: (FrontendFeature | FrontendFeatureLoader)[]; } ``` diff --git a/packages/frontend-dev-utils/src/createDevApp.test.tsx b/packages/frontend-dev-utils/src/createDevApp.test.tsx index 7f94c5543e..ea377e2e2f 100644 --- a/packages/frontend-dev-utils/src/createDevApp.test.tsx +++ b/packages/frontend-dev-utils/src/createDevApp.test.tsx @@ -18,7 +18,7 @@ import { PageBlueprint, createFrontendPlugin, } from '@backstage/frontend-plugin-api'; -import { within, waitFor } from '@testing-library/react'; +import { within } from '@testing-library/react'; import { mockApis } from '@backstage/test-utils'; import { createDevApp } from './createDevApp'; @@ -46,19 +46,12 @@ describe('createDevApp', () => { createDevApp({ features: [testPlugin], - createAppOptions: { - advanced: { - configLoader: async () => ({ config: mockApis.config() }), - }, + advanced: { + configLoader: async () => ({ config: mockApis.config() }), }, }); const body = within(document.body); - await waitFor( - () => { - expect(body.getByText('Test Plugin Page')).toBeDefined(); - }, - { timeout: 10000 }, - ); + await body.findByText('Test Plugin Page', {}, { timeout: 10000 }); }, 15000); }); diff --git a/packages/frontend-dev-utils/src/createDevApp.tsx b/packages/frontend-dev-utils/src/createDevApp.tsx index 54eea25d06..294b360040 100644 --- a/packages/frontend-dev-utils/src/createDevApp.tsx +++ b/packages/frontend-dev-utils/src/createDevApp.tsx @@ -25,21 +25,32 @@ import { createApp, CreateAppOptions } from '@backstage/frontend-defaults'; import appPlugin from '@backstage/plugin-app'; import ReactDOM from 'react-dom/client'; +type AppPluginWithSimpleOverrides = { + withOverrides(options: { extensions: unknown[] }): FrontendFeature; +}; + +// Collapse the deeply nested override types to avoid excessive instantiation. +const appPluginOverride = ( + appPlugin as unknown as AppPluginWithSimpleOverrides +).withOverrides({ + extensions: [ + appPlugin.getExtension('sign-in-page:app').override({ + disabled: true, + }), + ], +}); + /** * Options for {@link createDevApp}. * * @public */ -export interface CreateDevAppOptions { +export interface CreateDevAppOptions + extends Omit { /** * The list of features to load in the dev app. */ features: (FrontendFeature | FrontendFeatureLoader)[]; - - /** - * Additional options to pass through to `createApp`. - */ - createAppOptions?: Omit; } /** @@ -57,19 +68,16 @@ export interface CreateDevAppOptions { * @public */ export function createDevApp(options: CreateDevAppOptions): void { - const app = createApp({ - ...options.createAppOptions, - features: [ - appPlugin.withOverrides({ - extensions: [ - appPlugin - .getExtension('sign-in-page:app') - .override({ disabled: true }), - ], - }), - ...options.features, - ], - }); + const { features, ...createAppOptions } = options; + const devFeatures: CreateAppOptions['features'] = [ + appPluginOverride, + ...features, + ]; + const appOptions: CreateAppOptions = { + ...createAppOptions, + features: devFeatures, + }; + const app = createApp(appOptions); ReactDOM.createRoot(document.getElementById('root')!).render( app.createRoot(), From 21ba6588f9bc817b76c12f7878593f0896e500a1 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 16 Mar 2026 15:10:19 +0100 Subject: [PATCH 08/14] Lazily load BUI CSS in createDevApp Move the BUI stylesheet into a lazily rendered helper so importing frontend-dev-utils no longer triggers global CSS side effects. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- packages/frontend-dev-utils/src/BuiCss.tsx | 25 +++++++++++++++++++ .../frontend-dev-utils/src/createDevApp.tsx | 12 ++++++--- 2 files changed, 33 insertions(+), 4 deletions(-) create mode 100644 packages/frontend-dev-utils/src/BuiCss.tsx diff --git a/packages/frontend-dev-utils/src/BuiCss.tsx b/packages/frontend-dev-utils/src/BuiCss.tsx new file mode 100644 index 0000000000..d5ece19b71 --- /dev/null +++ b/packages/frontend-dev-utils/src/BuiCss.tsx @@ -0,0 +1,25 @@ +/* + * Copyright 2026 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. + */ + +// This ensures that dev apps always have the BUI CSS loaded. +// eslint-disable-next-line @backstage/no-ui-css-imports-in-non-frontend +import '@backstage/ui/css/styles.css'; + +/** + * Placeholder component to allow lazy loading of the BUI CSS import. This + * ensures that we don't load the CSS as soon as anyone imports this package. + */ +export default () => null; diff --git a/packages/frontend-dev-utils/src/createDevApp.tsx b/packages/frontend-dev-utils/src/createDevApp.tsx index 294b360040..7ec34b929b 100644 --- a/packages/frontend-dev-utils/src/createDevApp.tsx +++ b/packages/frontend-dev-utils/src/createDevApp.tsx @@ -14,9 +14,6 @@ * limitations under the License. */ -// eslint-disable-next-line @backstage/no-ui-css-imports-in-non-frontend -import '@backstage/ui/css/styles.css'; - import { FrontendFeature, FrontendFeatureLoader, @@ -24,6 +21,7 @@ import { import { createApp, CreateAppOptions } from '@backstage/frontend-defaults'; import appPlugin from '@backstage/plugin-app'; import ReactDOM from 'react-dom/client'; +import { Suspense, lazy } from 'react'; type AppPluginWithSimpleOverrides = { withOverrides(options: { extensions: unknown[] }): FrontendFeature; @@ -40,6 +38,8 @@ const appPluginOverride = ( ], }); +const BuiCss = lazy(() => import('./BuiCss')); + /** * Options for {@link createDevApp}. * @@ -78,8 +78,12 @@ export function createDevApp(options: CreateDevAppOptions): void { features: devFeatures, }; const app = createApp(appOptions); + const AppRoot = app.createRoot(); ReactDOM.createRoot(document.getElementById('root')!).render( - app.createRoot(), + + + {AppRoot} + , ); } From 989a1dcc9bd14b601c02a4654b2299ac1380ca3a Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 16 Mar 2026 16:09:40 +0100 Subject: [PATCH 09/14] Restrict createDevApp options Limit createDevApp to features and bindRoutes so advanced createApp configuration stays out of the dev-app helper API. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .changeset/add-frontend-dev-utils.md | 2 +- .../building-plugins/01-index.md | 2 +- packages/frontend-dev-utils/report.api.md | 2 +- .../src/createDevApp.test.tsx | 17 +++++++++++++---- .../frontend-dev-utils/src/createDevApp.tsx | 6 +++--- 5 files changed, 19 insertions(+), 10 deletions(-) diff --git a/.changeset/add-frontend-dev-utils.md b/.changeset/add-frontend-dev-utils.md index 902dc8019a..987f02c6d9 100644 --- a/.changeset/add-frontend-dev-utils.md +++ b/.changeset/add-frontend-dev-utils.md @@ -2,4 +2,4 @@ '@backstage/frontend-dev-utils': minor --- -Added `@backstage/frontend-dev-utils`, a new package that provides a minimal helper for wiring up a development app for frontend plugins using the new frontend system. It exports a `createDevApp` function that handles creating and rendering a development app from a `dev/` entry point. The dev app automatically bypasses the sign-in page and loads the `@backstage/ui` CSS. The options interface extends all `createApp` options from `@backstage/frontend-defaults` (except `features`), such as `bindRoutes` and `advanced`. +Added `@backstage/frontend-dev-utils`, a new package that provides a minimal helper for wiring up a development app for frontend plugins using the new frontend system. It exports a `createDevApp` function that handles creating and rendering a development app from a `dev/` entry point. The dev app automatically bypasses the sign-in page and loads the `@backstage/ui` CSS. The options interface accepts `features` together with route bindings through `bindRoutes`. diff --git a/docs/frontend-system/building-plugins/01-index.md b/docs/frontend-system/building-plugins/01-index.md index b8e1346bee..861f454280 100644 --- a/docs/frontend-system/building-plugins/01-index.md +++ b/docs/frontend-system/building-plugins/01-index.md @@ -121,7 +121,7 @@ import myPlugin from '../src'; createDevApp({ features: [myPlugin] }); ``` -This will create and render a Backstage app with only your plugin installed. If you need to include additional features that your plugin depends on, pass them along in the `features` array. The options also accept all other `createApp` options from `@backstage/frontend-defaults`, such as `bindRoutes` and `advanced`. +This will create and render a Backstage app with only your plugin installed. If you need to include additional features that your plugin depends on, pass them along in the `features` array. You can also use `bindRoutes` to wire up any external routes that your plugin depends on. The dev setup is started by running `yarn start` in the plugin directory, which uses the `backstage-cli package start` command. It sets up a local development server with hot reloading, just like a full app. diff --git a/packages/frontend-dev-utils/report.api.md b/packages/frontend-dev-utils/report.api.md index 42261680ab..0f8f1dbd6a 100644 --- a/packages/frontend-dev-utils/report.api.md +++ b/packages/frontend-dev-utils/report.api.md @@ -12,7 +12,7 @@ export function createDevApp(options: CreateDevAppOptions): void; // @public export interface CreateDevAppOptions - extends Omit { + extends Pick { features: (FrontendFeature | FrontendFeatureLoader)[]; } ``` diff --git a/packages/frontend-dev-utils/src/createDevApp.test.tsx b/packages/frontend-dev-utils/src/createDevApp.test.tsx index ea377e2e2f..2dc4a69101 100644 --- a/packages/frontend-dev-utils/src/createDevApp.test.tsx +++ b/packages/frontend-dev-utils/src/createDevApp.test.tsx @@ -19,11 +19,13 @@ import { createFrontendPlugin, } from '@backstage/frontend-plugin-api'; import { within } from '@testing-library/react'; -import { mockApis } from '@backstage/test-utils'; import { createDevApp } from './createDevApp'; +const anyEnv = (process.env = { ...process.env }) as any; + describe('createDevApp', () => { afterEach(() => { + delete anyEnv.APP_CONFIG; document.getElementById('root')?.remove(); }); @@ -44,11 +46,18 @@ describe('createDevApp', () => { ], }); + anyEnv.APP_CONFIG = [ + { + context: 'test', + data: { + app: { title: 'Test App' }, + backend: { baseUrl: 'http://localhost' }, + }, + }, + ]; + createDevApp({ features: [testPlugin], - advanced: { - configLoader: async () => ({ config: mockApis.config() }), - }, }); const body = within(document.body); diff --git a/packages/frontend-dev-utils/src/createDevApp.tsx b/packages/frontend-dev-utils/src/createDevApp.tsx index 7ec34b929b..09616e7286 100644 --- a/packages/frontend-dev-utils/src/createDevApp.tsx +++ b/packages/frontend-dev-utils/src/createDevApp.tsx @@ -46,7 +46,7 @@ const BuiCss = lazy(() => import('./BuiCss')); * @public */ export interface CreateDevAppOptions - extends Omit { + extends Pick { /** * The list of features to load in the dev app. */ @@ -68,13 +68,13 @@ export interface CreateDevAppOptions * @public */ export function createDevApp(options: CreateDevAppOptions): void { - const { features, ...createAppOptions } = options; + const { features, bindRoutes } = options; const devFeatures: CreateAppOptions['features'] = [ appPluginOverride, ...features, ]; const appOptions: CreateAppOptions = { - ...createAppOptions, + bindRoutes, features: devFeatures, }; const app = createApp(appOptions); From ebeb0d4d8b272ce2e0aa9dd83117691c093fd3a7 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 16 Mar 2026 16:13:02 +0100 Subject: [PATCH 10/14] Use frontend-dev-utils in new frontend plugin template Update the new frontend plugin template to use createDevApp in its dev entry point and teach cli-module-new to resolve frontend-dev-utils versions for generated packages. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .changeset/new-frontend-plugin-template-dev-utils.md | 6 ++++++ packages/cli-module-new/src/lib/version.test.ts | 3 +++ packages/cli-module-new/src/lib/version.ts | 2 ++ packages/cli/templates/new-frontend-plugin/dev/index.tsx | 9 ++------- .../cli/templates/new-frontend-plugin/package.json.hbs | 2 +- 5 files changed, 14 insertions(+), 8 deletions(-) create mode 100644 .changeset/new-frontend-plugin-template-dev-utils.md diff --git a/.changeset/new-frontend-plugin-template-dev-utils.md b/.changeset/new-frontend-plugin-template-dev-utils.md new file mode 100644 index 0000000000..5808a9307a --- /dev/null +++ b/.changeset/new-frontend-plugin-template-dev-utils.md @@ -0,0 +1,6 @@ +--- +'@backstage/cli': patch +'@backstage/cli-module-new': patch +--- + +Updated the new frontend plugin template to use `@backstage/frontend-dev-utils` in its `dev/` entry point instead of wiring `createApp` manually. Generated plugins now get the same dev app helper setup as the built-in examples. diff --git a/packages/cli-module-new/src/lib/version.test.ts b/packages/cli-module-new/src/lib/version.test.ts index 86b92e35c3..829a2f3d9e 100644 --- a/packages/cli-module-new/src/lib/version.test.ts +++ b/packages/cli-module-new/src/lib/version.test.ts @@ -78,6 +78,9 @@ describe('createPackageVersionProvider', () => { expect(provider('@backstage/core-plugin-api')).toBe( `^${corePluginApiPkg.version}`, ); + expect(provider('@backstage/frontend-dev-utils')).toBe( + `^${packageVersions['@backstage/frontend-dev-utils']}`, + ); }); describe('with backstage protocol options', () => { diff --git a/packages/cli-module-new/src/lib/version.ts b/packages/cli-module-new/src/lib/version.ts index fd65135f4c..27e63bafdf 100644 --- a/packages/cli-module-new/src/lib/version.ts +++ b/packages/cli-module-new/src/lib/version.ts @@ -42,6 +42,7 @@ import { version as coreComponents } from '../../../core-components/package.json import { version as corePluginApi } from '../../../core-plugin-api/package.json'; import { version as devUtils } from '../../../dev-utils/package.json'; import { version as errors } from '../../../errors/package.json'; +import { version as frontendDevUtils } from '../../../frontend-dev-utils/package.json'; import { version as frontendDefaults } from '../../../frontend-defaults/package.json'; import { version as frontendPluginApi } from '../../../frontend-plugin-api/package.json'; import { version as frontendTestUtils } from '../../../frontend-test-utils/package.json'; @@ -66,6 +67,7 @@ export const packageVersions: Record = { '@backstage/core-plugin-api': corePluginApi, '@backstage/dev-utils': devUtils, '@backstage/errors': errors, + '@backstage/frontend-dev-utils': frontendDevUtils, '@backstage/frontend-defaults': frontendDefaults, '@backstage/frontend-plugin-api': frontendPluginApi, '@backstage/frontend-test-utils': frontendTestUtils, diff --git a/packages/cli/templates/new-frontend-plugin/dev/index.tsx b/packages/cli/templates/new-frontend-plugin/dev/index.tsx index e1bcb0401e..21f842925b 100644 --- a/packages/cli/templates/new-frontend-plugin/dev/index.tsx +++ b/packages/cli/templates/new-frontend-plugin/dev/index.tsx @@ -1,10 +1,5 @@ -import { createApp } from '@backstage/frontend-defaults'; -import ReactDOM from 'react-dom'; +import { createDevApp } from '@backstage/frontend-dev-utils'; import plugin from '../src'; -const app = createApp({ - features: [plugin], -}); - -ReactDOM.render(app.createRoot(), document.getElementById('root')); +createDevApp({ features: [plugin] }); diff --git a/packages/cli/templates/new-frontend-plugin/package.json.hbs b/packages/cli/templates/new-frontend-plugin/package.json.hbs index 7ee1aed5e9..4a37a4fa63 100644 --- a/packages/cli/templates/new-frontend-plugin/package.json.hbs +++ b/packages/cli/templates/new-frontend-plugin/package.json.hbs @@ -35,7 +35,7 @@ }, "devDependencies": { "@backstage/cli": "{{versionQuery '@backstage/cli'}}", - "@backstage/frontend-defaults": "{{versionQuery '@backstage/frontend-defaults'}}", + "@backstage/frontend-dev-utils": "{{versionQuery '@backstage/frontend-dev-utils'}}", "@backstage/frontend-test-utils": "{{versionQuery '@backstage/frontend-test-utils'}}", "@testing-library/jest-dom": "{{versionQuery '@testing-library/jest-dom' '6.0.0'}}", "@testing-library/react": "{{versionQuery '@testing-library/react' '14.0.0'}}", From b1033a1fc96fb5bc6346e52b1e6f4ef19b8f7334 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 16 Mar 2026 16:45:18 +0100 Subject: [PATCH 11/14] Make createDevApp options explicit Define createDevApp options directly so the helper only exposes features and a typed bindRoutes property without inheriting from createApp options. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- packages/frontend-dev-utils/report.api.md | 4 ++-- packages/frontend-dev-utils/src/createDevApp.tsx | 8 ++++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/frontend-dev-utils/report.api.md b/packages/frontend-dev-utils/report.api.md index 0f8f1dbd6a..39ca2aec2b 100644 --- a/packages/frontend-dev-utils/report.api.md +++ b/packages/frontend-dev-utils/report.api.md @@ -11,8 +11,8 @@ import { FrontendFeatureLoader } from '@backstage/frontend-plugin-api'; export function createDevApp(options: CreateDevAppOptions): void; // @public -export interface CreateDevAppOptions - extends Pick { +export interface CreateDevAppOptions { + bindRoutes?: CreateAppOptions['bindRoutes']; features: (FrontendFeature | FrontendFeatureLoader)[]; } ``` diff --git a/packages/frontend-dev-utils/src/createDevApp.tsx b/packages/frontend-dev-utils/src/createDevApp.tsx index 09616e7286..1f75e8d090 100644 --- a/packages/frontend-dev-utils/src/createDevApp.tsx +++ b/packages/frontend-dev-utils/src/createDevApp.tsx @@ -45,12 +45,16 @@ const BuiCss = lazy(() => import('./BuiCss')); * * @public */ -export interface CreateDevAppOptions - extends Pick { +export interface CreateDevAppOptions { /** * The list of features to load in the dev app. */ features: (FrontendFeature | FrontendFeatureLoader)[]; + + /** + * Allows for the binding of plugins' external route refs within the dev app. + */ + bindRoutes?: CreateAppOptions['bindRoutes']; } /** From a67b79bb2113f9194239719cf88990630ded1377 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 16 Mar 2026 18:54:04 +0100 Subject: [PATCH 12/14] 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 Made-with: Cursor --- .../src/createDevApp.test.tsx | 126 +++++++++++++++++- .../frontend-dev-utils/src/createDevApp.tsx | 44 +++++- 2 files changed, 157 insertions(+), 13 deletions(-) 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); + } + }); } From 846fb1ea57c292eb895feda23da548a38e225b97 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 16 Mar 2026 19:03:58 +0100 Subject: [PATCH 13/14] Fix TS2454 in createDevApp test Use definite assignment assertions for variables assigned inside jest.isolateModules callbacks. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .../src/createDevApp.test.tsx | 28 ++++++++++++------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/packages/frontend-dev-utils/src/createDevApp.test.tsx b/packages/frontend-dev-utils/src/createDevApp.test.tsx index b5beb79c1c..6624681152 100644 --- a/packages/frontend-dev-utils/src/createDevApp.test.tsx +++ b/packages/frontend-dev-utils/src/createDevApp.test.tsx @@ -25,6 +25,22 @@ jest.setTimeout(15000); const originalEnv = process.env; +function loadCreateDevAppIsolated(): typeof import('./createDevApp').createDevApp { + let isolatedCreateDevApp: + | typeof import('./createDevApp').createDevApp + | undefined; + + jest.isolateModules(() => { + ({ createDevApp: isolatedCreateDevApp } = require('./createDevApp')); + }); + + if (!isolatedCreateDevApp) { + throw new Error('Expected createDevApp to be loaded in isolation'); + } + + return isolatedCreateDevApp; +} + describe('createDevApp', () => { beforeEach(() => { process.env = { ...originalEnv }; @@ -105,11 +121,7 @@ describe('createDevApp', () => { root.id = 'root'; document.body.appendChild(root); - let isolatedCreateDevApp: typeof import('./createDevApp').createDevApp; - jest.isolateModules(() => { - ({ createDevApp: isolatedCreateDevApp } = require('./createDevApp')); - }); - + const isolatedCreateDevApp = loadCreateDevAppIsolated(); isolatedCreateDevApp({ bindRoutes, features: ['plugin-feature'] as any, @@ -160,11 +172,7 @@ describe('createDevApp', () => { root.id = 'root'; document.body.appendChild(root); - let isolatedCreateDevApp: typeof import('./createDevApp').createDevApp; - jest.isolateModules(() => { - ({ createDevApp: isolatedCreateDevApp } = require('./createDevApp')); - }); - + const isolatedCreateDevApp = loadCreateDevAppIsolated(); isolatedCreateDevApp({ features: ['plugin-feature'] as any, }); From 1c00e2590199bbf571c70c074f8bf1484ed18bab Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 16 Mar 2026 19:09:55 +0100 Subject: [PATCH 14/14] Preserve app loading fallback in createDevApp. Only suspend the lazy BUI CSS import so the app root keeps its built-in loading UI, and lock that behavior down in the mocked render test. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- packages/frontend-dev-utils/src/createDevApp.test.tsx | 5 +++++ packages/frontend-dev-utils/src/createDevApp.tsx | 8 +++++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/frontend-dev-utils/src/createDevApp.test.tsx b/packages/frontend-dev-utils/src/createDevApp.test.tsx index 6624681152..5c0b21ab25 100644 --- a/packages/frontend-dev-utils/src/createDevApp.test.tsx +++ b/packages/frontend-dev-utils/src/createDevApp.test.tsx @@ -134,6 +134,11 @@ describe('createDevApp', () => { }); expect(createRoot).toHaveBeenCalledWith(root); }); + + const renderedNode = render.mock.calls[0][0] as any; + expect(renderedNode.props.children).toHaveLength(2); + expect(renderedNode.props.children[0].props.fallback).toBeNull(); + expect(renderedNode.props.children[1].props.children).toBe('Test App Root'); }); it('should throw a clear error when the root element is missing', () => { diff --git a/packages/frontend-dev-utils/src/createDevApp.tsx b/packages/frontend-dev-utils/src/createDevApp.tsx index 112901ed52..80cc95dc22 100644 --- a/packages/frontend-dev-utils/src/createDevApp.tsx +++ b/packages/frontend-dev-utils/src/createDevApp.tsx @@ -108,10 +108,12 @@ export function createDevApp(options: CreateDevAppOptions): void { ReactDOMPromise.then(ReactDOM => { const rootNode = ( - - + <> + + + {AppRoot} - + ); if ('createRoot' in ReactDOM) {