Merge pull request #32496 from backstage/rugvip/internal

frontend-plugin-api: add new internal extension input option, complete app-react deprecations
This commit is contained in:
Patrik Oldsberg
2026-01-26 12:13:24 +01:00
committed by GitHub
70 changed files with 523 additions and 633 deletions
+8
View File
@@ -59,6 +59,14 @@ export type AppErrorTypes = {
inputName: string;
};
};
EXTENSION_INPUT_INTERNAL_IGNORED: {
context: {
node: AppNode;
inputName: string;
extensionId: string;
plugin: FrontendPlugin;
};
};
EXTENSION_ATTACHMENT_CONFLICT: {
context: {
node: AppNode;
@@ -142,6 +142,36 @@ function createV1Extension(opts: {
return ext;
}
function mirrorInputs(ctx: {
inputs: {
[name in string]:
| undefined
| ResolvedExtensionInput<ExtensionInput>
| Array<ResolvedExtensionInput<ExtensionInput>>;
};
}) {
return [
inputMirrorDataRef(
Object.fromEntries(
Object.entries(ctx.inputs).map(([k, v]) => [
k,
Array.isArray(v)
? v.map(vi => ({
node: vi.node,
test: vi.get(testDataRef),
other: vi.get(otherDataRef),
}))
: {
node: v?.node,
test: v?.get(testDataRef),
other: v?.get(otherDataRef),
},
]),
),
),
];
}
describe('instantiateAppNodeTree', () => {
describe('v1', () => {
const simpleExtension = createV1Extension({
@@ -237,6 +267,60 @@ describe('instantiateAppNodeTree', () => {
expect(childNode?.instance).toBeDefined();
});
it('should ignore non-matching plugin attachments for internal inputs', () => {
const otherPlugin = createFrontendPlugin({ pluginId: 'other' });
const tree = resolveAppTree(
'root-node',
[
makeSpec(
resolveExtensionDefinition(
createExtension({
attachTo: { id: 'ignored', input: 'ignored' },
inputs: {
test: createExtensionInput([testDataRef], {
singleton: true,
internal: true,
}),
},
output: [inputMirrorDataRef],
factory: mirrorInputs,
}),
{ namespace: 'root-node' },
),
),
makeSpec(simpleExtension, {
id: 'child-node-app',
attachTo: { id: 'root-node', input: 'test' },
}),
makeSpec(simpleExtension, {
id: 'child-node-other',
attachTo: { id: 'root-node', input: 'test' },
plugin: otherPlugin,
}),
],
collector,
);
instantiateAppNodeTree(tree.root, testApis, collector);
expect(tree.root.instance?.getData(inputMirrorDataRef)).toMatchObject({
test: { node: { spec: { id: 'child-node-app' } }, test: 'test' },
});
expect(collector.collectErrors()).toEqual([
{
code: 'EXTENSION_INPUT_INTERNAL_IGNORED',
message:
"extension 'child-node-other' from plugin 'other' attached to input 'test' on 'root-node' was ignored, the input is marked as internal and attached extensions must therefore be provided via an override or a module for the 'app' plugin, not the 'other' plugin",
context: {
node: tree.root,
inputName: 'test',
extensionId: 'child-node-other',
plugin: otherPlugin,
},
},
]);
});
it('should not instantiate disabled attachments', () => {
const tree = resolveAppTree(
'root-node',
@@ -783,36 +867,6 @@ describe('instantiateAppNodeTree', () => {
{ namespace: 'app' },
);
function mirrorInputs(ctx: {
inputs: {
[name in string]:
| undefined
| ResolvedExtensionInput<ExtensionInput>
| Array<ResolvedExtensionInput<ExtensionInput>>;
};
}) {
return [
inputMirrorDataRef(
Object.fromEntries(
Object.entries(ctx.inputs).map(([k, v]) => [
k,
Array.isArray(v)
? v.map(vi => ({
node: vi.node,
test: vi.get(testDataRef),
other: vi.get(otherDataRef),
}))
: {
node: v?.node,
test: v?.get(testDataRef),
other: v?.get(otherDataRef),
},
]),
),
),
];
}
it('should instantiate a single node', () => {
const tree = resolveAppTree(
'root-node',
@@ -247,10 +247,32 @@ function resolveV2Inputs(
inputMap: { [inputName in string]: ExtensionInput },
attachments: ReadonlyMap<string, AppNode[]>,
parentCollector: ErrorCollector<{ node: AppNode }>,
node: AppNode,
): ResolvedExtensionInputs<{ [inputName in string]: ExtensionInput }> {
return mapValues(inputMap, (input, inputName) => {
const attachedNodes = attachments.get(inputName) ?? [];
const allAttachedNodes = attachments.get(inputName) ?? [];
const collector = parentCollector.child({ inputName });
const inputPluginId = node.spec.plugin.id;
const attachedNodes = input.config.internal
? allAttachedNodes.filter(attachment => {
const attachmentPluginId = attachment.spec.plugin.id;
if (attachmentPluginId !== inputPluginId) {
collector.report({
code: 'EXTENSION_INPUT_INTERNAL_IGNORED',
message:
`extension '${attachment.spec.id}' from plugin '${attachmentPluginId}' attached to input '${inputName}' on '${node.spec.id}' was ignored, ` +
`the input is marked as internal and attached extensions must therefore be provided via an override or a module for the '${inputPluginId}' plugin, not the '${attachmentPluginId}' plugin`,
context: {
extensionId: attachment.spec.id,
plugin: attachment.spec.plugin,
},
});
return false;
}
return true;
})
: allAttachedNodes;
if (input.config.singleton) {
if (attachedNodes.length > 1) {
@@ -371,6 +393,7 @@ export function createAppNodeInstance(options: {
internalExtension.inputs,
attachments,
collector,
node,
),
};
const outputDataValues = options.extensionFactoryMiddleware
@@ -38,6 +38,14 @@ export type AppErrorTypes = {
EXTENSION_INPUT_DATA_MISSING: {
context: { node: AppNode; inputName: string };
};
EXTENSION_INPUT_INTERNAL_IGNORED: {
context: {
node: AppNode;
inputName: string;
extensionId: string;
plugin: FrontendPlugin;
};
};
EXTENSION_ATTACHMENT_CONFLICT: {
context: { node: AppNode; inputName: string };
};