frontend-plugin-api: simplify deprecation of AppRootWrapperBlueprint

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2026-01-18 13:58:01 +01:00
parent 3799135578
commit 85c8156984
13 changed files with 48 additions and 247 deletions
+1
View File
@@ -4,6 +4,7 @@
Moved the following blueprints from `@backstage/frontend-plugin-api`:
- `AppRootWrapperBlueprint`
- `IconBundleBlueprint`
- `NavContentBlueprint`
- `RouterBlueprint`
-5
View File
@@ -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.
+1 -1
View File
@@ -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`.
+1
View File
@@ -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`
+1 -1
View File
@@ -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.
+1
View File
@@ -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`
@@ -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',
+21 -10
View File
@@ -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<infer IInnerComponentProps, any>
?
| (() => (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<infer IInnerComponentProps, any>
?
| (() => (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<
@@ -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: () => <div>Hello</div>,
},
});
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: () => <div>Hello</div>,
},
});
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 }) => (
<div data-testid={`${config.name}-${inputs.children.length}`}>
{inputs.children.flatMap((c, index) => (
<Fragment key={index}>
{c.get(coreExtensionData.reactElement)}
</Fragment>
))}
{children}
</div>
),
});
},
});
renderTestApp({
features: [
createFrontendModule({
pluginId: 'app',
extensions: [
extension,
createExtension({
name: 'test-child',
attachTo: extension.inputs.children,
output: [coreExtensionData.reactElement],
factory: () => [
coreExtensionData.reactElement(<div>Its Me</div>),
],
}),
],
}),
],
config: {
app: {
extensions: [
{
'app-root-wrapper:app': { config: { name: 'Robin' } },
},
],
},
},
});
await waitFor(() => {
expect(screen.getByTestId('Robin-1')).toBeInTheDocument();
expect(screen.getByText('Its Me')).toBeInTheDocument();
});
});
});
@@ -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);
},
});
+8 -1
View File
@@ -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.
+5 -16
View File
@@ -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;
+6 -19
View File
@@ -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 = <Component>{content}</Component>;
} else {
content = <Component>{content}</Component>;
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 = <OldComponent>{content}</OldComponent>;
}
}
}