diff --git a/.changeset/add-frontend-dev-utils.md b/.changeset/add-frontend-dev-utils.md new file mode 100644 index 0000000000..987f02c6d9 --- /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. 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/.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/.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/.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/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..861f454280 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 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. + ## 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 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'}}", 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..33eadabbe2 --- /dev/null +++ b/packages/frontend-dev-utils/package.json @@ -0,0 +1,65 @@ +{ + "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:^", + "@backstage/plugin-app": "workspace:^", + "@backstage/ui": "workspace:^" + }, + "devDependencies": { + "@backstage/cli": "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..39ca2aec2b --- /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 { + bindRoutes?: CreateAppOptions['bindRoutes']; + features: (FrontendFeature | FrontendFeatureLoader)[]; +} +``` 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.test.tsx b/packages/frontend-dev-utils/src/createDevApp.test.tsx new file mode 100644 index 0000000000..5c0b21ab25 --- /dev/null +++ b/packages/frontend-dev-utils/src/createDevApp.test.tsx @@ -0,0 +1,193 @@ +/* + * 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 { waitFor, within } from '@testing-library/react'; +import { createDevApp } from './createDevApp'; + +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 }; + }); + + afterEach(() => { + document.body.innerHTML = ''; + jest.resetAllMocks(); + }); + + afterAll(() => { + process.env = originalEnv; + }); + + 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
, + }, + }), + ], + }); + + (process.env as any).APP_CONFIG = [ + { + context: 'test', + data: { + app: { title: 'Test App' }, + backend: { baseUrl: 'http://localhost' }, + }, + }, + ]; + + createDevApp({ + features: [testPlugin], + }); + + const body = within(document.body); + await body.findByText('Test Plugin Page', {}, { timeout: 10000 }); + }); + + 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); + + const isolatedCreateDevApp = loadCreateDevAppIsolated(); + isolatedCreateDevApp({ + bindRoutes, + features: ['plugin-feature'] as any, + }); + + await waitFor(() => { + expect(createApp).toHaveBeenCalledWith({ + bindRoutes, + features: ['app-plugin-override', 'plugin-feature'], + }); + 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', () => { + 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); + + const isolatedCreateDevApp = loadCreateDevAppIsolated(); + 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 new file mode 100644 index 0000000000..80cc95dc22 --- /dev/null +++ b/packages/frontend-dev-utils/src/createDevApp.tsx @@ -0,0 +1,125 @@ +/* + * 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 appPlugin from '@backstage/plugin-app'; +import { Suspense, lazy } from 'react'; +import 'react-dom'; + +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, + }), + ], +}); + +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}. + * + * @public + */ +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']; +} + +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. + * + * @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 rootElement = getRootElement(); + const { features, bindRoutes } = options; + const devFeatures: CreateAppOptions['features'] = [ + appPluginOverride, + ...features, + ]; + const appOptions: CreateAppOptions = { + bindRoutes, + features: devFeatures, + }; + const app = createApp(appOptions); + const AppRoot = app.createRoot(); + + ReactDOMPromise.then(ReactDOM => { + const rootNode = ( + <> + + + + {AppRoot} + + ); + + if ('createRoot' in ReactDOM) { + ReactDOM.createRoot(rootElement).render(rootNode); + } else { + ReactDOM.render(rootNode, rootElement); + } + }); +} 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/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}
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/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', diff --git a/yarn.lock b/yarn.lock index b533dd3afc..b0c5fe797b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3671,6 +3671,33 @@ __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:^" + "@backstage/ui": "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 +4053,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"