From a9440f06224380ce3350e0c520e85170958cead3 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 5 Mar 2026 10:37:05 +0100 Subject: [PATCH] Simplify the `ExtensionAttachTo` type Simplified the `ExtensionAttachTo` type to only support a single attachment target, removing the array form for attaching to multiple extension points. Also removed the deprecated `ExtensionAttachToSpec` type alias. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .changeset/simplify-extension-attach-to.md | 5 ++ .../src/collectEntityPageContents.test.tsx | 4 +- .../src/tree/resolveAppTree.test.ts | 54 -------------- .../src/tree/resolveAppTree.ts | 41 ---------- packages/frontend-plugin-api/report.api.md | 18 ++--- .../src/wiring/createExtension.ts | 60 +++++++-------- .../frontend-plugin-api/src/wiring/index.ts | 1 - .../wiring/resolveExtensionDefinition.test.ts | 37 ---------- .../src/wiring/resolveExtensionDefinition.ts | 74 +++++++------------ 9 files changed, 68 insertions(+), 226 deletions(-) create mode 100644 .changeset/simplify-extension-attach-to.md diff --git a/.changeset/simplify-extension-attach-to.md b/.changeset/simplify-extension-attach-to.md new file mode 100644 index 0000000000..2beb00d056 --- /dev/null +++ b/.changeset/simplify-extension-attach-to.md @@ -0,0 +1,5 @@ +--- +'@backstage/frontend-plugin-api': minor +--- + +**BREAKING**: Simplified the `ExtensionAttachTo` type to only support a single attachment target. The array form for attaching to multiple extension points has been removed. Also removed the deprecated `ExtensionAttachToSpec` type alias. diff --git a/packages/core-compat-api/src/collectEntityPageContents.test.tsx b/packages/core-compat-api/src/collectEntityPageContents.test.tsx index fd74a7e89b..5ee1439360 100644 --- a/packages/core-compat-api/src/collectEntityPageContents.test.tsx +++ b/packages/core-compat-api/src/collectEntityPageContents.test.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { ExtensionAttachToSpec } from '@backstage/frontend-plugin-api'; +import { ExtensionAttachTo } from '@backstage/frontend-plugin-api'; import { EntityLayout, EntitySwitch, isKind } from '@backstage/plugin-catalog'; import { JSX } from 'react'; import { collectEntityPageContents } from './collectEntityPageContents'; @@ -73,7 +73,7 @@ const otherTestContent = ( function collect(element: JSX.Element) { const result = new Array<{ id: string; - attachTo: ExtensionAttachToSpec; + attachTo: ExtensionAttachTo; }>(); collectEntityPageContents(element, { diff --git a/packages/frontend-app-api/src/tree/resolveAppTree.test.ts b/packages/frontend-app-api/src/tree/resolveAppTree.test.ts index bffc156509..80d478646f 100644 --- a/packages/frontend-app-api/src/tree/resolveAppTree.test.ts +++ b/packages/frontend-app-api/src/tree/resolveAppTree.test.ts @@ -137,60 +137,6 @@ 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' }, - ], - }, - { - ...baseSpec, - id: 'd', - attachTo: [ - { id: 'b', input: 'x' }, - { id: 'c', input: 'x' }, - ], - }, - ], - 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 c2054c4bac..bdd9be0fbf 100644 --- a/packages/frontend-app-api/src/tree/resolveAppTree.ts +++ b/packages/frontend-app-api/src/tree/resolveAppTree.ts @@ -153,7 +153,6 @@ 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; @@ -164,46 +163,6 @@ 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/report.api.md b/packages/frontend-plugin-api/report.api.md index c83511fc47..83c4035e3c 100644 --- a/packages/frontend-plugin-api/report.api.md +++ b/packages/frontend-plugin-api/report.api.md @@ -924,7 +924,7 @@ export interface Extension { // (undocumented) $$type: '@backstage/Extension'; // (undocumented) - readonly attachTo: ExtensionAttachToSpec; + readonly attachTo: ExtensionAttachTo; // (undocumented) readonly configSchema?: PortableSchema; // (undocumented) @@ -934,18 +934,10 @@ export interface Extension { } // @public (undocumented) -export type ExtensionAttachTo = - | { - id: string; - input: string; - } - | Array<{ - id: string; - input: string; - }>; - -// @public @deprecated (undocumented) -export type ExtensionAttachToSpec = ExtensionAttachTo; +export type ExtensionAttachTo = { + id: string; + input: string; +}; // @public (undocumented) export interface ExtensionBlueprint< diff --git a/packages/frontend-plugin-api/src/wiring/createExtension.ts b/packages/frontend-plugin-api/src/wiring/createExtension.ts index 4d3eeece2c..42611e78ca 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtension.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtension.ts @@ -488,40 +488,36 @@ export function createExtension< if (options.name) { parts.push(`name=${options.name}`); } - 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}`; + 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}`; } - 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}`; + if (context?.name) { + id = `${id}/${context?.name}`; } - if ('id' in a) { - return `${a.id}@${a.input}`; - } - throw new Error('Invalid attachment point specification'); - }) - .join('+'); + 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'); + } parts.push(`attachTo=${attachTo}`); return `ExtensionDefinition{${parts.join(',')}}`; }, diff --git a/packages/frontend-plugin-api/src/wiring/index.ts b/packages/frontend-plugin-api/src/wiring/index.ts index 6ce1bb4998..3ec4631543 100644 --- a/packages/frontend-plugin-api/src/wiring/index.ts +++ b/packages/frontend-plugin-api/src/wiring/index.ts @@ -58,7 +58,6 @@ export { export { type Extension, type ExtensionAttachTo, - type ExtensionAttachToSpec, } from './resolveExtensionDefinition'; export { type ExtensionDataContainer, diff --git a/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.test.ts b/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.test.ts index e080ab1f64..79275ab03c 100644 --- a/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.test.ts +++ b/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.test.ts @@ -139,43 +139,6 @@ 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 605e75bfe8..a314983b25 100644 --- a/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.ts +++ b/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.ts @@ -30,21 +30,13 @@ import { } from '@internal/frontend'; /** @public */ -export type ExtensionAttachTo = - | { id: string; input: string } - | Array<{ id: string; input: string }>; - -/** - * @deprecated Use {@link ExtensionAttachTo} instead. - * @public - */ -export type ExtensionAttachToSpec = ExtensionAttachTo; +export type ExtensionAttachTo = { id: string; input: string }; /** @public */ export interface Extension { $$type: '@backstage/Extension'; readonly id: string; - readonly attachTo: ExtensionAttachToSpec; + readonly attachTo: ExtensionAttachTo; readonly disabled: boolean; readonly configSchema?: PortableSchema; } @@ -149,45 +141,35 @@ function resolveExtensionId( } function resolveAttachTo( - attachTo: ExtensionDefinitionAttachTo | ExtensionDefinitionAttachTo[], + attachTo: ExtensionDefinitionAttachTo, namespace?: string, -): ExtensionAttachToSpec { - 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, - }; +): 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', + ); } - 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); + return { + id: resolveExtensionId(context.kind, namespace, context.name), + input: context.input, + }; } - - return resolveSpec(attachTo); + 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'); } /** @internal */