diff --git a/.changeset/component-refs-app.md b/.changeset/component-refs-app.md
new file mode 100644
index 0000000000..5a7b6723bf
--- /dev/null
+++ b/.changeset/component-refs-app.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-app': patch
+---
+
+Default implementations of core components are now provided by this package.
diff --git a/.changeset/component-refs-breaking-app.md b/.changeset/component-refs-breaking-app.md
new file mode 100644
index 0000000000..9ba6307f60
--- /dev/null
+++ b/.changeset/component-refs-breaking-app.md
@@ -0,0 +1,15 @@
+---
+'@backstage/plugin-app': minor
+---
+
+**BREAKING**: The `componentsApi` implementation has been removed from the plugin and replaced with the new `SwappableComponentsApi` instead.
+
+If you were overriding the `componentsApi` implementation, you can now use the new `SwappableComponentsApi` instead.
+
+```ts
+// old
+appPlugin.getExtension('api:app/components').override(...)
+
+// new
+appPlugin.getExtension('api:app/swappable-components').override(...)
+```
diff --git a/.changeset/component-refs-breaking-frontend-plugin-api.md b/.changeset/component-refs-breaking-frontend-plugin-api.md
new file mode 100644
index 0000000000..47c40865d3
--- /dev/null
+++ b/.changeset/component-refs-breaking-frontend-plugin-api.md
@@ -0,0 +1,96 @@
+---
+'@backstage/frontend-plugin-api': minor
+---
+
+**BREAKING**: The component system has been overhauled to use `SwappableComponent` instead of `ComponentRef`. Several APIs have been removed and replaced:
+
+- Removed: `createComponentRef`, `createComponentExtension`, `ComponentRef`, `ComponentsApi`, `componentsApiRef`, `useComponentRef`, `coreComponentRefs`
+- Added: `createSwappableComponent`, `SwappableComponentBlueprint`, `SwappableComponentRef`, `SwappableComponentsApi`, `swappableComponentsApiRef`
+
+**BREAKING**: The default `componentRefs` and exported `Core*Props` have been removed and have replacement `SwappableComponents` and revised type names instead.
+
+- The `errorBoundaryFallback` component and `CoreErrorBoundaryFallbackProps` type have been replaced with `ErrorDisplay` swappable component and `CoreErrorDisplayProps` respectively.
+- The `progress` component and `CoreProgressProps` type have been replaced with `Progress` swappable component and `ProgressProps` respectively.
+- The `notFoundErrorPage` component and `CoreNotFoundErrorPageProps` type have been replaced with `NotFoundErrorPage` swappable component and `NotFoundErrorPageProps` respectively.
+
+**Migration for creating swappable components:**
+
+```tsx
+// OLD: Using createComponentRef and createComponentExtension
+import {
+ createComponentRef,
+ createComponentExtension,
+} from '@backstage/frontend-plugin-api';
+
+const myComponentRef = createComponentRef<{ title: string }>({
+ id: 'my-plugin.my-component',
+});
+
+const myComponentExtension = createComponentExtension({
+ ref: myComponentRef,
+ loader: {
+ lazy: () => import('./MyComponent').then(m => m.MyComponent),
+ },
+});
+
+// NEW: Using createSwappableComponent and SwappableComponentBlueprint
+import {
+ createSwappableComponent,
+ SwappableComponentBlueprint,
+} from '@backstage/frontend-plugin-api';
+
+const MySwappableComponent = createSwappableComponent({
+ id: 'my-plugin.my-component',
+ loader: () => import('./MyComponent').then(m => m.MyComponent),
+});
+
+const myComponentExtension = SwappableComponentBlueprint.make({
+ name: 'my-component',
+ params: {
+ component: MySwappableComponent,
+ loader: () => import('./MyComponent').then(m => m.MyComponent),
+ },
+});
+```
+
+**Migration for using components:**
+
+```tsx
+// OLD: Using ComponentsApi and useComponentRef
+import {
+ useComponentRef,
+ componentsApiRef,
+ useApi,
+ coreComponentRefs,
+} from '@backstage/frontend-plugin-api';
+
+const MyComponent = useComponentRef(myComponentRef);
+const ProgressComponent = useComponentRef(coreComponentRefs.progress);
+
+
+// NEW: Direct component usage
+import { Progress } from '@backstage/frontend-plugin-api';
+
+// Use directly as React Component
+
+
+```
+
+**Migration for core component references:**
+
+```tsx
+// OLD: Core component refs
+import { coreComponentRefs } from '@backstage/frontend-plugin-api';
+
+coreComponentRefs.progress
+coreComponentRefs.notFoundErrorPage
+coreComponentRefs.errorBoundaryFallback
+
+// NEW: Direct swappable component imports
+import { Progress, NotFoundErrorPage, ErrorDisplay } from '@backstage/frontend-plugin-api';
+
+// Use directly as React components
+
+
+
+```
diff --git a/.changeset/fuzzy-ducks-jump.md b/.changeset/fuzzy-ducks-jump.md
new file mode 100644
index 0000000000..9aa2288598
--- /dev/null
+++ b/.changeset/fuzzy-ducks-jump.md
@@ -0,0 +1,5 @@
+---
+'@backstage/core-compat-api': minor
+---
+
+**BREAKING**: The `componentsApi` implementation has been removed from the plugin and replaced with the new `SwappableComponentsApi` instead. Which means that the `componentsApi` is not longer backwards compatible with legacy plugins.
diff --git a/.changeset/strong-dogs-raise.md b/.changeset/strong-dogs-raise.md
new file mode 100644
index 0000000000..76220474a9
--- /dev/null
+++ b/.changeset/strong-dogs-raise.md
@@ -0,0 +1,5 @@
+---
+'@backstage/frontend-app-api': patch
+---
+
+Added a default implementation of the `SwappableComponentsApi` and removing the legacy `ComponentsApi` implementation
diff --git a/.github/vale/config/vocabularies/Backstage/accept.txt b/.github/vale/config/vocabularies/Backstage/accept.txt
index 86c9be9b98..5eba84e61d 100644
--- a/.github/vale/config/vocabularies/Backstage/accept.txt
+++ b/.github/vale/config/vocabularies/Backstage/accept.txt
@@ -467,6 +467,7 @@ Superfences
superset
supertype
SVGs
+swappable
talkdesk
Talkdesk
Tanzu
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 e1e903eadd..330da0b97e 100644
--- a/docs/frontend-system/building-plugins/03-common-extension-blueprints.md
+++ b/docs/frontend-system/building-plugins/03-common-extension-blueprints.md
@@ -17,10 +17,6 @@ These are the [extension blueprints](../architecture/23-extension-blueprints.md)
An API extension is used to add or override [Utility API factories](../utility-apis/01-index.md) in the app. They are commonly used by plugins for both internal and shared APIs. There are also many built-in Api extensions provided by the framework that you are able to override.
-### Component - [Reference](../../reference/frontend-plugin-api.createcomponentextension.md)
-
-Components extensions are used to override the component associated with a component reference throughout the app. This uses an extension creator function rather than a blueprint, but will likely be migrated to a blueprint in the future.
-
### NavItem - [Reference](../../reference/frontend-plugin-api.navitemblueprint.md)
Navigation item extensions are used to provide menu items that link to different parts of the app. By default nav items are attached to the app nav extension, which by default is rendered as the left sidebar in the app.
@@ -33,6 +29,10 @@ Page extensions provide content for a particular route in the app. By default pa
Sign-in page extension have a single purpose - to implement a custom sign-in page. They are always attached to the app root extension and are rendered before the rest of the app until the user is signed in.
+### SwappableComponent - [Reference](../../reference/frontend-plugin-api.swappablecomponentblueprint.md)
+
+Swappable Components are extensions that are used to replace the implementations of components in the app and plugins.
+
### Theme - [Reference](../../reference/frontend-plugin-api.themeblueprint.md)
Theme extensions provide custom themes for the app. They are always attached to the app extension and you can have any number of themes extensions installed in an app at once, letting the user choose which theme to use.
diff --git a/packages/app-next/src/examples/notFoundErrorPageExtension.tsx b/packages/app-next/src/examples/notFoundErrorPageExtension.tsx
index a636b2b220..7f690f90cb 100644
--- a/packages/app-next/src/examples/notFoundErrorPageExtension.tsx
+++ b/packages/app-next/src/examples/notFoundErrorPageExtension.tsx
@@ -15,14 +15,14 @@
*/
import {
- createComponentExtension,
- coreComponentRefs,
+ SwappableComponentBlueprint,
+ NotFoundErrorPage,
} from '@backstage/frontend-plugin-api';
import Box from '@material-ui/core/Box';
import Typography from '@material-ui/core/Typography';
import { Button } from '@backstage/core-components';
-export function CustomNotFoundErrorPage() {
+function CustomNotFoundErrorPage() {
return (
CustomNotFoundErrorPage },
+ params: define =>
+ define({
+ component: NotFoundErrorPage,
+ loader: () => CustomNotFoundErrorPage,
+ }),
});
diff --git a/packages/core-compat-api/src/compatWrapper/BackwardsCompatProvider.tsx b/packages/core-compat-api/src/compatWrapper/BackwardsCompatProvider.tsx
index 0a5dd68aa3..6abaa433aa 100644
--- a/packages/core-compat-api/src/compatWrapper/BackwardsCompatProvider.tsx
+++ b/packages/core-compat-api/src/compatWrapper/BackwardsCompatProvider.tsx
@@ -24,11 +24,12 @@ import {
createFrontendPlugin as createNewPlugin,
FrontendPlugin as NewFrontendPlugin,
appTreeApiRef,
- componentsApiRef,
- coreComponentRefs,
iconsApiRef,
useApi,
routeResolutionApiRef,
+ ErrorDisplay,
+ NotFoundErrorPage,
+ Progress,
} from '@backstage/frontend-plugin-api';
import {
AppComponents,
@@ -92,7 +93,6 @@ function toNewPlugin(plugin: LegacyBackstagePlugin): NewFrontendPlugin {
// Recreates the old AppContext APIs using the various new APIs that replaced it
function LegacyAppContextProvider(props: { children: ReactNode }) {
const appTreeApi = useApi(appTreeApiRef);
- const componentsApi = useApi(componentsApiRef);
const iconsApi = useApi(iconsApiRef);
const appContext = useMemo(() => {
@@ -100,15 +100,9 @@ function LegacyAppContextProvider(props: { children: ReactNode }) {
let gatheredPlugins: LegacyBackstagePlugin[] | undefined = undefined;
- const ErrorBoundaryFallback = componentsApi.getComponent(
- coreComponentRefs.errorBoundaryFallback,
- );
const ErrorBoundaryFallbackWrapper: AppComponents['ErrorBoundaryFallback'] =
({ plugin, ...rest }) => (
-
+
);
return {
@@ -141,15 +135,13 @@ function LegacyAppContextProvider(props: { children: ReactNode }) {
getComponents(): AppComponents {
return {
- NotFoundErrorPage: componentsApi.getComponent(
- coreComponentRefs.notFoundErrorPage,
- ),
+ NotFoundErrorPage: NotFoundErrorPage,
BootErrorPage() {
throw new Error(
'The BootErrorPage app component should not be accessed by plugins',
);
},
- Progress: componentsApi.getComponent(coreComponentRefs.progress),
+ Progress: Progress,
Router() {
throw new Error(
'The Router app component should not be accessed by plugins',
@@ -159,7 +151,7 @@ function LegacyAppContextProvider(props: { children: ReactNode }) {
};
},
};
- }, [appTreeApi, componentsApi, iconsApi]);
+ }, [appTreeApi, iconsApi]);
return (
diff --git a/packages/core-compat-api/src/compatWrapper/ForwardsCompatProvider.tsx b/packages/core-compat-api/src/compatWrapper/ForwardsCompatProvider.tsx
index 925d30306c..6464032673 100644
--- a/packages/core-compat-api/src/compatWrapper/ForwardsCompatProvider.tsx
+++ b/packages/core-compat-api/src/compatWrapper/ForwardsCompatProvider.tsx
@@ -22,11 +22,11 @@ import {
} from '@backstage/core-plugin-api';
import {
AnyRouteRefParams,
- ComponentRef,
- ComponentsApi,
- CoreErrorBoundaryFallbackProps,
- CoreNotFoundErrorPageProps,
- CoreProgressProps,
+ SwappableComponentRef,
+ SwappableComponentsApi,
+ ErrorDisplayProps,
+ NotFoundErrorPageProps,
+ ProgressProps,
ExternalRouteRef,
IconComponent,
IconsApi,
@@ -34,10 +34,12 @@ import {
RouteRef,
RouteResolutionApi,
SubRouteRef,
- componentsApiRef,
- coreComponentRefs,
+ swappableComponentsApiRef,
iconsApiRef,
routeResolutionApiRef,
+ Progress,
+ NotFoundErrorPage,
+ ErrorDisplay,
} from '@backstage/frontend-plugin-api';
import { ComponentType, useMemo } from 'react';
import { ReactNode } from 'react';
@@ -49,14 +51,14 @@ import { useVersionedContext } from '@backstage/version-bridge';
import { type RouteResolver } from '../../../core-plugin-api/src/routing/useRouteRef';
import { convertLegacyRouteRef } from '../convertLegacyRouteRef';
-class CompatComponentsApi implements ComponentsApi {
- readonly #Progress: ComponentType;
- readonly #NotFoundErrorPage: ComponentType;
- readonly #ErrorBoundaryFallback: ComponentType;
+class CompatComponentsApi implements SwappableComponentsApi {
+ readonly #Progress: ComponentType;
+ readonly #NotFoundErrorPage: ComponentType;
+ readonly #ErrorBoundaryFallback: ComponentType;
constructor(app: AppContext) {
const components = app.getComponents();
- const ErrorBoundaryFallback = (props: CoreErrorBoundaryFallbackProps) => (
+ const ErrorBoundaryFallback = (props: ErrorDisplayProps) => (
(ref: ComponentRef): ComponentType {
+ getComponent<
+ TInnerComponentProps extends {},
+ TExternalComponentProps extends {} = TInnerComponentProps,
+ >(
+ ref: SwappableComponentRef,
+ ): (props: TInnerComponentProps) => JSX.Element | null {
switch (ref.id) {
- case coreComponentRefs.progress.id:
- return this.#Progress as ComponentType;
- case coreComponentRefs.notFoundErrorPage.id:
- return this.#NotFoundErrorPage as ComponentType;
- case coreComponentRefs.errorBoundaryFallback.id:
- return this.#ErrorBoundaryFallback as ComponentType;
+ case Progress.ref.id:
+ return this.#Progress as (props: object) => JSX.Element | null;
+ case NotFoundErrorPage.ref.id:
+ return this.#NotFoundErrorPage as (props: object) => JSX.Element | null;
+ case ErrorDisplay.ref.id:
+ return this.#ErrorBoundaryFallback as (
+ props: object,
+ ) => JSX.Element | null;
default:
throw new Error(
`No backwards compatible component is available for ref '${ref.id}'`,
@@ -119,7 +128,7 @@ class CompatRouteResolutionApi implements RouteResolutionApi {
}
class ForwardsCompatApis implements ApiHolder {
- readonly #componentsApi: ComponentsApi;
+ readonly #componentsApi: SwappableComponentsApi;
readonly #iconsApi: IconsApi;
readonly #routeResolutionApi: RouteResolutionApi;
@@ -130,7 +139,7 @@ class ForwardsCompatApis implements ApiHolder {
}
get(ref: ApiRef): T | undefined {
- if (ref.id === componentsApiRef.id) {
+ if (ref.id === swappableComponentsApiRef.id) {
return this.#componentsApi as T;
} else if (ref.id === iconsApiRef.id) {
return this.#iconsApi as T;
diff --git a/packages/core-compat-api/src/compatWrapper/compatWrapper.test.tsx b/packages/core-compat-api/src/compatWrapper/compatWrapper.test.tsx
index f1418e545f..fe9dab9910 100644
--- a/packages/core-compat-api/src/compatWrapper/compatWrapper.test.tsx
+++ b/packages/core-compat-api/src/compatWrapper/compatWrapper.test.tsx
@@ -15,14 +15,16 @@
*/
import {
- componentsApiRef,
- coreComponentRefs,
+ swappableComponentsApiRef,
coreExtensionData,
createExtension,
iconsApiRef,
useRouteRef as useNewRouteRef,
createRouteRef as createNewRouteRef,
useApi,
+ NotFoundErrorPage,
+ ErrorDisplay,
+ Progress,
} from '@backstage/frontend-plugin-api';
import {
createExtensionTester,
@@ -97,13 +99,19 @@ describe('BackwardsCompatProvider', () => {
describe('ForwardsCompatProvider', () => {
it('should convert the app context', async () => {
+ const defaultComponentRefs = {
+ progress: Progress.ref,
+ notFoundErrorPage: NotFoundErrorPage.ref,
+ errorDisplay: ErrorDisplay.ref,
+ };
+
function Component() {
- const components = useApi(componentsApiRef);
+ const components = useApi(swappableComponentsApiRef);
const icons = useApi(iconsApiRef);
return (
components:{' '}
- {Object.entries(coreComponentRefs)
+ {Object.entries(defaultComponentRefs)
.map(
([name, ref]) =>
`${name}=${Boolean(components.getComponent(ref))}`,
@@ -118,7 +126,7 @@ describe('ForwardsCompatProvider', () => {
await renderInOldTestApp(compatWrapper());
expect(screen.getByTestId('ctx').textContent).toMatchInlineSnapshot(`
- "components: progress=true, notFoundErrorPage=true, errorBoundaryFallback=true
+ "components: progress=true, notFoundErrorPage=true, errorDisplay=true
icons: kind:api, kind:component, kind:domain, kind:group, kind:location, kind:system, kind:user, kind:resource, kind:template, brokenImage, catalog, scaffolder, techdocs, search, chat, dashboard, docs, email, github, group, help, user, warning, star, unstarred"
`);
});
diff --git a/packages/core-compat-api/src/convertLegacyAppOptions.tsx b/packages/core-compat-api/src/convertLegacyAppOptions.tsx
index f6c4e163ad..650ea9cd58 100644
--- a/packages/core-compat-api/src/convertLegacyAppOptions.tsx
+++ b/packages/core-compat-api/src/convertLegacyAppOptions.tsx
@@ -16,10 +16,9 @@
import { ComponentType } from 'react';
import {
+ SwappableComponentBlueprint,
ApiBlueprint,
- coreComponentRefs,
- CoreErrorBoundaryFallbackProps,
- createComponentExtension,
+ ErrorDisplayProps,
createExtension,
createFrontendModule,
ExtensionDefinition,
@@ -28,6 +27,9 @@ import {
RouterBlueprint,
SignInPageBlueprint,
ThemeBlueprint,
+ ErrorDisplay as SwappableErrorDisplay,
+ NotFoundErrorPage as SwappableNotFoundErrorPage,
+ Progress as SwappableProgress,
} from '@backstage/frontend-plugin-api';
import {
AnyApiFactory,
@@ -154,36 +156,45 @@ export function convertLegacyAppOptions(
}
if (Progress) {
extensions.push(
- createComponentExtension({
- ref: coreComponentRefs.progress,
- loader: { sync: () => componentCompatWrapper(Progress) },
+ SwappableComponentBlueprint.make({
+ params: define =>
+ define({
+ component: SwappableProgress,
+ loader: () => componentCompatWrapper(Progress),
+ }),
}),
);
}
+
if (NotFoundErrorPage) {
extensions.push(
- createComponentExtension({
- ref: coreComponentRefs.notFoundErrorPage,
- loader: { sync: () => componentCompatWrapper(NotFoundErrorPage) },
+ SwappableComponentBlueprint.make({
+ params: define =>
+ define({
+ component: SwappableNotFoundErrorPage,
+ loader: () => componentCompatWrapper(NotFoundErrorPage),
+ }),
}),
);
}
+
if (ErrorBoundaryFallback) {
- const WrappedErrorBoundaryFallback = (
- props: CoreErrorBoundaryFallbackProps,
- ) =>
+ const WrappedErrorBoundaryFallback = (props: ErrorDisplayProps) =>
compatWrapper(
,
);
+
extensions.push(
- createComponentExtension({
- ref: coreComponentRefs.errorBoundaryFallback,
- loader: {
- sync: () => componentCompatWrapper(WrappedErrorBoundaryFallback),
- },
+ SwappableComponentBlueprint.make({
+ params: define =>
+ define({
+ component: SwappableErrorDisplay,
+ loader: () =>
+ componentCompatWrapper(WrappedErrorBoundaryFallback),
+ }),
}),
);
}
diff --git a/packages/frontend-app-api/package.json b/packages/frontend-app-api/package.json
index ed71d61fab..ff891a4b24 100644
--- a/packages/frontend-app-api/package.json
+++ b/packages/frontend-app-api/package.json
@@ -45,6 +45,7 @@
},
"devDependencies": {
"@backstage/cli": "workspace:^",
+ "@backstage/frontend-test-utils": "workspace:^",
"@backstage/plugin-app": "workspace:^",
"@backstage/test-utils": "workspace:^",
"@testing-library/jest-dom": "^6.0.0",
diff --git a/packages/frontend-app-api/src/apis/implementations/ComponentsApi/DefaultComponentsApi.test.tsx b/packages/frontend-app-api/src/apis/implementations/ComponentsApi/DefaultComponentsApi.test.tsx
deleted file mode 100644
index 7d06d55a92..0000000000
--- a/packages/frontend-app-api/src/apis/implementations/ComponentsApi/DefaultComponentsApi.test.tsx
+++ /dev/null
@@ -1,57 +0,0 @@
-/*
- * Copyright 2023 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 { createComponentRef } from '@backstage/frontend-plugin-api';
-import { DefaultComponentsApi } from './DefaultComponentsApi';
-import { render, screen } from '@testing-library/react';
-
-const testRefA = createComponentRef({ id: 'test.a' });
-const testRefB1 = createComponentRef({ id: 'test.b' });
-const testRefB2 = createComponentRef({ id: 'test.b' });
-
-describe('DefaultComponentsApi', () => {
- it('should provide components', () => {
- const api = DefaultComponentsApi.fromComponents([
- {
- ref: testRefA,
- impl: () =>
,
- },
- ]);
-
- const ComponentB1 = api.getComponent(testRefB1);
- const ComponentB2 = api.getComponent(testRefB2);
-
- expect(ComponentB1).toBe(ComponentB2);
-
- render();
-
- expect(screen.getByText('test.b')).toBeInTheDocument();
- });
-});
diff --git a/packages/frontend-app-api/src/apis/implementations/ComponentsApi/DefaultComponentsApi.ts b/packages/frontend-app-api/src/apis/implementations/ComponentsApi/DefaultComponentsApi.ts
deleted file mode 100644
index 317233d797..0000000000
--- a/packages/frontend-app-api/src/apis/implementations/ComponentsApi/DefaultComponentsApi.ts
+++ /dev/null
@@ -1,51 +0,0 @@
-/*
- * Copyright 2023 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 } from 'react';
-import {
- ComponentRef,
- ComponentsApi,
- createComponentExtension,
-} from '@backstage/frontend-plugin-api';
-
-/**
- * Implementation for the {@linkComponentApi}
- *
- * @internal
- */
-export class DefaultComponentsApi implements ComponentsApi {
- #components: Map>;
-
- static fromComponents(
- components: Array,
- ) {
- return new DefaultComponentsApi(
- new Map(components.map(entry => [entry.ref.id, entry.impl])),
- );
- }
-
- constructor(components: Map) {
- this.#components = components;
- }
-
- getComponent(ref: ComponentRef): ComponentType {
- const impl = this.#components.get(ref.id);
- if (!impl) {
- throw new Error(`No implementation found for component ref ${ref}`);
- }
- return impl;
- }
-}
diff --git a/packages/frontend-app-api/src/apis/implementations/SwappableComponentsApi/DefaultSwappableComponentsApi.test.tsx b/packages/frontend-app-api/src/apis/implementations/SwappableComponentsApi/DefaultSwappableComponentsApi.test.tsx
new file mode 100644
index 0000000000..823d48e21c
--- /dev/null
+++ b/packages/frontend-app-api/src/apis/implementations/SwappableComponentsApi/DefaultSwappableComponentsApi.test.tsx
@@ -0,0 +1,264 @@
+/*
+ * Copyright 2023 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 {
+ ApiBlueprint,
+ createExtensionInput,
+ createSwappableComponent,
+ SwappableComponentBlueprint,
+ swappableComponentsApiRef,
+} from '@backstage/frontend-plugin-api';
+import { DefaultSwappableComponentsApi } from './DefaultSwappableComponentsApi';
+import { render, screen } from '@testing-library/react';
+import { renderInTestApp } from '@backstage/frontend-test-utils';
+
+const { ref: testRefA } = createSwappableComponent({ id: 'test.a' });
+const { ref: testRefB1 } = createSwappableComponent({ id: 'test.b' });
+const { ref: testRefB2 } = createSwappableComponent({ id: 'test.b' });
+
+describe('DefaultSwappableComponentsApi', () => {
+ it('should provide components', async () => {
+ const api = DefaultSwappableComponentsApi.fromComponents([
+ {
+ ref: testRefA,
+ loader: () => () =>
,
+ transformProps: ({ hello }) => ({ hello: `tr ${hello}` }),
+ });
+
+ renderInTestApp(, {
+ extensions: [
+ PageBlueprint.make({
+ params: define =>
+ define({
+ // todo(blam): there's a bug that this path cannot be `/`?
+ path: '/test',
+ loader: async () => ,
+ }),
+ }),
+ ],
+ initialRouteEntries: ['/test'],
+ });
+
+ await waitFor(() =>
+ expect(screen.getByText('tr test!')).toBeInTheDocument(),
+ );
+ });
+});
diff --git a/packages/frontend-plugin-api/src/blueprints/SwappableComponentBlueprint.ts b/packages/frontend-plugin-api/src/blueprints/SwappableComponentBlueprint.ts
new file mode 100644
index 0000000000..b72d14d87f
--- /dev/null
+++ b/packages/frontend-plugin-api/src/blueprints/SwappableComponentBlueprint.ts
@@ -0,0 +1,63 @@
+/*
+ * Copyright 2025 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 { SwappableComponentRef } from '../components';
+import {
+ createExtensionBlueprint,
+ createExtensionBlueprintParams,
+ createExtensionDataRef,
+} from '../wiring';
+
+export const componentDataRef = createExtensionDataRef<{
+ ref: SwappableComponentRef;
+ loader:
+ | (() => (props: {}) => JSX.Element | null)
+ | (() => Promise<(props: {}) => JSX.Element | null>);
+}>().with({ id: 'core.swappableComponent' });
+
+/**
+ * Blueprint for creating swappable components from a SwappableComponentRef and a loader
+ *
+ * @public
+ */
+export const SwappableComponentBlueprint = createExtensionBlueprint({
+ kind: 'component',
+ attachTo: { id: 'api:app/swappable-components', input: 'components' },
+ output: [componentDataRef],
+ dataRefs: {
+ component: componentDataRef,
+ },
+ defineParams>(params: {
+ component: Ref extends SwappableComponentRef<
+ any,
+ infer IExternalComponentProps
+ >
+ ? { ref: Ref } & ((props: IExternalComponentProps) => JSX.Element | null)
+ : never;
+ loader: Ref extends SwappableComponentRef
+ ?
+ | (() => (props: IInnerComponentProps) => JSX.Element | null)
+ | (() => Promise<(props: IInnerComponentProps) => JSX.Element | null>)
+ : never;
+ }) {
+ return createExtensionBlueprintParams(params);
+ },
+ factory: params => [
+ componentDataRef({
+ ref: params.component.ref,
+ loader: params.loader,
+ }),
+ ],
+});
diff --git a/packages/frontend-plugin-api/src/blueprints/index.ts b/packages/frontend-plugin-api/src/blueprints/index.ts
index 0060571ca2..c8699b4232 100644
--- a/packages/frontend-plugin-api/src/blueprints/index.ts
+++ b/packages/frontend-plugin-api/src/blueprints/index.ts
@@ -33,3 +33,4 @@ export { RouterBlueprint } from './RouterBlueprint';
export { SignInPageBlueprint } from './SignInPageBlueprint';
export { ThemeBlueprint } from './ThemeBlueprint';
export { TranslationBlueprint } from './TranslationBlueprint';
+export { SwappableComponentBlueprint } from './SwappableComponentBlueprint';
diff --git a/packages/frontend-plugin-api/src/components/DefaultSwappableComponents.tsx b/packages/frontend-plugin-api/src/components/DefaultSwappableComponents.tsx
new file mode 100644
index 0000000000..e9a4220142
--- /dev/null
+++ b/packages/frontend-plugin-api/src/components/DefaultSwappableComponents.tsx
@@ -0,0 +1,46 @@
+/*
+ * Copyright 2025 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 {
+ ErrorDisplayProps,
+ NotFoundErrorPageProps,
+ ProgressProps,
+} from '../types';
+import { createSwappableComponent } from './createSwappableComponent';
+
+/**
+ * @public
+ */
+export const Progress = createSwappableComponent({
+ id: 'core.components.progress',
+});
+
+/**
+ * @public
+ */
+export const NotFoundErrorPage =
+ createSwappableComponent({
+ id: 'core.components.notFoundErrorPage',
+ });
+
+/**
+ * @public
+ */
+export const ErrorDisplay = createSwappableComponent({
+ id: 'core.components.errorDisplay',
+ loader: () => props =>
+
{props.error.message}
,
+});
diff --git a/packages/frontend-plugin-api/src/components/ErrorBoundary.tsx b/packages/frontend-plugin-api/src/components/ErrorBoundary.tsx
index 8842ff55cc..4cde84ee93 100644
--- a/packages/frontend-plugin-api/src/components/ErrorBoundary.tsx
+++ b/packages/frontend-plugin-api/src/components/ErrorBoundary.tsx
@@ -14,13 +14,12 @@
* limitations under the License.
*/
-import { Component, ComponentType, PropsWithChildren } from 'react';
+import { Component, PropsWithChildren } from 'react';
import { FrontendPlugin } from '../wiring';
-import { CoreErrorBoundaryFallbackProps } from '../types';
+import { ErrorDisplay } from './DefaultSwappableComponents';
type ErrorBoundaryProps = PropsWithChildren<{
plugin?: FrontendPlugin;
- Fallback: ComponentType;
}>;
type ErrorBoundaryState = { error?: Error };
@@ -41,13 +40,15 @@ export class ErrorBoundary extends Component<
render() {
const { error } = this.state;
- const { plugin, children, Fallback } = this.props;
+ const { plugin, children } = this.props;
if (error) {
return (
-
);
diff --git a/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx b/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx
index f653fa1742..848ea64a2c 100644
--- a/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx
+++ b/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx
@@ -25,10 +25,10 @@ import { AnalyticsContext, useAnalytics } from '@backstage/core-plugin-api';
import { ErrorBoundary } from './ErrorBoundary';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { routableExtensionRenderedEvent } from '../../../core-plugin-api/src/analytics/Tracker';
-import { AppNode, useComponentRef } from '../apis';
-import { coreComponentRefs } from './coreComponentRefs';
+import { AppNode } from '../apis';
import { coreExtensionData } from '../wiring';
import { AppNodeProvider } from './AppNodeProvider';
+import { Progress } from './DefaultSwappableComponents';
type RouteTrackerProps = PropsWithChildren<{
enabled?: boolean;
@@ -66,8 +66,6 @@ export function ExtensionBoundary(props: ExtensionBoundaryProps) {
);
const plugin = node.spec.plugin;
- const Progress = useComponentRef(coreComponentRefs.progress);
- const fallback = useComponentRef(coreComponentRefs.errorBoundaryFallback);
// Skipping "routeRef" attribute in the new system, the extension "id" should provide more insight
const attributes = {
@@ -78,7 +76,7 @@ export function ExtensionBoundary(props: ExtensionBoundaryProps) {
return (
}>
-
+ {children}
diff --git a/packages/frontend-plugin-api/src/components/coreComponentRefs.ts b/packages/frontend-plugin-api/src/components/coreComponentRefs.ts
deleted file mode 100644
index 64412b8710..0000000000
--- a/packages/frontend-plugin-api/src/components/coreComponentRefs.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-/*
- * Copyright 2023 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 {
- CoreErrorBoundaryFallbackProps,
- CoreNotFoundErrorPageProps,
- CoreProgressProps,
-} from '../types';
-import { createComponentRef } from './createComponentRef';
-
-const coreProgressComponentRef = createComponentRef({
- id: 'core.components.progress',
-});
-
-const coreNotFoundErrorPageComponentRef =
- createComponentRef({
- id: 'core.components.notFoundErrorPage',
- });
-
-const coreErrorBoundaryFallbackComponentRef =
- createComponentRef({
- id: 'core.components.errorBoundaryFallback',
- });
-
-/** @public */
-export const coreComponentRefs = {
- progress: coreProgressComponentRef,
- notFoundErrorPage: coreNotFoundErrorPageComponentRef,
- errorBoundaryFallback: coreErrorBoundaryFallbackComponentRef,
-};
diff --git a/packages/frontend-plugin-api/src/components/createComponentRef.tsx b/packages/frontend-plugin-api/src/components/createComponentRef.tsx
deleted file mode 100644
index 88181dd079..0000000000
--- a/packages/frontend-plugin-api/src/components/createComponentRef.tsx
+++ /dev/null
@@ -1,34 +0,0 @@
-/*
- * Copyright 2023 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.
- */
-
-/** @public */
-export type ComponentRef = {
- id: string;
- T: T;
-};
-
-/** @public */
-export function createComponentRef(options: {
- id: string;
-}): ComponentRef {
- const { id } = options;
- return {
- id,
- toString() {
- return `ComponentRef{id=${id}}`;
- },
- } as ComponentRef;
-}
diff --git a/packages/frontend-plugin-api/src/components/createSwappableComponent.test.tsx b/packages/frontend-plugin-api/src/components/createSwappableComponent.test.tsx
new file mode 100644
index 0000000000..2a73a4da42
--- /dev/null
+++ b/packages/frontend-plugin-api/src/components/createSwappableComponent.test.tsx
@@ -0,0 +1,163 @@
+/*
+ * Copyright 2023 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 { createSwappableComponent } from './createSwappableComponent';
+
+describe('createSwappableComponent', () => {
+ it('can be created and read', () => {
+ const { ref } = createSwappableComponent({ id: 'foo' });
+ expect(ref.id).toBe('foo');
+ expect(String(ref)).toBe('SwappableComponentRef{id=foo}');
+ });
+
+ it('should allow defining a default component implementation', () => {
+ const Test = () =>