Skip to content

Commit 28a771e

Browse files
committed
Extract topological sort into a separate util
1 parent 9a4c442 commit 28a771e

6 files changed

Lines changed: 271 additions & 265 deletions

File tree

packages/plugin/src/PluginUtils.test.tsx

Lines changed: 0 additions & 160 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,8 @@ import {
2828
getPluginModuleValue,
2929
processLoadedModule,
3030
registerPlugin,
31-
sortPluginsByDependency,
3231
createChainedComponent,
3332
createChainedPanelComponent,
34-
type PluginManifestPluginInfo,
3533
} from './PluginUtils';
3634

3735
function TestWidget() {
@@ -609,164 +607,6 @@ describe('processLoadedModule', () => {
609607
});
610608
});
611609

612-
describe('sortPluginsByDependency', () => {
613-
function makeManifestPlugin(
614-
name: string,
615-
opts?: {
616-
package?: string;
617-
dependencies?: string[];
618-
}
619-
): PluginManifestPluginInfo {
620-
return {
621-
name,
622-
main: 'index.js',
623-
version: '1.0.0',
624-
package: opts?.package,
625-
dependencies: opts?.dependencies,
626-
};
627-
}
628-
629-
it('returns plugins in original order when no dependencies', () => {
630-
const plugins = [
631-
makeManifestPlugin('a'),
632-
makeManifestPlugin('b'),
633-
makeManifestPlugin('c'),
634-
];
635-
636-
const sorted = sortPluginsByDependency(plugins);
637-
638-
expect(sorted.map(p => p.name)).toEqual(['a', 'b', 'c']);
639-
});
640-
641-
it('reorders so dependency loads before consumer', () => {
642-
const plugins = [
643-
makeManifestPlugin('consumer', {
644-
dependencies: ['@scope/dep'],
645-
}),
646-
makeManifestPlugin('dep', {
647-
package: '@scope/dep',
648-
}),
649-
];
650-
651-
const sorted = sortPluginsByDependency(plugins);
652-
653-
expect(sorted.map(p => p.name)).toEqual(['dep', 'consumer']);
654-
});
655-
656-
it('handles a chain of dependencies: a → b → c', () => {
657-
const plugins = [
658-
makeManifestPlugin('c', {
659-
package: '@scope/c',
660-
dependencies: ['@scope/b'],
661-
}),
662-
makeManifestPlugin('b', {
663-
package: '@scope/b',
664-
dependencies: ['@scope/a'],
665-
}),
666-
makeManifestPlugin('a', {
667-
package: '@scope/a',
668-
}),
669-
];
670-
671-
const sorted = sortPluginsByDependency(plugins);
672-
673-
const names = sorted.map(p => p.name);
674-
expect(names.indexOf('a')).toBeLessThan(names.indexOf('b'));
675-
expect(names.indexOf('b')).toBeLessThan(names.indexOf('c'));
676-
});
677-
678-
it('preserves original order among independent plugins', () => {
679-
const plugins = [
680-
makeManifestPlugin('x'),
681-
makeManifestPlugin('dep', { package: '@scope/dep' }),
682-
makeManifestPlugin('y'),
683-
makeManifestPlugin('consumer', { dependencies: ['@scope/dep'] }),
684-
makeManifestPlugin('z'),
685-
];
686-
687-
const sorted = sortPluginsByDependency(plugins);
688-
689-
const names = sorted.map(p => p.name);
690-
// dep must come before consumer
691-
expect(names.indexOf('dep')).toBeLessThan(names.indexOf('consumer'));
692-
// independent plugins keep their relative order
693-
expect(names.indexOf('x')).toBeLessThan(names.indexOf('y'));
694-
expect(names.indexOf('y')).toBeLessThan(names.indexOf('z'));
695-
});
696-
697-
it('throws on circular dependencies', () => {
698-
const plugins = [
699-
makeManifestPlugin('a', {
700-
package: '@scope/a',
701-
dependencies: ['@scope/b'],
702-
}),
703-
makeManifestPlugin('b', {
704-
package: '@scope/b',
705-
dependencies: ['@scope/a'],
706-
}),
707-
];
708-
709-
expect(() => sortPluginsByDependency(plugins)).toThrow(
710-
/Circular plugin dependency/
711-
);
712-
});
713-
714-
it('warns and ignores dependencies not in the manifest', () => {
715-
const plugins = [
716-
makeManifestPlugin('consumer', {
717-
dependencies: ['@scope/nonexistent'],
718-
}),
719-
];
720-
721-
const sorted = sortPluginsByDependency(plugins);
722-
723-
expect(sorted.map(p => p.name)).toEqual(['consumer']);
724-
});
725-
726-
it('handles multiple dependencies', () => {
727-
const plugins = [
728-
makeManifestPlugin('consumer', {
729-
dependencies: ['@scope/dep-a', '@scope/dep-b'],
730-
}),
731-
makeManifestPlugin('dep-a', { package: '@scope/dep-a' }),
732-
makeManifestPlugin('dep-b', { package: '@scope/dep-b' }),
733-
];
734-
735-
const sorted = sortPluginsByDependency(plugins);
736-
737-
const names = sorted.map(p => p.name);
738-
expect(names.indexOf('dep-a')).toBeLessThan(names.indexOf('consumer'));
739-
expect(names.indexOf('dep-b')).toBeLessThan(names.indexOf('consumer'));
740-
});
741-
742-
it('does not mutate the input array', () => {
743-
const plugins = [
744-
makeManifestPlugin('consumer', { dependencies: ['@scope/dep'] }),
745-
makeManifestPlugin('dep', { package: '@scope/dep' }),
746-
];
747-
const original = [...plugins];
748-
749-
sortPluginsByDependency(plugins);
750-
751-
expect(plugins).toEqual(original);
752-
});
753-
754-
it('handles empty plugin list', () => {
755-
expect(sortPluginsByDependency([])).toEqual([]);
756-
});
757-
758-
it('handles plugins with empty dependencies array', () => {
759-
const plugins = [
760-
makeManifestPlugin('a', { dependencies: [] }),
761-
makeManifestPlugin('b'),
762-
];
763-
764-
const sorted = sortPluginsByDependency(plugins);
765-
766-
expect(sorted.map(p => p.name)).toEqual(['a', 'b']);
767-
});
768-
});
769-
770610
describe('createChainedComponent', () => {
771611
function BaseWidget({ fetch }: WidgetComponentProps) {
772612
return <div data-testid="base">BaseWidget</div>;

packages/plugin/src/PluginUtils.tsx

Lines changed: 0 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -263,111 +263,6 @@ export type PluginManifestPluginInfo = {
263263

264264
export type PluginManifest = { plugins: PluginManifestPluginInfo[] };
265265

266-
/**
267-
* Topologically sort plugins so that dependencies are loaded before the
268-
* plugins that depend on them. Plugins without dependencies or whose
269-
* dependencies are not in the manifest keep their original relative order
270-
* (stable sort). Throws if a dependency cycle is detected.
271-
*
272-
* @param plugins The plugin list from the manifest
273-
* @returns A new array with plugins sorted so dependencies come first
274-
*/
275-
export function sortPluginsByDependency<
276-
T extends Pick<PluginManifestPluginInfo, 'name' | 'package' | 'dependencies'>,
277-
>(plugins: readonly T[]): T[] {
278-
// Build a lookup from package name → plugin index
279-
const packageToIndex = new Map<string, number>();
280-
plugins.forEach((p, i) => {
281-
if (p.package != null) {
282-
packageToIndex.set(p.package, i);
283-
}
284-
});
285-
286-
// Build adjacency list: index → indices it depends on
287-
const depIndices = new Map<number, number[]>();
288-
plugins.forEach((p, i) => {
289-
if (p.dependencies != null && p.dependencies.length > 0) {
290-
const resolved: number[] = [];
291-
p.dependencies.forEach(dep => {
292-
const idx = packageToIndex.get(dep);
293-
if (idx != null) {
294-
resolved.push(idx);
295-
} else {
296-
log.warn(
297-
`Plugin '${p.name}' depends on '${dep}' which is not in the manifest`
298-
);
299-
}
300-
});
301-
if (resolved.length > 0) {
302-
depIndices.set(i, resolved);
303-
}
304-
}
305-
});
306-
307-
// If no plugin has in-manifest dependencies, return original order
308-
if (depIndices.size === 0) {
309-
return [...plugins];
310-
}
311-
312-
// Kahn's algorithm for topological sort (stable — preserves original order
313-
// among plugins at the same dependency depth)
314-
const inDegree = new Array<number>(plugins.length).fill(0);
315-
316-
// Reverse adjacency: who depends on me?
317-
const dependents = new Map<number, number[]>();
318-
depIndices.forEach((deps, idx) => {
319-
deps.forEach(dep => {
320-
if (!dependents.has(dep)) {
321-
dependents.set(dep, []);
322-
}
323-
const depList = dependents.get(dep);
324-
if (depList != null) {
325-
depList.push(idx);
326-
}
327-
inDegree[idx] += 1;
328-
});
329-
});
330-
331-
// Seed queue with all nodes that have no in-manifest dependencies,
332-
// in their original order
333-
const queue: number[] = [];
334-
for (let i = 0; i < plugins.length; i += 1) {
335-
if (inDegree[i] === 0) {
336-
queue.push(i);
337-
}
338-
}
339-
340-
const sorted: T[] = [];
341-
while (queue.length > 0) {
342-
const idx = queue.shift();
343-
if (idx == null) {
344-
break;
345-
}
346-
sorted.push(plugins[idx]);
347-
const deps = dependents.get(idx);
348-
if (deps != null) {
349-
// Process dependents in original manifest order for stability
350-
deps.sort((a, b) => a - b);
351-
deps.forEach(depIdx => {
352-
inDegree[depIdx] -= 1;
353-
if (inDegree[depIdx] === 0) {
354-
queue.push(depIdx);
355-
}
356-
});
357-
}
358-
}
359-
360-
if (sorted.length !== plugins.length) {
361-
// Find the cycle participants for a useful error message
362-
const inCycle = plugins.filter((_, i) => inDegree[i] > 0).map(p => p.name);
363-
throw new Error(
364-
`Circular plugin dependency detected among: ${inCycle.join(', ')}`
365-
);
366-
}
367-
368-
return sorted;
369-
}
370-
371266
function hasDefaultExport(value: unknown): value is { default: Plugin } {
372267
return (
373268
typeof value === 'object' &&

packages/plugin/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export * from './PluginsContext';
22
export * from './PluginTypes';
33
export * from './PluginUtils';
4+
export * from './pluginSortUtil';
45
export * from './TablePlugin';
56
export * from './useCustomThemes';
67
export * from './useDashboardPlugins';
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './sortPluginsByDependency';

0 commit comments

Comments
 (0)