diff --git a/packages/frontend-app-api/src/tree/resolveAppTree.test.ts b/packages/frontend-app-api/src/tree/resolveAppTree.test.ts
index 80d478646f..a8e63f82e0 100644
--- a/packages/frontend-app-api/src/tree/resolveAppTree.test.ts
+++ b/packages/frontend-app-api/src/tree/resolveAppTree.test.ts
@@ -137,6 +137,60 @@ describe('buildAppTree', () => {
`);
});
+ it('should create a tree with clones', () => {
+ const tree = resolveAppTree(
+ 'a',
+ [
+ { ...baseSpec, id: 'a' },
+ { ...baseSpec, id: 'b', attachTo: { id: 'a', input: 'x' } },
+ {
+ ...baseSpec,
+ id: 'c',
+ attachTo: [
+ { id: 'a', input: 'x' },
+ { id: 'b', input: 'x' },
+ ] as any,
+ },
+ {
+ ...baseSpec,
+ id: 'd',
+ attachTo: [
+ { id: 'b', input: 'x' },
+ { id: 'c', input: 'x' },
+ ] as any,
+ },
+ ],
+ collector,
+ );
+
+ expect(Array.from(tree.nodes.keys())).toEqual(['a', 'b', 'c', 'd']);
+
+ expect(String(tree.root)).toMatchInlineSnapshot(`
+ "
+ x [
+
+ x [
+
+ x [
+
+ ]
+
+
+ ]
+
+
+ x [
+
+ ]
+
+ ]
+ "
+ `);
+
+ const orphans = Array.from(tree.orphans).map(String);
+ expect(orphans).toMatchInlineSnapshot(`[]`);
+ });
+
it('should create a tree out of order', () => {
const tree = resolveAppTree(
'b',
diff --git a/packages/frontend-app-api/src/tree/resolveAppTree.ts b/packages/frontend-app-api/src/tree/resolveAppTree.ts
index bdd9be0fbf..c2054c4bac 100644
--- a/packages/frontend-app-api/src/tree/resolveAppTree.ts
+++ b/packages/frontend-app-api/src/tree/resolveAppTree.ts
@@ -153,6 +153,7 @@ export function resolveAppTree(
}
const orphans = new Array();
+ const clones = new Map>();
// A node with the provided rootNodeId must be found in the tree, and it must not be attached to anything
let rootNode: AppNode | undefined = undefined;
@@ -163,6 +164,46 @@ export function resolveAppTree(
// TODO: For now we simply ignore the attachTo spec of the root node, but it'd be cleaner if we could avoid defining it
if (spec.id === rootNodeId) {
rootNode = node;
+ } else if (Array.isArray(spec.attachTo)) {
+ // eslint-disable-next-line no-console
+ console.warn(
+ `Extension '${spec.id}' is using multiple attachment points which is deprecated and will be removed in a future release. ` +
+ `Use a Utility API instead to share functionality across multiple locations. ` +
+ `See https://backstage.io/docs/frontend-system/architecture/27-sharing-extensions for migration guidance.`,
+ );
+ let foundFirstParent = false;
+ for (const origAttachTo of spec.attachTo) {
+ let attachTo = origAttachTo;
+
+ if (!isValidAttachmentPoint(attachTo, nodes)) {
+ attachTo =
+ redirectTargetsByKey.get(makeRedirectKey(attachTo)) ?? attachTo;
+ }
+
+ const parent = nodes.get(attachTo.id);
+ if (parent) {
+ const cloneParents = clones.get(attachTo.id) ?? [];
+
+ if (!foundFirstParent) {
+ foundFirstParent = true;
+ node.setParent(parent, attachTo.input);
+ } else {
+ cloneParents.unshift(parent);
+ }
+
+ for (const extraParent of cloneParents) {
+ const clonedNode = new SerializableAppNode(spec);
+ clonedNode.setParent(extraParent, attachTo.input);
+ clones.set(
+ spec.id,
+ clones.get(spec.id)?.concat(clonedNode) ?? [clonedNode],
+ );
+ }
+ }
+ }
+ if (!foundFirstParent) {
+ orphans.push(node);
+ }
} else {
let attachTo = spec.attachTo;
if (!isValidAttachmentPoint(attachTo, nodes)) {
diff --git a/packages/frontend-plugin-api/src/wiring/createExtension.ts b/packages/frontend-plugin-api/src/wiring/createExtension.ts
index 42611e78ca..4d3eeece2c 100644
--- a/packages/frontend-plugin-api/src/wiring/createExtension.ts
+++ b/packages/frontend-plugin-api/src/wiring/createExtension.ts
@@ -488,36 +488,40 @@ export function createExtension<
if (options.name) {
parts.push(`name=${options.name}`);
}
- const a = options.attachTo;
- let attachTo: string;
- if (OpaqueExtensionInput.isType(a)) {
- const { context } = OpaqueExtensionInput.toInternal(a);
- if (!context) {
- attachTo = '';
- } else {
- let id = '';
- if (context?.kind) {
- id = `${context?.kind}:${id}`;
+ const attachTo = [options.attachTo]
+ .flat()
+ .map(aAny => {
+ const a = aAny as ExtensionDefinitionAttachTo;
+ if (OpaqueExtensionInput.isType(a)) {
+ const { context } = OpaqueExtensionInput.toInternal(a);
+ if (!context) {
+ return '';
+ }
+ let id = '';
+ if (context?.kind) {
+ id = `${context?.kind}:${id}`;
+ }
+ if (context?.name) {
+ id = `${id}/${context?.name}`;
+ }
+ return `${id}@${context.input}`;
}
- if (context?.name) {
- id = `${id}/${context?.name}`;
+ if ('relative' in a && a.relative) {
+ let id = '';
+ if (a.relative.kind) {
+ id = `${a.relative.kind}:${id}`;
+ }
+ if (a.relative.name) {
+ id = `${id}/${a.relative.name}`;
+ }
+ return `${id}@${a.input}`;
}
- attachTo = `${id}@${context.input}`;
- }
- } else if ('relative' in a && a.relative) {
- let id = '';
- if (a.relative.kind) {
- id = `${a.relative.kind}:${id}`;
- }
- if (a.relative.name) {
- id = `${id}/${a.relative.name}`;
- }
- attachTo = `${id}@${a.input}`;
- } else if ('id' in a) {
- attachTo = `${a.id}@${a.input}`;
- } else {
- throw new Error('Invalid attachment point specification');
- }
+ if ('id' in a) {
+ return `${a.id}@${a.input}`;
+ }
+ throw new Error('Invalid attachment point specification');
+ })
+ .join('+');
parts.push(`attachTo=${attachTo}`);
return `ExtensionDefinition{${parts.join(',')}}`;
},
diff --git a/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.test.ts b/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.test.ts
index 79275ab03c..e080ab1f64 100644
--- a/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.test.ts
+++ b/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.test.ts
@@ -139,6 +139,43 @@ describe('resolveExtensionDefinition', () => {
id: 'test',
input: 'children',
});
+
+ // Test for backward compatibility - runtime still supports multiple attachment points
+ expect(
+ resolveExtensionDefinition(
+ OpaqueExtensionDefinition.toInternal({
+ ...baseDef,
+ attachTo: [
+ baseInpuf.withContext?.({
+ kind: 'k1',
+ input: 'children',
+ }),
+ baseInpuf.withContext?.({
+ kind: 'k2',
+ input: 'children',
+ }),
+ baseInpuf.withContext?.({
+ kind: 'k3',
+ input: 'children',
+ }),
+ ] as any,
+ }),
+ { namespace: 'test' },
+ ).attachTo,
+ ).toEqual([
+ {
+ id: 'k1:test',
+ input: 'children',
+ },
+ {
+ id: 'k2:test',
+ input: 'children',
+ },
+ {
+ id: 'k3:test',
+ input: 'children',
+ },
+ ]);
});
});
diff --git a/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.ts b/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.ts
index a314983b25..70a0bd7837 100644
--- a/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.ts
+++ b/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.ts
@@ -141,35 +141,45 @@ function resolveExtensionId(
}
function resolveAttachTo(
- attachTo: ExtensionDefinitionAttachTo,
+ attachTo: ExtensionDefinitionAttachTo | ExtensionDefinitionAttachTo[],
namespace?: string,
-): ExtensionAttachTo {
- if (OpaqueExtensionInput.isType(attachTo)) {
- const { context } = OpaqueExtensionInput.toInternal(attachTo);
- if (!context) {
- throw new Error(
- 'Invalid input object without a parent extension used as attachment point',
- );
+): ExtensionAttachTo | ExtensionAttachTo[] {
+ const resolveSpec = (
+ spec: ExtensionDefinitionAttachTo,
+ ): { id: string; input: string } => {
+ if (OpaqueExtensionInput.isType(spec)) {
+ const { context } = OpaqueExtensionInput.toInternal(spec);
+ if (!context) {
+ throw new Error(
+ 'Invalid input object without a parent extension used as attachment point',
+ );
+ }
+ return {
+ id: resolveExtensionId(context.kind, namespace, context.name),
+ input: context.input,
+ };
}
- return {
- id: resolveExtensionId(context.kind, namespace, context.name),
- input: context.input,
- };
+ if ('relative' in spec && spec.relative) {
+ return {
+ id: resolveExtensionId(
+ spec.relative.kind,
+ namespace,
+ spec.relative.name,
+ ),
+ input: spec.input,
+ };
+ }
+ if ('id' in spec) {
+ return { id: spec.id, input: spec.input };
+ }
+ throw new Error('Invalid attachment point specification');
+ };
+
+ if (Array.isArray(attachTo)) {
+ return attachTo.map(resolveSpec);
}
- if ('relative' in attachTo && attachTo.relative) {
- return {
- id: resolveExtensionId(
- attachTo.relative.kind,
- namespace,
- attachTo.relative.name,
- ),
- input: attachTo.input,
- };
- }
- if ('id' in attachTo) {
- return { id: attachTo.id, input: attachTo.input };
- }
- throw new Error('Invalid attachment point specification');
+
+ return resolveSpec(attachTo);
}
/** @internal */
@@ -195,7 +205,7 @@ export function resolveExtensionDefinition<
return {
...rest,
- attachTo: resolveAttachTo(attachTo, namespace),
+ attachTo: resolveAttachTo(attachTo, namespace) as ExtensionAttachTo,
$$type: '@backstage/Extension',
version: internalDefinition.version,
id,