diff --git a/.changeset/fresh-timers-walk.md b/.changeset/fresh-timers-walk.md
new file mode 100644
index 0000000000..a187bede83
--- /dev/null
+++ b/.changeset/fresh-timers-walk.md
@@ -0,0 +1,20 @@
+---
+'@backstage/frontend-plugin-api': patch
+---
+
+Support overriding of plugin extensions using the new `plugin.withOverrides` method.
+
+```tsx
+import homePlugin from '@backstage/plugin-home';
+
+export default homePlugin.withOverrides({
+ extensions: [
+ homePage.getExtension('page:home').override({
+ *factory(originalFactory) {
+ yield* originalFactory();
+ yield coreExtensionData.reactElement(
My custom home page
);
+ },
+ }),
+ ],
+});
+```
diff --git a/packages/frontend-plugin-api/api-report.md b/packages/frontend-plugin-api/api-report.md
index 6d548e3c26..f841fc2ce2 100644
--- a/packages/frontend-plugin-api/api-report.md
+++ b/packages/frontend-plugin-api/api-report.md
@@ -356,6 +356,10 @@ export interface BackstagePlugin<
readonly id: string;
// (undocumented)
readonly routes: TRoutes;
+ // (undocumented)
+ withOverrides(options: {
+ extensions: Array>;
+ }): BackstagePlugin;
}
export { BackstageUserIdentity };
diff --git a/packages/frontend-plugin-api/src/wiring/createPlugin.test.ts b/packages/frontend-plugin-api/src/wiring/createPlugin.test.ts
index 76cc030be3..f76e4ec9a5 100644
--- a/packages/frontend-plugin-api/src/wiring/createPlugin.test.ts
+++ b/packages/frontend-plugin-api/src/wiring/createPlugin.test.ts
@@ -148,7 +148,28 @@ describe('createPlugin', () => {
});
expect(plugin).toBeDefined();
- expect(plugin.getExtension('test/1')).toBe(Extension1);
+ expect(plugin.getExtension('test/1')).toMatchInlineSnapshot(`
+ {
+ "$$type": "@backstage/ExtensionDefinition",
+ "attachTo": {
+ "id": "test/output",
+ "input": "names",
+ },
+ "configSchema": undefined,
+ "disabled": false,
+ "factory": [Function],
+ "inputs": {},
+ "kind": undefined,
+ "name": "1",
+ "namespace": "test",
+ "output": [
+ [Function],
+ ],
+ "override": [Function],
+ "toString": [Function],
+ "version": "v2",
+ }
+ `);
// @ts-expect-error
expect(plugin.getExtension('nonexistent')).toBeUndefined();
@@ -250,4 +271,68 @@ describe('createPlugin', () => {
}),
).toThrow("Plugin 'test' provided duplicate extensions: test/2, test/3");
});
+
+ describe('overrides', () => {
+ it('should return a plugin instance with the correct namespace', () => {
+ const plugin = createPlugin({
+ id: 'test',
+ extensions: [Extension1, Extension2],
+ });
+
+ expect(plugin.getExtension('test/1')).toMatchInlineSnapshot(`
+ {
+ "$$type": "@backstage/ExtensionDefinition",
+ "attachTo": {
+ "id": "test/output",
+ "input": "names",
+ },
+ "configSchema": undefined,
+ "disabled": false,
+ "factory": [Function],
+ "inputs": {},
+ "kind": undefined,
+ "name": "1",
+ "namespace": "test",
+ "output": [
+ [Function],
+ ],
+ "override": [Function],
+ "toString": [Function],
+ "version": "v2",
+ }
+ `);
+ });
+
+ it('should allow overriding extensions that have a matching ID, while keeping old extensions that do not have overlapping IDs', async () => {
+ const plugin = createPlugin({
+ id: 'test',
+ extensions: [Extension1, Extension2, outputExtension],
+ });
+
+ await renderWithEffects(
+ createTestAppRoot({
+ features: [
+ plugin.withOverrides({
+ extensions: [
+ plugin.getExtension('test/1').override({
+ factory() {
+ return [nameExtensionDataRef('overridden')];
+ },
+ }),
+ ],
+ }),
+ ],
+ config: {
+ app: {
+ extensions: [{ 'app/root': false }],
+ },
+ },
+ }),
+ );
+
+ await expect(
+ screen.findByText('Names: extension-2, overridden'),
+ ).resolves.toBeInTheDocument();
+ });
+ });
});
diff --git a/packages/frontend-plugin-api/src/wiring/createPlugin.ts b/packages/frontend-plugin-api/src/wiring/createPlugin.ts
index 1d2a12ffe2..2f7a813c90 100644
--- a/packages/frontend-plugin-api/src/wiring/createPlugin.ts
+++ b/packages/frontend-plugin-api/src/wiring/createPlugin.ts
@@ -78,7 +78,7 @@ export function createPlugin<
for (const def of options.extensions ?? []) {
const ext = resolveExtensionDefinition(def, { namespace: options.id });
extensions.push(ext);
- extensionDefinitionsById.set(ext.id, def);
+ extensionDefinitionsById.set(ext.id, { ...def, namespace: options.id });
}
if (extensions.length !== extensionDefinitionsById.size) {
@@ -110,6 +110,23 @@ export function createPlugin<
toString() {
return `Plugin{id=${options.id}}`;
},
+ withOverrides(overrides) {
+ const overrideExtensionIds = new Set(
+ overrides.extensions.map(
+ e => resolveExtensionDefinition(e, { namespace: options.id }).id,
+ ),
+ );
+ const nonOverridenExtensions = (options.extensions ?? []).filter(
+ e =>
+ !overrideExtensionIds.has(
+ resolveExtensionDefinition(e, { namespace: options.id }).id,
+ ),
+ );
+ return createPlugin({
+ ...options,
+ extensions: [...nonOverridenExtensions, ...overrides.extensions],
+ });
+ },
} as InternalBackstagePlugin;
}
diff --git a/packages/frontend-plugin-api/src/wiring/types.ts b/packages/frontend-plugin-api/src/wiring/types.ts
index b1afd4e7ea..4a7d972b37 100644
--- a/packages/frontend-plugin-api/src/wiring/types.ts
+++ b/packages/frontend-plugin-api/src/wiring/types.ts
@@ -51,6 +51,9 @@ export interface BackstagePlugin<
readonly routes: TRoutes;
readonly externalRoutes: TExternalRoutes;
getExtension(id: TId): TExtensionMap[TId];
+ withOverrides(options: {
+ extensions: Array>;
+ }): BackstagePlugin;
}
/** @public */