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/moody-wasps-travel.md b/.changeset/moody-wasps-travel.md
new file mode 100644
index 0000000000..30a526345d
--- /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/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
new file mode 100644
index 0000000000..85f0aaf028
--- /dev/null
+++ b/.changeset/violet-otters-share.md
@@ -0,0 +1,5 @@
+---
+'@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.
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/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..aff66f3cc6 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 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)
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/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 ceba08d7c3..1a7f2e3c7a 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/alpha.ts b/packages/frontend-plugin-api/src/alpha.ts
new file mode 100644
index 0000000000..2251bfafb7
--- /dev/null
+++ b/packages/frontend-plugin-api/src/alpha.ts
@@ -0,0 +1,21 @@
+/*
+ * 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 { 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
new file mode 100644
index 0000000000..a4b66d6128
--- /dev/null
+++ b/packages/frontend-plugin-api/src/apis/definitions/PluginWrapperApi.ts
@@ -0,0 +1,50 @@
+/*
+ * 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 { createApiRef } from '@backstage/frontend-plugin-api';
+
+/**
+ * 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.
+ *
+ * @alpha
+ */
+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}.
+ *
+ * @alpha
+ */
+export const pluginWrapperApiRef = createApiRef({
+ id: 'core.plugin-wrapper.alpha',
+});
diff --git a/packages/frontend-plugin-api/src/blueprints/AppRootWrapperBlueprint.tsx b/packages/frontend-plugin-api/src/blueprints/AppRootWrapperBlueprint.tsx
index 493fba05f6..184a8c3327 100644
--- a/packages/frontend-plugin-api/src/blueprints/AppRootWrapperBlueprint.tsx
+++ b/packages/frontend-plugin-api/src/blueprints/AppRootWrapperBlueprint.tsx
@@ -27,6 +27,9 @@ const componentDataRef = createExtensionDataRef<
* and similar.
*
* @public
+ * @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/packages/frontend-plugin-api/src/blueprints/PluginWrapperBlueprint.tsx b/packages/frontend-plugin-api/src/blueprints/PluginWrapperBlueprint.tsx
new file mode 100644
index 0000000000..f6f673bd93
--- /dev/null
+++ b/packages/frontend-plugin-api/src/blueprints/PluginWrapperBlueprint.tsx
@@ -0,0 +1,50 @@
+/*
+ * 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.
+ *
+ * @alpha
+ */
+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);
+ },
+ *factory(params) {
+ yield wrapperDataRef(params.loader);
+ },
+});
diff --git a/packages/frontend-plugin-api/src/components/ExtensionBoundary.test.tsx b/packages/frontend-plugin-api/src/components/ExtensionBoundary.test.tsx
index ae741f8922..c0068e8f7d 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,85 @@ 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');
+ });
+
+ 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 () => {
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-react/report.api.md b/plugins/app-react/report.api.md
index 48ec50749d..251152a9e6 100644
--- a/plugins/app-react/report.api.md
+++ b/plugins/app-react/report.api.md
@@ -18,6 +18,30 @@ 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?: [error: 'Use the `component` parameter instead'];
+ component: (props: { children: ReactNode }) => JSX.Element | null;
+ };
+ output: ExtensionDataRef<
+ (props: { children: ReactNode }) => JSX.Element | null,
+ 'app.root.wrapper',
+ {}
+ >;
+ inputs: {};
+ config: {};
+ configInput: {};
+ dataRefs: {
+ component: ConfigurableExtensionDataRef<
+ (props: { children: ReactNode }) => JSX.Element | null,
+ 'app.root.wrapper',
+ {}
+ >;
+ };
+}>;
+
// @public
export const IconBundleBlueprint: ExtensionBlueprint<{
kind: 'icon-bundle';
@@ -136,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<
@@ -150,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/index.ts b/plugins/app-react/src/blueprints/index.ts
index 09d05b71ed..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,6 +28,14 @@ import {
TranslationBlueprint as _TranslationBlueprint,
} from '@backstage/frontend-plugin-api';
+/**
+ * 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 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/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..7e602ee23f
--- /dev/null
+++ b/plugins/app/src/apis/PluginWrapperApi/DefaultPluginWrapperApi.tsx
@@ -0,0 +1,109 @@
+/*
+ * 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/alpha';
+import { ComponentType, ReactNode, useEffect, useMemo, 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;
+ }
+
+ return useMemo(() => {
+ if (!loadedWrappers) {
+ return null;
+ }
+
+ let current = props.children;
+
+ for (const Wrapper of loadedWrappers) {
+ current = {current};
+ }
+
+ return current;
+ }, [loadedWrappers, props.children]);
+ };
+
+ 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/AppRoot.tsx b/plugins/app/src/extensions/AppRoot.tsx
index 47ea50a641..c9f3035e5f 100644
--- a/plugins/app/src/extensions/AppRoot.tsx
+++ b/plugins/app/src/extensions/AppRoot.tsx
@@ -117,7 +117,17 @@ 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) {
+ content = {content};
+ if (pluginId !== 'app') {
+ // eslint-disable-next-line no-console
+ console.warn(
+ `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}`,
+ );
+ }
+ }
}
return [
diff --git a/plugins/app/src/extensions/PluginWrapperApi.ts b/plugins/app/src/extensions/PluginWrapperApi.ts
new file mode 100644
index 0000000000..e402f094f1
--- /dev/null
+++ b/plugins/app/src/extensions/PluginWrapperApi.ts
@@ -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 {
+ PluginWrapperBlueprint,
+ pluginWrapperApiRef,
+} from '@backstage/frontend-plugin-api/alpha';
+import {
+ createExtensionInput,
+ ApiBlueprint,
+} 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';
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,