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 [