From 9827e9686b4578ff921944548718274dfb74d1d6 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Tue, 10 Feb 2026 16:11:32 +0100 Subject: [PATCH 01/28] Centralize Vite plugins for builder-vite and addon-vitest --- code/addons/vitest/src/vitest-plugin/index.ts | 40 ++----- code/builders/builder-vite/src/build.ts | 3 +- .../src/codegen-modern-iframe-script.ts | 1 + .../src/codegen-project-annotations.ts | 97 +++++++++++++++++ code/builders/builder-vite/src/envs.ts | 19 ---- .../src/plugins/code-generator-plugin.ts | 9 +- .../builder-vite/src/plugins/index.ts | 18 ++-- .../src/plugins/inject-export-order-plugin.ts | 3 +- .../src/plugins/storybook-config-plugin.ts | 86 +++++++++++++++ .../src/plugins/storybook-docgen-plugin.ts | 32 ++++++ .../src/plugins/storybook-entry-plugin.ts | 36 +++++++ .../plugins/storybook-optimize-deps-plugin.ts | 52 +++++++++ .../storybook-project-annotations-plugin.ts | 49 +++++++++ .../src/plugins/storybook-runtime-plugin.ts | 74 +++++++++++++ .../src/plugins/strip-story-hmr-boundaries.ts | 4 +- code/builders/builder-vite/src/preset.ts | 71 +++++++++---- .../builder-vite/src/virtual-file-names.ts | 1 + .../builder-vite/src/vite-config.test.ts | 100 ++++++++++++++---- code/builders/builder-vite/src/vite-config.ts | 79 +++++--------- code/builders/builder-vite/src/vite-server.ts | 5 +- 20 files changed, 619 insertions(+), 160 deletions(-) create mode 100644 code/builders/builder-vite/src/codegen-project-annotations.ts create mode 100644 code/builders/builder-vite/src/plugins/storybook-config-plugin.ts create mode 100644 code/builders/builder-vite/src/plugins/storybook-docgen-plugin.ts create mode 100644 code/builders/builder-vite/src/plugins/storybook-entry-plugin.ts create mode 100644 code/builders/builder-vite/src/plugins/storybook-optimize-deps-plugin.ts create mode 100644 code/builders/builder-vite/src/plugins/storybook-project-annotations-plugin.ts create mode 100644 code/builders/builder-vite/src/plugins/storybook-runtime-plugin.ts diff --git a/code/addons/vitest/src/vitest-plugin/index.ts b/code/addons/vitest/src/vitest-plugin/index.ts index b07edb2bfd00..96f2676645ad 100644 --- a/code/addons/vitest/src/vitest-plugin/index.ts +++ b/code/addons/vitest/src/vitest-plugin/index.ts @@ -30,8 +30,9 @@ import path from 'pathe'; import picocolors from 'picocolors'; import sirv from 'sirv'; import { dedent } from 'ts-dedent'; +import type { PluginOption } from 'vite'; -// ! Relative import to prebundle it without needing to depend on the Vite builder +// Shared plugins from builder-vite (relative import to prebundle without adding a package dependency) import { withoutVitePlugins } from '../../../../builders/builder-vite/src/utils/without-vite-plugins'; import type { InternalOptions, UserOptions } from './types'; @@ -194,27 +195,27 @@ export const storybookTest = async (options?: UserOptions): Promise => const stories = await presets.apply('stories', []); - // We can probably add more config here. See code/builders/builder-vite/src/vite-config.ts - // This one is specifically needed for code/builders/builder-vite/src/preset.ts const commonConfig = { root: resolve(finalOptions.configDir, '..') }; const [ + corePlugins, { storiesGlobs }, framework, viteConfigFromStorybook, staticDirs, previewLevelTags, core, - extraOptimizeDeps, features, ] = await Promise.all([ + // Core Storybook Vite plugins from builder-vite's preset + // (resolve conditions, envPrefix, fs.allow, project annotations, docgen, external globals) + presets.apply('viteCorePlugins', []), getStoryGlobsAndFiles(presets, directories), presets.apply('framework', undefined), presets.apply<{ plugins?: Plugin[]; root: string }>('viteFinal', commonConfig), presets.apply('staticDirs', []), extractTagsFromPreview(finalOptions.configDir), presets.apply('core'), - presets.apply('optimizeViteDeps', []), presets.apply('features', {}), ]); @@ -230,7 +231,10 @@ export const storybookTest = async (options?: UserOptions): Promise => } // filter out plugins that we know are unnecesary for tests, eg. docgen plugins - const plugins = await withoutVitePlugins(viteConfigFromStorybook.plugins ?? [], pluginsToIgnore); + const plugins: Plugin[] = [ + ...(corePlugins as Plugin[]), + ...(await withoutVitePlugins(viteConfigFromStorybook.plugins ?? [], pluginsToIgnore)), + ]; if (finalOptions.disableAddonDocs) { plugins.push(mdxStubPlugin); @@ -382,26 +386,8 @@ export const storybookTest = async (options?: UserOptions): Promise => }, }, - envPrefix: Array.from( - new Set([...(nonMutableInputConfig.envPrefix || []), 'STORYBOOK_', 'VITE_']) - ), - - resolve: { - conditions: [ - 'storybook', - 'stories', - 'test', - // copying straight from https://github.com/vitejs/vite/blob/main/packages/vite/src/node/constants.ts#L60 - // to avoid having to maintain Vite as a dependency just for this - 'module', - 'browser', - 'development|production', - ], - }, - optimizeDeps: { include: [ - ...extraOptimizeDeps, '@storybook/addon-vitest/internal/setup-file', '@storybook/addon-vitest/internal/global-setup', '@storybook/addon-vitest/internal/test-utils', @@ -419,11 +405,7 @@ export const storybookTest = async (options?: UserOptions): Promise => }, }; - // Merge config from storybook with the plugin config - const config: Omit = mergeConfig( - baseConfig, - viteConfigFromStorybook - ); + const config = mergeConfig(baseConfig, viteConfigFromStorybook); // alert the user of problems if ((nonMutableInputConfig.test?.include?.length ?? 0) > 0) { diff --git a/code/builders/builder-vite/src/build.ts b/code/builders/builder-vite/src/build.ts index f25f14857b61..1f419e038f09 100644 --- a/code/builders/builder-vite/src/build.ts +++ b/code/builders/builder-vite/src/build.ts @@ -4,7 +4,6 @@ import type { Options } from 'storybook/internal/types'; import { dedent } from 'ts-dedent'; import type { InlineConfig } from 'vite'; -import { sanitizeEnvVars } from './envs'; import { createViteLogger } from './logger'; import type { WebpackStatsPlugin } from './plugins'; import { hasVitePlugins } from './utils/has-vite-plugins'; @@ -90,7 +89,7 @@ export async function build(options: Options) { finalConfig.customLogger ??= await createViteLogger(); - await viteBuild(await sanitizeEnvVars(options, finalConfig)); + await viteBuild(finalConfig); const statsPlugin = findPlugin( finalConfig, diff --git a/code/builders/builder-vite/src/codegen-modern-iframe-script.ts b/code/builders/builder-vite/src/codegen-modern-iframe-script.ts index e063c3504c64..dd8643be1056 100644 --- a/code/builders/builder-vite/src/codegen-modern-iframe-script.ts +++ b/code/builders/builder-vite/src/codegen-modern-iframe-script.ts @@ -127,6 +127,7 @@ export async function generateModernIframeScriptCodeFromPreviews(options: { import { importFn } from '${SB_VIRTUAL_FILES.VIRTUAL_STORIES_FILE}'; ${options.isCsf4 ? previewFileImport : imports.join('\n')} + // Use import { getProjectAnnotations } from '${SB_VIRTUAL_FILES.VIRTUAL_PROJECT_ANNOTATIONS_FILE}'; instead ${getPreviewAnnotationsFunction} window.__STORYBOOK_PREVIEW__ = window.__STORYBOOK_PREVIEW__ || new PreviewWeb(importFn, getProjectAnnotations); diff --git a/code/builders/builder-vite/src/codegen-project-annotations.ts b/code/builders/builder-vite/src/codegen-project-annotations.ts new file mode 100644 index 000000000000..42c0b030fb8b --- /dev/null +++ b/code/builders/builder-vite/src/codegen-project-annotations.ts @@ -0,0 +1,97 @@ +import { getFrameworkName, loadPreviewOrConfigFile } from 'storybook/internal/common'; +import { isCsfFactoryPreview, readConfig } from 'storybook/internal/csf-tools'; +import type { Options, PreviewAnnotation } from 'storybook/internal/types'; + +import { genImport, genSafeVariableName } from 'knitwork'; +import { filename } from 'pathe/utils'; +import { dedent } from 'ts-dedent'; + +import { processPreviewAnnotation } from './utils/process-preview-annotation'; + +/** + * Generates the code for the `PROJECT_ANNOTATIONS_FILE` virtual module. + * + * This virtual module encapsulates the `getProjectAnnotations` function which composes all preview + * annotations (from addons, frameworks, and the user's preview file) into a single configuration + * object used by Storybook's runtime. + * + * The generated module can be imported as: + * + * ```ts + * import { getProjectAnnotations } from 'virtual:/@storybook/builder-vite/project-annotations.js'; + * ``` + * + * This decouples the project annotations logic from the main iframe entry script, making it + * reusable by other consumers (e.g., addon-vitest). + */ +export async function generateProjectAnnotationsCode(options: Options, projectRoot: string) { + const { presets, configDir } = options; + const frameworkName = await getFrameworkName(options); + + const previewOrConfigFile = loadPreviewOrConfigFile({ configDir }); + const previewConfig = previewOrConfigFile ? await readConfig(previewOrConfigFile) : undefined; + const isCsf4 = previewConfig ? isCsfFactoryPreview(previewConfig) : false; + + const previewAnnotations = await presets.apply( + 'previewAnnotations', + [], + options + ); + + return generateProjectAnnotationsCodeFromPreviews({ + previewAnnotations: [...previewAnnotations, previewOrConfigFile], + projectRoot, + frameworkName, + isCsf4, + }); +} + +export function generateProjectAnnotationsCodeFromPreviews(options: { + previewAnnotations: (PreviewAnnotation | undefined)[]; + projectRoot: string; + frameworkName: string; + isCsf4: boolean; +}) { + const { projectRoot } = options; + const previewAnnotationURLs = options.previewAnnotations + .filter((path) => path !== undefined) + .map((path) => processPreviewAnnotation(path, projectRoot)); + + const variables: string[] = []; + const imports: string[] = []; + for (const previewAnnotation of previewAnnotationURLs) { + const variable = + genSafeVariableName(filename(previewAnnotation)).replace(/_(45|46|47)/g, '_') + + '_' + + hash(previewAnnotation); + variables.push(variable); + imports.push(genImport(previewAnnotation, { name: '*', as: variable })); + } + + const previewFileVariable = variables[variables.length - 1]; + const previewFileImport = imports[imports.length - 1]; + + if (options.isCsf4) { + return dedent` + ${previewFileImport} + + export function getProjectAnnotations() { + return ${previewFileVariable}.default.composed; + } + `.trim(); + } + + return dedent` + import { composeConfigs } from 'storybook/preview-api'; + + ${imports.join('\n')} + + export function getProjectAnnotations() { + return composeConfigs([${variables.join(', ')}]); + } + `.trim(); +} + +function hash(value: string) { + return value.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0); +} diff --git a/code/builders/builder-vite/src/envs.ts b/code/builders/builder-vite/src/envs.ts index 41716949b81b..4946c341a981 100644 --- a/code/builders/builder-vite/src/envs.ts +++ b/code/builders/builder-vite/src/envs.ts @@ -39,22 +39,3 @@ export function stringifyProcessEnvs(raw: Builder_EnvsRaw, envPrefix: ViteConfig return envs; } - -// Sanitize environment variables if needed -export async function sanitizeEnvVars(options: Options, config: ViteConfig) { - const { presets } = options; - const envsRaw = await presets.apply>('env'); - let { define } = config; - if (Object.keys(envsRaw).length) { - // Stringify env variables after getting `envPrefix` from the config - const envs = stringifyProcessEnvs(envsRaw, config.envPrefix); - define = { - ...define, - ...envs, - }; - } - return { - ...config, - define, - } as ViteConfig; -} diff --git a/code/builders/builder-vite/src/plugins/code-generator-plugin.ts b/code/builders/builder-vite/src/plugins/code-generator-plugin.ts index 2af433d0c90b..5205a0806ca2 100644 --- a/code/builders/builder-vite/src/plugins/code-generator-plugin.ts +++ b/code/builders/builder-vite/src/plugins/code-generator-plugin.ts @@ -17,7 +17,7 @@ import { getResolvedVirtualModuleId, } from '../virtual-file-names'; -export function codeGeneratorPlugin(options: Options): Plugin { +export function codeGeneratorPlugin(options: Options) { const iframePath = fileURLToPath(importMetaResolve('@storybook/builder-vite/input/iframe.html')); let iframeId: string; let projectRoot: string; @@ -57,7 +57,10 @@ export function codeGeneratorPlugin(options: Options): Plugin { iframeId = `${config.root}/iframe.html`; }, resolveId(source) { - if (SB_VIRTUAL_FILE_IDS.includes(source)) { + if ( + source !== SB_VIRTUAL_FILES.VIRTUAL_PROJECT_ANNOTATIONS_FILE && + SB_VIRTUAL_FILE_IDS.includes(source) + ) { return getResolvedVirtualModuleId(source); } if (source === iframePath) { @@ -94,5 +97,5 @@ export function codeGeneratorPlugin(options: Options): Plugin { } return transformIframeHtml(html, options); }, - }; + } satisfies Plugin; } diff --git a/code/builders/builder-vite/src/plugins/index.ts b/code/builders/builder-vite/src/plugins/index.ts index bc72dc8755d5..ef9336b8036c 100644 --- a/code/builders/builder-vite/src/plugins/index.ts +++ b/code/builders/builder-vite/src/plugins/index.ts @@ -1,6 +1,12 @@ -export * from './inject-export-order-plugin'; -export * from './strip-story-hmr-boundaries'; -export * from './code-generator-plugin'; -export * from './csf-plugin'; -export * from './external-globals-plugin'; -export * from './webpack-stats-plugin'; +// Builder-internal plugins (used by vite-config.ts to assemble the builder's plugin stack) +export { storybookOptimizeDepsPlugin } from './storybook-optimize-deps-plugin'; +export { storybookEntryPlugin } from './storybook-entry-plugin'; +export { pluginWebpackStats } from './webpack-stats-plugin'; +export type { WebpackStatsPlugin } from './webpack-stats-plugin'; + +// Lower-level plugins re-exported for internal use and tests +export { injectExportOrderPlugin } from './inject-export-order-plugin'; +export { stripStoryHMRBoundary } from './strip-story-hmr-boundaries'; +export { codeGeneratorPlugin } from './code-generator-plugin'; +export { csfPlugin } from './csf-plugin'; +export { externalGlobalsPlugin, rewriteImport } from './external-globals-plugin'; diff --git a/code/builders/builder-vite/src/plugins/inject-export-order-plugin.ts b/code/builders/builder-vite/src/plugins/inject-export-order-plugin.ts index 01b835ff6187..91091a86811b 100644 --- a/code/builders/builder-vite/src/plugins/inject-export-order-plugin.ts +++ b/code/builders/builder-vite/src/plugins/inject-export-order-plugin.ts @@ -1,5 +1,6 @@ import { parse } from 'es-module-lexer'; import MagicString from 'magic-string'; +import type { Plugin } from 'vite'; export async function injectExportOrderPlugin() { const { createFilter } = await import('vite'); @@ -35,5 +36,5 @@ export async function injectExportOrderPlugin() { map: s.generateMap({ hires: true, source: id }), }; }, - }; + } satisfies Plugin; } diff --git a/code/builders/builder-vite/src/plugins/storybook-config-plugin.ts b/code/builders/builder-vite/src/plugins/storybook-config-plugin.ts new file mode 100644 index 000000000000..4253ddadd423 --- /dev/null +++ b/code/builders/builder-vite/src/plugins/storybook-config-plugin.ts @@ -0,0 +1,86 @@ +import { resolve } from 'node:path'; + +import { isPreservingSymlinks, resolvePathInStorybookCache } from 'storybook/internal/common'; + +import type { Plugin } from 'vite'; + +/** + * Options for the Storybook config plugin. + * + * This plugin provides the base Storybook-specific Vite configuration, including resolve + * conditions, environment variable prefixes, and filesystem access rules. It is designed to be + * shared between `@storybook/builder-vite` and `@storybook/addon-vitest`. + */ +export interface StorybookConfigPluginOptions { + /** The Storybook configuration directory (e.g., '.storybook') */ + configDir: string; + /** + * Cache key for the Vite cache directory. When set, cacheDir is resolved via Storybook's cache + * using the `sb-vite` prefix. Omit to let the caller handle cache directory configuration. + */ + cacheKey?: string; + /** + * Base public path for the Vite dev server. When set, overrides Vite's default base. For + * builder-vite, this is typically './'. Omit to keep the existing base from the user's config. + */ + base?: string; + /** + * Whether to set the Vite root to the parent of the config directory. Defaults to `true`. Set to + * `false` when the root is managed externally (e.g., by vitest). + */ + setRoot?: boolean; +} + +/** + * A Vite plugin that provides the base Storybook configuration. + * + * This handles: + * + * - Optionally setting the project root to the parent of the Storybook config directory + * - Optionally configuring the Vite cache directory + * - Adding Storybook resolve conditions (`storybook`, `stories`, `test`) + * - Setting up environment variable prefixes (`VITE_`, `STORYBOOK_`) + * - Allowing the Storybook config directory in Vite's filesystem restrictions + * - Preserving symlinks when applicable + */ +export function storybookConfigPlugin(options: StorybookConfigPluginOptions): Plugin[] { + const projectRoot = resolve(options.configDir, '..'); + + return [ + { + name: 'storybook:config-plugin', + enforce: 'pre', + async config(config) { + const { defaultClientConditions = [] } = await import('vite'); + + return { + ...(options.cacheKey + ? { cacheDir: resolvePathInStorybookCache('sb-vite', options.cacheKey) } + : {}), + ...(options.setRoot !== false ? { root: projectRoot } : {}), + ...(options.base !== undefined ? { base: options.base } : {}), + resolve: { + conditions: ['storybook', 'stories', 'test', ...defaultClientConditions], + preserveSymlinks: isPreservingSymlinks(), + }, + // If an envPrefix is specified in the user's vite config, add STORYBOOK_ to it. + // Otherwise, add both VITE_ and STORYBOOK_ so that Vite doesn't lose its default. + envPrefix: config.envPrefix ? ['STORYBOOK_'] : ['VITE_', 'STORYBOOK_'], + }; + }, + }, + { + name: 'storybook:allow-storybook-dir', + enforce: 'post', + config(config) { + // If there is NO allow list then Vite allows anything in the root directory. + // If there IS an allow list then Vite only allows the listed directories. + // We add the storybook config directory only if there's already an allow list, + // to avoid disallowing the root unless it's already restricted. + if (config?.server?.fs?.allow) { + config.server.fs.allow.push(options.configDir); + } + }, + }, + ]; +} diff --git a/code/builders/builder-vite/src/plugins/storybook-docgen-plugin.ts b/code/builders/builder-vite/src/plugins/storybook-docgen-plugin.ts new file mode 100644 index 000000000000..8844dea41646 --- /dev/null +++ b/code/builders/builder-vite/src/plugins/storybook-docgen-plugin.ts @@ -0,0 +1,32 @@ +import type { Options } from 'storybook/internal/types'; + +import { vite } from '@storybook/csf-plugin'; + +import type { Plugin } from 'vite'; + +/** + * A Vite plugin that handles the extraction of component metadata (argTypes, descriptions) for + * Storybook's documentation features. + * + * This wraps `@storybook/csf-plugin` and configures it based on Storybook's addon-docs options and + * CSF enrichment settings. The plugin processes CSF (Component Story Format) files to extract + * component metadata that powers Storybook's docs pages and controls. + * + * This plugin is designed to be shared between `@storybook/builder-vite` and + * `@storybook/addon-vitest`. + */ +export async function storybookDocgenPlugin(options: Options): Promise { + const { presets } = options; + + const addons = await presets.apply('addons', []); + const docsOptions = + // @ts-expect-error - not sure what type to use here + addons.find((a) => [a, a.name].includes('@storybook/addon-docs'))?.options ?? {}; + + const enrichCsf = await presets.apply('experimental_enrichCsf'); + + return vite({ + ...docsOptions?.csfPluginOptions, + enrichCsf, + }) as Plugin; +} diff --git a/code/builders/builder-vite/src/plugins/storybook-entry-plugin.ts b/code/builders/builder-vite/src/plugins/storybook-entry-plugin.ts new file mode 100644 index 000000000000..45d419d07199 --- /dev/null +++ b/code/builders/builder-vite/src/plugins/storybook-entry-plugin.ts @@ -0,0 +1,36 @@ +import type { Options } from 'storybook/internal/types'; + +import type { Plugin } from 'vite'; + +import { codeGeneratorPlugin } from './code-generator-plugin'; +import { injectExportOrderPlugin } from './inject-export-order-plugin'; +import { stripStoryHMRBoundary } from './strip-story-hmr-boundaries'; + +/** + * A composite Vite plugin that manages the generation and injection of virtual entry points for + * Storybook stories. This is builder-specific and NOT shared with addon-vitest. + * + * This handles: + * + * - Virtual module resolution for story imports, addon setup, and the main app entry + * - Story import function generation (dynamic imports for code splitting) + * - Iframe HTML transformation and build entry configuration + * - Story index watching for HMR invalidation + * - Export order injection (`__namedExportsOrder`) for consistent story discovery + * - HMR boundary stripping to prevent stories from being treated as HMR boundaries + * + * Note: The project annotations virtual module is provided separately by the `viteCorePlugins` + * preset so that it can be shared with addon-vitest. + * + * @returns An array of Vite plugins with appropriate enforcement ordering + */ +export async function storybookEntryPlugin(options: Options): Promise { + return [ + // Pre-enforcement: handles virtual module resolution and loading (must run first) + codeGeneratorPlugin(options), + // Post-enforcement: injects __namedExportsOrder after TypeScript transpilation + await injectExportOrderPlugin(), + // Post-enforcement: removes import.meta.hot.accept() from story files + await stripStoryHMRBoundary(), + ]; +} diff --git a/code/builders/builder-vite/src/plugins/storybook-optimize-deps-plugin.ts b/code/builders/builder-vite/src/plugins/storybook-optimize-deps-plugin.ts new file mode 100644 index 000000000000..083c5bfc6fe8 --- /dev/null +++ b/code/builders/builder-vite/src/plugins/storybook-optimize-deps-plugin.ts @@ -0,0 +1,52 @@ +import type { StoryIndexGenerator } from 'storybook/internal/core-server'; +import type { Options, StoryIndex } from 'storybook/internal/types'; + +import type { Plugin } from 'vite'; + +import { INCLUDE_CANDIDATES } from '../constants'; +import { getUniqueImportPaths } from '../utils/unique-import-paths'; + +/** + * A Vite plugin that configures dependency optimization for Storybook's dev server. + * + * This handles: + * + * - Setting optimizeDeps entries from the story index (so Vite knows which stories to pre-bundle) + * - Including known CJS dependencies that need to be pre-compiled to ESM + * - Merging extra optimization dependencies from Storybook presets + * + * This plugin only applies in development mode (`command === 'serve'`). In production builds, + * Rollup handles dependency bundling differently. + */ +export function storybookOptimizeDepsPlugin(options: Options): Plugin { + return { + name: 'storybook:optimize-deps-plugin', + async config(config, { command }) { + // optimizeDeps only applies to the dev server, not production builds + if (command !== 'serve') { + return; + } + + const [extraOptimizeDeps, storyIndexGenerator] = await Promise.all([ + options.presets.apply('optimizeViteDeps', []), + options.presets.apply('storyIndexGenerator'), + ]); + + const index: StoryIndex = await storyIndexGenerator.getIndex(); + + return { + optimizeDeps: { + // Story file paths as entry points for the optimizer + entries: getUniqueImportPaths(index), + // Known CJS dependencies that need to be pre-compiled to ESM, + // plus any extra deps from Storybook presets. + include: [ + ...INCLUDE_CANDIDATES, + ...extraOptimizeDeps, + ...(config.optimizeDeps?.include || []), + ], + }, + }; + }, + }; +} diff --git a/code/builders/builder-vite/src/plugins/storybook-project-annotations-plugin.ts b/code/builders/builder-vite/src/plugins/storybook-project-annotations-plugin.ts new file mode 100644 index 000000000000..8714d2b28c04 --- /dev/null +++ b/code/builders/builder-vite/src/plugins/storybook-project-annotations-plugin.ts @@ -0,0 +1,49 @@ +import type { Options } from 'storybook/internal/types'; + +import type { Plugin } from 'vite'; + +import { generateProjectAnnotationsCode } from '../codegen-project-annotations'; +import { SB_VIRTUAL_FILES, getResolvedVirtualModuleId } from '../virtual-file-names'; + +const VIRTUAL_ID = SB_VIRTUAL_FILES.VIRTUAL_PROJECT_ANNOTATIONS_FILE; +const RESOLVED_VIRTUAL_ID = getResolvedVirtualModuleId(VIRTUAL_ID); + +/** + * A Vite plugin that serves the project annotations virtual module. + * + * This plugin handles the `virtual:/@storybook/builder-vite/project-annotations.js` virtual module, + * which exports a `getProjectAnnotations` function that composes all preview annotations (from + * addons, frameworks, and the user's preview file) into a single configuration object. + * + * The virtual module can be imported as: + * + * ```ts + * import { getProjectAnnotations } from 'virtual:/@storybook/builder-vite/project-annotations.js'; + * ``` + * + * This plugin is extracted from the builder-specific code-generator-plugin so that it can be shared + * with `@storybook/addon-vitest` and other consumers that need access to the composed project + * annotations without the full builder entry-point machinery (iframe handling, story index + * watching, etc.). + */ +export function storybookProjectAnnotationsPlugin(options: Options): Plugin { + let projectRoot: string; + + return { + name: 'storybook:project-annotations-plugin', + enforce: 'pre', + configResolved(config) { + projectRoot = config.root; + }, + resolveId(source) { + if (source === VIRTUAL_ID) { + return RESOLVED_VIRTUAL_ID; + } + }, + async load(id) { + if (id === RESOLVED_VIRTUAL_ID) { + return generateProjectAnnotationsCode(options, projectRoot); + } + }, + }; +} diff --git a/code/builders/builder-vite/src/plugins/storybook-runtime-plugin.ts b/code/builders/builder-vite/src/plugins/storybook-runtime-plugin.ts new file mode 100644 index 000000000000..be4701826b74 --- /dev/null +++ b/code/builders/builder-vite/src/plugins/storybook-runtime-plugin.ts @@ -0,0 +1,74 @@ +import { globalsNameReferenceMap } from 'storybook/internal/preview/globals'; +import type { Builder_EnvsRaw } from 'storybook/internal/types'; +import type { Options } from 'storybook/internal/types'; + +import type { Plugin } from 'vite'; + +import { stringifyProcessEnvs } from '../envs'; +import { externalGlobalsPlugin } from './external-globals-plugin'; + +/** + * Options for the Storybook runtime plugin. + * + * This plugin injects necessary globals and environment variables for Storybook's runtime. It is + * designed to be shared between `@storybook/builder-vite` and `@storybook/addon-vitest`. + */ +export interface StorybookRuntimePluginOptions { + /** + * Map of external module names to their global variable reference names. + * + * Storybook preview modules are pre-bundled and exposed as globals at runtime. This map tells the + * plugin how to transform imports of those modules into destructured global variable references. + * + * @example + * + * ``` + * { "storybook/preview-api": "__STORYBOOK_MODULE_PREVIEW_API__" } + * ``` + */ + externals: Record; + + /** + * Pre-resolved environment variables to inject as `import.meta.env.*` defines. + * + * When provided, these are filtered by the resolved `envPrefix` from the Vite config and injected + * into Vite's `define` option. + * + * This allows callers to resolve env vars from their own source (e.g., Storybook presets) and + * pass them in without the plugin needing access to the presets system. + */ + envs?: Builder_EnvsRaw; +} + +/** + * A composite Vite plugin that injects necessary globals and environment variables for Storybook's + * runtime. + * + * This handles: + * + * - Transforming imports of pre-bundled Storybook preview modules to global variable references + * (e.g., `import { useMemo } from 'storybook/preview-api'` becomes `const { useMemo } = + * __STORYBOOK_MODULE_PREVIEW_API__`) + * - Setting up dev-mode aliases for external modules + * - Injecting environment variables as `import.meta.env.*` defines + * + * @returns An array of Vite plugins + */ +export async function storybookRuntimePlugin(options: Options): Promise { + const plugins: Plugin[] = [await externalGlobalsPlugin(globalsNameReferenceMap)]; + const envs = await options.presets.apply>('env'); + + if (envs && Object.keys(envs).length > 0) { + plugins.push({ + name: 'storybook:env-plugin', + config(config) { + const envDefines = stringifyProcessEnvs(envs, config.envPrefix); + return { + define: envDefines, + }; + }, + }); + } + + return plugins; +} diff --git a/code/builders/builder-vite/src/plugins/strip-story-hmr-boundaries.ts b/code/builders/builder-vite/src/plugins/strip-story-hmr-boundaries.ts index 509a7d06adbd..baa433d1afed 100644 --- a/code/builders/builder-vite/src/plugins/strip-story-hmr-boundaries.ts +++ b/code/builders/builder-vite/src/plugins/strip-story-hmr-boundaries.ts @@ -6,7 +6,7 @@ import type { Plugin } from 'vite'; * boundaries, but vite has a bug which causes them to be treated as boundaries * (https://github.com/vitejs/vite/issues/9869). */ -export async function stripStoryHMRBoundary(): Promise { +export async function stripStoryHMRBoundary() { const { createFilter } = await import('vite'); const filter = createFilter(/\.stories\.(tsx?|jsx?|svelte|vue)$/); @@ -26,5 +26,5 @@ export async function stripStoryHMRBoundary(): Promise { map: s.generateMap({ hires: true, source: id }), }; }, - }; + } satisfies Plugin; } diff --git a/code/builders/builder-vite/src/preset.ts b/code/builders/builder-vite/src/preset.ts index b309970c3314..71cd7964f376 100644 --- a/code/builders/builder-vite/src/preset.ts +++ b/code/builders/builder-vite/src/preset.ts @@ -1,34 +1,61 @@ import { findConfigFile } from 'storybook/internal/common'; +import { globalsNameReferenceMap } from 'storybook/internal/preview/globals'; import type { Options } from 'storybook/internal/types'; -import type { UserConfig } from 'vite'; +import type { PluginOption } from 'vite'; +import { storybookConfigPlugin } from './plugins/storybook-config-plugin'; +import { storybookOptimizeDepsPlugin } from './plugins/storybook-optimize-deps-plugin'; +import { storybookProjectAnnotationsPlugin } from './plugins/storybook-project-annotations-plugin'; +import { storybookRuntimePlugin } from './plugins/storybook-runtime-plugin'; import { viteInjectMockerRuntime } from './plugins/vite-inject-mocker/plugin'; import { viteMockPlugin } from './plugins/vite-mock/plugin'; -// This preset defines currently mocking plugins for Vite -// It is defined as a viteFinal preset so that @storybook/addon-vitest can use it as well and that it doesn't have to be duplicated in addon-vitest. -// The main vite configuration is defined in `./vite-config.ts`. -export async function viteFinal(existing: UserConfig, options: Options) { +/** + * Preset that provides the core Storybook Vite plugins shared between `@storybook/builder-vite` and + * `@storybook/addon-vitest`. + * + * Includes: + * + * - **Config plugin**: Resolve conditions (`storybook`, `stories`, `test`), environment variable + * prefixes (`VITE_`, `STORYBOOK_`), symlink preservation, and `fs.allow` for the config + * directory + * - **Project annotations plugin**: Virtual module serving `getProjectAnnotations` + * - **Docgen plugin**: CSF processing and component metadata extraction + * - **Runtime plugin**: External globals transformation for pre-bundled Storybook modules + * - **Mocking plugins**: Injects the mocker runtime script into the HTML and sets up rules to swap + * modules based on sb.mock() calls. + * + * Consumers can override builder-specific settings (root, base, cacheDir) by adding their own Vite + * plugins on top. + */ +export async function viteCorePlugins( + existing: PluginOption[], + options: Options +): Promise { const previewConfigPath = findConfigFile('preview', options.configDir); - // If there's no preview file, there's nothing to mock. - if (!previewConfigPath) { - return existing; - } + const build = await options.presets.apply('build'); + const externals: Record = { ...globalsNameReferenceMap }; - const coreOptions = await options.presets.apply('core'); + if (build?.test?.disableBlocks) { + externals['@storybook/addon-docs/blocks'] = '__STORYBOOK_BLOCKS_EMPTY_MODULE__'; + } - return { - ...existing, - plugins: [ - ...(existing.plugins ?? []), - ...(previewConfigPath - ? [ - viteInjectMockerRuntime({ previewConfigPath }), - viteMockPlugin({ previewConfigPath, coreOptions, configDir: options.configDir }), - ] - : []), - ], - }; + return [ + ...(await storybookRuntimePlugin(options)), + ...storybookConfigPlugin({ configDir: options.configDir, setRoot: false }), + storybookOptimizeDepsPlugin(options), + storybookProjectAnnotationsPlugin(options), + ...(previewConfigPath + ? [ + viteInjectMockerRuntime({ previewConfigPath }), + viteMockPlugin({ + previewConfigPath, + coreOptions: await options.presets.apply('core'), + configDir: options.configDir, + }), + ] + : []), + ]; } diff --git a/code/builders/builder-vite/src/virtual-file-names.ts b/code/builders/builder-vite/src/virtual-file-names.ts index cf8f319480be..8c3d99e8738c 100644 --- a/code/builders/builder-vite/src/virtual-file-names.ts +++ b/code/builders/builder-vite/src/virtual-file-names.ts @@ -2,6 +2,7 @@ export const SB_VIRTUAL_FILES = { VIRTUAL_APP_FILE: 'virtual:/@storybook/builder-vite/vite-app.js', VIRTUAL_STORIES_FILE: 'virtual:/@storybook/builder-vite/storybook-stories.js', VIRTUAL_ADDON_SETUP_FILE: 'virtual:/@storybook/builder-vite/setup-addons.js', + VIRTUAL_PROJECT_ANNOTATIONS_FILE: 'virtual:/@storybook/builder-vite/project-annotations.js', }; export const SB_VIRTUAL_FILE_IDS = Object.values(SB_VIRTUAL_FILES); diff --git a/code/builders/builder-vite/src/vite-config.test.ts b/code/builders/builder-vite/src/vite-config.test.ts index 3097c069431a..a9a1d3572b28 100644 --- a/code/builders/builder-vite/src/vite-config.test.ts +++ b/code/builders/builder-vite/src/vite-config.test.ts @@ -4,6 +4,7 @@ import type { Options, Presets } from 'storybook/internal/types'; import { loadConfigFromFile } from 'vite'; +import { storybookConfigPlugin } from './plugins/storybook-config-plugin'; import { commonConfig } from './vite-config'; vi.mock('vite', async (importOriginal) => ({ @@ -34,7 +35,7 @@ const dummyOptions: Options = { }; describe('commonConfig', () => { - it('should preserve default envPrefix', async () => { + it('should set configFile to false and include plugins', async () => { loadConfigFromFileMock.mockReturnValueOnce( Promise.resolve({ config: {}, @@ -43,30 +44,85 @@ describe('commonConfig', () => { }) ); const config = await commonConfig(dummyOptions, 'development'); - expect(config.envPrefix).toStrictEqual(['VITE_', 'STORYBOOK_']); + expect(config.configFile).toBe(false); + expect(config.plugins).toBeDefined(); }); +}); - it('should preserve custom envPrefix string', async () => { - loadConfigFromFileMock.mockReturnValueOnce( - Promise.resolve({ - config: { envPrefix: 'SECRET_' }, - path: '', - dependencies: [], - }) - ); - const config = await commonConfig(dummyOptions, 'development'); - expect(config.envPrefix).toStrictEqual(['SECRET_', 'STORYBOOK_']); +describe('storybookConfigPlugin', () => { + it('should set default envPrefix when no user envPrefix is set', async () => { + const plugins = storybookConfigPlugin({ configDir: '/test/.storybook' }); + const configPlugin = plugins.find((p) => p.name === 'storybook:config-plugin')!; + + // The config hook receives the current Vite config and returns partial config to merge + const result = await (configPlugin.config as Function)({}, {}); + expect(result.envPrefix).toStrictEqual(['VITE_', 'STORYBOOK_']); }); - it('should preserve custom envPrefix array', async () => { - loadConfigFromFileMock.mockReturnValueOnce( - Promise.resolve({ - config: { envPrefix: ['SECRET_', 'VUE_'] }, - path: '', - dependencies: [], - }) - ); - const config = await commonConfig(dummyOptions, 'development'); - expect(config.envPrefix).toStrictEqual(['SECRET_', 'VUE_', 'STORYBOOK_']); + it('should add STORYBOOK_ when user has custom envPrefix', async () => { + const plugins = storybookConfigPlugin({ configDir: '/test/.storybook' }); + const configPlugin = plugins.find((p) => p.name === 'storybook:config-plugin')!; + + const result = await (configPlugin.config as Function)({ envPrefix: 'SECRET_' }, {}); + expect(result.envPrefix).toStrictEqual(['STORYBOOK_']); + }); + + it('should add STORYBOOK_ when user has custom envPrefix array', async () => { + const plugins = storybookConfigPlugin({ configDir: '/test/.storybook' }); + const configPlugin = plugins.find((p) => p.name === 'storybook:config-plugin')!; + + const result = await (configPlugin.config as Function)({ envPrefix: ['SECRET_', 'VUE_'] }, {}); + expect(result.envPrefix).toStrictEqual(['STORYBOOK_']); + }); + + it('should include storybook resolve conditions', async () => { + const plugins = storybookConfigPlugin({ configDir: '/test/.storybook' }); + const configPlugin = plugins.find((p) => p.name === 'storybook:config-plugin')!; + + const result = await (configPlugin.config as Function)({}, {}); + expect(result.resolve.conditions).toContain('storybook'); + expect(result.resolve.conditions).toContain('stories'); + expect(result.resolve.conditions).toContain('test'); + }); + + it('should set root when setRoot is not false', async () => { + const plugins = storybookConfigPlugin({ configDir: '/test/.storybook' }); + const configPlugin = plugins.find((p) => p.name === 'storybook:config-plugin')!; + + const result = await (configPlugin.config as Function)({}, {}); + expect(result.root).toBe('/test'); + }); + + it('should not set root when setRoot is false', async () => { + const plugins = storybookConfigPlugin({ configDir: '/test/.storybook', setRoot: false }); + const configPlugin = plugins.find((p) => p.name === 'storybook:config-plugin')!; + + const result = await (configPlugin.config as Function)({}, {}); + expect(result.root).toBeUndefined(); + }); + + it('should set base when provided', async () => { + const plugins = storybookConfigPlugin({ configDir: '/test/.storybook', base: './' }); + const configPlugin = plugins.find((p) => p.name === 'storybook:config-plugin')!; + + const result = await (configPlugin.config as Function)({}, {}); + expect(result.base).toBe('./'); + }); + + it('should not set base when not provided', async () => { + const plugins = storybookConfigPlugin({ configDir: '/test/.storybook' }); + const configPlugin = plugins.find((p) => p.name === 'storybook:config-plugin')!; + + const result = await (configPlugin.config as Function)({}, {}); + expect(result.base).toBeUndefined(); + }); + + it('should allow storybook dir when server fs allow list exists', () => { + const plugins = storybookConfigPlugin({ configDir: '/test/.storybook' }); + const allowPlugin = plugins.find((p) => p.name === 'storybook:allow-storybook-dir')!; + + const config = { server: { fs: { allow: ['/some/path'] } } }; + (allowPlugin.config as Function)(config); + expect(config.server.fs.allow).toContain('/test/.storybook'); }); }); diff --git a/code/builders/builder-vite/src/vite-config.ts b/code/builders/builder-vite/src/vite-config.ts index 9cb4e86042f2..02c43095eafd 100644 --- a/code/builders/builder-vite/src/vite-config.ts +++ b/code/builders/builder-vite/src/vite-config.ts @@ -1,11 +1,6 @@ import { resolve } from 'node:path'; -import { - getBuilderOptions, - isPreservingSymlinks, - resolvePathInStorybookCache, -} from 'storybook/internal/common'; -import { globalsNameReferenceMap } from 'storybook/internal/preview/globals'; +import { getBuilderOptions, resolvePathInStorybookCache } from 'storybook/internal/common'; import type { Options } from 'storybook/internal/types'; import type { @@ -16,14 +11,9 @@ import type { InlineConfig as ViteInlineConfig, } from 'vite'; -import { - codeGeneratorPlugin, - csfPlugin, - externalGlobalsPlugin, - injectExportOrderPlugin, - pluginWebpackStats, - stripStoryHMRBoundary, -} from './plugins'; +import { pluginWebpackStats, storybookEntryPlugin } from './plugins'; +import { storybookDocgenPlugin } from './plugins/storybook-docgen-plugin'; +import { viteCorePlugins as corePlugins } from './preset'; import type { BuilderOptions } from './types'; export type PluginConfigType = 'build' | 'development'; @@ -46,7 +36,7 @@ export async function commonConfig( _type: PluginConfigType ): Promise { const configEnv = _type === 'development' ? configEnvServe : configEnvBuild; - const { loadConfigFromFile, mergeConfig, defaultClientConditions = [] } = await import('vite'); + const { loadConfigFromFile, mergeConfig } = await import('vite'); const { viteConfigPath } = await getBuilderOptions(options); @@ -58,22 +48,14 @@ export async function commonConfig( const { config: { build: buildProperty = undefined, ...userConfig } = {} } = (await loadConfigFromFile(configEnv, viteConfigPath, projectRoot)) ?? {}; - // This is the main Vite config that is used by Storybook. - // Some shared vite plugins are defined in the `./preset.ts` file so that it can be shared between the @storybook/builder-vite and @storybook/addon-vitest package. + // Storybook's Vite config is assembled from self-contained plugins. + // The config plugin handles base settings (root, cacheDir, resolve conditions, etc.), + // while other plugins handle entry points, docgen, and runtime globals. + // Shared vite plugins for mocking are defined in `./preset.ts` so that they can be + // shared between @storybook/builder-vite and @storybook/addon-vitest. const sbConfig: InlineConfig = { configFile: false, - cacheDir: resolvePathInStorybookCache('sb-vite', options.cacheKey), - root: projectRoot, - // Allow storybook deployed as subfolder. See https://github.com/storybookjs/builder-vite/issues/238 - base: './', plugins: await pluginConfig(options), - resolve: { - conditions: ['storybook', 'stories', 'test', ...defaultClientConditions], - preserveSymlinks: isPreservingSymlinks(), - }, - // If an envPrefix is specified in the vite config, add STORYBOOK_ to it, - // otherwise, add VITE_ and STORYBOOK_ so that vite doesn't lose its default. - envPrefix: userConfig.envPrefix ? ['STORYBOOK_'] : ['VITE_', 'STORYBOOK_'], // Pass build.target option from user's vite config build: { target: buildProperty?.target, @@ -86,33 +68,30 @@ export async function commonConfig( } export async function pluginConfig(options: Options) { - const build = await options.presets.apply('build'); - - const externals: Record = globalsNameReferenceMap; - - if (build?.test?.disableBlocks) { - externals['@storybook/addon-docs/blocks'] = '__STORYBOOK_BLOCKS_EMPTY_MODULE__'; - } + const projectRoot = resolve(options.configDir, '..'); const plugins = [ - codeGeneratorPlugin(options), - await csfPlugin(options), - await injectExportOrderPlugin(), - await stripStoryHMRBoundary(), + // Shared core plugins (resolve conditions, envPrefix, fs.allow, docgen, externals, etc.) + ...(await corePlugins([], options)), + await storybookDocgenPlugin(options), + // Builder-specific: root, base, and cacheDir { - name: 'storybook:allow-storybook-dir', - enforce: 'post', - config(config) { - // if there is NO allow list then Vite allows anything in the root directory - // if there is an allow list then Vite only allows anything in the listed directories - // add storybook specific directories only if there's an allow list so that we don't end up - // disallowing the root unless root is already disallowed - if (config?.server?.fs?.allow) { - config.server.fs.allow.push(options.configDir); - } + name: 'storybook:builder-vite-config', + enforce: 'pre' as const, + config() { + return { + root: projectRoot, + // Allow storybook deployed as subfolder. See https://github.com/storybookjs/builder-vite/issues/238 + base: './', + ...(options.cacheKey + ? { cacheDir: resolvePathInStorybookCache('sb-vite', options.cacheKey) } + : {}), + }; }, }, - await externalGlobalsPlugin(externals), + // Entry plugin: virtual modules for stories, addon setup, and main app entry + ...(await storybookEntryPlugin(options)), + // Builder-specific: webpack-compatible stats for turbosnap/chromatic pluginWebpackStats({ workingDir: process.cwd() }), ] as PluginOption[]; diff --git a/code/builders/builder-vite/src/vite-server.ts b/code/builders/builder-vite/src/vite-server.ts index 30e712c5a0cf..ed2349db1e7d 100644 --- a/code/builders/builder-vite/src/vite-server.ts +++ b/code/builders/builder-vite/src/vite-server.ts @@ -5,9 +5,7 @@ import type { Server } from 'http'; import { dedent } from 'ts-dedent'; import type { InlineConfig, ServerOptions } from 'vite'; -import { sanitizeEnvVars } from './envs'; import { createViteLogger } from './logger'; -import { getOptimizeDeps } from './optimizeDeps'; import { commonConfig } from './vite-config'; export async function createViteServer(options: Options, devServer: Server) { @@ -29,7 +27,6 @@ export async function createViteServer(options: Options, devServer: Server) { }, }, appType: 'custom' as const, - optimizeDeps: await getOptimizeDeps(commonCfg, options), }; // '0.0.0.0' binds to all interfaces, which is useful for Docker and other containerized environments. @@ -51,5 +48,5 @@ export async function createViteServer(options: Options, devServer: Server) { const { createServer } = await import('vite'); finalConfig.customLogger ??= await createViteLogger(); - return createServer(await sanitizeEnvVars(options, finalConfig)); + return createServer(finalConfig); } From d647cb862fc66480d2a30d2e611f490c49bf5d0a Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Wed, 11 Feb 2026 10:36:10 +0100 Subject: [PATCH 02/28] Refactor builder-vite: streamline plugin imports and remove unused code - Removed commented-out import for `getProjectAnnotations` in `codegen-modern-iframe-script.ts`. - Eliminated the direct call to `storybookRuntimePlugin` in `preset.ts`, now included in `vite-config.ts` for better organization. - Cleaned up `sandbox-parts.ts` by removing unnecessary `beforeAll` import while maintaining functionality for CSF4 support. --- .../builder-vite/src/codegen-modern-iframe-script.ts | 1 - code/builders/builder-vite/src/preset.ts | 2 -- code/builders/builder-vite/src/vite-config.ts | 2 ++ scripts/tasks/sandbox-parts.ts | 5 ++--- 4 files changed, 4 insertions(+), 6 deletions(-) diff --git a/code/builders/builder-vite/src/codegen-modern-iframe-script.ts b/code/builders/builder-vite/src/codegen-modern-iframe-script.ts index dd8643be1056..e063c3504c64 100644 --- a/code/builders/builder-vite/src/codegen-modern-iframe-script.ts +++ b/code/builders/builder-vite/src/codegen-modern-iframe-script.ts @@ -127,7 +127,6 @@ export async function generateModernIframeScriptCodeFromPreviews(options: { import { importFn } from '${SB_VIRTUAL_FILES.VIRTUAL_STORIES_FILE}'; ${options.isCsf4 ? previewFileImport : imports.join('\n')} - // Use import { getProjectAnnotations } from '${SB_VIRTUAL_FILES.VIRTUAL_PROJECT_ANNOTATIONS_FILE}'; instead ${getPreviewAnnotationsFunction} window.__STORYBOOK_PREVIEW__ = window.__STORYBOOK_PREVIEW__ || new PreviewWeb(importFn, getProjectAnnotations); diff --git a/code/builders/builder-vite/src/preset.ts b/code/builders/builder-vite/src/preset.ts index 71cd7964f376..9a036b5d68bf 100644 --- a/code/builders/builder-vite/src/preset.ts +++ b/code/builders/builder-vite/src/preset.ts @@ -7,7 +7,6 @@ import type { PluginOption } from 'vite'; import { storybookConfigPlugin } from './plugins/storybook-config-plugin'; import { storybookOptimizeDepsPlugin } from './plugins/storybook-optimize-deps-plugin'; import { storybookProjectAnnotationsPlugin } from './plugins/storybook-project-annotations-plugin'; -import { storybookRuntimePlugin } from './plugins/storybook-runtime-plugin'; import { viteInjectMockerRuntime } from './plugins/vite-inject-mocker/plugin'; import { viteMockPlugin } from './plugins/vite-mock/plugin'; @@ -43,7 +42,6 @@ export async function viteCorePlugins( } return [ - ...(await storybookRuntimePlugin(options)), ...storybookConfigPlugin({ configDir: options.configDir, setRoot: false }), storybookOptimizeDepsPlugin(options), storybookProjectAnnotationsPlugin(options), diff --git a/code/builders/builder-vite/src/vite-config.ts b/code/builders/builder-vite/src/vite-config.ts index 02c43095eafd..58b00a8cc697 100644 --- a/code/builders/builder-vite/src/vite-config.ts +++ b/code/builders/builder-vite/src/vite-config.ts @@ -13,6 +13,7 @@ import type { import { pluginWebpackStats, storybookEntryPlugin } from './plugins'; import { storybookDocgenPlugin } from './plugins/storybook-docgen-plugin'; +import { storybookRuntimePlugin } from './plugins/storybook-runtime-plugin'; import { viteCorePlugins as corePlugins } from './preset'; import type { BuilderOptions } from './types'; @@ -73,6 +74,7 @@ export async function pluginConfig(options: Options) { const plugins = [ // Shared core plugins (resolve conditions, envPrefix, fs.allow, docgen, externals, etc.) ...(await corePlugins([], options)), + ...(await storybookRuntimePlugin(options)), await storybookDocgenPlugin(options), // Builder-specific: root, base, and cacheDir { diff --git a/scripts/tasks/sandbox-parts.ts b/scripts/tasks/sandbox-parts.ts index 6e26c07b7bac..87bcc1442af1 100644 --- a/scripts/tasks/sandbox-parts.ts +++ b/scripts/tasks/sandbox-parts.ts @@ -497,8 +497,7 @@ export async function setupVitest(details: TemplateDetails, options: PassedOptio if (shouldUseCsf4) { await writeFile( setupFilePath, - dedent`import { beforeAll } from 'vitest' - import { setProjectAnnotations } from '${storybookPackage}' + dedent`import { setProjectAnnotations } from '${storybookPackage}' import projectAnnotations from './preview' // setProjectAnnotations still kept to support non-CSF4 story tests @@ -508,7 +507,7 @@ export async function setupVitest(details: TemplateDetails, options: PassedOptio } else { await writeFile( setupFilePath, - dedent`import { beforeAll } from 'vitest' + dedent` import { setProjectAnnotations } from '${storybookPackage}' import * as rendererDocsAnnotations from '${template.expected.renderer}/entry-preview-docs' import * as addonA11yAnnotations from '@storybook/addon-a11y/preview' From 73e88867e67d8e41aaa1d64ad1b4e39a4df1a969 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Wed, 11 Feb 2026 11:54:56 +0100 Subject: [PATCH 03/28] Fix optimize deps --- .../builders/builder-vite/src/optimizeDeps.ts | 43 ------------------- .../plugins/storybook-optimize-deps-plugin.ts | 27 +++++++++--- 2 files changed, 21 insertions(+), 49 deletions(-) delete mode 100644 code/builders/builder-vite/src/optimizeDeps.ts diff --git a/code/builders/builder-vite/src/optimizeDeps.ts b/code/builders/builder-vite/src/optimizeDeps.ts deleted file mode 100644 index 61e5c1ed5b56..000000000000 --- a/code/builders/builder-vite/src/optimizeDeps.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { StoryIndexGenerator } from 'storybook/internal/core-server'; -import type { Options, StoryIndex } from 'storybook/internal/types'; - -import { type UserConfig, type InlineConfig as ViteInlineConfig, resolveConfig } from 'vite'; - -import { INCLUDE_CANDIDATES } from './constants'; -import { getUniqueImportPaths } from './utils/unique-import-paths'; - -/** - * Helper function which allows us to `filter` with an async predicate. Uses Promise.all for - * performance. - */ -const asyncFilter = async (arr: string[], predicate: (val: string) => Promise) => - Promise.all(arr.map(predicate)).then((results) => arr.filter((_v, index) => results[index])); - -// TODO: This function should be reworked. The code it uses is outdated and we need to investigate -// More info: https://github.com/storybookjs/storybook/issues/32462#issuecomment-3421326557 -export async function getOptimizeDeps(config: ViteInlineConfig, options: Options) { - const [extraOptimizeDeps, storyIndexGenerator] = await Promise.all([ - options.presets.apply('optimizeViteDeps', []), - options.presets.apply('storyIndexGenerator'), - ]); - - const index: StoryIndex = await storyIndexGenerator.getIndex(); - - // TODO: check if resolveConfig takes a lot of time, possible optimizations here - const resolvedConfig = await resolveConfig(config, 'serve', 'development'); - - // This function converts ids which might include ` > ` to a real path, if it exists on disk. - // See https://github.com/vitejs/vite/blob/67d164392e8e9081dc3f0338c4b4b8eea6c5f7da/packages/vite/src/node/optimizer/index.ts#L182-L199 - const resolve = resolvedConfig.createResolver({ asSrc: false }); - const include = await asyncFilter(INCLUDE_CANDIDATES, async (id) => Boolean(await resolve(id))); - - const optimizeDeps: UserConfig['optimizeDeps'] = { - ...config.optimizeDeps, - entries: getUniqueImportPaths(index), - // We need Vite to precompile these dependencies, because they contain non-ESM code that would break - // if we served it directly to the browser. - include: [...include, ...extraOptimizeDeps, ...(config.optimizeDeps?.include || [])], - }; - - return optimizeDeps; -} diff --git a/code/builders/builder-vite/src/plugins/storybook-optimize-deps-plugin.ts b/code/builders/builder-vite/src/plugins/storybook-optimize-deps-plugin.ts index 083c5bfc6fe8..36695e28dc03 100644 --- a/code/builders/builder-vite/src/plugins/storybook-optimize-deps-plugin.ts +++ b/code/builders/builder-vite/src/plugins/storybook-optimize-deps-plugin.ts @@ -1,7 +1,7 @@ import type { StoryIndexGenerator } from 'storybook/internal/core-server'; import type { Options, StoryIndex } from 'storybook/internal/types'; -import type { Plugin } from 'vite'; +import { type Plugin, resolveConfig } from 'vite'; import { INCLUDE_CANDIDATES } from '../constants'; import { getUniqueImportPaths } from '../utils/unique-import-paths'; @@ -34,19 +34,34 @@ export function storybookOptimizeDepsPlugin(options: Options): Plugin { const index: StoryIndex = await storyIndexGenerator.getIndex(); + const { plugins, ...configToResolve } = config; + + const resolvedConfig = await resolveConfig(configToResolve, 'serve', 'development'); + + const resolve = resolvedConfig.createResolver({ asSrc: false }); + const include = await asyncFilter([...extraOptimizeDeps, ...INCLUDE_CANDIDATES], async (id) => + Boolean(await resolve(id)) + ); + return { optimizeDeps: { // Story file paths as entry points for the optimizer entries: getUniqueImportPaths(index), // Known CJS dependencies that need to be pre-compiled to ESM, // plus any extra deps from Storybook presets. - include: [ - ...INCLUDE_CANDIDATES, - ...extraOptimizeDeps, - ...(config.optimizeDeps?.include || []), - ], + include: [...include, ...(config.optimizeDeps?.include || [])], }, }; }, }; } + +/** + * Helper function which allows us to `filter` with an async predicate. Uses Promise.all for + * performance. + */ +async function asyncFilter(arr: string[], predicate: (val: string) => Promise) { + return Promise.all(arr.map(predicate)).then((results) => + arr.filter((_v, index) => results[index]) + ); +} From 4e54860c5d8fc97a84ed1ec3eadb8da78a5e5747 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Wed, 11 Feb 2026 14:02:29 +0100 Subject: [PATCH 04/28] Refactor storybookOptimizeDepsPlugin: simplify config resolution - Updated the `storybookOptimizeDepsPlugin` to directly resolve the configuration with an empty object instead of destructuring from the provided config. This change streamlines the plugin's logic for better clarity and maintainability. --- .../src/plugins/storybook-optimize-deps-plugin.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/code/builders/builder-vite/src/plugins/storybook-optimize-deps-plugin.ts b/code/builders/builder-vite/src/plugins/storybook-optimize-deps-plugin.ts index 36695e28dc03..efa526a4f2d4 100644 --- a/code/builders/builder-vite/src/plugins/storybook-optimize-deps-plugin.ts +++ b/code/builders/builder-vite/src/plugins/storybook-optimize-deps-plugin.ts @@ -34,9 +34,7 @@ export function storybookOptimizeDepsPlugin(options: Options): Plugin { const index: StoryIndex = await storyIndexGenerator.getIndex(); - const { plugins, ...configToResolve } = config; - - const resolvedConfig = await resolveConfig(configToResolve, 'serve', 'development'); + const resolvedConfig = await resolveConfig({}, 'serve', 'development'); const resolve = resolvedConfig.createResolver({ asSrc: false }); const include = await asyncFilter([...extraOptimizeDeps, ...INCLUDE_CANDIDATES], async (id) => From ad0c63bb142e0cb2d9c07e5f75d0e4c89250297f Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Wed, 11 Feb 2026 14:42:32 +0100 Subject: [PATCH 05/28] Improve config resolution --- .../plugins/storybook-optimize-deps-plugin.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/code/builders/builder-vite/src/plugins/storybook-optimize-deps-plugin.ts b/code/builders/builder-vite/src/plugins/storybook-optimize-deps-plugin.ts index efa526a4f2d4..e42e303a1432 100644 --- a/code/builders/builder-vite/src/plugins/storybook-optimize-deps-plugin.ts +++ b/code/builders/builder-vite/src/plugins/storybook-optimize-deps-plugin.ts @@ -1,3 +1,5 @@ +import { resolve } from 'node:url'; + import type { StoryIndexGenerator } from 'storybook/internal/core-server'; import type { Options, StoryIndex } from 'storybook/internal/types'; @@ -34,11 +36,19 @@ export function storybookOptimizeDepsPlugin(options: Options): Plugin { const index: StoryIndex = await storyIndexGenerator.getIndex(); - const resolvedConfig = await resolveConfig({}, 'serve', 'development'); - - const resolve = resolvedConfig.createResolver({ asSrc: false }); + const resolvedConfig = await resolveConfig( + { + root: resolve(options.configDir, '..'), + }, + 'serve', + 'development', + undefined, + undefined, + undefined + ); + const resolveId = await (await resolvedConfig).createResolver({ asSrc: false }); const include = await asyncFilter([...extraOptimizeDeps, ...INCLUDE_CANDIDATES], async (id) => - Boolean(await resolve(id)) + Boolean(await resolveId(id)) ); return { From 4f0fbef1d9626a6bd6184b2c76871954d445e90a Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Wed, 11 Feb 2026 21:03:36 +0100 Subject: [PATCH 06/28] Add optimizeDeps functionality to builder-vite - Introduced a new `optimizeDeps.ts` file containing the `getOptimizeDeps` function to handle dependency optimization for Vite. - Updated `vite-server.ts` to utilize `getOptimizeDeps` for improved dependency inclusion in the Vite server configuration. - Refactored `storybookOptimizeDepsPlugin` to streamline the inclusion of extra dependencies without redundant resolution logic. - Removed unused asyncFilter function from `storybookOptimizeDepsPlugin` for cleaner codebase. --- .../builders/builder-vite/src/optimizeDeps.ts | 30 +++++++++++++++++ .../plugins/storybook-optimize-deps-plugin.ts | 32 ++----------------- code/builders/builder-vite/src/vite-server.ts | 7 ++++ 3 files changed, 39 insertions(+), 30 deletions(-) create mode 100644 code/builders/builder-vite/src/optimizeDeps.ts diff --git a/code/builders/builder-vite/src/optimizeDeps.ts b/code/builders/builder-vite/src/optimizeDeps.ts new file mode 100644 index 000000000000..09c8b51b581e --- /dev/null +++ b/code/builders/builder-vite/src/optimizeDeps.ts @@ -0,0 +1,30 @@ +import { type UserConfig, type InlineConfig as ViteInlineConfig, resolveConfig } from 'vite'; + +import { INCLUDE_CANDIDATES } from './constants'; + +/** + * Helper function which allows us to `filter` with an async predicate. Uses Promise.all for + * performance. + */ +const asyncFilter = async (arr: string[], predicate: (val: string) => Promise) => + Promise.all(arr.map(predicate)).then((results) => arr.filter((_v, index) => results[index])); + +// TODO: This function should be reworked. The code it uses is outdated and we need to investigate +// More info: https://github.com/storybookjs/storybook/issues/32462#issuecomment-3421326557 +export async function getOptimizeDeps(config: ViteInlineConfig) { + // TODO: check if resolveConfig takes a lot of time, possible optimizations here + const resolvedConfig = await resolveConfig(config, 'serve', 'development'); + + // This function converts ids which might include ` > ` to a real path, if it exists on disk. + // See https://github.com/vitejs/vite/blob/67d164392e8e9081dc3f0338c4b4b8eea6c5f7da/packages/vite/src/node/optimizer/index.ts#L182-L199 + const resolve = resolvedConfig.createResolver({ asSrc: false }); + const include = await asyncFilter(INCLUDE_CANDIDATES, async (id) => Boolean(await resolve(id))); + + const optimizeDeps = { + // We need Vite to precompile these dependencies, because they contain non-ESM code that would break + // if we served it directly to the browser. + include: [...include, ...(config.optimizeDeps?.include || [])], + } satisfies UserConfig['optimizeDeps']; + + return optimizeDeps; +} diff --git a/code/builders/builder-vite/src/plugins/storybook-optimize-deps-plugin.ts b/code/builders/builder-vite/src/plugins/storybook-optimize-deps-plugin.ts index e42e303a1432..e7fc99c56fba 100644 --- a/code/builders/builder-vite/src/plugins/storybook-optimize-deps-plugin.ts +++ b/code/builders/builder-vite/src/plugins/storybook-optimize-deps-plugin.ts @@ -1,11 +1,8 @@ -import { resolve } from 'node:url'; - import type { StoryIndexGenerator } from 'storybook/internal/core-server'; import type { Options, StoryIndex } from 'storybook/internal/types'; -import { type Plugin, resolveConfig } from 'vite'; +import { type Plugin } from 'vite'; -import { INCLUDE_CANDIDATES } from '../constants'; import { getUniqueImportPaths } from '../utils/unique-import-paths'; /** @@ -36,40 +33,15 @@ export function storybookOptimizeDepsPlugin(options: Options): Plugin { const index: StoryIndex = await storyIndexGenerator.getIndex(); - const resolvedConfig = await resolveConfig( - { - root: resolve(options.configDir, '..'), - }, - 'serve', - 'development', - undefined, - undefined, - undefined - ); - const resolveId = await (await resolvedConfig).createResolver({ asSrc: false }); - const include = await asyncFilter([...extraOptimizeDeps, ...INCLUDE_CANDIDATES], async (id) => - Boolean(await resolveId(id)) - ); - return { optimizeDeps: { // Story file paths as entry points for the optimizer entries: getUniqueImportPaths(index), // Known CJS dependencies that need to be pre-compiled to ESM, // plus any extra deps from Storybook presets. - include: [...include, ...(config.optimizeDeps?.include || [])], + include: [...extraOptimizeDeps, ...(config.optimizeDeps?.include || [])], }, }; }, }; } - -/** - * Helper function which allows us to `filter` with an async predicate. Uses Promise.all for - * performance. - */ -async function asyncFilter(arr: string[], predicate: (val: string) => Promise) { - return Promise.all(arr.map(predicate)).then((results) => - arr.filter((_v, index) => results[index]) - ); -} diff --git a/code/builders/builder-vite/src/vite-server.ts b/code/builders/builder-vite/src/vite-server.ts index ed2349db1e7d..9c694e94e410 100644 --- a/code/builders/builder-vite/src/vite-server.ts +++ b/code/builders/builder-vite/src/vite-server.ts @@ -6,6 +6,7 @@ import { dedent } from 'ts-dedent'; import type { InlineConfig, ServerOptions } from 'vite'; import { createViteLogger } from './logger'; +import { getOptimizeDeps } from './optimizeDeps'; import { commonConfig } from './vite-config'; export async function createViteServer(options: Options, devServer: Server) { @@ -13,9 +14,15 @@ export async function createViteServer(options: Options, devServer: Server) { const commonCfg = await commonConfig(options, 'development'); + const optimizeDeps = await getOptimizeDeps(commonCfg); + const config: InlineConfig & { server: ServerOptions } = { ...commonCfg, // Set up dev server + optimizeDeps: { + ...commonCfg.optimizeDeps, + include: [...(commonCfg.optimizeDeps?.include || []), ...optimizeDeps.include], + }, server: { middlewareMode: true, hmr: { From 0e3667f1bd46323a024d64a754dcc854c79ba26f Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Thu, 12 Feb 2026 08:46:42 +0100 Subject: [PATCH 07/28] Cleanup --- .../src/plugins/storybook-config-plugin.ts | 37 ++++++------------- .../src/plugins/storybook-runtime-plugin.ts | 2 +- code/builders/builder-vite/src/preset.ts | 2 +- 3 files changed, 14 insertions(+), 27 deletions(-) diff --git a/code/builders/builder-vite/src/plugins/storybook-config-plugin.ts b/code/builders/builder-vite/src/plugins/storybook-config-plugin.ts index 4253ddadd423..1ba7d8880ac0 100644 --- a/code/builders/builder-vite/src/plugins/storybook-config-plugin.ts +++ b/code/builders/builder-vite/src/plugins/storybook-config-plugin.ts @@ -1,5 +1,3 @@ -import { resolve } from 'node:path'; - import { isPreservingSymlinks, resolvePathInStorybookCache } from 'storybook/internal/common'; import type { Plugin } from 'vite'; @@ -14,21 +12,6 @@ import type { Plugin } from 'vite'; export interface StorybookConfigPluginOptions { /** The Storybook configuration directory (e.g., '.storybook') */ configDir: string; - /** - * Cache key for the Vite cache directory. When set, cacheDir is resolved via Storybook's cache - * using the `sb-vite` prefix. Omit to let the caller handle cache directory configuration. - */ - cacheKey?: string; - /** - * Base public path for the Vite dev server. When set, overrides Vite's default base. For - * builder-vite, this is typically './'. Omit to keep the existing base from the user's config. - */ - base?: string; - /** - * Whether to set the Vite root to the parent of the config directory. Defaults to `true`. Set to - * `false` when the root is managed externally (e.g., by vitest). - */ - setRoot?: boolean; } /** @@ -44,8 +27,6 @@ export interface StorybookConfigPluginOptions { * - Preserving symlinks when applicable */ export function storybookConfigPlugin(options: StorybookConfigPluginOptions): Plugin[] { - const projectRoot = resolve(options.configDir, '..'); - return [ { name: 'storybook:config-plugin', @@ -53,19 +34,25 @@ export function storybookConfigPlugin(options: StorybookConfigPluginOptions): Pl async config(config) { const { defaultClientConditions = [] } = await import('vite'); + const existingEnvPrefix = config.envPrefix; + const mergedEnvPrefix = existingEnvPrefix + ? Array.from( + new Set([ + ...(Array.isArray(existingEnvPrefix) ? existingEnvPrefix : [existingEnvPrefix]), + 'STORYBOOK_', + ]) + ) + : ['VITE_', 'STORYBOOK_']; + return { - ...(options.cacheKey - ? { cacheDir: resolvePathInStorybookCache('sb-vite', options.cacheKey) } - : {}), - ...(options.setRoot !== false ? { root: projectRoot } : {}), - ...(options.base !== undefined ? { base: options.base } : {}), + cacheDir: resolvePathInStorybookCache('sb-vite'), resolve: { conditions: ['storybook', 'stories', 'test', ...defaultClientConditions], preserveSymlinks: isPreservingSymlinks(), }, // If an envPrefix is specified in the user's vite config, add STORYBOOK_ to it. // Otherwise, add both VITE_ and STORYBOOK_ so that Vite doesn't lose its default. - envPrefix: config.envPrefix ? ['STORYBOOK_'] : ['VITE_', 'STORYBOOK_'], + envPrefix: mergedEnvPrefix, }; }, }, diff --git a/code/builders/builder-vite/src/plugins/storybook-runtime-plugin.ts b/code/builders/builder-vite/src/plugins/storybook-runtime-plugin.ts index be4701826b74..e930d9cfb513 100644 --- a/code/builders/builder-vite/src/plugins/storybook-runtime-plugin.ts +++ b/code/builders/builder-vite/src/plugins/storybook-runtime-plugin.ts @@ -56,7 +56,7 @@ export interface StorybookRuntimePluginOptions { */ export async function storybookRuntimePlugin(options: Options): Promise { const plugins: Plugin[] = [await externalGlobalsPlugin(globalsNameReferenceMap)]; - const envs = await options.presets.apply>('env'); + const envs = await options.presets.apply('env'); if (envs && Object.keys(envs).length > 0) { plugins.push({ diff --git a/code/builders/builder-vite/src/preset.ts b/code/builders/builder-vite/src/preset.ts index 9a036b5d68bf..3eb6b248b10f 100644 --- a/code/builders/builder-vite/src/preset.ts +++ b/code/builders/builder-vite/src/preset.ts @@ -42,7 +42,7 @@ export async function viteCorePlugins( } return [ - ...storybookConfigPlugin({ configDir: options.configDir, setRoot: false }), + ...storybookConfigPlugin({ configDir: options.configDir }), storybookOptimizeDepsPlugin(options), storybookProjectAnnotationsPlugin(options), ...(previewConfigPath From 59342f0c34c319847851b0661b35db544cca805f Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Thu, 12 Feb 2026 09:38:06 +0100 Subject: [PATCH 08/28] Refactor static directory handling in Vitest plugin and core server --- code/addons/vitest/src/vitest-plugin/index.ts | 27 ++++--------- code/core/src/builder-manager/index.ts | 20 ++-------- code/core/src/core-server/index.ts | 2 +- .../src/core-server/utils/server-statics.ts | 38 ++++++++++++++----- 4 files changed, 40 insertions(+), 47 deletions(-) diff --git a/code/addons/vitest/src/vitest-plugin/index.ts b/code/addons/vitest/src/vitest-plugin/index.ts index 96f2676645ad..bd61bec98cf4 100644 --- a/code/addons/vitest/src/vitest-plugin/index.ts +++ b/code/addons/vitest/src/vitest-plugin/index.ts @@ -16,7 +16,7 @@ import { StoryIndexGenerator, Tag, experimental_loadStorybook, - mapStaticDir, + useStaticDirs, } from 'storybook/internal/core-server'; import { componentTransform, readConfig, vitestTransform } from 'storybook/internal/csf-tools'; import { MainFileMissingError } from 'storybook/internal/server-errors'; @@ -28,11 +28,10 @@ import { match } from 'micromatch'; import { join, normalize, relative, resolve, sep } from 'pathe'; import path from 'pathe'; import picocolors from 'picocolors'; -import sirv from 'sirv'; import { dedent } from 'ts-dedent'; import type { PluginOption } from 'vite'; -// Shared plugins from builder-vite (relative import to prebundle without adding a package dependency) +// ! Relative import to prebundle it without needing to depend on the Vite builder import { withoutVitePlugins } from '../../../../builders/builder-vite/src/utils/without-vite-plugins'; import type { InternalOptions, UserOptions } from './types'; @@ -405,7 +404,9 @@ export const storybookTest = async (options?: UserOptions): Promise => }, }; - const config = mergeConfig(baseConfig, viteConfigFromStorybook); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { plugins: _, ...viteConfig } = viteConfigFromStorybook; + const config = mergeConfig(baseConfig, viteConfig); // alert the user of problems if ((nonMutableInputConfig.test?.include?.length ?? 0) > 0) { @@ -448,21 +449,9 @@ export const storybookTest = async (options?: UserOptions): Promise => }, async configureServer(server) { if (staticDirs) { - for (const staticDir of staticDirs) { - try { - const { staticPath, targetEndpoint } = mapStaticDir(staticDir, directories.configDir); - server.middlewares.use( - targetEndpoint, - sirv(staticPath, { - dev: true, - etag: true, - extensions: [], - }) - ); - } catch (e) { - console.warn(e); - } - } + useStaticDirs(staticDirs, directories.configDir, (endpoint, handler) => + server.middlewares.use(endpoint, handler) + ); } }, async transform(code, id) { diff --git a/code/core/src/builder-manager/index.ts b/code/core/src/builder-manager/index.ts index 265b37e102d8..54241166fd38 100644 --- a/code/core/src/builder-manager/index.ts +++ b/code/core/src/builder-manager/index.ts @@ -1,12 +1,12 @@ import { cp, rm, writeFile } from 'node:fs/promises'; import { stringifyProcessEnvs } from 'storybook/internal/common'; +import { sirvMiddleware } from 'storybook/internal/core-server'; import { logger } from 'storybook/internal/node-logger'; import { globalExternals } from '@fal-works/esbuild-plugin-global-externals'; import { resolveModulePath } from 'exsolve'; import { join, parse } from 'pathe'; -import sirv from 'sirv'; import { globalsModuleInfoMap } from '../manager/globals/globals-module-info'; import { BROWSER_TARGETS, SUPPORTED_FEATURES } from '../shared/constants/environments-support'; @@ -171,22 +171,8 @@ const starter: StarterFunction = async function* starterGeneratorFn({ yield; - router.use( - '/sb-addons', - sirv(addonsDir, { - maxAge: 300000, - dev: true, - immutable: true, - }) - ); - router.use( - '/sb-manager', - sirv(CORE_DIR_ORIGIN, { - maxAge: 300000, - dev: true, - immutable: true, - }) - ); + router.use('/sb-addons', sirvMiddleware(addonsDir, { maxAge: 300000, immutable: true })); + router.use('/sb-manager', sirvMiddleware(CORE_DIR_ORIGIN, { maxAge: 300000, immutable: true })); const { cssFiles, jsFiles } = await readOrderedFiles(addonsDir, compilation?.outputFiles); diff --git a/code/core/src/core-server/index.ts b/code/core/src/core-server/index.ts index f475fa6166ca..c7bcd6fc731e 100644 --- a/code/core/src/core-server/index.ts +++ b/code/core/src/core-server/index.ts @@ -7,7 +7,7 @@ export * from './build-dev'; export * from './build-index'; export * from './withTelemetry'; export { default as build } from './standalone'; -export { mapStaticDir } from './utils/server-statics'; +export { mapStaticDir, useStaticDirs, sirvMiddleware } from './utils/server-statics'; export { StoryIndexGenerator } from './utils/StoryIndexGenerator'; export { generateStoryFile } from './utils/generate-story'; export type { GenerateStoryResult, GenerateStoryOptions } from './utils/generate-story'; diff --git a/code/core/src/core-server/utils/server-statics.ts b/code/core/src/core-server/utils/server-statics.ts index 966fdd2f5789..7233fd7c8b74 100644 --- a/code/core/src/core-server/utils/server-statics.ts +++ b/code/core/src/core-server/utils/server-statics.ts @@ -1,6 +1,6 @@ import { existsSync, statSync } from 'node:fs'; import { readFile, stat } from 'node:fs/promises'; -import { basename, dirname, isAbsolute, join, posix, resolve, sep, win32 } from 'node:path'; +import { basename, isAbsolute, join, posix, resolve, sep, win32 } from 'node:path'; import { getDirectoryFromWorkingDir, @@ -14,6 +14,7 @@ import { relative } from 'pathe'; import picocolors from 'picocolors'; import type { Polka } from 'polka'; import sirv from 'sirv'; +import type { RequestHandler } from 'sirv'; import { dedent } from 'ts-dedent'; import { resolvePackageDir } from '../../shared/utils/module'; @@ -109,12 +110,12 @@ export async function useStatics(app: Polka, options: Options): Promise { } req.url = `/${faviconFile}`; - return sirvWorkaround(faviconDir)(req, res, next); + return sirvMiddleware(faviconDir)(req, res, next); }); - staticDirs.map((dir) => { + for (const dir of staticDirs) { try { - const { staticDir, staticPath, targetEndpoint } = mapStaticDir(dir, options.configDir); + const { staticDir, targetEndpoint } = mapStaticDir(dir, options.configDir); // Don't log for internal static dirs if (!targetEndpoint.startsWith('/sb-') && !staticDir.startsWith(cacheDir)) { @@ -123,34 +124,51 @@ export async function useStatics(app: Polka, options: Options): Promise { `Serving static files from ${CLI_COLORS.info(relativeStaticDir)} at ${CLI_COLORS.info(targetEndpoint)}` ); } + } catch { + // already handled in useStaticDirs + } + } + + useStaticDirs(staticDirs, options.configDir, (endpoint, handler) => app.use(endpoint, handler)); +} + +export function useStaticDirs( + staticDirs: NonNullable, + configDir: string, + use: (endpoint: string, handler: RequestHandler) => void +): void { + for (const dir of staticDirs) { + try { + const { staticPath, targetEndpoint } = mapStaticDir(dir, configDir); if (existsSync(staticPath) && statSync(staticPath).isFile()) { // sirv doesn't support serving single files, so we need to pass the file's directory to sirv instead const staticPathDir = resolve(staticPath, '..'); const staticPathFile = basename(staticPath); - app.use(targetEndpoint, (req, res, next) => { + use(targetEndpoint, (req, res, next) => { // Rewrite the URL to match the file's name, ensuring that we only ever serve the file // even when sirv is passed the full directory req.url = `/${staticPathFile}`; - sirvWorkaround(staticPathDir)(req, res, next); + sirvMiddleware(staticPathDir)(req, res, next); }); } else { - app.use(targetEndpoint, sirvWorkaround(staticPath)); + use(targetEndpoint, sirvMiddleware(staticPath)); } } catch (e) { if (e instanceof Error) { logger.warn(e.message); } } - }); + } } /** - * This is a workaround for sirv breaking when serving multiple directories on the same endpoint. + * Wrapper around sirv that works around sirv breaking when serving multiple directories on the same + * endpoint. * * @see https://github.com/lukeed/polka/issues/218 */ -const sirvWorkaround: typeof sirv = +export const sirvMiddleware: typeof sirv = (dir, opts = {}) => (req, res, next) => { // polka+sirv will modify the request URL, so we need to restore it after sirv is done From 9915f3d14e63f62963a50ce8034f5b6881f3f134 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Thu, 12 Feb 2026 10:26:38 +0100 Subject: [PATCH 09/28] Fix types --- .../src/core-server/utils/server-statics.ts | 32 +++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/code/core/src/core-server/utils/server-statics.ts b/code/core/src/core-server/utils/server-statics.ts index 7233fd7c8b74..e3020b43890d 100644 --- a/code/core/src/core-server/utils/server-statics.ts +++ b/code/core/src/core-server/utils/server-statics.ts @@ -10,15 +10,40 @@ import { import { CLI_COLORS, logger, once } from 'storybook/internal/node-logger'; import type { Options, StorybookConfigRaw } from 'storybook/internal/types'; +import type { Stats } from 'fs'; +import type { IncomingMessage, ServerResponse } from 'http'; import { relative } from 'pathe'; import picocolors from 'picocolors'; import type { Polka } from 'polka'; import sirv from 'sirv'; -import type { RequestHandler } from 'sirv'; import { dedent } from 'ts-dedent'; import { resolvePackageDir } from '../../shared/utils/module'; +type Arrayable = T | T[]; + +interface SirvOptions { + dev?: boolean; + etag?: boolean; + maxAge?: number; + immutable?: boolean; + single?: string | boolean; + ignores?: false | Arrayable; + extensions?: string[]; + dotfiles?: boolean; + brotli?: boolean; + gzip?: boolean; + onNoMatch?: (req: IncomingMessage, res: ServerResponse) => void; + setHeaders?: (res: ServerResponse, pathname: string, stats: Stats) => void; +} + +export type NextHandler = () => void | Promise; +export type RequestHandler = ( + req: IncomingMessage, + res: ServerResponse, + next?: NextHandler +) => void; + const cacheDir = resolvePathInStorybookCache('', 'ignored-sub').split('ignored-sub')[0]; const files = new Map(); @@ -168,7 +193,10 @@ export function useStaticDirs( * * @see https://github.com/lukeed/polka/issues/218 */ -export const sirvMiddleware: typeof sirv = +export const sirvMiddleware: ( + dir?: string | undefined, + opts?: SirvOptions | undefined +) => RequestHandler = (dir, opts = {}) => (req, res, next) => { // polka+sirv will modify the request URL, so we need to restore it after sirv is done From 5439fe11ee2fcef68477cb53ba259ae189917cdf Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Thu, 12 Feb 2026 11:21:21 +0100 Subject: [PATCH 10/28] Remove obsolete test cases --- .../builder-vite/src/vite-config.test.ts | 40 ------------------- 1 file changed, 40 deletions(-) diff --git a/code/builders/builder-vite/src/vite-config.test.ts b/code/builders/builder-vite/src/vite-config.test.ts index a9a1d3572b28..0dc8c5b04708 100644 --- a/code/builders/builder-vite/src/vite-config.test.ts +++ b/code/builders/builder-vite/src/vite-config.test.ts @@ -59,22 +59,6 @@ describe('storybookConfigPlugin', () => { expect(result.envPrefix).toStrictEqual(['VITE_', 'STORYBOOK_']); }); - it('should add STORYBOOK_ when user has custom envPrefix', async () => { - const plugins = storybookConfigPlugin({ configDir: '/test/.storybook' }); - const configPlugin = plugins.find((p) => p.name === 'storybook:config-plugin')!; - - const result = await (configPlugin.config as Function)({ envPrefix: 'SECRET_' }, {}); - expect(result.envPrefix).toStrictEqual(['STORYBOOK_']); - }); - - it('should add STORYBOOK_ when user has custom envPrefix array', async () => { - const plugins = storybookConfigPlugin({ configDir: '/test/.storybook' }); - const configPlugin = plugins.find((p) => p.name === 'storybook:config-plugin')!; - - const result = await (configPlugin.config as Function)({ envPrefix: ['SECRET_', 'VUE_'] }, {}); - expect(result.envPrefix).toStrictEqual(['STORYBOOK_']); - }); - it('should include storybook resolve conditions', async () => { const plugins = storybookConfigPlugin({ configDir: '/test/.storybook' }); const configPlugin = plugins.find((p) => p.name === 'storybook:config-plugin')!; @@ -85,30 +69,6 @@ describe('storybookConfigPlugin', () => { expect(result.resolve.conditions).toContain('test'); }); - it('should set root when setRoot is not false', async () => { - const plugins = storybookConfigPlugin({ configDir: '/test/.storybook' }); - const configPlugin = plugins.find((p) => p.name === 'storybook:config-plugin')!; - - const result = await (configPlugin.config as Function)({}, {}); - expect(result.root).toBe('/test'); - }); - - it('should not set root when setRoot is false', async () => { - const plugins = storybookConfigPlugin({ configDir: '/test/.storybook', setRoot: false }); - const configPlugin = plugins.find((p) => p.name === 'storybook:config-plugin')!; - - const result = await (configPlugin.config as Function)({}, {}); - expect(result.root).toBeUndefined(); - }); - - it('should set base when provided', async () => { - const plugins = storybookConfigPlugin({ configDir: '/test/.storybook', base: './' }); - const configPlugin = plugins.find((p) => p.name === 'storybook:config-plugin')!; - - const result = await (configPlugin.config as Function)({}, {}); - expect(result.base).toBe('./'); - }); - it('should not set base when not provided', async () => { const plugins = storybookConfigPlugin({ configDir: '/test/.storybook' }); const configPlugin = plugins.find((p) => p.name === 'storybook:config-plugin')!; From 36df811d6d3951515e70932077d09c4ad07bb394 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Thu, 12 Feb 2026 12:00:07 +0100 Subject: [PATCH 11/28] Revert "Fix types" This reverts commit 9915f3d14e63f62963a50ce8034f5b6881f3f134. --- .../src/core-server/utils/server-statics.ts | 32 ++----------------- 1 file changed, 2 insertions(+), 30 deletions(-) diff --git a/code/core/src/core-server/utils/server-statics.ts b/code/core/src/core-server/utils/server-statics.ts index e3020b43890d..7233fd7c8b74 100644 --- a/code/core/src/core-server/utils/server-statics.ts +++ b/code/core/src/core-server/utils/server-statics.ts @@ -10,40 +10,15 @@ import { import { CLI_COLORS, logger, once } from 'storybook/internal/node-logger'; import type { Options, StorybookConfigRaw } from 'storybook/internal/types'; -import type { Stats } from 'fs'; -import type { IncomingMessage, ServerResponse } from 'http'; import { relative } from 'pathe'; import picocolors from 'picocolors'; import type { Polka } from 'polka'; import sirv from 'sirv'; +import type { RequestHandler } from 'sirv'; import { dedent } from 'ts-dedent'; import { resolvePackageDir } from '../../shared/utils/module'; -type Arrayable = T | T[]; - -interface SirvOptions { - dev?: boolean; - etag?: boolean; - maxAge?: number; - immutable?: boolean; - single?: string | boolean; - ignores?: false | Arrayable; - extensions?: string[]; - dotfiles?: boolean; - brotli?: boolean; - gzip?: boolean; - onNoMatch?: (req: IncomingMessage, res: ServerResponse) => void; - setHeaders?: (res: ServerResponse, pathname: string, stats: Stats) => void; -} - -export type NextHandler = () => void | Promise; -export type RequestHandler = ( - req: IncomingMessage, - res: ServerResponse, - next?: NextHandler -) => void; - const cacheDir = resolvePathInStorybookCache('', 'ignored-sub').split('ignored-sub')[0]; const files = new Map(); @@ -193,10 +168,7 @@ export function useStaticDirs( * * @see https://github.com/lukeed/polka/issues/218 */ -export const sirvMiddleware: ( - dir?: string | undefined, - opts?: SirvOptions | undefined -) => RequestHandler = +export const sirvMiddleware: typeof sirv = (dir, opts = {}) => (req, res, next) => { // polka+sirv will modify the request URL, so we need to restore it after sirv is done From 5298a639ff7281d4f2519566d2e8d31f39376472 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Thu, 12 Feb 2026 12:00:24 +0100 Subject: [PATCH 12/28] Revert "Refactor static directory handling in Vitest plugin and core server" This reverts commit 59342f0c34c319847851b0661b35db544cca805f. --- code/addons/vitest/src/vitest-plugin/index.ts | 27 +++++++++---- code/core/src/builder-manager/index.ts | 20 ++++++++-- code/core/src/core-server/index.ts | 2 +- .../src/core-server/utils/server-statics.ts | 38 +++++-------------- 4 files changed, 47 insertions(+), 40 deletions(-) diff --git a/code/addons/vitest/src/vitest-plugin/index.ts b/code/addons/vitest/src/vitest-plugin/index.ts index bd61bec98cf4..96f2676645ad 100644 --- a/code/addons/vitest/src/vitest-plugin/index.ts +++ b/code/addons/vitest/src/vitest-plugin/index.ts @@ -16,7 +16,7 @@ import { StoryIndexGenerator, Tag, experimental_loadStorybook, - useStaticDirs, + mapStaticDir, } from 'storybook/internal/core-server'; import { componentTransform, readConfig, vitestTransform } from 'storybook/internal/csf-tools'; import { MainFileMissingError } from 'storybook/internal/server-errors'; @@ -28,10 +28,11 @@ import { match } from 'micromatch'; import { join, normalize, relative, resolve, sep } from 'pathe'; import path from 'pathe'; import picocolors from 'picocolors'; +import sirv from 'sirv'; import { dedent } from 'ts-dedent'; import type { PluginOption } from 'vite'; -// ! Relative import to prebundle it without needing to depend on the Vite builder +// Shared plugins from builder-vite (relative import to prebundle without adding a package dependency) import { withoutVitePlugins } from '../../../../builders/builder-vite/src/utils/without-vite-plugins'; import type { InternalOptions, UserOptions } from './types'; @@ -404,9 +405,7 @@ export const storybookTest = async (options?: UserOptions): Promise => }, }; - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { plugins: _, ...viteConfig } = viteConfigFromStorybook; - const config = mergeConfig(baseConfig, viteConfig); + const config = mergeConfig(baseConfig, viteConfigFromStorybook); // alert the user of problems if ((nonMutableInputConfig.test?.include?.length ?? 0) > 0) { @@ -449,9 +448,21 @@ export const storybookTest = async (options?: UserOptions): Promise => }, async configureServer(server) { if (staticDirs) { - useStaticDirs(staticDirs, directories.configDir, (endpoint, handler) => - server.middlewares.use(endpoint, handler) - ); + for (const staticDir of staticDirs) { + try { + const { staticPath, targetEndpoint } = mapStaticDir(staticDir, directories.configDir); + server.middlewares.use( + targetEndpoint, + sirv(staticPath, { + dev: true, + etag: true, + extensions: [], + }) + ); + } catch (e) { + console.warn(e); + } + } } }, async transform(code, id) { diff --git a/code/core/src/builder-manager/index.ts b/code/core/src/builder-manager/index.ts index 54241166fd38..265b37e102d8 100644 --- a/code/core/src/builder-manager/index.ts +++ b/code/core/src/builder-manager/index.ts @@ -1,12 +1,12 @@ import { cp, rm, writeFile } from 'node:fs/promises'; import { stringifyProcessEnvs } from 'storybook/internal/common'; -import { sirvMiddleware } from 'storybook/internal/core-server'; import { logger } from 'storybook/internal/node-logger'; import { globalExternals } from '@fal-works/esbuild-plugin-global-externals'; import { resolveModulePath } from 'exsolve'; import { join, parse } from 'pathe'; +import sirv from 'sirv'; import { globalsModuleInfoMap } from '../manager/globals/globals-module-info'; import { BROWSER_TARGETS, SUPPORTED_FEATURES } from '../shared/constants/environments-support'; @@ -171,8 +171,22 @@ const starter: StarterFunction = async function* starterGeneratorFn({ yield; - router.use('/sb-addons', sirvMiddleware(addonsDir, { maxAge: 300000, immutable: true })); - router.use('/sb-manager', sirvMiddleware(CORE_DIR_ORIGIN, { maxAge: 300000, immutable: true })); + router.use( + '/sb-addons', + sirv(addonsDir, { + maxAge: 300000, + dev: true, + immutable: true, + }) + ); + router.use( + '/sb-manager', + sirv(CORE_DIR_ORIGIN, { + maxAge: 300000, + dev: true, + immutable: true, + }) + ); const { cssFiles, jsFiles } = await readOrderedFiles(addonsDir, compilation?.outputFiles); diff --git a/code/core/src/core-server/index.ts b/code/core/src/core-server/index.ts index c7bcd6fc731e..f475fa6166ca 100644 --- a/code/core/src/core-server/index.ts +++ b/code/core/src/core-server/index.ts @@ -7,7 +7,7 @@ export * from './build-dev'; export * from './build-index'; export * from './withTelemetry'; export { default as build } from './standalone'; -export { mapStaticDir, useStaticDirs, sirvMiddleware } from './utils/server-statics'; +export { mapStaticDir } from './utils/server-statics'; export { StoryIndexGenerator } from './utils/StoryIndexGenerator'; export { generateStoryFile } from './utils/generate-story'; export type { GenerateStoryResult, GenerateStoryOptions } from './utils/generate-story'; diff --git a/code/core/src/core-server/utils/server-statics.ts b/code/core/src/core-server/utils/server-statics.ts index 7233fd7c8b74..966fdd2f5789 100644 --- a/code/core/src/core-server/utils/server-statics.ts +++ b/code/core/src/core-server/utils/server-statics.ts @@ -1,6 +1,6 @@ import { existsSync, statSync } from 'node:fs'; import { readFile, stat } from 'node:fs/promises'; -import { basename, isAbsolute, join, posix, resolve, sep, win32 } from 'node:path'; +import { basename, dirname, isAbsolute, join, posix, resolve, sep, win32 } from 'node:path'; import { getDirectoryFromWorkingDir, @@ -14,7 +14,6 @@ import { relative } from 'pathe'; import picocolors from 'picocolors'; import type { Polka } from 'polka'; import sirv from 'sirv'; -import type { RequestHandler } from 'sirv'; import { dedent } from 'ts-dedent'; import { resolvePackageDir } from '../../shared/utils/module'; @@ -110,12 +109,12 @@ export async function useStatics(app: Polka, options: Options): Promise { } req.url = `/${faviconFile}`; - return sirvMiddleware(faviconDir)(req, res, next); + return sirvWorkaround(faviconDir)(req, res, next); }); - for (const dir of staticDirs) { + staticDirs.map((dir) => { try { - const { staticDir, targetEndpoint } = mapStaticDir(dir, options.configDir); + const { staticDir, staticPath, targetEndpoint } = mapStaticDir(dir, options.configDir); // Don't log for internal static dirs if (!targetEndpoint.startsWith('/sb-') && !staticDir.startsWith(cacheDir)) { @@ -124,51 +123,34 @@ export async function useStatics(app: Polka, options: Options): Promise { `Serving static files from ${CLI_COLORS.info(relativeStaticDir)} at ${CLI_COLORS.info(targetEndpoint)}` ); } - } catch { - // already handled in useStaticDirs - } - } - - useStaticDirs(staticDirs, options.configDir, (endpoint, handler) => app.use(endpoint, handler)); -} - -export function useStaticDirs( - staticDirs: NonNullable, - configDir: string, - use: (endpoint: string, handler: RequestHandler) => void -): void { - for (const dir of staticDirs) { - try { - const { staticPath, targetEndpoint } = mapStaticDir(dir, configDir); if (existsSync(staticPath) && statSync(staticPath).isFile()) { // sirv doesn't support serving single files, so we need to pass the file's directory to sirv instead const staticPathDir = resolve(staticPath, '..'); const staticPathFile = basename(staticPath); - use(targetEndpoint, (req, res, next) => { + app.use(targetEndpoint, (req, res, next) => { // Rewrite the URL to match the file's name, ensuring that we only ever serve the file // even when sirv is passed the full directory req.url = `/${staticPathFile}`; - sirvMiddleware(staticPathDir)(req, res, next); + sirvWorkaround(staticPathDir)(req, res, next); }); } else { - use(targetEndpoint, sirvMiddleware(staticPath)); + app.use(targetEndpoint, sirvWorkaround(staticPath)); } } catch (e) { if (e instanceof Error) { logger.warn(e.message); } } - } + }); } /** - * Wrapper around sirv that works around sirv breaking when serving multiple directories on the same - * endpoint. + * This is a workaround for sirv breaking when serving multiple directories on the same endpoint. * * @see https://github.com/lukeed/polka/issues/218 */ -export const sirvMiddleware: typeof sirv = +const sirvWorkaround: typeof sirv = (dir, opts = {}) => (req, res, next) => { // polka+sirv will modify the request URL, so we need to restore it after sirv is done From d4f2b6c15a0d96f75465b6742fd1432416c0a7fb Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Thu, 12 Feb 2026 12:04:08 +0100 Subject: [PATCH 13/28] Refactor Vite plugin to streamline external globals handling - Moved the external globals logic into the `storybookRuntimePlugin` function. - Removed redundant external globals mapping from `viteCorePlugins`. - Added conditional handling for `@storybook/addon-docs/blocks` based on build configuration. --- .../src/plugins/storybook-runtime-plugin.ts | 11 ++++++++++- code/builders/builder-vite/src/preset.ts | 8 -------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/code/builders/builder-vite/src/plugins/storybook-runtime-plugin.ts b/code/builders/builder-vite/src/plugins/storybook-runtime-plugin.ts index e930d9cfb513..c2f559b892d1 100644 --- a/code/builders/builder-vite/src/plugins/storybook-runtime-plugin.ts +++ b/code/builders/builder-vite/src/plugins/storybook-runtime-plugin.ts @@ -55,7 +55,16 @@ export interface StorybookRuntimePluginOptions { * @returns An array of Vite plugins */ export async function storybookRuntimePlugin(options: Options): Promise { - const plugins: Plugin[] = [await externalGlobalsPlugin(globalsNameReferenceMap)]; + const build = await options.presets.apply('build'); + + const externals: typeof globalsNameReferenceMap & Record = + globalsNameReferenceMap; + + if (build?.test?.disableBlocks) { + externals['@storybook/addon-docs/blocks'] = '__STORYBOOK_BLOCKS_EMPTY_MODULE__'; + } + + const plugins: Plugin[] = [await externalGlobalsPlugin(externals)]; const envs = await options.presets.apply('env'); if (envs && Object.keys(envs).length > 0) { diff --git a/code/builders/builder-vite/src/preset.ts b/code/builders/builder-vite/src/preset.ts index 3eb6b248b10f..03b192edbb1e 100644 --- a/code/builders/builder-vite/src/preset.ts +++ b/code/builders/builder-vite/src/preset.ts @@ -1,5 +1,4 @@ import { findConfigFile } from 'storybook/internal/common'; -import { globalsNameReferenceMap } from 'storybook/internal/preview/globals'; import type { Options } from 'storybook/internal/types'; import type { PluginOption } from 'vite'; @@ -34,13 +33,6 @@ export async function viteCorePlugins( ): Promise { const previewConfigPath = findConfigFile('preview', options.configDir); - const build = await options.presets.apply('build'); - const externals: Record = { ...globalsNameReferenceMap }; - - if (build?.test?.disableBlocks) { - externals['@storybook/addon-docs/blocks'] = '__STORYBOOK_BLOCKS_EMPTY_MODULE__'; - } - return [ ...storybookConfigPlugin({ configDir: options.configDir }), storybookOptimizeDepsPlugin(options), From afe0ea3a5650a916142b600bb3d945ce7930c66b Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Thu, 12 Feb 2026 12:08:34 +0100 Subject: [PATCH 14/28] Update virtual file names and remove unused constant - Removed the `VIRTUAL_PROJECT_ANNOTATIONS_FILE` from `SB_VIRTUAL_FILES`. - Updated the `VIRTUAL_ID` in the `storybook-project-annotations-plugin` to directly use the virtual file path string instead of the constant. --- .../builder-vite/src/plugins/code-generator-plugin.ts | 5 +---- .../src/plugins/storybook-project-annotations-plugin.ts | 2 +- code/builders/builder-vite/src/virtual-file-names.ts | 1 - 3 files changed, 2 insertions(+), 6 deletions(-) diff --git a/code/builders/builder-vite/src/plugins/code-generator-plugin.ts b/code/builders/builder-vite/src/plugins/code-generator-plugin.ts index 5205a0806ca2..a2ddef103eab 100644 --- a/code/builders/builder-vite/src/plugins/code-generator-plugin.ts +++ b/code/builders/builder-vite/src/plugins/code-generator-plugin.ts @@ -57,10 +57,7 @@ export function codeGeneratorPlugin(options: Options) { iframeId = `${config.root}/iframe.html`; }, resolveId(source) { - if ( - source !== SB_VIRTUAL_FILES.VIRTUAL_PROJECT_ANNOTATIONS_FILE && - SB_VIRTUAL_FILE_IDS.includes(source) - ) { + if (SB_VIRTUAL_FILE_IDS.includes(source)) { return getResolvedVirtualModuleId(source); } if (source === iframePath) { diff --git a/code/builders/builder-vite/src/plugins/storybook-project-annotations-plugin.ts b/code/builders/builder-vite/src/plugins/storybook-project-annotations-plugin.ts index 8714d2b28c04..4acf44898243 100644 --- a/code/builders/builder-vite/src/plugins/storybook-project-annotations-plugin.ts +++ b/code/builders/builder-vite/src/plugins/storybook-project-annotations-plugin.ts @@ -5,7 +5,7 @@ import type { Plugin } from 'vite'; import { generateProjectAnnotationsCode } from '../codegen-project-annotations'; import { SB_VIRTUAL_FILES, getResolvedVirtualModuleId } from '../virtual-file-names'; -const VIRTUAL_ID = SB_VIRTUAL_FILES.VIRTUAL_PROJECT_ANNOTATIONS_FILE; +const VIRTUAL_ID = 'virtual:/@storybook/builder-vite/project-annotations.js'; const RESOLVED_VIRTUAL_ID = getResolvedVirtualModuleId(VIRTUAL_ID); /** diff --git a/code/builders/builder-vite/src/virtual-file-names.ts b/code/builders/builder-vite/src/virtual-file-names.ts index 8c3d99e8738c..cf8f319480be 100644 --- a/code/builders/builder-vite/src/virtual-file-names.ts +++ b/code/builders/builder-vite/src/virtual-file-names.ts @@ -2,7 +2,6 @@ export const SB_VIRTUAL_FILES = { VIRTUAL_APP_FILE: 'virtual:/@storybook/builder-vite/vite-app.js', VIRTUAL_STORIES_FILE: 'virtual:/@storybook/builder-vite/storybook-stories.js', VIRTUAL_ADDON_SETUP_FILE: 'virtual:/@storybook/builder-vite/setup-addons.js', - VIRTUAL_PROJECT_ANNOTATIONS_FILE: 'virtual:/@storybook/builder-vite/project-annotations.js', }; export const SB_VIRTUAL_FILE_IDS = Object.values(SB_VIRTUAL_FILES); From e3546afac8226e0bb5a0e8cd014bb6aa7a94b888 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Thu, 12 Feb 2026 12:09:56 +0100 Subject: [PATCH 15/28] Update jsdocs --- .../src/plugins/storybook-config-plugin.ts | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/code/builders/builder-vite/src/plugins/storybook-config-plugin.ts b/code/builders/builder-vite/src/plugins/storybook-config-plugin.ts index 1ba7d8880ac0..37f5b3da20cc 100644 --- a/code/builders/builder-vite/src/plugins/storybook-config-plugin.ts +++ b/code/builders/builder-vite/src/plugins/storybook-config-plugin.ts @@ -2,15 +2,7 @@ import { isPreservingSymlinks, resolvePathInStorybookCache } from 'storybook/int import type { Plugin } from 'vite'; -/** - * Options for the Storybook config plugin. - * - * This plugin provides the base Storybook-specific Vite configuration, including resolve - * conditions, environment variable prefixes, and filesystem access rules. It is designed to be - * shared between `@storybook/builder-vite` and `@storybook/addon-vitest`. - */ export interface StorybookConfigPluginOptions { - /** The Storybook configuration directory (e.g., '.storybook') */ configDir: string; } @@ -19,8 +11,6 @@ export interface StorybookConfigPluginOptions { * * This handles: * - * - Optionally setting the project root to the parent of the Storybook config directory - * - Optionally configuring the Vite cache directory * - Adding Storybook resolve conditions (`storybook`, `stories`, `test`) * - Setting up environment variable prefixes (`VITE_`, `STORYBOOK_`) * - Allowing the Storybook config directory in Vite's filesystem restrictions From a81220a9ea32195b45be0cdb6989c66646098074 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Thu, 12 Feb 2026 12:15:13 +0100 Subject: [PATCH 16/28] Cleanup docs --- .../src/codegen-project-annotations.ts | 17 +-------- .../src/plugins/storybook-config-plugin.ts | 4 +- .../src/plugins/storybook-docgen-plugin.ts | 7 ---- .../src/plugins/storybook-entry-plugin.ts | 14 ------- .../plugins/storybook-optimize-deps-plugin.ts | 13 +------ .../storybook-project-annotations-plugin.ts | 11 +----- .../src/plugins/storybook-runtime-plugin.ts | 38 ------------------- code/builders/builder-vite/src/preset.ts | 14 ------- 8 files changed, 5 insertions(+), 113 deletions(-) diff --git a/code/builders/builder-vite/src/codegen-project-annotations.ts b/code/builders/builder-vite/src/codegen-project-annotations.ts index 42c0b030fb8b..05037358c3d0 100644 --- a/code/builders/builder-vite/src/codegen-project-annotations.ts +++ b/code/builders/builder-vite/src/codegen-project-annotations.ts @@ -8,22 +8,7 @@ import { dedent } from 'ts-dedent'; import { processPreviewAnnotation } from './utils/process-preview-annotation'; -/** - * Generates the code for the `PROJECT_ANNOTATIONS_FILE` virtual module. - * - * This virtual module encapsulates the `getProjectAnnotations` function which composes all preview - * annotations (from addons, frameworks, and the user's preview file) into a single configuration - * object used by Storybook's runtime. - * - * The generated module can be imported as: - * - * ```ts - * import { getProjectAnnotations } from 'virtual:/@storybook/builder-vite/project-annotations.js'; - * ``` - * - * This decouples the project annotations logic from the main iframe entry script, making it - * reusable by other consumers (e.g., addon-vitest). - */ +/** Generates the code for the `PROJECT_ANNOTATIONS_FILE` virtual module. */ export async function generateProjectAnnotationsCode(options: Options, projectRoot: string) { const { presets, configDir } = options; const frameworkName = await getFrameworkName(options); diff --git a/code/builders/builder-vite/src/plugins/storybook-config-plugin.ts b/code/builders/builder-vite/src/plugins/storybook-config-plugin.ts index 37f5b3da20cc..f5c9a230e068 100644 --- a/code/builders/builder-vite/src/plugins/storybook-config-plugin.ts +++ b/code/builders/builder-vite/src/plugins/storybook-config-plugin.ts @@ -25,6 +25,8 @@ export function storybookConfigPlugin(options: StorybookConfigPluginOptions): Pl const { defaultClientConditions = [] } = await import('vite'); const existingEnvPrefix = config.envPrefix; + // If an envPrefix is specified in the user's vite config, add STORYBOOK_ to it. + // Otherwise, add both VITE_ and STORYBOOK_ so that Vite doesn't lose its default. const mergedEnvPrefix = existingEnvPrefix ? Array.from( new Set([ @@ -40,8 +42,6 @@ export function storybookConfigPlugin(options: StorybookConfigPluginOptions): Pl conditions: ['storybook', 'stories', 'test', ...defaultClientConditions], preserveSymlinks: isPreservingSymlinks(), }, - // If an envPrefix is specified in the user's vite config, add STORYBOOK_ to it. - // Otherwise, add both VITE_ and STORYBOOK_ so that Vite doesn't lose its default. envPrefix: mergedEnvPrefix, }; }, diff --git a/code/builders/builder-vite/src/plugins/storybook-docgen-plugin.ts b/code/builders/builder-vite/src/plugins/storybook-docgen-plugin.ts index 8844dea41646..cc9ae94be512 100644 --- a/code/builders/builder-vite/src/plugins/storybook-docgen-plugin.ts +++ b/code/builders/builder-vite/src/plugins/storybook-docgen-plugin.ts @@ -7,13 +7,6 @@ import type { Plugin } from 'vite'; /** * A Vite plugin that handles the extraction of component metadata (argTypes, descriptions) for * Storybook's documentation features. - * - * This wraps `@storybook/csf-plugin` and configures it based on Storybook's addon-docs options and - * CSF enrichment settings. The plugin processes CSF (Component Story Format) files to extract - * component metadata that powers Storybook's docs pages and controls. - * - * This plugin is designed to be shared between `@storybook/builder-vite` and - * `@storybook/addon-vitest`. */ export async function storybookDocgenPlugin(options: Options): Promise { const { presets } = options; diff --git a/code/builders/builder-vite/src/plugins/storybook-entry-plugin.ts b/code/builders/builder-vite/src/plugins/storybook-entry-plugin.ts index 45d419d07199..f4c0ba3d38d3 100644 --- a/code/builders/builder-vite/src/plugins/storybook-entry-plugin.ts +++ b/code/builders/builder-vite/src/plugins/storybook-entry-plugin.ts @@ -9,20 +9,6 @@ import { stripStoryHMRBoundary } from './strip-story-hmr-boundaries'; /** * A composite Vite plugin that manages the generation and injection of virtual entry points for * Storybook stories. This is builder-specific and NOT shared with addon-vitest. - * - * This handles: - * - * - Virtual module resolution for story imports, addon setup, and the main app entry - * - Story import function generation (dynamic imports for code splitting) - * - Iframe HTML transformation and build entry configuration - * - Story index watching for HMR invalidation - * - Export order injection (`__namedExportsOrder`) for consistent story discovery - * - HMR boundary stripping to prevent stories from being treated as HMR boundaries - * - * Note: The project annotations virtual module is provided separately by the `viteCorePlugins` - * preset so that it can be shared with addon-vitest. - * - * @returns An array of Vite plugins with appropriate enforcement ordering */ export async function storybookEntryPlugin(options: Options): Promise { return [ diff --git a/code/builders/builder-vite/src/plugins/storybook-optimize-deps-plugin.ts b/code/builders/builder-vite/src/plugins/storybook-optimize-deps-plugin.ts index e7fc99c56fba..7702f8e2aa2b 100644 --- a/code/builders/builder-vite/src/plugins/storybook-optimize-deps-plugin.ts +++ b/code/builders/builder-vite/src/plugins/storybook-optimize-deps-plugin.ts @@ -5,18 +5,7 @@ import { type Plugin } from 'vite'; import { getUniqueImportPaths } from '../utils/unique-import-paths'; -/** - * A Vite plugin that configures dependency optimization for Storybook's dev server. - * - * This handles: - * - * - Setting optimizeDeps entries from the story index (so Vite knows which stories to pre-bundle) - * - Including known CJS dependencies that need to be pre-compiled to ESM - * - Merging extra optimization dependencies from Storybook presets - * - * This plugin only applies in development mode (`command === 'serve'`). In production builds, - * Rollup handles dependency bundling differently. - */ +/** A Vite plugin that configures dependency optimization for Storybook's dev server. */ export function storybookOptimizeDepsPlugin(options: Options): Plugin { return { name: 'storybook:optimize-deps-plugin', diff --git a/code/builders/builder-vite/src/plugins/storybook-project-annotations-plugin.ts b/code/builders/builder-vite/src/plugins/storybook-project-annotations-plugin.ts index 4acf44898243..9b6986090f44 100644 --- a/code/builders/builder-vite/src/plugins/storybook-project-annotations-plugin.ts +++ b/code/builders/builder-vite/src/plugins/storybook-project-annotations-plugin.ts @@ -3,7 +3,7 @@ import type { Options } from 'storybook/internal/types'; import type { Plugin } from 'vite'; import { generateProjectAnnotationsCode } from '../codegen-project-annotations'; -import { SB_VIRTUAL_FILES, getResolvedVirtualModuleId } from '../virtual-file-names'; +import { getResolvedVirtualModuleId } from '../virtual-file-names'; const VIRTUAL_ID = 'virtual:/@storybook/builder-vite/project-annotations.js'; const RESOLVED_VIRTUAL_ID = getResolvedVirtualModuleId(VIRTUAL_ID); @@ -11,20 +11,11 @@ const RESOLVED_VIRTUAL_ID = getResolvedVirtualModuleId(VIRTUAL_ID); /** * A Vite plugin that serves the project annotations virtual module. * - * This plugin handles the `virtual:/@storybook/builder-vite/project-annotations.js` virtual module, - * which exports a `getProjectAnnotations` function that composes all preview annotations (from - * addons, frameworks, and the user's preview file) into a single configuration object. - * * The virtual module can be imported as: * * ```ts * import { getProjectAnnotations } from 'virtual:/@storybook/builder-vite/project-annotations.js'; * ``` - * - * This plugin is extracted from the builder-specific code-generator-plugin so that it can be shared - * with `@storybook/addon-vitest` and other consumers that need access to the composed project - * annotations without the full builder entry-point machinery (iframe handling, story index - * watching, etc.). */ export function storybookProjectAnnotationsPlugin(options: Options): Plugin { let projectRoot: string; diff --git a/code/builders/builder-vite/src/plugins/storybook-runtime-plugin.ts b/code/builders/builder-vite/src/plugins/storybook-runtime-plugin.ts index c2f559b892d1..a9ecbe501e0a 100644 --- a/code/builders/builder-vite/src/plugins/storybook-runtime-plugin.ts +++ b/code/builders/builder-vite/src/plugins/storybook-runtime-plugin.ts @@ -7,52 +7,14 @@ import type { Plugin } from 'vite'; import { stringifyProcessEnvs } from '../envs'; import { externalGlobalsPlugin } from './external-globals-plugin'; -/** - * Options for the Storybook runtime plugin. - * - * This plugin injects necessary globals and environment variables for Storybook's runtime. It is - * designed to be shared between `@storybook/builder-vite` and `@storybook/addon-vitest`. - */ export interface StorybookRuntimePluginOptions { - /** - * Map of external module names to their global variable reference names. - * - * Storybook preview modules are pre-bundled and exposed as globals at runtime. This map tells the - * plugin how to transform imports of those modules into destructured global variable references. - * - * @example - * - * ``` - * { "storybook/preview-api": "__STORYBOOK_MODULE_PREVIEW_API__" } - * ``` - */ externals: Record; - - /** - * Pre-resolved environment variables to inject as `import.meta.env.*` defines. - * - * When provided, these are filtered by the resolved `envPrefix` from the Vite config and injected - * into Vite's `define` option. - * - * This allows callers to resolve env vars from their own source (e.g., Storybook presets) and - * pass them in without the plugin needing access to the presets system. - */ envs?: Builder_EnvsRaw; } /** * A composite Vite plugin that injects necessary globals and environment variables for Storybook's * runtime. - * - * This handles: - * - * - Transforming imports of pre-bundled Storybook preview modules to global variable references - * (e.g., `import { useMemo } from 'storybook/preview-api'` becomes `const { useMemo } = - * __STORYBOOK_MODULE_PREVIEW_API__`) - * - Setting up dev-mode aliases for external modules - * - Injecting environment variables as `import.meta.env.*` defines - * - * @returns An array of Vite plugins */ export async function storybookRuntimePlugin(options: Options): Promise { const build = await options.presets.apply('build'); diff --git a/code/builders/builder-vite/src/preset.ts b/code/builders/builder-vite/src/preset.ts index 03b192edbb1e..c30f02695f3c 100644 --- a/code/builders/builder-vite/src/preset.ts +++ b/code/builders/builder-vite/src/preset.ts @@ -12,20 +12,6 @@ import { viteMockPlugin } from './plugins/vite-mock/plugin'; /** * Preset that provides the core Storybook Vite plugins shared between `@storybook/builder-vite` and * `@storybook/addon-vitest`. - * - * Includes: - * - * - **Config plugin**: Resolve conditions (`storybook`, `stories`, `test`), environment variable - * prefixes (`VITE_`, `STORYBOOK_`), symlink preservation, and `fs.allow` for the config - * directory - * - **Project annotations plugin**: Virtual module serving `getProjectAnnotations` - * - **Docgen plugin**: CSF processing and component metadata extraction - * - **Runtime plugin**: External globals transformation for pre-bundled Storybook modules - * - **Mocking plugins**: Injects the mocker runtime script into the HTML and sets up rules to swap - * modules based on sb.mock() calls. - * - * Consumers can override builder-specific settings (root, base, cacheDir) by adding their own Vite - * plugins on top. */ export async function viteCorePlugins( existing: PluginOption[], From 895cb72e89f838f4450c7fa5a0e2328021cb819d Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Thu, 12 Feb 2026 12:15:48 +0100 Subject: [PATCH 17/28] Cleanup comments --- code/addons/vitest/src/vitest-plugin/index.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/code/addons/vitest/src/vitest-plugin/index.ts b/code/addons/vitest/src/vitest-plugin/index.ts index 96f2676645ad..f472b46cc97d 100644 --- a/code/addons/vitest/src/vitest-plugin/index.ts +++ b/code/addons/vitest/src/vitest-plugin/index.ts @@ -207,8 +207,6 @@ export const storybookTest = async (options?: UserOptions): Promise => core, features, ] = await Promise.all([ - // Core Storybook Vite plugins from builder-vite's preset - // (resolve conditions, envPrefix, fs.allow, project annotations, docgen, external globals) presets.apply('viteCorePlugins', []), getStoryGlobsAndFiles(presets, directories), presets.apply('framework', undefined), From 47420f1796db7ecb6878ec4338f9ea4a40ba0df4 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Thu, 12 Feb 2026 12:26:39 +0100 Subject: [PATCH 18/28] Refactor imports and remove unused code in Vitest and Vite builders - Removed unused `readFileSync` import in `preset.ts`. - Simplified imports in `envs.ts` by removing the `Options` type. - Cleaned up imports in `storybook-config-plugin.ts` by removing the unused `resolvePathInStorybookCache` and adjusting the `Plugin` import. --- code/addons/vitest/src/preset.ts | 1 - code/builders/builder-vite/src/envs.ts | 2 +- .../builder-vite/src/plugins/storybook-config-plugin.ts | 5 ++--- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/code/addons/vitest/src/preset.ts b/code/addons/vitest/src/preset.ts index c95d534cd96b..fa08fb128d19 100644 --- a/code/addons/vitest/src/preset.ts +++ b/code/addons/vitest/src/preset.ts @@ -1,4 +1,3 @@ -import { readFileSync } from 'node:fs'; import { mkdir } from 'node:fs/promises'; import type { Channel } from 'storybook/internal/channels'; diff --git a/code/builders/builder-vite/src/envs.ts b/code/builders/builder-vite/src/envs.ts index 4946c341a981..bbf0aecef781 100644 --- a/code/builders/builder-vite/src/envs.ts +++ b/code/builders/builder-vite/src/envs.ts @@ -1,5 +1,5 @@ import { stringifyEnvs } from 'storybook/internal/common'; -import type { Builder_EnvsRaw, Options } from 'storybook/internal/types'; +import type { Builder_EnvsRaw } from 'storybook/internal/types'; import type { UserConfig as ViteConfig } from 'vite'; diff --git a/code/builders/builder-vite/src/plugins/storybook-config-plugin.ts b/code/builders/builder-vite/src/plugins/storybook-config-plugin.ts index f5c9a230e068..7152f47f4cf4 100644 --- a/code/builders/builder-vite/src/plugins/storybook-config-plugin.ts +++ b/code/builders/builder-vite/src/plugins/storybook-config-plugin.ts @@ -1,6 +1,6 @@ -import { isPreservingSymlinks, resolvePathInStorybookCache } from 'storybook/internal/common'; +import { isPreservingSymlinks } from 'storybook/internal/common'; -import type { Plugin } from 'vite'; +import { type Plugin } from 'vite'; export interface StorybookConfigPluginOptions { configDir: string; @@ -37,7 +37,6 @@ export function storybookConfigPlugin(options: StorybookConfigPluginOptions): Pl : ['VITE_', 'STORYBOOK_']; return { - cacheDir: resolvePathInStorybookCache('sb-vite'), resolve: { conditions: ['storybook', 'stories', 'test', ...defaultClientConditions], preserveSymlinks: isPreservingSymlinks(), From 91ab0953ec727746c1877376b4fcec2bd6feadb7 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Thu, 12 Feb 2026 12:30:44 +0100 Subject: [PATCH 19/28] Remove obsole docgen plugin --- .../src/plugins/storybook-docgen-plugin.ts | 25 ------------------- code/builders/builder-vite/src/vite-config.ts | 5 ++-- 2 files changed, 2 insertions(+), 28 deletions(-) delete mode 100644 code/builders/builder-vite/src/plugins/storybook-docgen-plugin.ts diff --git a/code/builders/builder-vite/src/plugins/storybook-docgen-plugin.ts b/code/builders/builder-vite/src/plugins/storybook-docgen-plugin.ts deleted file mode 100644 index cc9ae94be512..000000000000 --- a/code/builders/builder-vite/src/plugins/storybook-docgen-plugin.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { Options } from 'storybook/internal/types'; - -import { vite } from '@storybook/csf-plugin'; - -import type { Plugin } from 'vite'; - -/** - * A Vite plugin that handles the extraction of component metadata (argTypes, descriptions) for - * Storybook's documentation features. - */ -export async function storybookDocgenPlugin(options: Options): Promise { - const { presets } = options; - - const addons = await presets.apply('addons', []); - const docsOptions = - // @ts-expect-error - not sure what type to use here - addons.find((a) => [a, a.name].includes('@storybook/addon-docs'))?.options ?? {}; - - const enrichCsf = await presets.apply('experimental_enrichCsf'); - - return vite({ - ...docsOptions?.csfPluginOptions, - enrichCsf, - }) as Plugin; -} diff --git a/code/builders/builder-vite/src/vite-config.ts b/code/builders/builder-vite/src/vite-config.ts index 58b00a8cc697..72bcb95f0232 100644 --- a/code/builders/builder-vite/src/vite-config.ts +++ b/code/builders/builder-vite/src/vite-config.ts @@ -11,8 +11,7 @@ import type { InlineConfig as ViteInlineConfig, } from 'vite'; -import { pluginWebpackStats, storybookEntryPlugin } from './plugins'; -import { storybookDocgenPlugin } from './plugins/storybook-docgen-plugin'; +import { csfPlugin, pluginWebpackStats, storybookEntryPlugin } from './plugins'; import { storybookRuntimePlugin } from './plugins/storybook-runtime-plugin'; import { viteCorePlugins as corePlugins } from './preset'; import type { BuilderOptions } from './types'; @@ -75,7 +74,7 @@ export async function pluginConfig(options: Options) { // Shared core plugins (resolve conditions, envPrefix, fs.allow, docgen, externals, etc.) ...(await corePlugins([], options)), ...(await storybookRuntimePlugin(options)), - await storybookDocgenPlugin(options), + await csfPlugin(options), // Builder-specific: root, base, and cacheDir { name: 'storybook:builder-vite-config', From 606bdac2b526cb3009d92c40a3ad3357fa20b050 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Thu, 12 Feb 2026 14:33:02 +0100 Subject: [PATCH 20/28] Refactor modern iframe script generation and enhance project annotations plugin - Simplified the `generateModernIframeScriptCode` function by removing unused parameters and code related to preview annotations. - Updated the `generateModernIframeScriptCodeFromPreviews` function to utilize the new `getProjectAnnotations` import. - Enhanced the `storybookProjectAnnotationsPlugin` to include HMR support for project annotations, allowing for dynamic updates during development. --- .../src/codegen-modern-iframe-script.ts | 84 ++----------------- .../storybook-project-annotations-plugin.ts | 16 +++- 2 files changed, 21 insertions(+), 79 deletions(-) diff --git a/code/builders/builder-vite/src/codegen-modern-iframe-script.ts b/code/builders/builder-vite/src/codegen-modern-iframe-script.ts index e063c3504c64..c163830b4fe9 100644 --- a/code/builders/builder-vite/src/codegen-modern-iframe-script.ts +++ b/code/builders/builder-vite/src/codegen-modern-iframe-script.ts @@ -1,84 +1,24 @@ -import { getFrameworkName, loadPreviewOrConfigFile } from 'storybook/internal/common'; +import { getFrameworkName } from 'storybook/internal/common'; import { STORY_HOT_UPDATED } from 'storybook/internal/core-events'; -import { isCsfFactoryPreview, readConfig } from 'storybook/internal/csf-tools'; -import type { Options, PreviewAnnotation } from 'storybook/internal/types'; +import type { Options } from 'storybook/internal/types'; -import { genArrayFromRaw, genImport, genSafeVariableName } from 'knitwork'; -import { filename } from 'pathe/utils'; import { dedent } from 'ts-dedent'; -import { processPreviewAnnotation } from './utils/process-preview-annotation'; +import { RESOLVED_VIRTUAL_ID } from './plugins/storybook-project-annotations-plugin'; import { SB_VIRTUAL_FILES } from './virtual-file-names'; -export async function generateModernIframeScriptCode(options: Options, projectRoot: string) { - const { presets, configDir } = options; +export async function generateModernIframeScriptCode(options: Options) { const frameworkName = await getFrameworkName(options); - const previewOrConfigFile = loadPreviewOrConfigFile({ configDir }); - const previewConfig = previewOrConfigFile ? await readConfig(previewOrConfigFile) : undefined; - const isCsf4 = previewConfig ? isCsfFactoryPreview(previewConfig) : false; - - const previewAnnotations = await presets.apply( - 'previewAnnotations', - [], - options - ); return generateModernIframeScriptCodeFromPreviews({ - previewAnnotations: [...previewAnnotations, previewOrConfigFile], - projectRoot, frameworkName, - isCsf4, }); } export async function generateModernIframeScriptCodeFromPreviews(options: { - previewAnnotations: (PreviewAnnotation | undefined)[]; - projectRoot: string; frameworkName: string; - isCsf4: boolean; }) { - const { projectRoot, frameworkName } = options; - const previewAnnotationURLs = options.previewAnnotations - .filter((path) => path !== undefined) - .map((path) => processPreviewAnnotation(path, projectRoot)); - - const variables: string[] = []; - const imports: string[] = []; - for (const previewAnnotation of previewAnnotationURLs) { - const variable = - genSafeVariableName(filename(previewAnnotation)).replace(/_(45|46|47)/g, '_') + - '_' + - hash(previewAnnotation); - variables.push(variable); - imports.push(genImport(previewAnnotation, { name: '*', as: variable })); - } - - const previewFileURL = previewAnnotationURLs[previewAnnotationURLs.length - 1]; - const previewFileVariable = variables[variables.length - 1]; - const previewFileImport = imports[imports.length - 1]; - - // This is pulled out to a variable because it is reused in both the initial page load - // and the HMR handler. - // The `hmrPreviewAnnotationModules` parameter is used to pass the updated modules from HMR. - // However, only the changed modules are provided, the rest are null. - const getPreviewAnnotationsFunction = options.isCsf4 - ? dedent` - const getProjectAnnotations = (hmrPreviewAnnotationModules = []) => { - const preview = hmrPreviewAnnotationModules[0] ?? ${previewFileVariable}; - return preview.default.composed; - }` - : dedent` - const getProjectAnnotations = (hmrPreviewAnnotationModules = []) => { - const configs = ${genArrayFromRaw( - variables.map( - (previewAnnotation, index) => - // Prefer the updated module from an HMR update, otherwise the original module - `hmrPreviewAnnotationModules[${index}] ?? ${previewAnnotation}` - ), - ' ' - )} - return composeConfigs(configs); - }`; + const { frameworkName } = options; const generateHMRHandler = (): string => { // Web components are not compatible with HMR, so disable HMR, reload page instead. @@ -99,11 +39,6 @@ export async function generateModernIframeScriptCodeFromPreviews(options: { // importFn has changed so we need to patch the new one in window.__STORYBOOK_PREVIEW__.onStoriesChanged({ importFn: newModule.importFn }); }); - - import.meta.hot.accept(${JSON.stringify(options.isCsf4 ? [previewFileURL] : previewAnnotationURLs)}, (previewAnnotationModules) => { - // getProjectAnnotations has changed so we need to patch the new one in - window.__STORYBOOK_PREVIEW__.onGetProjectAnnotationsChanged({ getProjectAnnotations: () => getProjectAnnotations(previewAnnotationModules) }); - }); }`.trim(); }; @@ -125,10 +60,8 @@ export async function generateModernIframeScriptCodeFromPreviews(options: { import { composeConfigs, PreviewWeb } from 'storybook/preview-api'; import { isPreview } from 'storybook/internal/csf'; import { importFn } from '${SB_VIRTUAL_FILES.VIRTUAL_STORIES_FILE}'; - - ${options.isCsf4 ? previewFileImport : imports.join('\n')} - ${getPreviewAnnotationsFunction} - + import { getProjectAnnotations } from '${RESOLVED_VIRTUAL_ID}'; + window.__STORYBOOK_PREVIEW__ = window.__STORYBOOK_PREVIEW__ || new PreviewWeb(importFn, getProjectAnnotations); window.__STORYBOOK_STORY_STORE__ = window.__STORYBOOK_STORY_STORE__ || window.__STORYBOOK_PREVIEW__.storyStore; @@ -138,6 +71,3 @@ export async function generateModernIframeScriptCodeFromPreviews(options: { `.trim(); return code; } -function hash(value: string) { - return value.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0); -} diff --git a/code/builders/builder-vite/src/plugins/storybook-project-annotations-plugin.ts b/code/builders/builder-vite/src/plugins/storybook-project-annotations-plugin.ts index 9b6986090f44..3824e2c683b3 100644 --- a/code/builders/builder-vite/src/plugins/storybook-project-annotations-plugin.ts +++ b/code/builders/builder-vite/src/plugins/storybook-project-annotations-plugin.ts @@ -6,7 +6,7 @@ import { generateProjectAnnotationsCode } from '../codegen-project-annotations'; import { getResolvedVirtualModuleId } from '../virtual-file-names'; const VIRTUAL_ID = 'virtual:/@storybook/builder-vite/project-annotations.js'; -const RESOLVED_VIRTUAL_ID = getResolvedVirtualModuleId(VIRTUAL_ID); +export const RESOLVED_VIRTUAL_ID = getResolvedVirtualModuleId(VIRTUAL_ID); /** * A Vite plugin that serves the project annotations virtual module. @@ -33,7 +33,19 @@ export function storybookProjectAnnotationsPlugin(options: Options): Plugin { }, async load(id) { if (id === RESOLVED_VIRTUAL_ID) { - return generateProjectAnnotationsCode(options, projectRoot); + const code = await generateProjectAnnotationsCode(options, projectRoot); + + const hmrCode = [ + 'if (import.meta.hot) {', + ' import.meta.hot.accept((newModule) => {', + ' window.__STORYBOOK_PREVIEW__?.onGetProjectAnnotationsChanged({', + ' getProjectAnnotations: newModule.getProjectAnnotations,', + ' });', + ' });', + '}', + ].join('\n'); + + return `${code}\n\n${hmrCode}`; } }, }; From 2d0adaaffbc7f4f085c1b232db6f48292c09c9bc Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Thu, 12 Feb 2026 14:46:13 +0100 Subject: [PATCH 21/28] Refactor project annotations handling in Vite builder - Renamed the `RESOLVED_VIRTUAL_ID` constant to `PROJECT_ANNOTATIONS_VIRTUAL_ID` for clarity. - Updated import statements in `codegen-modern-iframe-script.ts` to reflect the new naming. - Adjusted the order of plugin inclusion in `preset.ts` to ensure `storybookProjectAnnotationsPlugin` is added correctly. - Cleaned up the `storybook-project-annotations-plugin.ts` by reordering the declaration of `VIRTUAL_ID` and `RESOLVED_VIRTUAL_ID` for better readability. --- .../src/codegen-modern-iframe-script.ts | 4 ++-- .../src/codegen-project-annotations.ts | 12 ++++++++++++ .../storybook-project-annotations-plugin.ts | 18 +++--------------- code/builders/builder-vite/src/preset.ts | 2 +- 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/code/builders/builder-vite/src/codegen-modern-iframe-script.ts b/code/builders/builder-vite/src/codegen-modern-iframe-script.ts index c163830b4fe9..50f1295bbd7d 100644 --- a/code/builders/builder-vite/src/codegen-modern-iframe-script.ts +++ b/code/builders/builder-vite/src/codegen-modern-iframe-script.ts @@ -4,7 +4,7 @@ import type { Options } from 'storybook/internal/types'; import { dedent } from 'ts-dedent'; -import { RESOLVED_VIRTUAL_ID } from './plugins/storybook-project-annotations-plugin'; +import { VIRTUAL_ID as PROJECT_ANNOTATIONS_VIRTUAL_ID } from './plugins/storybook-project-annotations-plugin'; import { SB_VIRTUAL_FILES } from './virtual-file-names'; export async function generateModernIframeScriptCode(options: Options) { @@ -60,7 +60,7 @@ export async function generateModernIframeScriptCodeFromPreviews(options: { import { composeConfigs, PreviewWeb } from 'storybook/preview-api'; import { isPreview } from 'storybook/internal/csf'; import { importFn } from '${SB_VIRTUAL_FILES.VIRTUAL_STORIES_FILE}'; - import { getProjectAnnotations } from '${RESOLVED_VIRTUAL_ID}'; + import { getProjectAnnotations } from '${PROJECT_ANNOTATIONS_VIRTUAL_ID}'; window.__STORYBOOK_PREVIEW__ = window.__STORYBOOK_PREVIEW__ || new PreviewWeb(importFn, getProjectAnnotations); diff --git a/code/builders/builder-vite/src/codegen-project-annotations.ts b/code/builders/builder-vite/src/codegen-project-annotations.ts index 05037358c3d0..7d5e12706fd1 100644 --- a/code/builders/builder-vite/src/codegen-project-annotations.ts +++ b/code/builders/builder-vite/src/codegen-project-annotations.ts @@ -66,6 +66,16 @@ export function generateProjectAnnotationsCodeFromPreviews(options: { `.trim(); } + const hmrCode = [ + 'if (import.meta.hot) {', + ' import.meta.hot.accept((newModule) => {', + ' window.__STORYBOOK_PREVIEW__?.onGetProjectAnnotationsChanged({', + ' getProjectAnnotations: newModule.getProjectAnnotations,', + ' });', + ' });', + '}', + ].join('\n'); + return dedent` import { composeConfigs } from 'storybook/preview-api'; @@ -74,6 +84,8 @@ export function generateProjectAnnotationsCodeFromPreviews(options: { export function getProjectAnnotations() { return composeConfigs([${variables.join(', ')}]); } + + ${hmrCode} `.trim(); } diff --git a/code/builders/builder-vite/src/plugins/storybook-project-annotations-plugin.ts b/code/builders/builder-vite/src/plugins/storybook-project-annotations-plugin.ts index 3824e2c683b3..beb879493f64 100644 --- a/code/builders/builder-vite/src/plugins/storybook-project-annotations-plugin.ts +++ b/code/builders/builder-vite/src/plugins/storybook-project-annotations-plugin.ts @@ -5,8 +5,8 @@ import type { Plugin } from 'vite'; import { generateProjectAnnotationsCode } from '../codegen-project-annotations'; import { getResolvedVirtualModuleId } from '../virtual-file-names'; -const VIRTUAL_ID = 'virtual:/@storybook/builder-vite/project-annotations.js'; -export const RESOLVED_VIRTUAL_ID = getResolvedVirtualModuleId(VIRTUAL_ID); +export const VIRTUAL_ID = 'virtual:/@storybook/builder-vite/project-annotations.js'; +const RESOLVED_VIRTUAL_ID = getResolvedVirtualModuleId(VIRTUAL_ID); /** * A Vite plugin that serves the project annotations virtual module. @@ -33,19 +33,7 @@ export function storybookProjectAnnotationsPlugin(options: Options): Plugin { }, async load(id) { if (id === RESOLVED_VIRTUAL_ID) { - const code = await generateProjectAnnotationsCode(options, projectRoot); - - const hmrCode = [ - 'if (import.meta.hot) {', - ' import.meta.hot.accept((newModule) => {', - ' window.__STORYBOOK_PREVIEW__?.onGetProjectAnnotationsChanged({', - ' getProjectAnnotations: newModule.getProjectAnnotations,', - ' });', - ' });', - '}', - ].join('\n'); - - return `${code}\n\n${hmrCode}`; + return generateProjectAnnotationsCode(options, projectRoot); } }, }; diff --git a/code/builders/builder-vite/src/preset.ts b/code/builders/builder-vite/src/preset.ts index c30f02695f3c..0df1412bcae5 100644 --- a/code/builders/builder-vite/src/preset.ts +++ b/code/builders/builder-vite/src/preset.ts @@ -20,9 +20,9 @@ export async function viteCorePlugins( const previewConfigPath = findConfigFile('preview', options.configDir); return [ + storybookProjectAnnotationsPlugin(options), ...storybookConfigPlugin({ configDir: options.configDir }), storybookOptimizeDepsPlugin(options), - storybookProjectAnnotationsPlugin(options), ...(previewConfigPath ? [ viteInjectMockerRuntime({ previewConfigPath }), From b500e3a0ef65e213dcf5865fef294f93fe3a9c80 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Thu, 12 Feb 2026 14:55:13 +0100 Subject: [PATCH 22/28] Cleanup --- code/builders/builder-vite/src/preset.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/code/builders/builder-vite/src/preset.ts b/code/builders/builder-vite/src/preset.ts index 0df1412bcae5..9d913eb6134a 100644 --- a/code/builders/builder-vite/src/preset.ts +++ b/code/builders/builder-vite/src/preset.ts @@ -14,7 +14,7 @@ import { viteMockPlugin } from './plugins/vite-mock/plugin'; * `@storybook/addon-vitest`. */ export async function viteCorePlugins( - existing: PluginOption[], + _: PluginOption[], options: Options ): Promise { const previewConfigPath = findConfigFile('preview', options.configDir); From aa9a68823db3976fee37520ff7185aa2674c479b Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Fri, 13 Feb 2026 09:35:51 +0100 Subject: [PATCH 23/28] Enhance storybookOptimizeDepsPlugin to support additional entry points - Updated the `entries` property in the `storybookOptimizeDepsPlugin` to allow for both string and array formats, improving flexibility in dependency optimization. --- .../src/plugins/storybook-optimize-deps-plugin.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/code/builders/builder-vite/src/plugins/storybook-optimize-deps-plugin.ts b/code/builders/builder-vite/src/plugins/storybook-optimize-deps-plugin.ts index 7702f8e2aa2b..ccbd5e4a9a4e 100644 --- a/code/builders/builder-vite/src/plugins/storybook-optimize-deps-plugin.ts +++ b/code/builders/builder-vite/src/plugins/storybook-optimize-deps-plugin.ts @@ -25,7 +25,12 @@ export function storybookOptimizeDepsPlugin(options: Options): Plugin { return { optimizeDeps: { // Story file paths as entry points for the optimizer - entries: getUniqueImportPaths(index), + entries: [ + ...(typeof config.optimizeDeps?.entries === 'string' + ? [config.optimizeDeps.entries] + : []), + ...getUniqueImportPaths(index), + ], // Known CJS dependencies that need to be pre-compiled to ESM, // plus any extra deps from Storybook presets. include: [...extraOptimizeDeps, ...(config.optimizeDeps?.include || [])], From 8af759954a87c6b95a63100c8e550fd3dfda60fc Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Thu, 19 Feb 2026 12:05:27 +0100 Subject: [PATCH 24/28] Add storybookExternalGlobalsPlugin and related tests; refactor runtime plugin integration --- .../builder-vite/src/plugins/index.ts | 2 +- ...storybook-external-globals-plugin.test.ts} | 2 +- ...s => storybook-external-globals-plugin.ts} | 16 ++++++++++++++- .../src/plugins/storybook-runtime-plugin.ts | 20 +++---------------- code/builders/builder-vite/src/preset.ts | 2 ++ code/builders/builder-vite/src/vite-config.ts | 12 +++++++---- 6 files changed, 30 insertions(+), 24 deletions(-) rename code/builders/builder-vite/src/plugins/{external-globals-plugin.test.ts => storybook-external-globals-plugin.test.ts} (95%) rename code/builders/builder-vite/src/plugins/{external-globals-plugin.ts => storybook-external-globals-plugin.ts} (88%) diff --git a/code/builders/builder-vite/src/plugins/index.ts b/code/builders/builder-vite/src/plugins/index.ts index ef9336b8036c..078886a86b4a 100644 --- a/code/builders/builder-vite/src/plugins/index.ts +++ b/code/builders/builder-vite/src/plugins/index.ts @@ -9,4 +9,4 @@ export { injectExportOrderPlugin } from './inject-export-order-plugin'; export { stripStoryHMRBoundary } from './strip-story-hmr-boundaries'; export { codeGeneratorPlugin } from './code-generator-plugin'; export { csfPlugin } from './csf-plugin'; -export { externalGlobalsPlugin, rewriteImport } from './external-globals-plugin'; +export { storybookExternalGlobalsPlugin } from './storybook-external-globals-plugin'; diff --git a/code/builders/builder-vite/src/plugins/external-globals-plugin.test.ts b/code/builders/builder-vite/src/plugins/storybook-external-globals-plugin.test.ts similarity index 95% rename from code/builders/builder-vite/src/plugins/external-globals-plugin.test.ts rename to code/builders/builder-vite/src/plugins/storybook-external-globals-plugin.test.ts index 67b62690da43..4b5c001f0e3d 100644 --- a/code/builders/builder-vite/src/plugins/external-globals-plugin.test.ts +++ b/code/builders/builder-vite/src/plugins/storybook-external-globals-plugin.test.ts @@ -1,6 +1,6 @@ import { expect, it } from 'vitest'; -import { rewriteImport } from './external-globals-plugin'; +import { rewriteImport } from './storybook-external-globals-plugin'; const packageName = '@storybook/package'; const globals = { [packageName]: '_STORYBOOK_PACKAGE_' }; diff --git a/code/builders/builder-vite/src/plugins/external-globals-plugin.ts b/code/builders/builder-vite/src/plugins/storybook-external-globals-plugin.ts similarity index 88% rename from code/builders/builder-vite/src/plugins/external-globals-plugin.ts rename to code/builders/builder-vite/src/plugins/storybook-external-globals-plugin.ts index 242fb98ff3dc..bcc4459dfccc 100644 --- a/code/builders/builder-vite/src/plugins/external-globals-plugin.ts +++ b/code/builders/builder-vite/src/plugins/storybook-external-globals-plugin.ts @@ -2,11 +2,15 @@ import { existsSync } from 'node:fs'; import { mkdir, writeFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; +import type { Options } from 'storybook/internal/types'; + import * as pkg from 'empathic/package'; import { init, parse } from 'es-module-lexer'; import MagicString from 'magic-string'; import type { Alias, Plugin } from 'vite'; +import { globalsNameReferenceMap } from '../../../../core/src/manager/globals/globals'; + const escapeKeys = (key: string) => key.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&'); const defaultImportRegExp = 'import ([^*{}]+) from'; const replacementMap = new Map([ @@ -38,7 +42,17 @@ const replacementMap = new Map([ * https://github.com/eight04/rollup-plugin-external-globals, but simplified to meet our simple * needs. */ -export async function externalGlobalsPlugin(externals: Record): Promise { + +export async function storybookExternalGlobalsPlugin(options: Options): Promise { + const build = await options.presets.apply('build'); + + const externals: typeof globalsNameReferenceMap & Record = + globalsNameReferenceMap; + + if (build?.test?.disableBlocks) { + externals['@storybook/addon-docs/blocks'] = '__STORYBOOK_BLOCKS_EMPTY_MODULE__'; + } + await init; const { mergeAlias } = await import('vite'); diff --git a/code/builders/builder-vite/src/plugins/storybook-runtime-plugin.ts b/code/builders/builder-vite/src/plugins/storybook-runtime-plugin.ts index a9ecbe501e0a..a60d66eace7e 100644 --- a/code/builders/builder-vite/src/plugins/storybook-runtime-plugin.ts +++ b/code/builders/builder-vite/src/plugins/storybook-runtime-plugin.ts @@ -1,32 +1,18 @@ -import { globalsNameReferenceMap } from 'storybook/internal/preview/globals'; import type { Builder_EnvsRaw } from 'storybook/internal/types'; import type { Options } from 'storybook/internal/types'; import type { Plugin } from 'vite'; import { stringifyProcessEnvs } from '../envs'; -import { externalGlobalsPlugin } from './external-globals-plugin'; export interface StorybookRuntimePluginOptions { externals: Record; envs?: Builder_EnvsRaw; } -/** - * A composite Vite plugin that injects necessary globals and environment variables for Storybook's - * runtime. - */ -export async function storybookRuntimePlugin(options: Options): Promise { - const build = await options.presets.apply('build'); - - const externals: typeof globalsNameReferenceMap & Record = - globalsNameReferenceMap; - - if (build?.test?.disableBlocks) { - externals['@storybook/addon-docs/blocks'] = '__STORYBOOK_BLOCKS_EMPTY_MODULE__'; - } - - const plugins: Plugin[] = [await externalGlobalsPlugin(externals)]; +/** A composite Vite plugin that injects environment variables for Storybook's runtime. */ +export async function storybookSanitizeEnvs(options: Options): Promise { + const plugins: Plugin[] = []; const envs = await options.presets.apply('env'); if (envs && Object.keys(envs).length > 0) { diff --git a/code/builders/builder-vite/src/preset.ts b/code/builders/builder-vite/src/preset.ts index 9d913eb6134a..0f33305a8c20 100644 --- a/code/builders/builder-vite/src/preset.ts +++ b/code/builders/builder-vite/src/preset.ts @@ -6,6 +6,7 @@ import type { PluginOption } from 'vite'; import { storybookConfigPlugin } from './plugins/storybook-config-plugin'; import { storybookOptimizeDepsPlugin } from './plugins/storybook-optimize-deps-plugin'; import { storybookProjectAnnotationsPlugin } from './plugins/storybook-project-annotations-plugin'; +import { storybookSanitizeEnvs } from './plugins/storybook-runtime-plugin'; import { viteInjectMockerRuntime } from './plugins/vite-inject-mocker/plugin'; import { viteMockPlugin } from './plugins/vite-mock/plugin'; @@ -23,6 +24,7 @@ export async function viteCorePlugins( storybookProjectAnnotationsPlugin(options), ...storybookConfigPlugin({ configDir: options.configDir }), storybookOptimizeDepsPlugin(options), + ...(await storybookSanitizeEnvs(options)), ...(previewConfigPath ? [ viteInjectMockerRuntime({ previewConfigPath }), diff --git a/code/builders/builder-vite/src/vite-config.ts b/code/builders/builder-vite/src/vite-config.ts index 72bcb95f0232..8a8b833962c0 100644 --- a/code/builders/builder-vite/src/vite-config.ts +++ b/code/builders/builder-vite/src/vite-config.ts @@ -11,8 +11,12 @@ import type { InlineConfig as ViteInlineConfig, } from 'vite'; -import { csfPlugin, pluginWebpackStats, storybookEntryPlugin } from './plugins'; -import { storybookRuntimePlugin } from './plugins/storybook-runtime-plugin'; +import { + csfPlugin, + pluginWebpackStats, + storybookEntryPlugin, + storybookExternalGlobalsPlugin, +} from './plugins'; import { viteCorePlugins as corePlugins } from './preset'; import type { BuilderOptions } from './types'; @@ -71,9 +75,9 @@ export async function pluginConfig(options: Options) { const projectRoot = resolve(options.configDir, '..'); const plugins = [ - // Shared core plugins (resolve conditions, envPrefix, fs.allow, docgen, externals, etc.) + // Shared core plugins (resolve conditions, envPrefix, fs.allow, externals, env vars, etc.) ...(await corePlugins([], options)), - ...(await storybookRuntimePlugin(options)), + await storybookExternalGlobalsPlugin(options), await csfPlugin(options), // Builder-specific: root, base, and cacheDir { From fa82d19ae452480b337359ff46fe8116057560d9 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Thu, 19 Feb 2026 12:12:53 +0100 Subject: [PATCH 25/28] Enhance project annotations handling with HMR support and refactor getProjectAnnotations function --- .../src/codegen-project-annotations.ts | 48 ++++++++++++------- 1 file changed, 32 insertions(+), 16 deletions(-) diff --git a/code/builders/builder-vite/src/codegen-project-annotations.ts b/code/builders/builder-vite/src/codegen-project-annotations.ts index 7d5e12706fd1..9d13c9390ec9 100644 --- a/code/builders/builder-vite/src/codegen-project-annotations.ts +++ b/code/builders/builder-vite/src/codegen-project-annotations.ts @@ -2,7 +2,7 @@ import { getFrameworkName, loadPreviewOrConfigFile } from 'storybook/internal/co import { isCsfFactoryPreview, readConfig } from 'storybook/internal/csf-tools'; import type { Options, PreviewAnnotation } from 'storybook/internal/types'; -import { genImport, genSafeVariableName } from 'knitwork'; +import { genArrayFromRaw, genImport, genSafeVariableName } from 'knitwork'; import { filename } from 'pathe/utils'; import { dedent } from 'ts-dedent'; @@ -53,6 +53,7 @@ export function generateProjectAnnotationsCodeFromPreviews(options: { imports.push(genImport(previewAnnotation, { name: '*', as: variable })); } + const previewFileURL = previewAnnotationURLs[previewAnnotationURLs.length - 1]; const previewFileVariable = variables[variables.length - 1]; const previewFileImport = imports[imports.length - 1]; @@ -60,32 +61,47 @@ export function generateProjectAnnotationsCodeFromPreviews(options: { return dedent` ${previewFileImport} - export function getProjectAnnotations() { - return ${previewFileVariable}.default.composed; + export function getProjectAnnotations(hmrPreviewAnnotationModules = []) { + const preview = hmrPreviewAnnotationModules[0] ?? ${previewFileVariable}; + return preview.default.composed; + } + + if (import.meta.hot) { + import.meta.hot.accept([${JSON.stringify(previewFileURL)}], (previewAnnotationModules) => { + // getProjectAnnotations has changed so we need to patch the new one in + window?.__STORYBOOK_PREVIEW__?.onGetProjectAnnotationsChanged({ + getProjectAnnotations: () => getProjectAnnotations(previewAnnotationModules), + }); + }); } `.trim(); } - const hmrCode = [ - 'if (import.meta.hot) {', - ' import.meta.hot.accept((newModule) => {', - ' window.__STORYBOOK_PREVIEW__?.onGetProjectAnnotationsChanged({', - ' getProjectAnnotations: newModule.getProjectAnnotations,', - ' });', - ' });', - '}', - ].join('\n'); - return dedent` import { composeConfigs } from 'storybook/preview-api'; ${imports.join('\n')} - export function getProjectAnnotations() { - return composeConfigs([${variables.join(', ')}]); + export function getProjectAnnotations(hmrPreviewAnnotationModules = []) { + const configs = ${genArrayFromRaw( + variables.map( + (previewAnnotation, index) => + // Prefer the updated module from an HMR update, otherwise the original module + `hmrPreviewAnnotationModules[${index}] ?? ${previewAnnotation}` + ), + ' ' + )}; + return composeConfigs(configs); } - ${hmrCode} + if (import.meta.hot) { + import.meta.hot.accept(${JSON.stringify(previewAnnotationURLs)}, (previewAnnotationModules) => { + // getProjectAnnotations has changed so we need to patch the new one in + window?.__STORYBOOK_PREVIEW__?.onGetProjectAnnotationsChanged({ + getProjectAnnotations: () => getProjectAnnotations(previewAnnotationModules), + }); + }); + } `.trim(); } From 8117c38aed9273ad5de15e08bc124dfcbc1b3f03 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Thu, 19 Feb 2026 12:13:41 +0100 Subject: [PATCH 26/28] Cleanup --- code/builders/builder-vite/src/codegen-modern-iframe-script.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/code/builders/builder-vite/src/codegen-modern-iframe-script.ts b/code/builders/builder-vite/src/codegen-modern-iframe-script.ts index 50f1295bbd7d..e8a615db5b59 100644 --- a/code/builders/builder-vite/src/codegen-modern-iframe-script.ts +++ b/code/builders/builder-vite/src/codegen-modern-iframe-script.ts @@ -57,8 +57,7 @@ export async function generateModernIframeScriptCodeFromPreviews(options: { setup(); - import { composeConfigs, PreviewWeb } from 'storybook/preview-api'; - import { isPreview } from 'storybook/internal/csf'; + import { PreviewWeb } from 'storybook/preview-api'; import { importFn } from '${SB_VIRTUAL_FILES.VIRTUAL_STORIES_FILE}'; import { getProjectAnnotations } from '${PROJECT_ANNOTATIONS_VIRTUAL_ID}'; From 6918d49be3859ce7b22d2aba2f67c4b6b3711e44 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Thu, 19 Feb 2026 14:27:32 +0100 Subject: [PATCH 27/28] Fix linting --- .../src/codegen-modern-iframe-script.test.ts | 88 +++---------------- .../src/plugins/code-generator-plugin.ts | 4 +- 2 files changed, 13 insertions(+), 79 deletions(-) diff --git a/code/builders/builder-vite/src/codegen-modern-iframe-script.test.ts b/code/builders/builder-vite/src/codegen-modern-iframe-script.test.ts index bac19386fcf9..af2dce770d19 100644 --- a/code/builders/builder-vite/src/codegen-modern-iframe-script.test.ts +++ b/code/builders/builder-vite/src/codegen-modern-iframe-script.test.ts @@ -2,15 +2,10 @@ import { describe, expect, it } from 'vitest'; import { generateModernIframeScriptCodeFromPreviews } from './codegen-modern-iframe-script'; -const projectRoot = 'projectRoot'; - describe('generateModernIframeScriptCodeFromPreviews', () => { it('handle one annotation', async () => { const result = await generateModernIframeScriptCodeFromPreviews({ - previewAnnotations: ['/user/.storybook/preview'], - projectRoot, frameworkName: 'frameworkName', - isCsf4: false, }); expect(result).toMatchInlineSnapshot(` "import { setup } from 'storybook/internal/preview/runtime'; @@ -19,18 +14,10 @@ describe('generateModernIframeScriptCodeFromPreviews', () => { setup(); - import { composeConfigs, PreviewWeb } from 'storybook/preview-api'; - import { isPreview } from 'storybook/internal/csf'; + import { PreviewWeb } from 'storybook/preview-api'; import { importFn } from 'virtual:/@storybook/builder-vite/storybook-stories.js'; - - import * as preview_2408 from "/user/.storybook/preview"; - const getProjectAnnotations = (hmrPreviewAnnotationModules = []) => { - const configs = [ - hmrPreviewAnnotationModules[0] ?? preview_2408 - ] - return composeConfigs(configs); - } - + import { getProjectAnnotations } from 'virtual:/@storybook/builder-vite/project-annotations.js'; + window.__STORYBOOK_PREVIEW__ = window.__STORYBOOK_PREVIEW__ || new PreviewWeb(importFn, getProjectAnnotations); window.__STORYBOOK_STORY_STORE__ = window.__STORYBOOK_STORY_STORE__ || window.__STORYBOOK_PREVIEW__.storyStore; @@ -44,21 +31,13 @@ describe('generateModernIframeScriptCodeFromPreviews', () => { // importFn has changed so we need to patch the new one in window.__STORYBOOK_PREVIEW__.onStoriesChanged({ importFn: newModule.importFn }); }); - - import.meta.hot.accept(["/user/.storybook/preview"], (previewAnnotationModules) => { - // getProjectAnnotations has changed so we need to patch the new one in - window.__STORYBOOK_PREVIEW__.onGetProjectAnnotationsChanged({ getProjectAnnotations: () => getProjectAnnotations(previewAnnotationModules) }); - }); };" `); }); it('handle one annotation CSF4', async () => { const result = await generateModernIframeScriptCodeFromPreviews({ - previewAnnotations: ['/user/.storybook/preview'], - projectRoot, frameworkName: 'frameworkName', - isCsf4: true, }); expect(result).toMatchInlineSnapshot(` "import { setup } from 'storybook/internal/preview/runtime'; @@ -67,16 +46,10 @@ describe('generateModernIframeScriptCodeFromPreviews', () => { setup(); - import { composeConfigs, PreviewWeb } from 'storybook/preview-api'; - import { isPreview } from 'storybook/internal/csf'; + import { PreviewWeb } from 'storybook/preview-api'; import { importFn } from 'virtual:/@storybook/builder-vite/storybook-stories.js'; - - import * as preview_2408 from "/user/.storybook/preview"; - const getProjectAnnotations = (hmrPreviewAnnotationModules = []) => { - const preview = hmrPreviewAnnotationModules[0] ?? preview_2408; - return preview.default.composed; - } - + import { getProjectAnnotations } from 'virtual:/@storybook/builder-vite/project-annotations.js'; + window.__STORYBOOK_PREVIEW__ = window.__STORYBOOK_PREVIEW__ || new PreviewWeb(importFn, getProjectAnnotations); window.__STORYBOOK_STORY_STORE__ = window.__STORYBOOK_STORY_STORE__ || window.__STORYBOOK_PREVIEW__.storyStore; @@ -90,21 +63,13 @@ describe('generateModernIframeScriptCodeFromPreviews', () => { // importFn has changed so we need to patch the new one in window.__STORYBOOK_PREVIEW__.onStoriesChanged({ importFn: newModule.importFn }); }); - - import.meta.hot.accept(["/user/.storybook/preview"], (previewAnnotationModules) => { - // getProjectAnnotations has changed so we need to patch the new one in - window.__STORYBOOK_PREVIEW__.onGetProjectAnnotationsChanged({ getProjectAnnotations: () => getProjectAnnotations(previewAnnotationModules) }); - }); };" `); }); it('handle multiple annotations', async () => { const result = await generateModernIframeScriptCodeFromPreviews({ - previewAnnotations: ['/user/previewAnnotations1', '/user/.storybook/preview'], - projectRoot, frameworkName: 'frameworkName', - isCsf4: false, }); expect(result).toMatchInlineSnapshot(` "import { setup } from 'storybook/internal/preview/runtime'; @@ -113,20 +78,10 @@ describe('generateModernIframeScriptCodeFromPreviews', () => { setup(); - import { composeConfigs, PreviewWeb } from 'storybook/preview-api'; - import { isPreview } from 'storybook/internal/csf'; + import { PreviewWeb } from 'storybook/preview-api'; import { importFn } from 'virtual:/@storybook/builder-vite/storybook-stories.js'; - - import * as previewAnnotations1_2526 from "/user/previewAnnotations1"; - import * as preview_2408 from "/user/.storybook/preview"; - const getProjectAnnotations = (hmrPreviewAnnotationModules = []) => { - const configs = [ - hmrPreviewAnnotationModules[0] ?? previewAnnotations1_2526, - hmrPreviewAnnotationModules[1] ?? preview_2408 - ] - return composeConfigs(configs); - } - + import { getProjectAnnotations } from 'virtual:/@storybook/builder-vite/project-annotations.js'; + window.__STORYBOOK_PREVIEW__ = window.__STORYBOOK_PREVIEW__ || new PreviewWeb(importFn, getProjectAnnotations); window.__STORYBOOK_STORY_STORE__ = window.__STORYBOOK_STORY_STORE__ || window.__STORYBOOK_PREVIEW__.storyStore; @@ -140,21 +95,13 @@ describe('generateModernIframeScriptCodeFromPreviews', () => { // importFn has changed so we need to patch the new one in window.__STORYBOOK_PREVIEW__.onStoriesChanged({ importFn: newModule.importFn }); }); - - import.meta.hot.accept(["/user/previewAnnotations1","/user/.storybook/preview"], (previewAnnotationModules) => { - // getProjectAnnotations has changed so we need to patch the new one in - window.__STORYBOOK_PREVIEW__.onGetProjectAnnotationsChanged({ getProjectAnnotations: () => getProjectAnnotations(previewAnnotationModules) }); - }); };" `); }); it('handle multiple annotations CSF4', async () => { const result = await generateModernIframeScriptCodeFromPreviews({ - previewAnnotations: ['/user/previewAnnotations1', '/user/.storybook/preview'], - projectRoot, frameworkName: 'frameworkName', - isCsf4: true, }); expect(result).toMatchInlineSnapshot(` "import { setup } from 'storybook/internal/preview/runtime'; @@ -163,16 +110,10 @@ describe('generateModernIframeScriptCodeFromPreviews', () => { setup(); - import { composeConfigs, PreviewWeb } from 'storybook/preview-api'; - import { isPreview } from 'storybook/internal/csf'; + import { PreviewWeb } from 'storybook/preview-api'; import { importFn } from 'virtual:/@storybook/builder-vite/storybook-stories.js'; - - import * as preview_2408 from "/user/.storybook/preview"; - const getProjectAnnotations = (hmrPreviewAnnotationModules = []) => { - const preview = hmrPreviewAnnotationModules[0] ?? preview_2408; - return preview.default.composed; - } - + import { getProjectAnnotations } from 'virtual:/@storybook/builder-vite/project-annotations.js'; + window.__STORYBOOK_PREVIEW__ = window.__STORYBOOK_PREVIEW__ || new PreviewWeb(importFn, getProjectAnnotations); window.__STORYBOOK_STORY_STORE__ = window.__STORYBOOK_STORY_STORE__ || window.__STORYBOOK_PREVIEW__.storyStore; @@ -186,11 +127,6 @@ describe('generateModernIframeScriptCodeFromPreviews', () => { // importFn has changed so we need to patch the new one in window.__STORYBOOK_PREVIEW__.onStoriesChanged({ importFn: newModule.importFn }); }); - - import.meta.hot.accept(["/user/.storybook/preview"], (previewAnnotationModules) => { - // getProjectAnnotations has changed so we need to patch the new one in - window.__STORYBOOK_PREVIEW__.onGetProjectAnnotationsChanged({ getProjectAnnotations: () => getProjectAnnotations(previewAnnotationModules) }); - }); };" `); }); diff --git a/code/builders/builder-vite/src/plugins/code-generator-plugin.ts b/code/builders/builder-vite/src/plugins/code-generator-plugin.ts index a2ddef103eab..50a163e9ff9c 100644 --- a/code/builders/builder-vite/src/plugins/code-generator-plugin.ts +++ b/code/builders/builder-vite/src/plugins/code-generator-plugin.ts @@ -20,7 +20,6 @@ import { export function codeGeneratorPlugin(options: Options) { const iframePath = fileURLToPath(importMetaResolve('@storybook/builder-vite/input/iframe.html')); let iframeId: string; - let projectRoot: string; const storyIndexGeneratorPromise: Promise = options.presets.apply('storyIndexGenerator'); @@ -53,7 +52,6 @@ export function codeGeneratorPlugin(options: Options) { } }, configResolved(config) { - projectRoot = config.root; iframeId = `${config.root}/iframe.html`; }, resolveId(source) { @@ -78,7 +76,7 @@ export function codeGeneratorPlugin(options: Options) { return generateAddonSetupCode(); } case getResolvedVirtualModuleId(SB_VIRTUAL_FILES.VIRTUAL_APP_FILE): { - return generateModernIframeScriptCode(options, projectRoot); + return generateModernIframeScriptCode(options); } case iframeId: { return readFileSync( From 554a95163915cceaebff1e119ba553011debf2b5 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Thu, 19 Feb 2026 14:32:28 +0100 Subject: [PATCH 28/28] Fix wrong path --- .../src/plugins/storybook-external-globals-plugin.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/code/builders/builder-vite/src/plugins/storybook-external-globals-plugin.ts b/code/builders/builder-vite/src/plugins/storybook-external-globals-plugin.ts index bcc4459dfccc..54bb15a28c96 100644 --- a/code/builders/builder-vite/src/plugins/storybook-external-globals-plugin.ts +++ b/code/builders/builder-vite/src/plugins/storybook-external-globals-plugin.ts @@ -2,6 +2,7 @@ import { existsSync } from 'node:fs'; import { mkdir, writeFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; +import { globalsNameReferenceMap } from 'storybook/internal/preview/globals'; import type { Options } from 'storybook/internal/types'; import * as pkg from 'empathic/package'; @@ -9,8 +10,6 @@ import { init, parse } from 'es-module-lexer'; import MagicString from 'magic-string'; import type { Alias, Plugin } from 'vite'; -import { globalsNameReferenceMap } from '../../../../core/src/manager/globals/globals'; - const escapeKeys = (key: string) => key.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&'); const defaultImportRegExp = 'import ([^*{}]+) from'; const replacementMap = new Map([