refactor(frontend-app-api): throw error when there are duplicated extensions

Signed-off-by: Camila Belo <camilaibs@gmail.com>
This commit is contained in:
Camila Belo
2023-09-22 10:08:41 +02:00
parent 7041414b06
commit 6142e35c96
2 changed files with 62 additions and 231 deletions
@@ -16,8 +16,6 @@
import {
createExtension,
createExtensionDataRef,
createExtensionInput,
createPageExtension,
createPlugin,
} from '@backstage/frontend-plugin-api';
@@ -72,228 +70,37 @@ describe('createInstances', () => {
);
});
describe('should deduplicated plugins', () => {
it('in an immediate dependency level (e.g., A -> A)', () => {
const config = new MockConfigApi({});
it('throws an error when duplicated extensions are detected', () => {
const config = new MockConfigApi({});
const addonExtensionData =
createExtensionDataRef<JSX.Element>('plugin.page.addon');
const CyclicalA = createExtension({
id: 'A', // Same id as ancestor A
at: 'A/addons',
inputs: {},
output: {
element: addonExtensionData,
},
factory({ bind }) {
bind({
element: <div key="cyclical-a">Cyclical A</div>,
});
},
});
const A = createPageExtension({
id: 'A',
defaultPath: '/',
routeRef: createRouteRef({ id: 'A.route' }),
inputs: {
addons: createExtensionInput({
element: addonExtensionData,
}),
},
loader: async ({ inputs }) => (
<div>A {inputs.addons.map(({ element }) => element)}</div>
),
});
const plugins = [
createPlugin({
id: 'plugin',
extensions: [A, CyclicalA],
}),
];
// It should not create an infinite loop
expect(() => createInstances({ config, plugins })).not.toThrow(
'Maximum call stack size exceeded',
);
const ExtensionA = createPageExtension({
id: 'A',
defaultPath: '/',
routeRef: createRouteRef({ id: 'A.route' }),
loader: async () => <div>Extension A</div>,
});
it('in an intermediate level (e.g., A -> B -> A)', () => {
const config = new MockConfigApi({});
const addonExtensionData =
createExtensionDataRef<JSX.Element>('plugin.page.addon');
const CyclicalA = createExtension({
id: 'A', // Same id as ancestor A
at: 'B/addons',
inputs: {
addons: createExtensionInput({
element: addonExtensionData,
}),
},
output: {
element: addonExtensionData,
},
factory({ bind }) {
bind({
element: <div key="cyclical-a">Cyclical A</div>,
});
},
});
const B = createExtension({
id: 'B',
at: 'A/addons',
inputs: {
addons: createExtensionInput({
element: addonExtensionData,
}),
},
output: {
element: addonExtensionData,
},
factory({ bind }) {
bind({
element: <div key="b">B</div>,
});
},
});
const A = createPageExtension({
id: 'A',
defaultPath: '/',
routeRef: createRouteRef({ id: 'A.route' }),
inputs: {
addons: createExtensionInput({
element: addonExtensionData,
}),
},
loader: async ({ inputs }) => (
<div>A {inputs.addons.map(({ element }) => element)}</div>
),
});
const plugins = [
createPlugin({
id: 'plugin',
extensions: [A, B, CyclicalA],
}),
];
// It should not create an infinite loop
expect(() => createInstances({ config, plugins })).not.toThrow(
'Maximum call stack size exceeded',
);
const ExtensionB = createPageExtension({
id: 'B',
defaultPath: '/',
routeRef: createRouteRef({ id: 'B.route' }),
loader: async () => <div>Extension B</div>,
});
it('in an deep level (e.g., A -> B -> C -> D -> B)', () => {
const config = new MockConfigApi({});
const addonRendererInput =
createExtensionDataRef<() => JSX.Element>('A.addon.renderer');
const addonElementInput =
createExtensionDataRef<JSX.Element>('A.addon.element');
const CyclicalB = createExtension({
id: 'B', // Same id as ancestor B
at: 'D/addons',
inputs: {},
output: {
element: addonElementInput,
},
factory({ bind }) {
bind({
element: <div key="cyclical-b">Cyclical B</div>,
});
},
});
const D = createExtension({
id: 'D',
at: 'C/addons',
inputs: {
addons: createExtensionInput({
element: addonElementInput,
}),
},
output: {
element: addonElementInput,
},
factory({ bind }) {
bind({
element: <div key="d">D</div>,
});
},
});
const C = createExtension({
id: 'C',
at: 'B/renderer',
inputs: {
addons: createExtensionInput({
element: addonElementInput,
}),
},
output: {
renderer: addonRendererInput,
},
factory({ bind }) {
bind({
renderer: () => <div key="c">C</div>,
});
},
});
const B = createExtension({
id: 'B',
at: 'A/addons',
inputs: {
renderer: createExtensionInput(
{
element: addonRendererInput,
},
{ singleton: true, required: false },
),
},
output: {
element: addonElementInput,
},
factory({ bind, inputs }) {
bind({
element: inputs.renderer?.element() ?? <div key="b">B</div>,
});
},
});
const A = createPageExtension({
id: 'A',
defaultPath: '/',
routeRef: createRouteRef({ id: 'A.route' }),
inputs: {
addons: createExtensionInput({
element: addonElementInput,
}),
},
loader: async ({ inputs }) => (
<div>A {inputs.addons.map(({ element }) => element)}</div>
),
});
const plugins = [
createPlugin({
id: 'plugin',
extensions: [A, B, C, D, CyclicalB],
}),
];
// It should not create an infinite loop
expect(() => createInstances({ config, plugins })).not.toThrow(
'Maximum call stack size exceeded',
);
const PluginA = createPlugin({
id: 'A',
extensions: [ExtensionA, ExtensionA],
});
const PluginB = createPlugin({
id: 'B',
extensions: [ExtensionA, ExtensionB, ExtensionB],
});
const plugins = [PluginA, PluginB];
expect(() => createInstances({ config, plugins })).toThrow(
"The following extensions are duplicated: The extension 'A' was provided 2 time(s) by the plugin 'A' and 1 time(s) by the plugin 'B', The extension 'B' was provided 2 time(s) by the plugin 'B'",
);
});
});
@@ -196,19 +196,8 @@ export function mergeExtensionParameters(options: {
}): ExtensionInstanceParameters[] {
const { sources, builtinExtensions, parameters } = options;
const pluginExtensionIds = new Set<string>();
const pluginExtensions = sources.flatMap(source => {
return source.extensions
.filter(extension => {
// Filter out duplicated extensions
const id = extension.id;
if (!pluginExtensionIds.has(id)) {
pluginExtensionIds.add(id);
return true;
}
return false;
})
.map(extension => ({ ...extension, source }));
return source.extensions.map(extension => ({ ...extension, source }));
});
// Prevent root override
@@ -244,6 +233,41 @@ export function mergeExtensionParameters(options: {
})),
];
const duplicatedExtensionIds = new Set<string>();
const duplicatedExtensionData = overrides.reduce<
Record<string, Record<string, number>>
>((data, { extension, params }) => {
const extensionId = extension.id;
const extensionData = data?.[extensionId];
if (extensionData) duplicatedExtensionIds.add(extensionId);
const pluginId = params.source?.id ?? 'internal';
const pluginCount = extensionData?.[pluginId] ?? 0;
return {
...data,
[extensionId]: { ...extensionData, [pluginId]: pluginCount + 1 },
};
}, {});
if (duplicatedExtensionIds.size > 0) {
throw new Error(
`The following extensions are duplicated: ${Array.from(
duplicatedExtensionIds,
)
.map(
extensionId =>
`The extension '${extensionId}' was provided ${Object.keys(
duplicatedExtensionData[extensionId],
)
.map(
pluginId =>
`${duplicatedExtensionData[extensionId][pluginId]} time(s) by the plugin '${pluginId}'`,
)
.join(' and ')}`,
)
.join(', ')}`,
);
}
for (const overrideParam of parameters) {
const extensionId = overrideParam.id;