Skip to content
Merged
7 changes: 6 additions & 1 deletion code/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -373,16 +373,21 @@
"typescript": "^5.8.3",
"unique-string": "^3.0.0",
"use-resize-observer": "^9.1.0",
"vite-plus": "^0.1.16",
"watchpack": "^2.5.0",
"wrap-ansi": "^9.0.2",
"zod": "^3.25.76"
},
"peerDependencies": {
"prettier": "^2 || ^3"
"prettier": "^2 || ^3",
"vite-plus": "^0.1.15"
},
"peerDependenciesMeta": {
"prettier": {
"optional": true
},
"vite-plus": {
"optional": true
}
},
"publishConfig": {
Expand Down
22 changes: 18 additions & 4 deletions code/core/src/common/js-package-manager/JsPackageManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { findFilesUp, getProjectRoot } from '../utils/paths.ts';
import storybookPackagesVersions from '../versions.ts';
import type { PackageJson, PackageJsonWithDepsAndDevDeps } from './PackageJson.ts';
import type { InstallationMetadata } from './types.ts';
import { getVitePlusVersions } from './vite-plus-versions.ts';

export enum PackageManagerName {
NPM = 'npm',
Expand Down Expand Up @@ -649,16 +650,29 @@ export abstract class JsPackageManager {
}

logger.debug(`Getting installed version for ${packageName}...`);
const installations = await this.findInstallations([packageName]);
if (!installations) {

// When vite-plus is used, packages like vite and vitest are vendored and their node_modules entries report the vite-plus wrapper version (e.g. 0.1.16) instead of the actual vendored version.
// Check vite-plus first so we always get the real version when it's available.
let version: string | null = null;
const vitePlusVersions = await getVitePlusVersions();
if (vitePlusVersions?.[packageName]) {
version = vitePlusVersions[packageName]!;
}

if (!version) {
const installations = await this.findInstallations([packageName]);
if (installations) {
version = Object.entries(installations.dependencies)[0]?.[1]?.[0].version || null;
}
}

if (!version) {
logger.debug(`No installations found for ${packageName}`);
// Cache the null result
JsPackageManager.installedVersionCache.set(cacheKey, null);
return null;
}

const version = Object.entries(installations.dependencies)[0]?.[1]?.[0].version || null;

const coercedVersion = coerce(version, { includePrerelease: true })?.toString() ?? version;

logger.debug(`Installed version for ${packageName}: ${coercedVersion}`);
Expand Down
1 change: 1 addition & 0 deletions code/core/src/common/js-package-manager/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export * from './JsPackageManagerFactory.ts';
export * from './JsPackageManager.ts';
export * from './PackageJson.ts';
export * from './types.ts';
export * from './vite-plus-versions.ts';
99 changes: 99 additions & 0 deletions code/core/src/common/js-package-manager/vite-plus-versions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { afterEach, describe, expect, it, vi } from 'vitest';

import { clearVitePlusCache, getVitePlusVersions } from './vite-plus-versions.ts';

vi.mock('storybook/internal/node-logger', () => ({
logger: { debug: vi.fn() },
}));

describe('getVitePlusVersions', () => {
afterEach(() => {
clearVitePlusCache();
vi.restoreAllMocks();
});

it('returns versions when vite-plus/versions is importable', async () => {
vi.doMock('vite-plus/versions', () => ({
versions: { vite: '6.1.0', vitest: '3.2.0', rolldown: '0.5.0' },
}));

clearVitePlusCache();
const { getVitePlusVersions: fn } = await import('./vite-plus-versions.ts');
clearVitePlusCache();

const result = await fn();

expect(result).toEqual(
expect.objectContaining({
vite: '6.1.0',
vitest: '3.2.0',
})
);

vi.doUnmock('vite-plus/versions');
});

it('returns null when vite-plus/versions is not available', async () => {
vi.doMock('vite-plus/versions', () => {
throw new Error("Cannot find module 'vite-plus/versions'");
});

clearVitePlusCache();
const { getVitePlusVersions: fn } = await import('./vite-plus-versions.ts');
clearVitePlusCache();

const result = await fn();

expect(result).toBeNull();

vi.doUnmock('vite-plus/versions');
});

it('caches results across calls', async () => {
vi.doMock('vite-plus/versions', () => ({
versions: { vite: '6.1.0', vitest: '3.2.0' },
}));

clearVitePlusCache();
const { getVitePlusVersions: fn } = await import('./vite-plus-versions.ts');
clearVitePlusCache();

const result1 = await fn();
const result2 = await fn();

expect(result1).toEqual(result2);
// Same cached reference
expect(result1).toBe(result2);

vi.doUnmock('vite-plus/versions');
});

it('clearVitePlusCache resets the cache allowing new values', async () => {
vi.doMock('vite-plus/versions', () => ({
versions: { vite: '6.1.0', vitest: '3.2.0' },
}));

clearVitePlusCache();
const mod = await import('./vite-plus-versions.ts');
mod.clearVitePlusCache();

const result1 = await mod.getVitePlusVersions();
expect(result1?.vite).toBe('6.1.0');

vi.doUnmock('vite-plus/versions');

// After clearing, mock with different values
vi.doMock('vite-plus/versions', () => ({
versions: { vite: '7.0.0', vitest: '4.0.0' },
}));

mod.clearVitePlusCache();
const { getVitePlusVersions: fn2 } = await import('./vite-plus-versions.ts');
mod.clearVitePlusCache();

const result2 = await fn2();
expect(result2?.vite).toBe('7.0.0');

vi.doUnmock('vite-plus/versions');
});
});
37 changes: 37 additions & 0 deletions code/core/src/common/js-package-manager/vite-plus-versions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { logger } from 'storybook/internal/node-logger';

/** Cached result: undefined = not yet checked, null = not available */
let cachedVersions: Record<string, string> | null | undefined;

/**
* Attempts to load vendored package versions from `vite-plus/versions`.
*
* When a project uses vite-plus (typically via `"vite": "npm:vite-plus@..."`), vitest and vite are
* vendored rather than installed as separate packages. This function retrieves their actual versions
* from the `vite-plus/versions` subpath export.
*
* Returns null when vite-plus is not installed or lacks the `/versions` export (older versions).
*/
export async function getVitePlusVersions(): Promise<Record<string, string> | null> {
if (cachedVersions !== undefined) {
return cachedVersions;
}

try {
const mod = await import('vite-plus/versions');
const versions = mod.versions ?? mod;

if (versions && typeof versions.vite === 'string') {
logger.debug(`Detected vite-plus: vite=${versions.vite}, vitest=${versions.vitest ?? 'N/A'}`);
cachedVersions = versions;
return versions;
}
} catch {}

cachedVersions = null;
return null;
}

export function clearVitePlusCache(): void {
cachedVersions = undefined;
}
25 changes: 19 additions & 6 deletions code/lib/cli-storybook/src/autoblock/utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { JsPackageManager } from 'storybook/internal/common';
import { getVitePlusVersions } from 'storybook/internal/common';
import { CLI_COLORS } from 'storybook/internal/node-logger';

import picocolors from 'picocolors';
Expand Down Expand Up @@ -29,12 +30,24 @@ export async function findOutdatedPackage<M extends Record<string, string>>(
}
): Promise<false | Result<M>> {
const list = await Promise.all(
typedKeys(minimalVersionsMap).map(async (packageName) => ({
packageName,
installedVersion:
(await options.packageManager.getModulePackageJSON(packageName))?.version ?? null,
minimumVersion: minimalVersionsMap[packageName],
}))
typedKeys(minimalVersionsMap).map(async (packageName) => {
// When vite-plus is used, packages like vite are overridden and their
// package.json reports the vite-plus wrapper version (e.g. 0.1.16) instead
// of the actual vendored version. Check vite-plus first.
const vitePlusVersions = await getVitePlusVersions();
let installedVersion = vitePlusVersions?.[packageName] ?? null;

if (!installedVersion) {
const packageJson = await options.packageManager.getModulePackageJSON(packageName);
installedVersion = packageJson?.version ?? null;
}

return {
packageName,
installedVersion,
minimumVersion: minimalVersionsMap[packageName],
};
})
);

return list.reduce<false | Result<M>>(
Expand Down
Loading
Loading