Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions packages/dashboard/vite/tests/meta-package.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import tsconfigPaths from 'tsconfig-paths';
import { describe, expect, it } from 'vitest';

import { compile } from '../utils/compiler.js';
import { filterActivePluginInfo } from '../utils/get-active-plugin-info.js';
import { debugLogger, noopLogger } from '../utils/logger.js';

// #4542 — scanner should follow transitive dependencies of imported packages
Expand Down Expand Up @@ -96,4 +97,38 @@ describe('detecting plugins via meta-package transitive dependencies', () => {
expect(result.pluginInfo).toHaveLength(3);
},
);

// #4706 — discovery walks transitive deps and finds all three child plugins,
// but `MetaPlugin.init()` only returns ChildPluginA and ChildPluginB to the
// runtime config. The consumer filter should drop ChildPluginC.
it(
'filterActivePluginInfo drops plugins discovered but not in runtime config',
{ timeout: 60_000 },
async () => {
const tempDir = join(__dirname, './__temp/meta-package-active-filter');
await rm(tempDir, { recursive: true, force: true });

tsconfigPaths.register({
baseUrl: fakeNodeModules,
paths: {
'meta-plugin': [join(fakeNodeModules, 'meta-plugin')],
},
});

const result = await compile({
outputPath: tempDir,
vendureConfigPath: join(__dirname, 'fixtures-meta-package', 'vendure-config.ts'),
logger,
pluginPackageScanner: {
nodeModulesRoot: fakeNodeModules,
},
});

expect(result.pluginInfo).toHaveLength(3);
const activeNames = filterActivePluginInfo(result.pluginInfo, result.vendureConfig)
.map(p => p.name)
.sort();
expect(activeNames).toEqual(['ChildPluginA', 'ChildPluginB']);
},
);
});
135 changes: 127 additions & 8 deletions packages/dashboard/vite/tests/plugin-hooks.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { Plugin } from 'vite';
import { describe, expect, it, vi } from 'vitest';

import { PluginInfo } from '../types.js';
import { filterActivePluginInfo } from '../utils/get-active-plugin-info.js';
import { viteConfigPlugin } from '../vite-plugin-config.js';
import { dashboardMetadataPlugin } from '../vite-plugin-dashboard-metadata.js';
import { hmrPlugin } from '../vite-plugin-hmr.js';
Expand Down Expand Up @@ -52,22 +53,41 @@ function callHandleHotUpdate(plugin: Plugin, ctx: Record<string, any>) {

// ─── Shared test factories ──────────────────────────────────────────────────

function createFakeConfigLoaderPlugin(pluginInfo: PluginInfo[]) {
/**
* Builds a fake `VendureConfig.plugins` array whose class names match the
* supplied PluginInfo entries, so that `filterActivePluginInfo` treats every
* entry as active by default. Pass an explicit `activePluginNames` set to
* mark only a subset as active (used to test filtering behaviour).
*/
function buildFakePluginsArray(pluginInfo: PluginInfo[], activePluginNames?: Set<string>) {
return pluginInfo
.filter(p => (activePluginNames ? activePluginNames.has(p.name) : true))
.map(p => {
const cls = class {};
Object.defineProperty(cls, 'name', { value: p.name });
return cls;
});
}

function createFakeConfigLoaderPlugin(pluginInfo: PluginInfo[], activePluginNames?: Set<string>) {
const plugins = buildFakePluginsArray(pluginInfo, activePluginNames);
return {
name: 'vendure:config-loader',
api: {
getVendureConfig: () =>
Promise.resolve({
pluginInfo,
vendureConfig: {},
vendureConfig: { plugins },
exportedSymbolName: 'config',
}),
},
};
}

function setupConfigLoaderPlugin(plugin: Plugin, pluginInfo: PluginInfo[]) {
callConfigResolved(plugin, { plugins: [createFakeConfigLoaderPlugin(pluginInfo)] });
function setupConfigLoaderPlugin(plugin: Plugin, pluginInfo: PluginInfo[], activePluginNames?: Set<string>) {
callConfigResolved(plugin, {
plugins: [createFakeConfigLoaderPlugin(pluginInfo, activePluginNames)],
});
return plugin;
}

Expand Down Expand Up @@ -342,8 +362,8 @@ describe('viteConfigPlugin', () => {
// ─── dashboardMetadataPlugin ─────────────────────────────────────────────────

describe('dashboardMetadataPlugin', () => {
function setupPlugin(pluginInfo: PluginInfo[]) {
return setupConfigLoaderPlugin(dashboardMetadataPlugin(), pluginInfo);
function setupPlugin(pluginInfo: PluginInfo[], activePluginNames?: Set<string>) {
return setupConfigLoaderPlugin(dashboardMetadataPlugin(), pluginInfo, activePluginNames);
}

it('resolveId returns resolved ID for virtual:dashboard-extensions', () => {
Expand Down Expand Up @@ -422,6 +442,29 @@ describe('dashboardMetadataPlugin', () => {
const result = await callLoad(plugin, fakeContext, 'some-other-id');
expect(result).toBeUndefined();
});

// #4706 — only plugins present in the runtime VendureConfig.plugins array
// should have their dashboard extensions bundled.
it('load filters out discovered plugins not active in vendureConfig.plugins', async () => {
const plugin = setupPlugin(
[
{ name: 'ActivePlugin', pluginPath: '/a/plugin.js', dashboardEntryPath: './ui.tsx' },
{
name: 'InactivePlugin',
pluginPath: '/b/plugin.js',
dashboardEntryPath: './ui.tsx',
},
],
new Set(['ActivePlugin']),
);
const fakeContext = { debug: noop, info: noop };
const result = await callLoad(plugin, fakeContext, '\0virtual:dashboard-extensions');
const expectedActive = pathToFileURL(path.resolve('/a', './ui.tsx')).toString();
const expectedInactive = pathToFileURL(path.resolve('/b', './ui.tsx')).toString();
expect(result).toContain(expectedActive);
expect(result).not.toContain(expectedInactive);
expect((result.match(/await import/g) || []).length).toBe(1);
});
});

// ─── hmrPlugin ──────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -515,8 +558,8 @@ describe('hmrPlugin', () => {
// ─── dashboardTailwindSourcePlugin ───────────────────────────────────────────

describe('dashboardTailwindSourcePlugin', () => {
function setupPlugin(pluginInfo: PluginInfo[]) {
return setupConfigLoaderPlugin(dashboardTailwindSourcePlugin(), pluginInfo);
function setupPlugin(pluginInfo: PluginInfo[], activePluginNames?: Set<string>) {
return setupConfigLoaderPlugin(dashboardTailwindSourcePlugin(), pluginInfo, activePluginNames);
}

const markerComment =
Expand Down Expand Up @@ -572,4 +615,80 @@ describe('dashboardTailwindSourcePlugin', () => {
.some((l: string) => l.trimStart().startsWith("@source '"));
expect(hasSourceDirective).toBe(false);
});

// #4706 — Tailwind @source directives should only be emitted for plugins
// actually present in the runtime VendureConfig.plugins array.
it('emits @source directives only for plugins active in vendureConfig.plugins', async () => {
const plugin = setupPlugin(
[
{
name: 'ActivePlugin',
pluginPath: '/active/plugin.js',
dashboardEntryPath: './dashboard/index.tsx',
},
{
name: 'InactivePlugin',
pluginPath: '/inactive/plugin.js',
dashboardEntryPath: './dashboard/index.tsx',
},
],
new Set(['ActivePlugin']),
);
const css = `@tailwind base;\n${markerComment}\n@tailwind components;`;
const result = await callTransformWithContext(plugin, {}, css, '/some/app/styles.css');
const sourceLines: string[] = result.code
.split('\n')
.filter((l: string) => l.trimStart().startsWith("@source '"));
expect(sourceLines.some((l: string) => l.includes("'/active/dashboard'"))).toBe(true);
expect(sourceLines.some((l: string) => l.includes('/inactive/'))).toBe(false);
});
});

// ─── filterActivePluginInfo ──────────────────────────────────────────────────

describe('filterActivePluginInfo', () => {
function makePluginInfo(name: string): PluginInfo {
return { name, pluginPath: `/${name}.js`, dashboardEntryPath: './ui.tsx' };
}

function makePluginClass(name: string) {
const cls = class {};
Object.defineProperty(cls, 'name', { value: name });
return cls;
}

it('keeps only entries whose class name appears in vendureConfig.plugins', () => {
const result = filterActivePluginInfo(
[makePluginInfo('A'), makePluginInfo('B'), makePluginInfo('C')],
{ plugins: [makePluginClass('A'), makePluginClass('C')] },
);
expect(result.map(p => p.name)).toEqual(['A', 'C']);
});

it('returns an empty array when vendureConfig.plugins is empty', () => {
const result = filterActivePluginInfo([makePluginInfo('A')], { plugins: [] });
expect(result).toEqual([]);
});

it('treats missing plugins array as no active plugins', () => {
const result = filterActivePluginInfo([makePluginInfo('A')], {} as { plugins?: undefined });
expect(result).toEqual([]);
});

// Some plugins return a NestJS DynamicModule from their `init()` method
// (`{ module: SomePluginClass, providers: [...] }`); the helper must
// unwrap that to read the class name.
it('reads class name from DynamicModule entries', () => {
const result = filterActivePluginInfo([makePluginInfo('DynamicOne')], {
plugins: [{ module: makePluginClass('DynamicOne') } as any],
});
expect(result.map(p => p.name)).toEqual(['DynamicOne']);
});

it('ignores entries without a resolvable class name', () => {
const result = filterActivePluginInfo([makePluginInfo('A'), makePluginInfo('B')], {
plugins: [null as any, undefined as any, {} as any, makePluginClass('A')],
});
expect(result.map(p => p.name)).toEqual(['A']);
});
});
66 changes: 66 additions & 0 deletions packages/dashboard/vite/utils/get-active-plugin-info.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import type { VendureConfig } from '@vendure/core';

import { PluginInfo } from '../types.js';

/**
* @description
* Filters the statically discovered `pluginInfo` against the plugins actually
* present in the runtime `VendureConfig.plugins` array.
*
* The dashboard's static-import-based plugin discovery walks the import graph
* starting from `vendure-config.ts` and additionally expands `package.json`
* `dependencies` of every imported package. That means it can find dashboard
* extensions for plugins that are reachable through imports but are not
* actually bootstrapped at runtime — e.g. when plugins are conditionally
* included based on options, env vars or feature flags, or when a wrapper
* package lists optional plugins as dependencies.
*
* Without this filter, those non-active plugins would still have their
* dashboard extensions, translations and Tailwind sources bundled into the
* built dashboard, while their server-side resolvers, GraphQL types and
* services would be absent — leading to broken nav items and runtime
* crashes inside the dashboard.
*
* @internal
*/
export function filterActivePluginInfo(
pluginInfo: PluginInfo[],
vendureConfig: Pick<VendureConfig, 'plugins'>,
): PluginInfo[] {
const activePluginNames = getActivePluginNames(vendureConfig);
return pluginInfo.filter(info => activePluginNames.has(info.name));
}

/**
* Returns the set of class names of the plugins active in the runtime config.
*
* Each entry in `VendureConfig.plugins` is either:
* - a class decorated with `@VendurePlugin` (the most common pattern,
* including the return value of `SomePlugin.init(opts)` which by
* convention returns the class itself), or
* - a NestJS `DynamicModule` of the shape `{ module: SomePluginClass, ... }`,
* which some plugins use to return additional providers/imports.
*
* Matching by class name (rather than by class reference) is necessary because
* the runtime config side and the static-discovery side load plugin modules
* through different import paths and therefore see distinct class objects.
* Two installed plugin packages sharing a class name would be indistinguishable
* here, but the same limitation already exists in the discovery step (which
* also keys on `name`), so this filter does not regress anything. If a future
* change tracks `(pluginPath, name)` tuples through discovery, this filter
* should be updated to match on the same key.
*/
function getActivePluginNames(vendureConfig: Pick<VendureConfig, 'plugins'>): Set<string> {
const names = new Set<string>();
for (const entry of vendureConfig.plugins ?? []) {
const pluginClass =
typeof entry === 'function'
? (entry as { name?: string })
: ((entry as { module?: { name?: string } } | null)?.module ?? undefined);
const name = pluginClass?.name;
if (name) {
names.add(name);
}
}
return names;
}
10 changes: 6 additions & 4 deletions packages/dashboard/vite/vite-plugin-dashboard-metadata.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import path from 'path';
import { pathToFileURL } from 'node:url';
import path from 'path';
import { Plugin } from 'vite';

import { CompileResult } from './utils/compiler.js';
import { filterActivePluginInfo } from './utils/get-active-plugin-info.js';
import { ConfigLoaderApi, getConfigLoaderApi } from './vite-plugin-config-loader.js';

const virtualModuleId = 'virtual:dashboard-extensions';
Expand Down Expand Up @@ -37,10 +38,11 @@ export function dashboardMetadataPlugin(): Plugin {
this.debug(`Loaded Vendure config in ${Date.now() - configStart}ms`);
}

const { pluginInfo } = loadVendureConfigResult;
const { pluginInfo, vendureConfig } = loadVendureConfigResult;
const activePluginInfo = filterActivePluginInfo(pluginInfo, vendureConfig);
const resolveStart = Date.now();
const pluginsWithExtensions =
pluginInfo
activePluginInfo
?.map(({ dashboardEntryPath, pluginPath, sourcePluginPath }) => {
if (!dashboardEntryPath) {
return null;
Expand All @@ -64,7 +66,7 @@ export function dashboardMetadataPlugin(): Plugin {
export async function runDashboardExtensions() {
${pluginsWithExtensions
.map(extension => {
return `await import(\`${pathToFileURL(extension)}\`);`;
return `await import(\`${pathToFileURL(extension).toString()}\`);`;
})
.join('\n')}
}`;
Expand Down
10 changes: 8 additions & 2 deletions packages/dashboard/vite/vite-plugin-lingui-babel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { createRequire } from 'node:module';
import type { Plugin } from 'vite';

import { CompileResult } from './utils/compiler.js';
import { filterActivePluginInfo } from './utils/get-active-plugin-info.js';
import { ConfigLoaderApi, getConfigLoaderApi } from './vite-plugin-config-loader.js';

const require = createRequire(import.meta.url);
Expand Down Expand Up @@ -98,8 +99,13 @@ export function linguiBabelPlugin(options?: LinguiBabelPluginOptions): Plugin {
if (configLoaderApi && !configResult) {
try {
configResult = await configLoaderApi.getVendureConfig();
// Extract package paths from discovered npm plugins
for (const plugin of configResult.pluginInfo) {
// Extract package paths from discovered npm plugins,
// restricted to plugins active in the runtime config.
const activePluginInfo = filterActivePluginInfo(
configResult.pluginInfo,
configResult.vendureConfig,
);
for (const plugin of activePluginInfo) {
if (!plugin.sourcePluginPath && plugin.pluginPath.includes('node_modules')) {
const packagePath = extractPackagePath(plugin.pluginPath);
if (packagePath) {
Expand Down
6 changes: 4 additions & 2 deletions packages/dashboard/vite/vite-plugin-tailwind-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import path from 'node:path';
import { Plugin } from 'vite';

import { CompileResult } from './utils/compiler.js';
import { filterActivePluginInfo } from './utils/get-active-plugin-info.js';
import { getDashboardPaths } from './utils/get-dashboard-paths.js';
import { ConfigLoaderApi, getConfigLoaderApi } from './vite-plugin-config-loader.js';
import { fixWindowsPath } from './vite-plugin-vendure-dashboard.js';
Expand Down Expand Up @@ -48,8 +49,9 @@ export function dashboardTailwindSourcePlugin(): Plugin {
if (!loadVendureConfigResult) {
loadVendureConfigResult = await configLoaderApi.getVendureConfig();
}
const { pluginInfo } = loadVendureConfigResult;
const dashboardExtensionDirs = getDashboardPaths(pluginInfo);
const { pluginInfo, vendureConfig } = loadVendureConfigResult;
const activePluginInfo = filterActivePluginInfo(pluginInfo, vendureConfig);
const dashboardExtensionDirs = getDashboardPaths(activePluginInfo);

const vendureUiSrcPath = resolveVendureUiSourcePath();
if (vendureUiSrcPath) {
Expand Down
Loading
Loading