From b164e20697c52c2f6b255378b8bf228c40fb388c Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 15 Jan 2026 11:40:36 +0100 Subject: [PATCH 01/13] frontend-plugin-api: added PluginWrapperBlueprint + implementation Signed-off-by: Patrik Oldsberg --- packages/frontend-plugin-api/report.api.md | 53 +++++++++ .../src/apis/definitions/PluginWrapperApi.ts | 41 +++++++ .../src/apis/definitions/index.ts | 1 + .../src/blueprints/PluginWrapperBlueprint.tsx | 56 +++++++++ .../src/blueprints/index.ts | 1 + .../src/components/ExtensionBoundary.tsx | 25 +++- .../DefaultPluginWrapperApi.test.tsx | 98 ++++++++++++++++ .../DefaultPluginWrapperApi.tsx | 107 ++++++++++++++++++ .../app/src/apis/PluginWrapperApi/index.ts | 17 +++ .../app/src/extensions/PluginWrapperApi.ts | 49 ++++++++ plugins/app/src/extensions/index.ts | 1 + 11 files changed, 448 insertions(+), 1 deletion(-) create mode 100644 packages/frontend-plugin-api/src/apis/definitions/PluginWrapperApi.ts create mode 100644 packages/frontend-plugin-api/src/blueprints/PluginWrapperBlueprint.tsx create mode 100644 plugins/app/src/apis/PluginWrapperApi/DefaultPluginWrapperApi.test.tsx create mode 100644 plugins/app/src/apis/PluginWrapperApi/DefaultPluginWrapperApi.tsx create mode 100644 plugins/app/src/apis/PluginWrapperApi/index.ts create mode 100644 plugins/app/src/extensions/PluginWrapperApi.ts diff --git a/packages/frontend-plugin-api/report.api.md b/packages/frontend-plugin-api/report.api.md index ceba08d7c3..9115d0732b 100644 --- a/packages/frontend-plugin-api/report.api.md +++ b/packages/frontend-plugin-api/report.api.md @@ -1868,6 +1868,59 @@ export interface PluginOptions< routes?: TRoutes; } +// @public +export type PluginWrapperApi = { + getPluginWrapper(pluginId: string): + | ComponentType<{ + children: ReactNode; + }> + | undefined; +}; + +// @public +export const pluginWrapperApiRef: ApiRef; + +// @public +export const PluginWrapperBlueprint: ExtensionBlueprint_2<{ + kind: 'plugin-wrapper'; + params: (params: { + loader: () => Promise<{ + component: ComponentType<{ + children: ReactNode; + }>; + }>; + }) => ExtensionBlueprintParams_2<{ + loader: () => Promise<{ + component: ComponentType<{ + children: ReactNode; + }>; + }>; + }>; + output: ExtensionDataRef_2< + () => Promise<{ + component: ComponentType<{ + children: ReactNode; + }>; + }>, + 'core.plugin-wrapper.loader', + {} + >; + inputs: {}; + config: {}; + configInput: {}; + dataRefs: { + wrapper: ConfigurableExtensionDataRef_2< + () => Promise<{ + component: ComponentType<{ + children: ReactNode; + }>; + }>, + 'core.plugin-wrapper.loader', + {} + >; + }; +}>; + // @public (undocumented) export type PortableSchema = { parse: (input: TInput) => TOutput; diff --git a/packages/frontend-plugin-api/src/apis/definitions/PluginWrapperApi.ts b/packages/frontend-plugin-api/src/apis/definitions/PluginWrapperApi.ts new file mode 100644 index 0000000000..b6c96dd760 --- /dev/null +++ b/packages/frontend-plugin-api/src/apis/definitions/PluginWrapperApi.ts @@ -0,0 +1,41 @@ +/* + * Copyright 2024 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 { ComponentType, ReactNode } from 'react'; +import { ApiRef, createApiRef } from '../system'; + +/** + * The Plugin Wrapper API allows plugins to wrap their extensions with providers. This API is only intended for internal use by the Backstage frontend system. To provide contexts to plugin components, use `ExtensionBoundary` instead. + * + * @public + */ +export type PluginWrapperApi = { + /** + * Returns a wrapper component for a specific plugin, or undefined if no wrappers exist. Do not use this API directly, instead use `ExtensionBoundary` to wrap your plugin components if needed. + */ + getPluginWrapper( + pluginId: string, + ): ComponentType<{ children: ReactNode }> | undefined; +}; + +/** + * The API reference of {@link PluginWrapperApi}. + * + * @public + */ +export const pluginWrapperApiRef: ApiRef = createApiRef({ + id: 'core.plugin-wrapper', +}); diff --git a/packages/frontend-plugin-api/src/apis/definitions/index.ts b/packages/frontend-plugin-api/src/apis/definitions/index.ts index 5e7f713fcf..7b90d38092 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/index.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/index.ts @@ -49,3 +49,4 @@ export * from './RouteResolutionApi'; export * from './StorageApi'; export * from './AnalyticsApi'; export * from './TranslationApi'; +export * from './PluginWrapperApi'; diff --git a/packages/frontend-plugin-api/src/blueprints/PluginWrapperBlueprint.tsx b/packages/frontend-plugin-api/src/blueprints/PluginWrapperBlueprint.tsx new file mode 100644 index 0000000000..a005342b31 --- /dev/null +++ b/packages/frontend-plugin-api/src/blueprints/PluginWrapperBlueprint.tsx @@ -0,0 +1,56 @@ +/* + * Copyright 2024 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 { ComponentType, ReactNode } from 'react'; +import { + createExtensionBlueprint, + createExtensionBlueprintParams, + createExtensionDataRef, +} from '../wiring'; + +const wrapperDataRef = createExtensionDataRef< + () => Promise<{ component: ComponentType<{ children: ReactNode }> }> +>().with({ id: 'core.plugin-wrapper.loader' }); + +/** + * Creates extensions that wrap plugin extensions with providers. + * + * @public + */ +export const PluginWrapperBlueprint = createExtensionBlueprint({ + kind: 'plugin-wrapper', + attachTo: { id: 'api:app/plugin-wrapper', input: 'wrappers' }, + output: [wrapperDataRef], + dataRefs: { + wrapper: wrapperDataRef, + }, + defineParams(params: { + loader: () => Promise<{ + component: ComponentType<{ children: ReactNode }>; + }>; + }) { + return createExtensionBlueprintParams( + params as { + loader: () => Promise<{ + component: ComponentType<{ children: ReactNode }>; + }>; + }, + ); + }, + *factory(params) { + yield wrapperDataRef(params.loader); + }, +}); diff --git a/packages/frontend-plugin-api/src/blueprints/index.ts b/packages/frontend-plugin-api/src/blueprints/index.ts index 379c8d237b..1d7e6eb478 100644 --- a/packages/frontend-plugin-api/src/blueprints/index.ts +++ b/packages/frontend-plugin-api/src/blueprints/index.ts @@ -21,6 +21,7 @@ export { export { ApiBlueprint } from './ApiBlueprint'; export { AppRootElementBlueprint } from './AppRootElementBlueprint'; export { AppRootWrapperBlueprint } from './AppRootWrapperBlueprint'; +export { PluginWrapperBlueprint } from './PluginWrapperBlueprint'; export { IconBundleBlueprint } from './IconBundleBlueprint'; export { NavContentBlueprint, diff --git a/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx b/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx index 6e4487ed9b..fefa2dc521 100644 --- a/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx +++ b/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx @@ -19,6 +19,7 @@ import { ReactNode, Suspense, useEffect, + useMemo, lazy as reactLazy, } from 'react'; import { AnalyticsContext, useAnalytics } from '../analytics'; @@ -27,6 +28,10 @@ import { ErrorApiBoundary } from './ErrorApiBoundary'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports import { routableExtensionRenderedEvent } from '../../../core-plugin-api/src/analytics/Tracker'; import { AppNode, ErrorApi, errorApiRef, useApi } from '../apis'; +import { + PluginWrapperApi, + pluginWrapperApiRef, +} from '../apis/definitions/PluginWrapperApi'; import { coreExtensionData } from '../wiring'; import { AppNodeProvider } from './AppNodeProvider'; import { Progress } from './DefaultSwappableComponents'; @@ -39,6 +44,13 @@ function useOptionalErrorApi(): ErrorApi | undefined { } } +function useOptionalPluginWrapperApi(): PluginWrapperApi | undefined { + try { + return useApi(pluginWrapperApiRef); + } catch { + return undefined; + } +} type RouteTrackerProps = PropsWithChildren<{ enabled?: boolean; }>; @@ -78,11 +90,18 @@ export function ExtensionBoundary(props: ExtensionBoundaryProps) { ); const plugin = node.spec.plugin; + const pluginId = plugin.id ?? 'app'; + + const pluginWrapperApi = useOptionalPluginWrapperApi(); + + const PluginWrapper = useMemo(() => { + return pluginWrapperApi?.getPluginWrapper(pluginId); + }, [pluginWrapperApi, pluginId]); // Skipping "routeRef" attribute in the new system, the extension "id" should provide more insight const attributes = { extensionId: node.spec.id, - pluginId: plugin.id ?? 'app', + pluginId, }; let content = ( @@ -91,6 +110,10 @@ export function ExtensionBoundary(props: ExtensionBoundaryProps) { ); + if (PluginWrapper) { + content = {content}; + } + if (props.errorPresentation === 'error-api') { content = ( diff --git a/plugins/app/src/apis/PluginWrapperApi/DefaultPluginWrapperApi.test.tsx b/plugins/app/src/apis/PluginWrapperApi/DefaultPluginWrapperApi.test.tsx new file mode 100644 index 0000000000..c723222f2f --- /dev/null +++ b/plugins/app/src/apis/PluginWrapperApi/DefaultPluginWrapperApi.test.tsx @@ -0,0 +1,98 @@ +/* + * Copyright 2024 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 { render, screen } from '@testing-library/react'; +import { DefaultPluginWrapperApi } from './DefaultPluginWrapperApi'; + +describe('DefaultPluginWrapperApi', () => { + it('should wrap multiple components with a single wrapper', async () => { + const api = DefaultPluginWrapperApi.fromWrappers([ + { + loader: async () => ({ + component: ({ children }) => <>Wrapper({children}), + }), + pluginId: 'plugin-1', + }, + ]); + + const Wrapper1 = api.getPluginWrapper('plugin-1')!; + const Wrapper2 = api.getPluginWrapper('plugin-1')!; + const Wrapper3 = api.getPluginWrapper('plugin-1')!; + + expect(Wrapper1).toBeDefined(); + expect(Wrapper2).toBeDefined(); + expect(Wrapper3).toBeDefined(); + + render( + <> +
+ 1 +
+
+ 2 +
+
+ 3 +
+ , + ); + + await expect(screen.findByText('Wrapper(1)')).resolves.toBeInTheDocument(); + await expect(screen.findByText('Wrapper(2)')).resolves.toBeInTheDocument(); + await expect(screen.findByText('Wrapper(3)')).resolves.toBeInTheDocument(); + }); + + it('should wrap multiple components with multiple wrappers', async () => { + const api = DefaultPluginWrapperApi.fromWrappers([ + { + loader: async () => ({ + component: ({ children }) => <>WrapperA({children}), + }), + pluginId: 'plugin-1', + }, + { + loader: async () => ({ + component: ({ children }) => <>WrapperB({children}), + }), + pluginId: 'plugin-1', + }, + ]); + + const Wrapper1 = api.getPluginWrapper('plugin-1')!; + const Wrapper2 = api.getPluginWrapper('plugin-1')!; + + expect(Wrapper1).toBeDefined(); + expect(Wrapper2).toBeDefined(); + + render( + <> +
+ 1 +
+
+ 2 +
+ , + ); + + await expect( + screen.findByText('WrapperB(WrapperA(1))'), + ).resolves.toBeInTheDocument(); + await expect( + screen.findByText('WrapperB(WrapperA(2))'), + ).resolves.toBeInTheDocument(); + }); +}); diff --git a/plugins/app/src/apis/PluginWrapperApi/DefaultPluginWrapperApi.tsx b/plugins/app/src/apis/PluginWrapperApi/DefaultPluginWrapperApi.tsx new file mode 100644 index 0000000000..2bf2744f21 --- /dev/null +++ b/plugins/app/src/apis/PluginWrapperApi/DefaultPluginWrapperApi.tsx @@ -0,0 +1,107 @@ +/* + * Copyright 2024 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 { PluginWrapperApi } from '@backstage/frontend-plugin-api'; +import { ComponentType, ReactNode, useEffect, useState } from 'react'; + +type WrapperInput = { + loader: () => Promise<{ component: ComponentType<{ children: ReactNode }> }>; + pluginId: string; +}; + +/** + * Default implementation of PluginWrapperApi. + * + * @internal + */ +export class DefaultPluginWrapperApi implements PluginWrapperApi { + constructor( + private readonly pluginWrappers: Map< + string, + ComponentType<{ children: ReactNode }> + >, + ) {} + + getPluginWrapper( + pluginId: string, + ): ComponentType<{ children: ReactNode }> | undefined { + return this.pluginWrappers.get(pluginId); + } + + static fromWrappers(wrappers: Array): DefaultPluginWrapperApi { + const loadersByPlugin = new Map< + string, + Array< + () => Promise<{ component: ComponentType<{ children: ReactNode }> }> + > + >(); + + for (const wrapper of wrappers) { + let loaders = loadersByPlugin.get(wrapper.pluginId); + if (!loaders) { + loaders = []; + loadersByPlugin.set(wrapper.pluginId, loaders); + } + loaders.push(wrapper.loader); + } + + const composedWrappers = new Map< + string, + ComponentType<{ children: ReactNode }> + >(); + + for (const [pluginId, loaders] of loadersByPlugin) { + if (loaders.length === 0) { + continue; + } + + const ComposedWrapper = (props: { children: ReactNode }) => { + const [loadedWrappers, setLoadedWrappers] = useState< + Array> | undefined + >(undefined); + const [error, setError] = useState(undefined); + + useEffect(() => { + Promise.all(loaders.map(loader => loader())) + .then(results => { + setLoadedWrappers(results.map(r => r.component)); + }) + .catch(setError); + }, []); + + if (error) { + throw error; + } + + if (!loadedWrappers) { + return null; + } + + let content = props.children; + + for (const Wrapper of loadedWrappers) { + content = {content}; + } + + return <>{content}; + }; + + composedWrappers.set(pluginId, ComposedWrapper); + } + + return new DefaultPluginWrapperApi(composedWrappers); + } +} diff --git a/plugins/app/src/apis/PluginWrapperApi/index.ts b/plugins/app/src/apis/PluginWrapperApi/index.ts new file mode 100644 index 0000000000..f11e40fdf0 --- /dev/null +++ b/plugins/app/src/apis/PluginWrapperApi/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2024 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. + */ + +export { DefaultPluginWrapperApi } from './DefaultPluginWrapperApi'; diff --git a/plugins/app/src/extensions/PluginWrapperApi.ts b/plugins/app/src/extensions/PluginWrapperApi.ts new file mode 100644 index 0000000000..14e41891a0 --- /dev/null +++ b/plugins/app/src/extensions/PluginWrapperApi.ts @@ -0,0 +1,49 @@ +/* + * Copyright 2024 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 { + PluginWrapperBlueprint, + createExtensionInput, + ApiBlueprint, + pluginWrapperApiRef, +} from '@backstage/frontend-plugin-api'; +import { DefaultPluginWrapperApi } from '../apis/PluginWrapperApi'; + +/** + * Contains the plugin wrappers installed into the app. + */ +export const PluginWrapperApi = ApiBlueprint.makeWithOverrides({ + name: 'plugin-wrapper', + inputs: { + wrappers: createExtensionInput([PluginWrapperBlueprint.dataRefs.wrapper]), + }, + factory: (originalFactory, { inputs }) => { + return originalFactory(defineParams => + defineParams({ + api: pluginWrapperApiRef, + deps: {}, + factory: () => { + return DefaultPluginWrapperApi.fromWrappers( + inputs.wrappers.map(wrapperInput => ({ + loader: wrapperInput.get(PluginWrapperBlueprint.dataRefs.wrapper), + pluginId: wrapperInput.node.spec.plugin.id ?? 'app', + })), + ); + }, + }), + ); + }, +}); diff --git a/plugins/app/src/extensions/index.ts b/plugins/app/src/extensions/index.ts index 0d3ad84e6f..17ac19ff4b 100644 --- a/plugins/app/src/extensions/index.ts +++ b/plugins/app/src/extensions/index.ts @@ -32,3 +32,4 @@ export { alertDisplayAppRootElement, } from './elements'; export { Progress, NotFoundErrorPage, ErrorDisplay } from './components'; +export { PluginWrapperApi } from './PluginWrapperApi'; From 0b494408a67f2d2f0c35149ab77c152018a92eff Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 15 Jan 2026 12:17:21 +0100 Subject: [PATCH 02/13] added new app-react library with AppRootWrapperBlueprint replacement Signed-off-by: Patrik Oldsberg --- packages/frontend-plugin-api/report.api.md | 2 +- .../blueprints/AppRootWrapperBlueprint.tsx | 4 + plugins/app-react/report.api.md | 27 ++++ .../AppRootWrapperBlueprint.test.tsx | 138 ++++++++++++++++++ .../blueprints/AppRootWrapperBlueprint.tsx | 51 +++++++ plugins/app-react/src/blueprints/index.ts | 2 + plugins/app/report.api.md | 21 ++- plugins/app/src/extensions/AppRoot.tsx | 29 +++- 8 files changed, 265 insertions(+), 9 deletions(-) create mode 100644 plugins/app-react/src/blueprints/AppRootWrapperBlueprint.test.tsx create mode 100644 plugins/app-react/src/blueprints/AppRootWrapperBlueprint.tsx diff --git a/packages/frontend-plugin-api/report.api.md b/packages/frontend-plugin-api/report.api.md index 9115d0732b..fe19b724ce 100644 --- a/packages/frontend-plugin-api/report.api.md +++ b/packages/frontend-plugin-api/report.api.md @@ -271,7 +271,7 @@ export const AppRootElementBlueprint: ExtensionBlueprint_2<{ dataRefs: never; }>; -// @public +// @public @deprecated export const AppRootWrapperBlueprint: ExtensionBlueprint_2<{ kind: 'app-root-wrapper'; params: { diff --git a/packages/frontend-plugin-api/src/blueprints/AppRootWrapperBlueprint.tsx b/packages/frontend-plugin-api/src/blueprints/AppRootWrapperBlueprint.tsx index 493fba05f6..62268bce77 100644 --- a/packages/frontend-plugin-api/src/blueprints/AppRootWrapperBlueprint.tsx +++ b/packages/frontend-plugin-api/src/blueprints/AppRootWrapperBlueprint.tsx @@ -27,6 +27,10 @@ const componentDataRef = createExtensionDataRef< * and similar. * * @public + * @deprecated Use {@link PluginWrapperBlueprint} instead if you want to wrap + * all plugin components in the same wrapper. If you want to wrap the entire + * app, use the `AppRootWrapperBlueprint` from `@backstage/plugin-app-react` + * instead, although note that only app modules are able to use that blueprint. */ export const AppRootWrapperBlueprint = createExtensionBlueprint({ kind: 'app-root-wrapper', diff --git a/plugins/app-react/report.api.md b/plugins/app-react/report.api.md index 48ec50749d..415ffe51bd 100644 --- a/plugins/app-react/report.api.md +++ b/plugins/app-react/report.api.md @@ -18,6 +18,33 @@ import { SwappableComponentRef } from '@backstage/frontend-plugin-api'; import { TranslationMessages } from '@backstage/frontend-plugin-api'; import { TranslationResource } from '@backstage/frontend-plugin-api'; +// @public +export const AppRootWrapperBlueprint: ExtensionBlueprint<{ + kind: 'app-root-wrapper'; + params: { + component: (props: { children: ReactNode }) => JSX.Element | null; + }; + output: ExtensionDataRef< + ComponentType<{ + children: ReactNode; + }>, + 'app.root-wrapper-component', + {} + >; + inputs: {}; + config: {}; + configInput: {}; + dataRefs: { + component: ConfigurableExtensionDataRef< + ComponentType<{ + children: ReactNode; + }>, + 'app.root-wrapper-component', + {} + >; + }; +}>; + // @public export const IconBundleBlueprint: ExtensionBlueprint<{ kind: 'icon-bundle'; diff --git a/plugins/app-react/src/blueprints/AppRootWrapperBlueprint.test.tsx b/plugins/app-react/src/blueprints/AppRootWrapperBlueprint.test.tsx new file mode 100644 index 0000000000..32aaeddeba --- /dev/null +++ b/plugins/app-react/src/blueprints/AppRootWrapperBlueprint.test.tsx @@ -0,0 +1,138 @@ +/* + * Copyright 2024 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 { Fragment } from 'react'; +import { AppRootWrapperBlueprint } from './AppRootWrapperBlueprint'; +import { screen, waitFor } from '@testing-library/react'; +import { + coreExtensionData, + createExtension, + createExtensionInput, + createFrontendModule, +} from '@backstage/frontend-plugin-api'; +import { renderTestApp } from '@backstage/frontend-test-utils'; + +describe('AppRootWrapperBlueprint', () => { + it('should return an extension with sensible defaults', () => { + const extension = AppRootWrapperBlueprint.make({ + params: { + component: () =>
Hello
, + }, + }); + + expect(extension).toMatchInlineSnapshot(` + { + "$$type": "@backstage/ExtensionDefinition", + "T": undefined, + "attachTo": { + "id": "app/root", + "input": "wrappers", + }, + "configSchema": undefined, + "disabled": false, + "factory": [Function], + "inputs": {}, + "kind": "app-root-wrapper", + "name": undefined, + "output": [ + [Function], + ], + "override": [Function], + "toString": [Function], + "version": "v2", + } + `); + }); + + it('should render a simple component wrapper', async () => { + const extension = AppRootWrapperBlueprint.make({ + name: 'test', + params: { + component: () =>
Hello
, + }, + }); + + renderTestApp({ + features: [ + createFrontendModule({ + pluginId: 'app', + extensions: [extension], + }), + ], + }); + + await waitFor(() => expect(screen.getByText('Hello')).toBeInTheDocument()); + }); + + it('should render a complex component wrapper', async () => { + const extension = AppRootWrapperBlueprint.makeWithOverrides({ + config: { + schema: { + name: z => z.string(), + }, + }, + inputs: { + children: createExtensionInput([coreExtensionData.reactElement]), + }, + *factory(originalFactory, { inputs, config }) { + yield* originalFactory({ + component: ({ children }) => ( +
+ {inputs.children.flatMap((c, index) => ( + + {c.get(coreExtensionData.reactElement)} + + ))} + {children} +
+ ), + }); + }, + }); + + renderTestApp({ + features: [ + createFrontendModule({ + pluginId: 'app', + extensions: [ + extension, + createExtension({ + name: 'test-child', + attachTo: extension.inputs.children, + output: [coreExtensionData.reactElement], + factory: () => [ + coreExtensionData.reactElement(
Its Me
), + ], + }), + ], + }), + ], + config: { + app: { + extensions: [ + { + 'app-root-wrapper:app': { config: { name: 'Robin' } }, + }, + ], + }, + }, + }); + + await waitFor(() => { + expect(screen.getByTestId('Robin-1')).toBeInTheDocument(); + expect(screen.getByText('Its Me')).toBeInTheDocument(); + }); + }); +}); diff --git a/plugins/app-react/src/blueprints/AppRootWrapperBlueprint.tsx b/plugins/app-react/src/blueprints/AppRootWrapperBlueprint.tsx new file mode 100644 index 0000000000..79a35e2bc0 --- /dev/null +++ b/plugins/app-react/src/blueprints/AppRootWrapperBlueprint.tsx @@ -0,0 +1,51 @@ +/* + * Copyright 2024 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 { ComponentType, ReactNode } from 'react'; +import { + createExtensionBlueprint, + createExtensionDataRef, +} from '@backstage/frontend-plugin-api'; + +const componentDataRef = createExtensionDataRef< + ComponentType<{ children: ReactNode }> +>().with({ id: 'app.root-wrapper-component' }); + +/** + * Creates an extension that renders a React wrapper at the app root, enclosing + * the app layout. + * + * @remarks + * + * This is useful for example for adding global React contexts and similar. This + * extension is only available for use by app modules, plugins should use the + * {@link @backstage/frontend-plugin-api#PluginWrapperBlueprint} instead. + * + * @public + */ +export const AppRootWrapperBlueprint = createExtensionBlueprint({ + kind: 'app-root-wrapper', + attachTo: { id: 'app/root', input: 'wrappers' }, + output: [componentDataRef], + dataRefs: { + component: componentDataRef, + }, + *factory(params: { + component: (props: { children: ReactNode }) => JSX.Element | null; + }) { + yield componentDataRef(params.component); + }, +}); diff --git a/plugins/app-react/src/blueprints/index.ts b/plugins/app-react/src/blueprints/index.ts index 09d05b71ed..cf036aa060 100644 --- a/plugins/app-react/src/blueprints/index.ts +++ b/plugins/app-react/src/blueprints/index.ts @@ -27,6 +27,8 @@ import { TranslationBlueprint as _TranslationBlueprint, } from '@backstage/frontend-plugin-api'; +export { AppRootWrapperBlueprint } from './AppRootWrapperBlueprint'; + /** * Creates an extension that adds/replaces an app theme. This blueprint is limited to use by the app plugin. * diff --git a/plugins/app/report.api.md b/plugins/app/report.api.md index ea34a1731f..57103b8000 100644 --- a/plugins/app/report.api.md +++ b/plugins/app/report.api.md @@ -149,11 +149,22 @@ const appPlugin: OverridableFrontendPlugin< } >; wrappers: ExtensionInput< - ConfigurableExtensionDataRef< - (props: { children: ReactNode }) => JSX.Element | null, - 'app.root.wrapper', - {} - >, + | ConfigurableExtensionDataRef< + (props: { children: ReactNode }) => JSX.Element | null, + 'app.root.wrapper', + { + optional: true; + } + > + | ConfigurableExtensionDataRef< + ComponentType<{ + children: ReactNode; + }>, + 'app.root-wrapper-component', + { + optional: true; + } + >, { singleton: false; optional: false; diff --git a/plugins/app/src/extensions/AppRoot.tsx b/plugins/app/src/extensions/AppRoot.tsx index 47ea50a641..e51c63ca28 100644 --- a/plugins/app/src/extensions/AppRoot.tsx +++ b/plugins/app/src/extensions/AppRoot.tsx @@ -22,7 +22,7 @@ import { JSX, } from 'react'; import { - AppRootWrapperBlueprint, + AppRootWrapperBlueprint as OldAppRootWrapperBlueprint, RouterBlueprint, SignInPageBlueprint, coreExtensionData, @@ -44,6 +44,7 @@ import { identityApiRef, useApi, } from '@backstage/core-plugin-api'; +import { AppRootWrapperBlueprint } from '@backstage/plugin-app-react'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports import { isProtectedApp } from '../../../../packages/core-app-api/src/app/isProtectedApp'; import { BrowserRouter } from 'react-router-dom'; @@ -69,7 +70,8 @@ export const AppRoot = createExtension({ }), elements: createExtensionInput([coreExtensionData.reactElement]), wrappers: createExtensionInput([ - AppRootWrapperBlueprint.dataRefs.component, + OldAppRootWrapperBlueprint.dataRefs.component.optional(), + AppRootWrapperBlueprint.dataRefs.component.optional(), ]), }, output: [coreExtensionData.reactElement], @@ -117,7 +119,28 @@ export const AppRoot = createExtension({ for (const wrapper of inputs.wrappers) { const Component = wrapper.get(AppRootWrapperBlueprint.dataRefs.component); - content = {content}; + const pluginId = wrapper.node.spec.plugin.id; + if (Component) { + if (pluginId === 'app') { + content = {content}; + } else { + // eslint-disable-next-line no-console + console.warn( + `Warning: The app root wrapper extension from the '${pluginId}' plugin was ignored, only the 'app' plugin is allowed to provide app root wrappers.`, + ); + } + } else { + const OldComponent = wrapper.get( + OldAppRootWrapperBlueprint.dataRefs.component, + ); + if (OldComponent) { + // eslint-disable-next-line no-console + console.warn( + `DEPRECATION WARNING: The ${pluginId} plugin provided a deprecated app root wrapper extension using the AppRootWrapperBlueprint from @backstage/frontend-plugin-api. This will break in a future release.`, + ); + content = {content}; + } + } } return [ From 4554a4e3550022ef2b6e3e0b750124ce29baa931 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 15 Jan 2026 12:17:48 +0100 Subject: [PATCH 03/13] added changesets for app root wrapper changes Signed-off-by: Patrik Oldsberg --- .changeset/fine-ears-yell.md | 5 +++++ .changeset/moody-wasps-travel.md | 5 +++++ .changeset/violet-otters-share.md | 5 +++++ 3 files changed, 15 insertions(+) create mode 100644 .changeset/fine-ears-yell.md create mode 100644 .changeset/moody-wasps-travel.md create mode 100644 .changeset/violet-otters-share.md diff --git a/.changeset/fine-ears-yell.md b/.changeset/fine-ears-yell.md new file mode 100644 index 0000000000..a1c3e06d41 --- /dev/null +++ b/.changeset/fine-ears-yell.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-app-react': minor +--- + +Added `AppRootWrapperBlueprint` that replaces the blueprint with the same name that was deprecated in `@backstage/frontend-plugin-api`. These blueprints are not directly interchangeable the new one exported from this package can only be used in `app` plugin modules and is restricted from use in other plugins. diff --git a/.changeset/moody-wasps-travel.md b/.changeset/moody-wasps-travel.md new file mode 100644 index 0000000000..fdb46dcc2d --- /dev/null +++ b/.changeset/moody-wasps-travel.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-app': patch +--- + +Implemented support for the new `PluginWrapperBlueprint` from `@backstage/frontend-plugin-api` and `AppRootWrapperBlueprint` from the new `@backstage/plugin-app-react` library. diff --git a/.changeset/violet-otters-share.md b/.changeset/violet-otters-share.md new file mode 100644 index 0000000000..daed39f98c --- /dev/null +++ b/.changeset/violet-otters-share.md @@ -0,0 +1,5 @@ +--- +'@backstage/frontend-plugin-api': patch +--- + +Added `PluginWrapperBlueprint`, which can install components that will wrap all plugin elements. The `AppRootWrapperBlueprint` has also been deprecated and should be replaced either with the new plugin wrapper, or for app overrides, the new blueprint with the same name from `@backstage/plugin-app-react`. From 872cecb902f114fa7c123f02a38662d79b051122 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 15 Jan 2026 12:22:21 +0100 Subject: [PATCH 04/13] docs: updates for app and plugin wrapper blueprint changes Signed-off-by: Patrik Oldsberg --- docs/frontend-system/building-apps/08-migrating.md | 2 +- .../building-plugins/03-common-extension-blueprints.md | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/frontend-system/building-apps/08-migrating.md b/docs/frontend-system/building-apps/08-migrating.md index 26dc3d085c..3ac5109fc7 100644 --- a/docs/frontend-system/building-apps/08-migrating.md +++ b/docs/frontend-system/building-apps/08-migrating.md @@ -660,7 +660,7 @@ const convertedRootFeatures = convertLegacyAppRoot( ); ``` -Any app root wrapper needs to be migrated to be an extension, created using `AppRootWrapperBlueprint`. Note that if you have multiple wrappers they must be completely independent of each other, i.e. the order in which they the appear in the React tree should not matter. If that is not the case then you should group them into a single wrapper. +Any app root wrapper needs to be migrated to be an extension, created using `AppRootWrapperBlueprint` from `@backstage/plugin-app-react`. Note that if you have multiple wrappers they must be completely independent of each other, i.e. the order in which they the appear in the React tree should not matter. If that is not the case then you should group them into a single wrapper. Here is an example converting the `CustomAppBarrier` into extension: diff --git a/docs/frontend-system/building-plugins/03-common-extension-blueprints.md b/docs/frontend-system/building-plugins/03-common-extension-blueprints.md index df83c696f6..9989191181 100644 --- a/docs/frontend-system/building-plugins/03-common-extension-blueprints.md +++ b/docs/frontend-system/building-plugins/03-common-extension-blueprints.md @@ -23,6 +23,12 @@ Navigation item extensions are used to provide menu items that link to different Page extensions provide content for a particular route in the app. By default pages are attached to the app routes extensions, which renders the root routes. +## Extension blueprints in `@backstage/frontend-plugin-api/alpha` + +### Plugin Wrapper - [Reference](https://backstage.io/api/stable/variables/_backstage_frontend-plugin-api.packages-frontend-plugin-api_src_alpha.PluginWrapperBlueprint.html) + +Plugin wrappers allow you to install components that will wrap all elements rendered as part of a plugin. This can be useful if you for example need to add a global provider for example for a query client. The provided wrapper will be rendered as separate elements for each wrapped plugin element, so be sure to use a central store like a [Utility API](../utility-apis/01-index.md) if you want to share state between wrapper instances. + ## Extension blueprints in `@backstage/plugin-app-react` ### SignInPage - [Reference](https://backstage.io/api/stable/variables/_backstage_frontend-plugin-api.SignInPageBlueprint.html) From 9d194418e79afb18d8734c9323b338efab717b16 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 15 Jan 2026 18:43:24 +0100 Subject: [PATCH 05/13] frontend-plugin-api: add test for PluginWrapperApi in ExtensionBoundary Signed-off-by: Patrik Oldsberg --- .../src/components/ExtensionBoundary.test.tsx | 44 ++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/packages/frontend-plugin-api/src/components/ExtensionBoundary.test.tsx b/packages/frontend-plugin-api/src/components/ExtensionBoundary.test.tsx index ae741f8922..c12cddcf43 100644 --- a/packages/frontend-plugin-api/src/components/ExtensionBoundary.test.tsx +++ b/packages/frontend-plugin-api/src/components/ExtensionBoundary.test.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { useEffect } from 'react'; +import { useEffect, ReactNode } from 'react'; import { act, screen, waitFor } from '@testing-library/react'; import { TestApiProvider, withLogCollector } from '@backstage/test-utils'; import { ExtensionBoundary } from './ExtensionBoundary'; @@ -26,6 +26,10 @@ import { createExtensionTester, renderInTestApp, } from '@backstage/frontend-test-utils'; +import { + pluginWrapperApiRef, + PluginWrapperApi, +} from '../apis/definitions/PluginWrapperApi'; const wrapInBoundaryExtension = (element?: JSX.Element) => { const routeRef = createRouteRef(); @@ -121,6 +125,44 @@ describe('ExtensionBoundary', () => { }); }); + it('should wrap children with PluginWrapper when provided', async () => { + const text = 'Wrapped Content'; + const TextComponent = () => { + return

{text}

; + }; + + const WrapperComponent = ({ children }: { children: ReactNode }) => { + return ( +
+ Wrapper + {children} +
+ ); + }; + + const pluginWrapperApi: PluginWrapperApi = { + getPluginWrapper: jest.fn((pluginId: string) => { + if (pluginId === 'app') { + return WrapperComponent; + } + return undefined; + }), + }; + + renderInTestApp( + + {createExtensionTester( + wrapInBoundaryExtension(), + ).reactElement()} + , + ); + + expect(await screen.findByTestId('plugin-wrapper')).toBeInTheDocument(); + expect(screen.getByText('Wrapper')).toBeInTheDocument(); + expect(screen.getByText(text)).toBeInTheDocument(); + expect(pluginWrapperApi.getPluginWrapper).toHaveBeenCalledWith('app'); + }); + // TODO(Rugvip): Need a way to be able to override APIs in the app to be able to test this properly // eslint-disable-next-line jest/no-disabled-tests it.skip('should emit analytics events if routable', async () => { From c4c2419daac9a0288da5cc1b37c9f5dc9fe495cf Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 15 Jan 2026 12:56:26 +0100 Subject: [PATCH 06/13] review fixes Signed-off-by: Patrik Oldsberg --- .changeset/fine-ears-yell.md | 2 +- .../src/apis/definitions/PluginWrapperApi.ts | 13 +++++++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/.changeset/fine-ears-yell.md b/.changeset/fine-ears-yell.md index a1c3e06d41..06c5df58c4 100644 --- a/.changeset/fine-ears-yell.md +++ b/.changeset/fine-ears-yell.md @@ -2,4 +2,4 @@ '@backstage/plugin-app-react': minor --- -Added `AppRootWrapperBlueprint` that replaces the blueprint with the same name that was deprecated in `@backstage/frontend-plugin-api`. These blueprints are not directly interchangeable the new one exported from this package can only be used in `app` plugin modules and is restricted from use in other plugins. +Added `AppRootWrapperBlueprint` that replaces the blueprint with the same name that was deprecated in `@backstage/frontend-plugin-api`. These blueprints are not directly interchangeable, the new one exported from this package can only be used in `app` plugin modules and is restricted from use in other plugins. diff --git a/packages/frontend-plugin-api/src/apis/definitions/PluginWrapperApi.ts b/packages/frontend-plugin-api/src/apis/definitions/PluginWrapperApi.ts index b6c96dd760..6a1f921282 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/PluginWrapperApi.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/PluginWrapperApi.ts @@ -18,13 +18,22 @@ import { ComponentType, ReactNode } from 'react'; import { ApiRef, createApiRef } from '../system'; /** - * The Plugin Wrapper API allows plugins to wrap their extensions with providers. This API is only intended for internal use by the Backstage frontend system. To provide contexts to plugin components, use `ExtensionBoundary` instead. + * The Plugin Wrapper API is used to wrap plugin extensions with providers, + * plugins should generally use `ExtensionBoundary` instead. + * + * @remarks + * + * This API is primarily intended for internal use by the Backstage frontend + * system, but can be used for advanced use-cases. If you do override it, be + * sure to include the default implementation as well. * * @public */ export type PluginWrapperApi = { /** - * Returns a wrapper component for a specific plugin, or undefined if no wrappers exist. Do not use this API directly, instead use `ExtensionBoundary` to wrap your plugin components if needed. + * Returns a wrapper component for a specific plugin, or undefined if no + * wrappers exist. Do not use this API directly, instead use + * `ExtensionBoundary` to wrap your plugin components if needed. */ getPluginWrapper( pluginId: string, From 7b7e547d469db3883e4ac3050588f477d8fa6960 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 16 Jan 2026 16:36:30 +0100 Subject: [PATCH 07/13] frontend-plugin-api: add ExtensionBoundary test for throwing PluginWrapper Signed-off-by: Patrik Oldsberg --- .../src/components/ExtensionBoundary.test.tsx | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/packages/frontend-plugin-api/src/components/ExtensionBoundary.test.tsx b/packages/frontend-plugin-api/src/components/ExtensionBoundary.test.tsx index c12cddcf43..c0068e8f7d 100644 --- a/packages/frontend-plugin-api/src/components/ExtensionBoundary.test.tsx +++ b/packages/frontend-plugin-api/src/components/ExtensionBoundary.test.tsx @@ -163,6 +163,47 @@ describe('ExtensionBoundary', () => { expect(pluginWrapperApi.getPluginWrapper).toHaveBeenCalledWith('app'); }); + it('should handle errors thrown by PluginWrapper with ErrorDisplayBoundary', async () => { + const errorMsg = 'PluginWrapper error'; + const TextComponent = () => { + return

Content

; + }; + + const ThrowingWrapper = () => { + throw new Error(errorMsg); + }; + + const pluginWrapperApi: PluginWrapperApi = { + getPluginWrapper: jest.fn((pluginId: string) => { + if (pluginId === 'app') { + return ThrowingWrapper; + } + return undefined; + }), + }; + + const { error } = await withLogCollector(['error'], async () => { + renderInTestApp( + + {createExtensionTester( + wrapInBoundaryExtension(), + ).reactElement()} + , + ); + await waitFor(() => + expect(screen.getByText(errorMsg)).toBeInTheDocument(), + ); + }); + + expect(error).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining(errorMsg), + }), + ]), + ); + }); + // TODO(Rugvip): Need a way to be able to override APIs in the app to be able to test this properly // eslint-disable-next-line jest/no-disabled-tests it.skip('should emit analytics events if routable', async () => { From d194acb0e3bee584de220306e1419c8b765f2050 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 16 Jan 2026 16:56:47 +0100 Subject: [PATCH 08/13] frontend-plugin-api: move new PluginWrapper to alpha Signed-off-by: Patrik Oldsberg --- .changeset/violet-otters-share.md | 2 +- packages/frontend-plugin-api/package.json | 4 ++ .../frontend-plugin-api/report-alpha.api.md | 68 +++++++++++++++++++ packages/frontend-plugin-api/report.api.md | 53 --------------- packages/frontend-plugin-api/src/alpha.ts | 27 ++++++++ .../src/apis/definitions/PluginWrapperApi.ts | 10 +-- .../src/apis/definitions/index.ts | 1 - .../blueprints/AppRootWrapperBlueprint.tsx | 9 +-- .../src/blueprints/PluginWrapperBlueprint.tsx | 2 +- .../src/blueprints/index.ts | 1 - .../DefaultPluginWrapperApi.tsx | 2 +- .../app/src/extensions/PluginWrapperApi.ts | 4 +- 12 files changed, 115 insertions(+), 68 deletions(-) create mode 100644 packages/frontend-plugin-api/report-alpha.api.md create mode 100644 packages/frontend-plugin-api/src/alpha.ts diff --git a/.changeset/violet-otters-share.md b/.changeset/violet-otters-share.md index daed39f98c..e60ba5041d 100644 --- a/.changeset/violet-otters-share.md +++ b/.changeset/violet-otters-share.md @@ -2,4 +2,4 @@ '@backstage/frontend-plugin-api': patch --- -Added `PluginWrapperBlueprint`, which can install components that will wrap all plugin elements. The `AppRootWrapperBlueprint` has also been deprecated and should be replaced either with the new plugin wrapper, or for app overrides, the new blueprint with the same name from `@backstage/plugin-app-react`. +Added an alpha `PluginWrapperBlueprint` exported from `@backstage/frontend-plugin-api/alpha`, which can install components that will wrap all plugin elements. The `AppRootWrapperBlueprint` has also been deprecated and should be replaced either with the new plugin wrapper, or for app overrides, the new blueprint with the same name from `@backstage/plugin-app-react`. diff --git a/packages/frontend-plugin-api/package.json b/packages/frontend-plugin-api/package.json index 358977bad4..5c82ef444d 100644 --- a/packages/frontend-plugin-api/package.json +++ b/packages/frontend-plugin-api/package.json @@ -16,12 +16,16 @@ "sideEffects": false, "exports": { ".": "./src/index.ts", + "./alpha": "./src/alpha.ts", "./package.json": "./package.json" }, "main": "src/index.ts", "types": "src/index.ts", "typesVersions": { "*": { + "alpha": [ + "src/alpha.ts" + ], "package.json": [ "package.json" ] diff --git a/packages/frontend-plugin-api/report-alpha.api.md b/packages/frontend-plugin-api/report-alpha.api.md new file mode 100644 index 0000000000..77a377bb8e --- /dev/null +++ b/packages/frontend-plugin-api/report-alpha.api.md @@ -0,0 +1,68 @@ +## API Report File for "@backstage/frontend-plugin-api" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { ApiRef } from '@backstage/frontend-plugin-api'; +import { ComponentType } from 'react'; +import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api'; +import { ExtensionBlueprint } from '@backstage/frontend-plugin-api'; +import { ExtensionBlueprintParams } from '@backstage/frontend-plugin-api'; +import { ExtensionDataRef } from '@backstage/frontend-plugin-api'; +import { ReactNode } from 'react'; + +// @alpha +export type PluginWrapperApi = { + getPluginWrapper(pluginId: string): + | ComponentType<{ + children: ReactNode; + }> + | undefined; +}; + +// @alpha +export const pluginWrapperApiRef: ApiRef; + +// @alpha +export const PluginWrapperBlueprint: ExtensionBlueprint<{ + kind: 'plugin-wrapper'; + params: (params: { + loader: () => Promise<{ + component: ComponentType<{ + children: ReactNode; + }>; + }>; + }) => ExtensionBlueprintParams<{ + loader: () => Promise<{ + component: ComponentType<{ + children: ReactNode; + }>; + }>; + }>; + output: ExtensionDataRef< + () => Promise<{ + component: ComponentType<{ + children: ReactNode; + }>; + }>, + 'core.plugin-wrapper.loader', + {} + >; + inputs: {}; + config: {}; + configInput: {}; + dataRefs: { + wrapper: ConfigurableExtensionDataRef< + () => Promise<{ + component: ComponentType<{ + children: ReactNode; + }>; + }>, + 'core.plugin-wrapper.loader', + {} + >; + }; +}>; + +// (No @packageDocumentation comment for this package) +``` diff --git a/packages/frontend-plugin-api/report.api.md b/packages/frontend-plugin-api/report.api.md index fe19b724ce..1a7f2e3c7a 100644 --- a/packages/frontend-plugin-api/report.api.md +++ b/packages/frontend-plugin-api/report.api.md @@ -1868,59 +1868,6 @@ export interface PluginOptions< routes?: TRoutes; } -// @public -export type PluginWrapperApi = { - getPluginWrapper(pluginId: string): - | ComponentType<{ - children: ReactNode; - }> - | undefined; -}; - -// @public -export const pluginWrapperApiRef: ApiRef; - -// @public -export const PluginWrapperBlueprint: ExtensionBlueprint_2<{ - kind: 'plugin-wrapper'; - params: (params: { - loader: () => Promise<{ - component: ComponentType<{ - children: ReactNode; - }>; - }>; - }) => ExtensionBlueprintParams_2<{ - loader: () => Promise<{ - component: ComponentType<{ - children: ReactNode; - }>; - }>; - }>; - output: ExtensionDataRef_2< - () => Promise<{ - component: ComponentType<{ - children: ReactNode; - }>; - }>, - 'core.plugin-wrapper.loader', - {} - >; - inputs: {}; - config: {}; - configInput: {}; - dataRefs: { - wrapper: ConfigurableExtensionDataRef_2< - () => Promise<{ - component: ComponentType<{ - children: ReactNode; - }>; - }>, - 'core.plugin-wrapper.loader', - {} - >; - }; -}>; - // @public (undocumented) export type PortableSchema = { parse: (input: TInput) => TOutput; diff --git a/packages/frontend-plugin-api/src/alpha.ts b/packages/frontend-plugin-api/src/alpha.ts new file mode 100644 index 0000000000..9d73b8cc84 --- /dev/null +++ b/packages/frontend-plugin-api/src/alpha.ts @@ -0,0 +1,27 @@ +/* + * Copyright 2024 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. + */ + +/** + * Alpha exports for plugin wrapper functionality. + * + * @alpha + */ + +export { PluginWrapperBlueprint } from './blueprints/PluginWrapperBlueprint'; +export { + type PluginWrapperApi, + pluginWrapperApiRef, +} from './apis/definitions/PluginWrapperApi'; diff --git a/packages/frontend-plugin-api/src/apis/definitions/PluginWrapperApi.ts b/packages/frontend-plugin-api/src/apis/definitions/PluginWrapperApi.ts index 6a1f921282..a4b66d6128 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/PluginWrapperApi.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/PluginWrapperApi.ts @@ -15,7 +15,7 @@ */ import { ComponentType, ReactNode } from 'react'; -import { ApiRef, createApiRef } from '../system'; +import { createApiRef } from '@backstage/frontend-plugin-api'; /** * The Plugin Wrapper API is used to wrap plugin extensions with providers, @@ -27,7 +27,7 @@ import { ApiRef, createApiRef } from '../system'; * system, but can be used for advanced use-cases. If you do override it, be * sure to include the default implementation as well. * - * @public + * @alpha */ export type PluginWrapperApi = { /** @@ -43,8 +43,8 @@ export type PluginWrapperApi = { /** * The API reference of {@link PluginWrapperApi}. * - * @public + * @alpha */ -export const pluginWrapperApiRef: ApiRef = createApiRef({ - id: 'core.plugin-wrapper', +export const pluginWrapperApiRef = createApiRef({ + id: 'core.plugin-wrapper.alpha', }); diff --git a/packages/frontend-plugin-api/src/apis/definitions/index.ts b/packages/frontend-plugin-api/src/apis/definitions/index.ts index 7b90d38092..5e7f713fcf 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/index.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/index.ts @@ -49,4 +49,3 @@ export * from './RouteResolutionApi'; export * from './StorageApi'; export * from './AnalyticsApi'; export * from './TranslationApi'; -export * from './PluginWrapperApi'; diff --git a/packages/frontend-plugin-api/src/blueprints/AppRootWrapperBlueprint.tsx b/packages/frontend-plugin-api/src/blueprints/AppRootWrapperBlueprint.tsx index 62268bce77..0fbefa7d08 100644 --- a/packages/frontend-plugin-api/src/blueprints/AppRootWrapperBlueprint.tsx +++ b/packages/frontend-plugin-api/src/blueprints/AppRootWrapperBlueprint.tsx @@ -27,10 +27,11 @@ const componentDataRef = createExtensionDataRef< * and similar. * * @public - * @deprecated Use {@link PluginWrapperBlueprint} instead if you want to wrap - * all plugin components in the same wrapper. If you want to wrap the entire - * app, use the `AppRootWrapperBlueprint` from `@backstage/plugin-app-react` - * instead, although note that only app modules are able to use that blueprint. + * @deprecated Use `PluginWrapperBlueprint` from + * `@backstage/frontend-plugin-api/alpha` instead if you want to wrap all plugin + * components in the same wrapper. If you want to wrap the entire app, use the + * `AppRootWrapperBlueprint` from `@backstage/plugin-app-react` instead, + * although note that only app modules are able to use that blueprint. */ export const AppRootWrapperBlueprint = createExtensionBlueprint({ kind: 'app-root-wrapper', diff --git a/packages/frontend-plugin-api/src/blueprints/PluginWrapperBlueprint.tsx b/packages/frontend-plugin-api/src/blueprints/PluginWrapperBlueprint.tsx index a005342b31..4bec4a4390 100644 --- a/packages/frontend-plugin-api/src/blueprints/PluginWrapperBlueprint.tsx +++ b/packages/frontend-plugin-api/src/blueprints/PluginWrapperBlueprint.tsx @@ -28,7 +28,7 @@ const wrapperDataRef = createExtensionDataRef< /** * Creates extensions that wrap plugin extensions with providers. * - * @public + * @alpha */ export const PluginWrapperBlueprint = createExtensionBlueprint({ kind: 'plugin-wrapper', diff --git a/packages/frontend-plugin-api/src/blueprints/index.ts b/packages/frontend-plugin-api/src/blueprints/index.ts index 1d7e6eb478..379c8d237b 100644 --- a/packages/frontend-plugin-api/src/blueprints/index.ts +++ b/packages/frontend-plugin-api/src/blueprints/index.ts @@ -21,7 +21,6 @@ export { export { ApiBlueprint } from './ApiBlueprint'; export { AppRootElementBlueprint } from './AppRootElementBlueprint'; export { AppRootWrapperBlueprint } from './AppRootWrapperBlueprint'; -export { PluginWrapperBlueprint } from './PluginWrapperBlueprint'; export { IconBundleBlueprint } from './IconBundleBlueprint'; export { NavContentBlueprint, diff --git a/plugins/app/src/apis/PluginWrapperApi/DefaultPluginWrapperApi.tsx b/plugins/app/src/apis/PluginWrapperApi/DefaultPluginWrapperApi.tsx index 2bf2744f21..65c41ee2e2 100644 --- a/plugins/app/src/apis/PluginWrapperApi/DefaultPluginWrapperApi.tsx +++ b/plugins/app/src/apis/PluginWrapperApi/DefaultPluginWrapperApi.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { PluginWrapperApi } from '@backstage/frontend-plugin-api'; +import { PluginWrapperApi } from '@backstage/frontend-plugin-api/alpha'; import { ComponentType, ReactNode, useEffect, useState } from 'react'; type WrapperInput = { diff --git a/plugins/app/src/extensions/PluginWrapperApi.ts b/plugins/app/src/extensions/PluginWrapperApi.ts index 14e41891a0..e402f094f1 100644 --- a/plugins/app/src/extensions/PluginWrapperApi.ts +++ b/plugins/app/src/extensions/PluginWrapperApi.ts @@ -16,9 +16,11 @@ import { PluginWrapperBlueprint, + pluginWrapperApiRef, +} from '@backstage/frontend-plugin-api/alpha'; +import { createExtensionInput, ApiBlueprint, - pluginWrapperApiRef, } from '@backstage/frontend-plugin-api'; import { DefaultPluginWrapperApi } from '../apis/PluginWrapperApi'; From 3799135578694b957ca2395f1f471fec68749445 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 17 Jan 2026 01:09:55 +0100 Subject: [PATCH 09/13] Update docs/frontend-system/building-plugins/03-common-extension-blueprints.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Patrik Oldsberg --- .../building-plugins/03-common-extension-blueprints.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/frontend-system/building-plugins/03-common-extension-blueprints.md b/docs/frontend-system/building-plugins/03-common-extension-blueprints.md index 9989191181..aff66f3cc6 100644 --- a/docs/frontend-system/building-plugins/03-common-extension-blueprints.md +++ b/docs/frontend-system/building-plugins/03-common-extension-blueprints.md @@ -27,7 +27,7 @@ Page extensions provide content for a particular route in the app. By default pa ### Plugin Wrapper - [Reference](https://backstage.io/api/stable/variables/_backstage_frontend-plugin-api.packages-frontend-plugin-api_src_alpha.PluginWrapperBlueprint.html) -Plugin wrappers allow you to install components that will wrap all elements rendered as part of a plugin. This can be useful if you for example need to add a global provider for example for a query client. The provided wrapper will be rendered as separate elements for each wrapped plugin element, so be sure to use a central store like a [Utility API](../utility-apis/01-index.md) if you want to share state between wrapper instances. +Plugin wrappers allow you to install components that will wrap all elements rendered as part of a plugin. This can be useful if you need to add a global provider, for example for a query client. The provided wrapper will be rendered as separate elements for each wrapped plugin element, so be sure to use a central store like a [Utility API](../utility-apis/01-index.md) if you want to share state between wrapper instances. ## Extension blueprints in `@backstage/plugin-app-react` From 85c8156984e5ab75c8670da33a2aff75c92b196a Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 18 Jan 2026 13:58:01 +0100 Subject: [PATCH 10/13] frontend-plugin-api: simplify deprecation of AppRootWrapperBlueprint Signed-off-by: Patrik Oldsberg --- .changeset/common-heads-start.md | 1 + .changeset/fine-ears-yell.md | 5 - .changeset/moody-wasps-travel.md | 2 +- .changeset/slick-beans-relax.md | 1 + .changeset/violet-otters-share.md | 2 +- .changeset/warm-pets-accept.md | 1 + .../blueprints/AppRootWrapperBlueprint.tsx | 8 +- plugins/app-react/report.api.md | 31 ++-- .../AppRootWrapperBlueprint.test.tsx | 138 ------------------ .../blueprints/AppRootWrapperBlueprint.tsx | 51 ------- plugins/app-react/src/blueprints/index.ts | 9 +- plugins/app/report.api.md | 21 +-- plugins/app/src/extensions/AppRoot.tsx | 25 +--- 13 files changed, 48 insertions(+), 247 deletions(-) delete mode 100644 .changeset/fine-ears-yell.md delete mode 100644 plugins/app-react/src/blueprints/AppRootWrapperBlueprint.test.tsx delete mode 100644 plugins/app-react/src/blueprints/AppRootWrapperBlueprint.tsx diff --git a/.changeset/common-heads-start.md b/.changeset/common-heads-start.md index 15ef218b12..2b0d5c473d 100644 --- a/.changeset/common-heads-start.md +++ b/.changeset/common-heads-start.md @@ -4,6 +4,7 @@ Moved the following blueprints from `@backstage/frontend-plugin-api`: +- `AppRootWrapperBlueprint` - `IconBundleBlueprint` - `NavContentBlueprint` - `RouterBlueprint` diff --git a/.changeset/fine-ears-yell.md b/.changeset/fine-ears-yell.md deleted file mode 100644 index 06c5df58c4..0000000000 --- a/.changeset/fine-ears-yell.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-app-react': minor ---- - -Added `AppRootWrapperBlueprint` that replaces the blueprint with the same name that was deprecated in `@backstage/frontend-plugin-api`. These blueprints are not directly interchangeable, the new one exported from this package can only be used in `app` plugin modules and is restricted from use in other plugins. diff --git a/.changeset/moody-wasps-travel.md b/.changeset/moody-wasps-travel.md index fdb46dcc2d..30a526345d 100644 --- a/.changeset/moody-wasps-travel.md +++ b/.changeset/moody-wasps-travel.md @@ -2,4 +2,4 @@ '@backstage/plugin-app': patch --- -Implemented support for the new `PluginWrapperBlueprint` from `@backstage/frontend-plugin-api` and `AppRootWrapperBlueprint` from the new `@backstage/plugin-app-react` library. +Implemented support for the new `PluginWrapperBlueprint` from `@backstage/frontend-plugin-api/alpha`. diff --git a/.changeset/slick-beans-relax.md b/.changeset/slick-beans-relax.md index b4fc3c3175..1bc6443d80 100644 --- a/.changeset/slick-beans-relax.md +++ b/.changeset/slick-beans-relax.md @@ -4,6 +4,7 @@ The following blueprints are being restricted to only be used in app plugin overrides and modules. They are being moved to the `@backstage/plugin-app-react` package and have been deprecated: +- `AppRootWrapperBlueprint` - `IconBundleBlueprint` - `NavContentBlueprint` - `RouterBlueprint` diff --git a/.changeset/violet-otters-share.md b/.changeset/violet-otters-share.md index e60ba5041d..85f0aaf028 100644 --- a/.changeset/violet-otters-share.md +++ b/.changeset/violet-otters-share.md @@ -2,4 +2,4 @@ '@backstage/frontend-plugin-api': patch --- -Added an alpha `PluginWrapperBlueprint` exported from `@backstage/frontend-plugin-api/alpha`, which can install components that will wrap all plugin elements. The `AppRootWrapperBlueprint` has also been deprecated and should be replaced either with the new plugin wrapper, or for app overrides, the new blueprint with the same name from `@backstage/plugin-app-react`. +Added an alpha `PluginWrapperBlueprint` exported from `@backstage/frontend-plugin-api/alpha`, which can install components that will wrap all plugin elements. diff --git a/.changeset/warm-pets-accept.md b/.changeset/warm-pets-accept.md index 5bf62cbb1a..5fc7bcd6d3 100644 --- a/.changeset/warm-pets-accept.md +++ b/.changeset/warm-pets-accept.md @@ -4,6 +4,7 @@ The following blueprints are being restricted to only be used in app plugin overrides and modules. They will now produce a deprecation warning when used outside of the app plugin: +- `AppRootWrapperBlueprint` - `IconBundleBlueprint` - `NavContentBlueprint` - `RouterBlueprint` diff --git a/packages/frontend-plugin-api/src/blueprints/AppRootWrapperBlueprint.tsx b/packages/frontend-plugin-api/src/blueprints/AppRootWrapperBlueprint.tsx index 0fbefa7d08..184a8c3327 100644 --- a/packages/frontend-plugin-api/src/blueprints/AppRootWrapperBlueprint.tsx +++ b/packages/frontend-plugin-api/src/blueprints/AppRootWrapperBlueprint.tsx @@ -27,11 +27,9 @@ const componentDataRef = createExtensionDataRef< * and similar. * * @public - * @deprecated Use `PluginWrapperBlueprint` from - * `@backstage/frontend-plugin-api/alpha` instead if you want to wrap all plugin - * components in the same wrapper. If you want to wrap the entire app, use the - * `AppRootWrapperBlueprint` from `@backstage/plugin-app-react` instead, - * although note that only app modules are able to use that blueprint. + * @deprecated Use {@link @backstage/plugin-app-react#AppRootWrapperBlueprint} instead. + * If you were using this blueprint to provide a context for your plugin, + * use `PluginWrapperBlueprint` from `@backstage/frontend-plugin-api/alpha` instead. */ export const AppRootWrapperBlueprint = createExtensionBlueprint({ kind: 'app-root-wrapper', diff --git a/plugins/app-react/report.api.md b/plugins/app-react/report.api.md index 415ffe51bd..251152a9e6 100644 --- a/plugins/app-react/report.api.md +++ b/plugins/app-react/report.api.md @@ -22,13 +22,12 @@ import { TranslationResource } from '@backstage/frontend-plugin-api'; export const AppRootWrapperBlueprint: ExtensionBlueprint<{ kind: 'app-root-wrapper'; params: { + Component?: [error: 'Use the `component` parameter instead']; component: (props: { children: ReactNode }) => JSX.Element | null; }; output: ExtensionDataRef< - ComponentType<{ - children: ReactNode; - }>, - 'app.root-wrapper-component', + (props: { children: ReactNode }) => JSX.Element | null, + 'app.root.wrapper', {} >; inputs: {}; @@ -36,10 +35,8 @@ export const AppRootWrapperBlueprint: ExtensionBlueprint<{ configInput: {}; dataRefs: { component: ConfigurableExtensionDataRef< - ComponentType<{ - children: ReactNode; - }>, - 'app.root-wrapper-component', + (props: { children: ReactNode }) => JSX.Element | null, + 'app.root.wrapper', {} >; }; @@ -163,7 +160,14 @@ export const SwappableComponentBlueprint: ExtensionBlueprint<{ loader: Ref extends SwappableComponentRef ? | (() => (props: IInnerComponentProps) => JSX.Element | null) - | (() => Promise<(props: IInnerComponentProps) => JSX.Element | null>) + | (() => Promise< + (props: IInnerComponentProps) => JSX.Element + /** + * Creates an extension that replaces the router component. This blueprint is limited to use by the app plugin. + * + * @public + */ | null + >) : never; }) => ExtensionBlueprintParams<{ component: Ref extends SwappableComponentRef< @@ -177,7 +181,14 @@ export const SwappableComponentBlueprint: ExtensionBlueprint<{ loader: Ref extends SwappableComponentRef ? | (() => (props: IInnerComponentProps) => JSX.Element | null) - | (() => Promise<(props: IInnerComponentProps) => JSX.Element | null>) + | (() => Promise< + (props: IInnerComponentProps) => JSX.Element + /** + * Creates an extension that replaces the router component. This blueprint is limited to use by the app plugin. + * + * @public + */ | null + >) : never; }>; output: ExtensionDataRef< diff --git a/plugins/app-react/src/blueprints/AppRootWrapperBlueprint.test.tsx b/plugins/app-react/src/blueprints/AppRootWrapperBlueprint.test.tsx deleted file mode 100644 index 32aaeddeba..0000000000 --- a/plugins/app-react/src/blueprints/AppRootWrapperBlueprint.test.tsx +++ /dev/null @@ -1,138 +0,0 @@ -/* - * Copyright 2024 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 { Fragment } from 'react'; -import { AppRootWrapperBlueprint } from './AppRootWrapperBlueprint'; -import { screen, waitFor } from '@testing-library/react'; -import { - coreExtensionData, - createExtension, - createExtensionInput, - createFrontendModule, -} from '@backstage/frontend-plugin-api'; -import { renderTestApp } from '@backstage/frontend-test-utils'; - -describe('AppRootWrapperBlueprint', () => { - it('should return an extension with sensible defaults', () => { - const extension = AppRootWrapperBlueprint.make({ - params: { - component: () =>
Hello
, - }, - }); - - expect(extension).toMatchInlineSnapshot(` - { - "$$type": "@backstage/ExtensionDefinition", - "T": undefined, - "attachTo": { - "id": "app/root", - "input": "wrappers", - }, - "configSchema": undefined, - "disabled": false, - "factory": [Function], - "inputs": {}, - "kind": "app-root-wrapper", - "name": undefined, - "output": [ - [Function], - ], - "override": [Function], - "toString": [Function], - "version": "v2", - } - `); - }); - - it('should render a simple component wrapper', async () => { - const extension = AppRootWrapperBlueprint.make({ - name: 'test', - params: { - component: () =>
Hello
, - }, - }); - - renderTestApp({ - features: [ - createFrontendModule({ - pluginId: 'app', - extensions: [extension], - }), - ], - }); - - await waitFor(() => expect(screen.getByText('Hello')).toBeInTheDocument()); - }); - - it('should render a complex component wrapper', async () => { - const extension = AppRootWrapperBlueprint.makeWithOverrides({ - config: { - schema: { - name: z => z.string(), - }, - }, - inputs: { - children: createExtensionInput([coreExtensionData.reactElement]), - }, - *factory(originalFactory, { inputs, config }) { - yield* originalFactory({ - component: ({ children }) => ( -
- {inputs.children.flatMap((c, index) => ( - - {c.get(coreExtensionData.reactElement)} - - ))} - {children} -
- ), - }); - }, - }); - - renderTestApp({ - features: [ - createFrontendModule({ - pluginId: 'app', - extensions: [ - extension, - createExtension({ - name: 'test-child', - attachTo: extension.inputs.children, - output: [coreExtensionData.reactElement], - factory: () => [ - coreExtensionData.reactElement(
Its Me
), - ], - }), - ], - }), - ], - config: { - app: { - extensions: [ - { - 'app-root-wrapper:app': { config: { name: 'Robin' } }, - }, - ], - }, - }, - }); - - await waitFor(() => { - expect(screen.getByTestId('Robin-1')).toBeInTheDocument(); - expect(screen.getByText('Its Me')).toBeInTheDocument(); - }); - }); -}); diff --git a/plugins/app-react/src/blueprints/AppRootWrapperBlueprint.tsx b/plugins/app-react/src/blueprints/AppRootWrapperBlueprint.tsx deleted file mode 100644 index 79a35e2bc0..0000000000 --- a/plugins/app-react/src/blueprints/AppRootWrapperBlueprint.tsx +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright 2024 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 { ComponentType, ReactNode } from 'react'; -import { - createExtensionBlueprint, - createExtensionDataRef, -} from '@backstage/frontend-plugin-api'; - -const componentDataRef = createExtensionDataRef< - ComponentType<{ children: ReactNode }> ->().with({ id: 'app.root-wrapper-component' }); - -/** - * Creates an extension that renders a React wrapper at the app root, enclosing - * the app layout. - * - * @remarks - * - * This is useful for example for adding global React contexts and similar. This - * extension is only available for use by app modules, plugins should use the - * {@link @backstage/frontend-plugin-api#PluginWrapperBlueprint} instead. - * - * @public - */ -export const AppRootWrapperBlueprint = createExtensionBlueprint({ - kind: 'app-root-wrapper', - attachTo: { id: 'app/root', input: 'wrappers' }, - output: [componentDataRef], - dataRefs: { - component: componentDataRef, - }, - *factory(params: { - component: (props: { children: ReactNode }) => JSX.Element | null; - }) { - yield componentDataRef(params.component); - }, -}); diff --git a/plugins/app-react/src/blueprints/index.ts b/plugins/app-react/src/blueprints/index.ts index cf036aa060..f5e4d7ac4a 100644 --- a/plugins/app-react/src/blueprints/index.ts +++ b/plugins/app-react/src/blueprints/index.ts @@ -15,6 +15,7 @@ */ import { + AppRootWrapperBlueprint as _AppRootWrapperBlueprint, IconBundleBlueprint as _IconBundleBlueprint, NavContentBlueprint as _NavContentBlueprint, type NavContentComponent, @@ -27,7 +28,13 @@ import { TranslationBlueprint as _TranslationBlueprint, } from '@backstage/frontend-plugin-api'; -export { AppRootWrapperBlueprint } from './AppRootWrapperBlueprint'; +/** + * Creates an extension that renders a React wrapper at the app root, enclosing + * the app layout. This blueprint is limited to use by the app plugin. + * + * @public + */ +export const AppRootWrapperBlueprint = _AppRootWrapperBlueprint; /** * Creates an extension that adds/replaces an app theme. This blueprint is limited to use by the app plugin. diff --git a/plugins/app/report.api.md b/plugins/app/report.api.md index 57103b8000..ea34a1731f 100644 --- a/plugins/app/report.api.md +++ b/plugins/app/report.api.md @@ -149,22 +149,11 @@ const appPlugin: OverridableFrontendPlugin< } >; wrappers: ExtensionInput< - | ConfigurableExtensionDataRef< - (props: { children: ReactNode }) => JSX.Element | null, - 'app.root.wrapper', - { - optional: true; - } - > - | ConfigurableExtensionDataRef< - ComponentType<{ - children: ReactNode; - }>, - 'app.root-wrapper-component', - { - optional: true; - } - >, + ConfigurableExtensionDataRef< + (props: { children: ReactNode }) => JSX.Element | null, + 'app.root.wrapper', + {} + >, { singleton: false; optional: false; diff --git a/plugins/app/src/extensions/AppRoot.tsx b/plugins/app/src/extensions/AppRoot.tsx index e51c63ca28..c9f3035e5f 100644 --- a/plugins/app/src/extensions/AppRoot.tsx +++ b/plugins/app/src/extensions/AppRoot.tsx @@ -22,7 +22,7 @@ import { JSX, } from 'react'; import { - AppRootWrapperBlueprint as OldAppRootWrapperBlueprint, + AppRootWrapperBlueprint, RouterBlueprint, SignInPageBlueprint, coreExtensionData, @@ -44,7 +44,6 @@ import { identityApiRef, useApi, } from '@backstage/core-plugin-api'; -import { AppRootWrapperBlueprint } from '@backstage/plugin-app-react'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports import { isProtectedApp } from '../../../../packages/core-app-api/src/app/isProtectedApp'; import { BrowserRouter } from 'react-router-dom'; @@ -70,8 +69,7 @@ export const AppRoot = createExtension({ }), elements: createExtensionInput([coreExtensionData.reactElement]), wrappers: createExtensionInput([ - OldAppRootWrapperBlueprint.dataRefs.component.optional(), - AppRootWrapperBlueprint.dataRefs.component.optional(), + AppRootWrapperBlueprint.dataRefs.component, ]), }, output: [coreExtensionData.reactElement], @@ -121,25 +119,14 @@ export const AppRoot = createExtension({ const Component = wrapper.get(AppRootWrapperBlueprint.dataRefs.component); const pluginId = wrapper.node.spec.plugin.id; if (Component) { - if (pluginId === 'app') { - content = {content}; - } else { + content = {content}; + if (pluginId !== 'app') { // eslint-disable-next-line no-console console.warn( - `Warning: The app root wrapper extension from the '${pluginId}' plugin was ignored, only the 'app' plugin is allowed to provide app root wrappers.`, + `DEPRECATION WARNING: AppRootWrappers should only be installed as an extension in the app plugin. ` + + `You can either use appPlugin.override(), or a module for the app plugin. The following extension will be ignored in the future: ${wrapper.node.spec.id}`, ); } - } else { - const OldComponent = wrapper.get( - OldAppRootWrapperBlueprint.dataRefs.component, - ); - if (OldComponent) { - // eslint-disable-next-line no-console - console.warn( - `DEPRECATION WARNING: The ${pluginId} plugin provided a deprecated app root wrapper extension using the AppRootWrapperBlueprint from @backstage/frontend-plugin-api. This will break in a future release.`, - ); - content = {content}; - } } } From f9d575635408d1af263b7b9d320147d1b3db30b5 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 19 Jan 2026 13:48:54 +0100 Subject: [PATCH 11/13] app: add missing plugin wrapper API Signed-off-by: Patrik Oldsberg --- .../frontend-defaults/src/createApp.test.tsx | 1 + plugins/app/report.api.md | 31 +++++++++++++++++++ plugins/app/src/plugin.ts | 2 ++ 3 files changed, 34 insertions(+) diff --git a/packages/frontend-defaults/src/createApp.test.tsx b/packages/frontend-defaults/src/createApp.test.tsx index 7b5e345e12..c0928e7815 100644 --- a/packages/frontend-defaults/src/createApp.test.tsx +++ b/packages/frontend-defaults/src/createApp.test.tsx @@ -392,6 +392,7 @@ describe('createApp', () => { + ] diff --git a/plugins/app/report.api.md b/plugins/app/report.api.md index ea34a1731f..c5ee77ef01 100644 --- a/plugins/app/report.api.md +++ b/plugins/app/report.api.md @@ -574,6 +574,37 @@ const appPlugin: OverridableFrontendPlugin< params: ApiFactory, ) => ExtensionBlueprintParams; }>; + 'api:app/plugin-wrapper': OverridableExtensionDefinition<{ + config: {}; + configInput: {}; + output: ExtensionDataRef; + inputs: { + wrappers: ExtensionInput< + ConfigurableExtensionDataRef< + () => Promise<{ + component: ComponentType<{ + children: ReactNode; + }>; + }>, + 'core.plugin-wrapper.loader', + {} + >, + { + singleton: false; + optional: false; + } + >; + }; + kind: 'api'; + name: 'plugin-wrapper'; + params: < + TApi, + TImpl extends TApi, + TDeps extends { [name in string]: unknown }, + >( + params: ApiFactory, + ) => ExtensionBlueprintParams; + }>; 'api:app/scm-auth': OverridableExtensionDefinition<{ kind: 'api'; name: 'scm-auth'; diff --git a/plugins/app/src/plugin.ts b/plugins/app/src/plugin.ts index c9deddb4f9..ec0c627f47 100644 --- a/plugins/app/src/plugin.ts +++ b/plugins/app/src/plugin.ts @@ -28,6 +28,7 @@ import { SwappableComponentsApi, IconsApi, FeatureFlagsApi, + PluginWrapperApi, TranslationsApi, oauthRequestDialogAppRootElement, alertDisplayAppRootElement, @@ -58,6 +59,7 @@ export const appPlugin = createFrontendPlugin({ SwappableComponentsApi, IconsApi, FeatureFlagsApi, + PluginWrapperApi, TranslationsApi, DefaultSignInPage, oauthRequestDialogAppRootElement, From 768b588203e47c264eb953a809ecbdb143ae600c Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 19 Jan 2026 14:43:15 +0100 Subject: [PATCH 12/13] frontend-plugin-api: remove redundant cast Signed-off-by: Patrik Oldsberg --- .../src/blueprints/PluginWrapperBlueprint.tsx | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/frontend-plugin-api/src/blueprints/PluginWrapperBlueprint.tsx b/packages/frontend-plugin-api/src/blueprints/PluginWrapperBlueprint.tsx index 4bec4a4390..f6f673bd93 100644 --- a/packages/frontend-plugin-api/src/blueprints/PluginWrapperBlueprint.tsx +++ b/packages/frontend-plugin-api/src/blueprints/PluginWrapperBlueprint.tsx @@ -42,13 +42,7 @@ export const PluginWrapperBlueprint = createExtensionBlueprint({ component: ComponentType<{ children: ReactNode }>; }>; }) { - return createExtensionBlueprintParams( - params as { - loader: () => Promise<{ - component: ComponentType<{ children: ReactNode }>; - }>; - }, - ); + return createExtensionBlueprintParams(params); }, *factory(params) { yield wrapperDataRef(params.loader); From a1be5e1770253b5c04232a9d56c4fcda08828fcf Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 20 Jan 2026 00:11:46 +0100 Subject: [PATCH 13/13] review fixes for plugin wrapper blueprint Signed-off-by: Patrik Oldsberg --- packages/frontend-plugin-api/src/alpha.ts | 6 ------ .../DefaultPluginWrapperApi.tsx | 20 ++++++++++--------- 2 files changed, 11 insertions(+), 15 deletions(-) diff --git a/packages/frontend-plugin-api/src/alpha.ts b/packages/frontend-plugin-api/src/alpha.ts index 9d73b8cc84..2251bfafb7 100644 --- a/packages/frontend-plugin-api/src/alpha.ts +++ b/packages/frontend-plugin-api/src/alpha.ts @@ -14,12 +14,6 @@ * limitations under the License. */ -/** - * Alpha exports for plugin wrapper functionality. - * - * @alpha - */ - export { PluginWrapperBlueprint } from './blueprints/PluginWrapperBlueprint'; export { type PluginWrapperApi, diff --git a/plugins/app/src/apis/PluginWrapperApi/DefaultPluginWrapperApi.tsx b/plugins/app/src/apis/PluginWrapperApi/DefaultPluginWrapperApi.tsx index 65c41ee2e2..7e602ee23f 100644 --- a/plugins/app/src/apis/PluginWrapperApi/DefaultPluginWrapperApi.tsx +++ b/plugins/app/src/apis/PluginWrapperApi/DefaultPluginWrapperApi.tsx @@ -15,7 +15,7 @@ */ import { PluginWrapperApi } from '@backstage/frontend-plugin-api/alpha'; -import { ComponentType, ReactNode, useEffect, useState } from 'react'; +import { ComponentType, ReactNode, useEffect, useMemo, useState } from 'react'; type WrapperInput = { loader: () => Promise<{ component: ComponentType<{ children: ReactNode }> }>; @@ -86,17 +86,19 @@ export class DefaultPluginWrapperApi implements PluginWrapperApi { throw error; } - if (!loadedWrappers) { - return null; - } + return useMemo(() => { + if (!loadedWrappers) { + return null; + } - let content = props.children; + let current = props.children; - for (const Wrapper of loadedWrappers) { - content = {content}; - } + for (const Wrapper of loadedWrappers) { + current = {current}; + } - return <>{content}; + return current; + }, [loadedWrappers, props.children]); }; composedWrappers.set(pluginId, ComposedWrapper);