Keep internal array attachTo support, only simplify public types

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
Made-with: Cursor
This commit is contained in:
Patrik Oldsberg
2026-03-05 11:51:10 +01:00
parent a9440f0622
commit 01929491d7
5 changed files with 201 additions and 55 deletions
@@ -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(`
"<a>
x [
<b>
x [
<c>
x [
<d />
]
</c>
<d />
]
</b>
<c>
x [
<d />
]
</c>
]
</a>"
`);
const orphans = Array.from(tree.orphans).map(String);
expect(orphans).toMatchInlineSnapshot(`[]`);
});
it('should create a tree out of order', () => {
const tree = resolveAppTree(
'b',
@@ -153,6 +153,7 @@ export function resolveAppTree(
}
const orphans = new Array<SerializableAppNode>();
const clones = new Map<string, Array<SerializableAppNode>>();
// 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)) {
@@ -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 = '<detached-input>';
} else {
let id = '<plugin>';
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 '<detached-input>';
}
let id = '<plugin>';
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 = '<plugin>';
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 = '<plugin>';
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(',')}}`;
},
@@ -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',
},
]);
});
});
@@ -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,