Deduplicate frontend plugin/module extension collection logic (#33869)

Extract the shared extension resolution and duplicate-check logic from
createFrontendPlugin and createFrontendModule into a new
resolveExtensionDefinitions helper. Also fixes the duplicate extension
error message in createFrontendModule to say "Module" instead of
"Plugin".


Made-with: Cursor

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2026-04-13 13:58:59 +02:00
committed by GitHub
parent 9b57f709e9
commit 4c09967317
4 changed files with 70 additions and 66 deletions
@@ -0,0 +1,5 @@
---
'@backstage/frontend-plugin-api': patch
---
Fixed the duplicate extension error message in `createFrontendModule` to correctly say "Module" instead of "Plugin".
@@ -14,11 +14,10 @@
* limitations under the License.
*/
import { OpaqueExtensionDefinition } from '@internal/frontend';
import { ExtensionDefinition } from './createExtension';
import {
Extension,
resolveExtensionDefinition,
resolveExtensionDefinitions,
} from './resolveExtensionDefinition';
import { FeatureFlagConfig } from './types';
import { FilterPredicate } from '@backstage/filter-predicates';
@@ -93,36 +92,10 @@ export function createFrontendModule<
>(options: CreateFrontendModuleOptions<TId, TExtensions>): FrontendModule {
const { pluginId } = options;
const extensions = new Array<Extension<any>>();
const extensionDefinitionsById = new Map<
string,
typeof OpaqueExtensionDefinition.TInternal
>();
for (const def of options.extensions ?? []) {
const internal = OpaqueExtensionDefinition.toInternal(def);
const ext = resolveExtensionDefinition(def, { namespace: pluginId });
extensions.push(ext);
extensionDefinitionsById.set(ext.id, {
...internal,
namespace: pluginId,
});
}
if (extensions.length !== extensionDefinitionsById.size) {
const extensionIds = extensions.map(e => e.id);
const duplicates = Array.from(
new Set(
extensionIds.filter((id, index) => extensionIds.indexOf(id) !== index),
),
);
// TODO(Rugvip): This could provide some more information about the kind + name of the extensions
throw new Error(
`Plugin '${pluginId}' provided duplicate extensions: ${duplicates.join(
', ',
)}`,
);
}
const { extensions } = resolveExtensionDefinitions(options.extensions ?? [], {
namespace: pluginId,
featureType: 'Module',
});
return {
$$type: '@backstage/FrontendModule',
@@ -14,17 +14,14 @@
* limitations under the License.
*/
import {
OpaqueExtensionDefinition,
OpaqueFrontendPlugin,
} from '@internal/frontend';
import { OpaqueFrontendPlugin } from '@internal/frontend';
import {
ExtensionDefinition,
OverridableExtensionDefinition,
} from './createExtension';
import {
Extension,
resolveExtensionDefinition,
resolveExtensionDefinitions,
} from './resolveExtensionDefinition';
import { FeatureFlagConfig } from './types';
import { MakeSortedExtensionsMap } from './MakeSortedExtensionsMap';
@@ -272,36 +269,13 @@ export function createFrontendPlugin<
);
}
const extensions = new Array<Extension<any>>();
const extensionDefinitionsById = new Map<
string,
typeof OpaqueExtensionDefinition.TInternal
>();
for (const def of options.extensions ?? []) {
const internal = OpaqueExtensionDefinition.toInternal(def);
const ext = resolveExtensionDefinition(def, { namespace: pluginId });
extensions.push(ext);
extensionDefinitionsById.set(ext.id, {
...internal,
const { extensions, extensionDefinitionsById } = resolveExtensionDefinitions(
options.extensions ?? [],
{
namespace: pluginId,
});
}
if (extensions.length !== extensionDefinitionsById.size) {
const extensionIds = extensions.map(e => e.id);
const duplicates = Array.from(
new Set(
extensionIds.filter((id, index) => extensionIds.indexOf(id) !== index),
),
);
// TODO(Rugvip): This could provide some more information about the kind + name of the extensions
throw new Error(
`Plugin '${pluginId}' provided duplicate extensions: ${duplicates.join(
', ',
)}`,
);
}
featureType: 'Plugin',
},
);
return OpaqueFrontendPlugin.createInstance('v1', {
pluginId,
@@ -184,6 +184,58 @@ function resolveAttachTo(
return resolveSpec(attachTo);
}
/**
* Resolves a list of extension definitions into extensions, returning both the
* resolved extensions and a map of extension definitions keyed by resolved ID.
* Throws if any two definitions resolve to the same ID.
*
* @internal
*/
export function resolveExtensionDefinitions(
definitions: Iterable<ExtensionDefinition>,
context: { namespace: string; featureType: string },
): {
extensions: Extension<any>[];
extensionDefinitionsById: Map<
string,
typeof OpaqueExtensionDefinition.TInternal
>;
} {
const extensions = new Array<Extension<any>>();
const extensionDefinitionsById = new Map<
string,
typeof OpaqueExtensionDefinition.TInternal
>();
for (const def of definitions) {
const internal = OpaqueExtensionDefinition.toInternal(def);
const ext = resolveExtensionDefinition(def, {
namespace: context.namespace,
});
extensions.push(ext);
extensionDefinitionsById.set(ext.id, {
...internal,
namespace: context.namespace,
});
}
if (extensions.length !== extensionDefinitionsById.size) {
const extensionIds = extensions.map(e => e.id);
const duplicates = Array.from(
new Set(
extensionIds.filter((id, index) => extensionIds.indexOf(id) !== index),
),
);
throw new Error(
`${context.featureType} '${
context.namespace
}' provided duplicate extensions: ${duplicates.join(', ')}`,
);
}
return { extensions, extensionDefinitionsById };
}
/** @internal */
export function resolveExtensionDefinition<
T extends ExtensionDefinitionParameters,