diff --git a/packages/dashboard/vite/tests/meta-package.spec.ts b/packages/dashboard/vite/tests/meta-package.spec.ts index eb127d476b..072f75a676 100644 --- a/packages/dashboard/vite/tests/meta-package.spec.ts +++ b/packages/dashboard/vite/tests/meta-package.spec.ts @@ -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 @@ -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']); + }, + ); }); diff --git a/packages/dashboard/vite/tests/plugin-hooks.spec.ts b/packages/dashboard/vite/tests/plugin-hooks.spec.ts index 90e083a203..242d8c100a 100644 --- a/packages/dashboard/vite/tests/plugin-hooks.spec.ts +++ b/packages/dashboard/vite/tests/plugin-hooks.spec.ts @@ -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'; @@ -52,22 +53,41 @@ function callHandleHotUpdate(plugin: Plugin, ctx: Record) { // ─── 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) { + 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) { + 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) { + callConfigResolved(plugin, { + plugins: [createFakeConfigLoaderPlugin(pluginInfo, activePluginNames)], + }); return plugin; } @@ -342,8 +362,8 @@ describe('viteConfigPlugin', () => { // ─── dashboardMetadataPlugin ───────────────────────────────────────────────── describe('dashboardMetadataPlugin', () => { - function setupPlugin(pluginInfo: PluginInfo[]) { - return setupConfigLoaderPlugin(dashboardMetadataPlugin(), pluginInfo); + function setupPlugin(pluginInfo: PluginInfo[], activePluginNames?: Set) { + return setupConfigLoaderPlugin(dashboardMetadataPlugin(), pluginInfo, activePluginNames); } it('resolveId returns resolved ID for virtual:dashboard-extensions', () => { @@ -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 ────────────────────────────────────────────────────────────── @@ -515,8 +558,8 @@ describe('hmrPlugin', () => { // ─── dashboardTailwindSourcePlugin ─────────────────────────────────────────── describe('dashboardTailwindSourcePlugin', () => { - function setupPlugin(pluginInfo: PluginInfo[]) { - return setupConfigLoaderPlugin(dashboardTailwindSourcePlugin(), pluginInfo); + function setupPlugin(pluginInfo: PluginInfo[], activePluginNames?: Set) { + return setupConfigLoaderPlugin(dashboardTailwindSourcePlugin(), pluginInfo, activePluginNames); } const markerComment = @@ -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']); + }); }); diff --git a/packages/dashboard/vite/utils/get-active-plugin-info.ts b/packages/dashboard/vite/utils/get-active-plugin-info.ts new file mode 100644 index 0000000000..d8f7569b3d --- /dev/null +++ b/packages/dashboard/vite/utils/get-active-plugin-info.ts @@ -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, +): 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): Set { + const names = new Set(); + 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; +} diff --git a/packages/dashboard/vite/vite-plugin-dashboard-metadata.ts b/packages/dashboard/vite/vite-plugin-dashboard-metadata.ts index 694db6da23..f8758ce0f8 100644 --- a/packages/dashboard/vite/vite-plugin-dashboard-metadata.ts +++ b/packages/dashboard/vite/vite-plugin-dashboard-metadata.ts @@ -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'; @@ -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; @@ -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')} }`; diff --git a/packages/dashboard/vite/vite-plugin-lingui-babel.ts b/packages/dashboard/vite/vite-plugin-lingui-babel.ts index 1aa0a6b2f0..eef4fab6f1 100644 --- a/packages/dashboard/vite/vite-plugin-lingui-babel.ts +++ b/packages/dashboard/vite/vite-plugin-lingui-babel.ts @@ -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); @@ -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) { diff --git a/packages/dashboard/vite/vite-plugin-tailwind-source.ts b/packages/dashboard/vite/vite-plugin-tailwind-source.ts index 0bb891cc7c..16664d411d 100644 --- a/packages/dashboard/vite/vite-plugin-tailwind-source.ts +++ b/packages/dashboard/vite/vite-plugin-tailwind-source.ts @@ -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'; @@ -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) { diff --git a/packages/dashboard/vite/vite-plugin-translations.ts b/packages/dashboard/vite/vite-plugin-translations.ts index 7e3f819ba5..4b8eab735e 100644 --- a/packages/dashboard/vite/vite-plugin-translations.ts +++ b/packages/dashboard/vite/vite-plugin-translations.ts @@ -12,6 +12,7 @@ import type { Plugin } from 'vite'; import { PluginInfo } from './types.js'; 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'; @@ -80,8 +81,9 @@ export function translationsPlugin(options: TranslationsPluginOptions): Plugin { loadVendureConfigResult = await configLoaderApi.getVendureConfig(); } - const { pluginInfo } = loadVendureConfigResult; - cachedPluginTranslations = await getPluginTranslations(pluginInfo); + const { pluginInfo, vendureConfig } = loadVendureConfigResult; + const activePluginInfo = filterActivePluginInfo(pluginInfo, vendureConfig); + cachedPluginTranslations = await getPluginTranslations(activePluginInfo); cachedLinguiConfig = getConfig({ configPath: path.join(options.packageRoot, 'lingui.config.js'), }); @@ -115,11 +117,12 @@ export function translationsPlugin(options: TranslationsPluginOptions): Plugin { if (!loadVendureConfigResult) { loadVendureConfigResult = await configLoaderApi.getVendureConfig(); } - const { pluginInfo } = loadVendureConfigResult; + const { pluginInfo, vendureConfig } = loadVendureConfigResult; + const activePluginInfo = filterActivePluginInfo(pluginInfo, vendureConfig); // Reuse cached data from load hook when available const pluginTranslations = - cachedPluginTranslations ?? (await getPluginTranslations(pluginInfo)); + cachedPluginTranslations ?? (await getPluginTranslations(activePluginInfo)); const pluginTranslationFiles = pluginTranslations.flatMap(p => p.translations); this.info(`Found ${pluginTranslationFiles.length} translation files from plugins`); this.debug(pluginTranslationFiles.join('\n'));