Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
04f6bcd
Core: Move builder-specific mocking logic into builders
valentinpalkovic Nov 3, 2025
6aae3d9
Fix tests
valentinpalkovic Nov 3, 2025
1f13f05
Fix tests
valentinpalkovic Nov 3, 2025
14becd3
Merge branch 'valentin/cli-init-rework' into valentin/cli-init-docs-p…
valentinpalkovic Nov 3, 2025
12ed766
Merge branch 'valentin/cli-init-rework' into valentin/cli-init-docs-p…
valentinpalkovic Nov 3, 2025
970ec6d
Merge branch 'valentin/cli-init-rework' into valentin/cli-init-docs-p…
valentinpalkovic Nov 3, 2025
16b7aab
Merge branch 'valentin/cli-init-rework' into valentin/cli-init-docs-p…
valentinpalkovic Nov 3, 2025
6ed2906
Merge branch 'valentin/cli-init-rework' into valentin/cli-init-docs-p…
valentinpalkovic Nov 4, 2025
e379604
Improve error logging in extractMockCalls function to include error m…
valentinpalkovic Nov 4, 2025
ca75222
Merge remote-tracking branch 'origin/valentin/cli-init-rework' into v…
valentinpalkovic Nov 4, 2025
423a1a9
Merge branch 'valentin/cli-init-rework' into valentin/cli-init-docs-p…
valentinpalkovic Nov 4, 2025
926fd23
Merge branch 'valentin/cli-init-rework' into valentin/cli-init-docs-p…
valentinpalkovic Nov 5, 2025
31bda49
Merge remote-tracking branch 'origin/valentin/cli-init-rework' into v…
valentinpalkovic Nov 5, 2025
a9da81c
Merge branch 'valentin/cli-init-rework' into valentin/cli-init-docs-p…
valentinpalkovic Nov 6, 2025
d1f6bed
Merge branch 'valentin/cli-init-rework' into valentin/cli-init-docs-p…
valentinpalkovic Nov 6, 2025
cabdff3
Merge branch 'valentin/cli-init-rework' into valentin/cli-init-docs-p…
valentinpalkovic Nov 6, 2025
5f21e96
Merge branch 'valentin/cli-init-rework' into valentin/cli-init-docs-p…
valentinpalkovic Nov 6, 2025
e8977ce
Merge branch 'valentin/cli-init-rework' into valentin/cli-init-docs-p…
valentinpalkovic Nov 13, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion code/addons/vitest/src/vitest-plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import { oneWayHash } from 'storybook/internal/telemetry';
import type { Presets } from 'storybook/internal/types';

import { match } from 'micromatch';
import { dirname, join, normalize, relative, resolve, sep } from 'pathe';
import { join, normalize, relative, resolve, sep } from 'pathe';
import picocolors from 'picocolors';
import sirv from 'sirv';
import { dedent } from 'ts-dedent';
Expand Down
5 changes: 5 additions & 0 deletions code/builders/builder-vite/build-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ const config: BuildEntries = {
exportEntries: ['.'],
entryPoint: './src/index.ts',
},
{
exportEntries: ['./preset'],
entryPoint: './src/preset.ts',
dts: false,
},
],
},
extraOutputs: {
Expand Down
5 changes: 4 additions & 1 deletion code/builders/builder-vite/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,16 @@
"default": "./dist/index.js"
},
"./input/iframe.html": "./input/iframe.html",
"./package.json": "./package.json"
"./package.json": "./package.json",
"./preset": "./dist/preset.js"
},
"files": [
"dist/**/*",
"input/**/*",
"README.md",
"*.js",
"*.d.ts",
"preset.js",
"!src/**/*"
],
"scripts": {
Expand All @@ -49,6 +51,7 @@
},
"dependencies": {
"@storybook/csf-plugin": "workspace:*",
"@vitest/mocker": "3.2.4",
"ts-dedent": "^2.0.0"
},
"devDependencies": {
Expand Down
1 change: 1 addition & 0 deletions code/builders/builder-vite/preset.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './dist/preset.js';
2 changes: 2 additions & 0 deletions code/builders/builder-vite/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,5 @@ export const start: ViteBuilder['start'] = async ({
export const build: ViteBuilder['build'] = async ({ options }) => {
return viteBuild(options as Options);
};

export const corePresets = [import.meta.resolve('@storybook/builder-vite/preset')];
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@ import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';

import { resolvePackageDir } from 'storybook/internal/common';

import { exactRegex } from '@rolldown/pluginutils';
import { dedent } from 'ts-dedent';
import type { ResolvedConfig, ViteDevServer } from 'vite';

import { resolvePackageDir } from '../../../../shared/utils/module';

const entryPath = '/vite-inject-mocker-entry.js';

const entryCode = dedent`
Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
import { readFileSync } from 'node:fs';

import {
babelParser,
extractMockCalls,
getAutomockCode,
getRealPath,
rewriteSbMockImportCalls,
} from 'storybook/internal/mocking-utils';
import { logger } from 'storybook/internal/node-logger';
import type { CoreConfig } from 'storybook/internal/types';

import { findMockRedirect } from '@vitest/mocker/redirect';
import { normalize } from 'pathe';
import type { Plugin, ResolvedConfig } from 'vite';

import { getAutomockCode } from '../../../mocking-utils/automock';
import {
babelParser,
extractMockCalls,
rewriteSbMockImportCalls,
} from '../../../mocking-utils/extract';
import { getRealPath } from '../../../mocking-utils/resolve';
import { type MockCall, getCleanId, invalidateAllRelatedModules } from './utils';

export interface MockPluginOptions {
Expand Down Expand Up @@ -55,7 +56,7 @@ export function viteMockPlugin(options: MockPluginOptions): Plugin[] {
},

buildStart() {
mockCalls = extractMockCalls(options, babelParser, viteConfig.root);
mockCalls = extractMockCalls(options, babelParser, viteConfig.root, findMockRedirect);
},

configureServer(server) {
Expand All @@ -64,7 +65,7 @@ export function viteMockPlugin(options: MockPluginOptions): Plugin[] {
// Store the old mocks before updating
const oldMockCalls = mockCalls;
// Re-extract mocks to get the latest list
mockCalls = extractMockCalls(options, babelParser, viteConfig.root);
mockCalls = extractMockCalls(options, babelParser, viteConfig.root, findMockRedirect);

// Invalidate the preview file
const previewMod = server.moduleGraph.getModuleById(options.previewConfigPath);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { realpathSync } from 'fs';
import type { ViteDevServer } from 'vite';

/**
Expand Down
34 changes: 34 additions & 0 deletions code/builders/builder-vite/src/preset.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { findConfigFile } from 'storybook/internal/common';
import type { Options } from 'storybook/internal/types';

import type { UserConfig } from 'vite';

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) {
const previewConfigPath = findConfigFile('preview', options.configDir);

// If there's no preview file, there's nothing to mock.
if (!previewConfigPath) {
return existing;
}

const coreOptions = await options.presets.apply('core');

return {
...existing,
plugins: [
...(existing.plugins ?? []),
...(previewConfigPath
? [
viteInjectMockerRuntime({ previewConfigPath }),
viteMockPlugin({ previewConfigPath, coreOptions, configDir: options.configDir }),
]
: []),
],
};
Comment on lines +24 to +33
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Normalize existing plugins before spreading

existing.plugins can legally be a single PluginOption (e.g. plugins: react()), so using the spread operator tries to iterate a non-iterable and throws at runtime. That regresses valid user configs. Normalize to an array before spreading so we don't crash.

-  return {
-    ...existing,
-    plugins: [
-      ...(existing.plugins ?? []),
-      ...(previewConfigPath
-        ? [
-            viteInjectMockerRuntime({ previewConfigPath }),
-            viteMockPlugin({ previewConfigPath, coreOptions, configDir: options.configDir }),
-          ]
-        : []),
-    ],
-  };
+  const existingPlugins = existing.plugins
+    ? Array.isArray(existing.plugins)
+      ? existing.plugins
+      : [existing.plugins]
+    : [];
+
+  return {
+    ...existing,
+    plugins: [
+      ...existingPlugins,
+      viteInjectMockerRuntime({ previewConfigPath }),
+      viteMockPlugin({ previewConfigPath, coreOptions, configDir: options.configDir }),
+    ],
+  };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
plugins: [
...(existing.plugins ?? []),
...(previewConfigPath
? [
viteInjectMockerRuntime({ previewConfigPath }),
viteMockPlugin({ previewConfigPath, coreOptions, configDir: options.configDir }),
]
: []),
],
};
const existingPlugins = existing.plugins
? Array.isArray(existing.plugins)
? existing.plugins
: [existing.plugins]
: [];
return {
...existing,
plugins: [
...existingPlugins,
...(previewConfigPath
? [
viteInjectMockerRuntime({ previewConfigPath }),
viteMockPlugin({ previewConfigPath, coreOptions, configDir: options.configDir }),
]
: []),
],
};
🤖 Prompt for AI Agents
In code/builders/builder-vite/src/preset.ts around lines 21 to 30,
existing.plugins may be a single PluginOption (non-iterable), so spreading it
causes a runtime crash; normalize existing.plugins to an array before spreading
by converting it to an array when it is not one (e.g. use
Array.isArray(existing.plugins) ? existing.plugins : [existing.plugins], or
fallback to [] if undefined), then spread that normalized array into plugins
alongside the preview plugins.

}
2 changes: 2 additions & 0 deletions code/builders/builder-vite/src/vite-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ 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.
const sbConfig: InlineConfig = {
configFile: false,
cacheDir: resolvePathInStorybookCache('sb-vite', options.cacheKey),
Expand Down
10 changes: 10 additions & 0 deletions code/builders/builder-webpack5/build-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,16 @@ const config: BuildEntries = {
entryPoint: './src/loaders/export-order-loader.ts',
dts: false,
},
{
exportEntries: ['./loaders/storybook-mock-transform-loader'],
entryPoint: './src/loaders/storybook-mock-transform-loader.ts',
dts: false,
},
{
exportEntries: ['./loaders/webpack-automock-loader'],
entryPoint: './src/loaders/webpack-automock-loader.ts',
dts: false,
},
],
},
extraOutputs: {
Expand Down
3 changes: 3 additions & 0 deletions code/builders/builder-webpack5/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@
"default": "./dist/index.js"
},
"./loaders/export-order-loader": "./dist/loaders/export-order-loader.js",
"./loaders/storybook-mock-transform-loader": "./dist/loaders/storybook-mock-transform-loader.js",
"./loaders/webpack-automock-loader": "./dist/loaders/webpack-automock-loader.js",
"./package.json": "./package.json",
"./presets/custom-webpack-preset": "./dist/presets/custom-webpack-preset.js",
"./presets/preview-preset": "./dist/presets/preview-preset.js",
Expand All @@ -54,6 +56,7 @@
},
"dependencies": {
"@storybook/core-webpack": "workspace:*",
"@vitest/mocker": "3.2.4",
"case-sensitive-paths-webpack-plugin": "^2.4.0",
"cjs-module-lexer": "^1.2.3",
"css-loader": "^7.1.2",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { rewriteSbMockImportCalls } from 'storybook/internal/mocking-utils';
import { logger } from 'storybook/internal/node-logger';

import type { LoaderDefinition } from 'webpack';

import { rewriteSbMockImportCalls } from '../../../mocking-utils/extract';

/**
* A Webpack loader that normalize sb.mock(import(...)) calls to sb.mock(...)
*
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import type { LoaderContext } from 'webpack';
import { babelParser, getAutomockCode } from 'storybook/internal/mocking-utils';

import { getAutomockCode } from '../../../mocking-utils/automock';
import { babelParser } from '../../../mocking-utils/extract';
import type { LoaderContext } from 'webpack';

/** Defines the options that can be passed to the webpack-automock-loader. */
interface AutomockLoaderOptions {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
import { join } from 'node:path';
import { getMockerRuntime } from 'storybook/internal/mocking-utils';

import { buildSync } from 'esbuild';
// HtmlWebpackPlugin is a standard part of Storybook's Webpack setup.
// We can assume it's available as a dependency.
import type HtmlWebpackPlugin from 'html-webpack-plugin';
import type { Compiler } from 'webpack';

import { resolvePackageDir } from '../../../../shared/utils/module';

const PLUGIN_NAME = 'WebpackInjectMockerRuntimePlugin';

/**
Expand Down Expand Up @@ -55,26 +52,7 @@ export class WebpackInjectMockerRuntimePlugin {
PLUGIN_NAME,
(data, cb) => {
try {
// The runtime template is the same for both dev and build in the final implementation,
// as all mocking logic is handled at build time or by the dev server's transform.
const runtimeTemplatePath = join(
resolvePackageDir('storybook'),
'assets',
'server',
'mocker-runtime.template.js'
);
// Use esbuild to bundle the runtime script and its dependencies (`@vitest/mocker`, etc.)
// into a single, self-contained string of code.
const bundleResult = buildSync({
entryPoints: [runtimeTemplatePath],
bundle: true,
write: false, // Return the result in memory instead of writing to disk
format: 'esm',
target: 'es2020',
external: ['msw/browser', 'msw/core/http'],
});

const runtimeScriptContent = bundleResult.outputFiles[0].text;
const runtimeScriptContent = getMockerRuntime();
const runtimeAssetName = 'mocker-runtime-injected.js';

// Use the documented `emitAsset` method to add the pre-bundled runtime script
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,16 @@
import { createRequire } from 'node:module';
import { dirname, isAbsolute } from 'node:path';
import { fileURLToPath } from 'node:url';

import type { Compiler } from 'webpack';

import { babelParser, extractMockCalls } from '../../../mocking-utils/extract';
import {
babelParser,
extractMockCalls,
getIsExternal,
resolveExternalModule,
resolveWithExtensions,
} from '../../../mocking-utils/resolve';
} from 'storybook/internal/mocking-utils';

const require = createRequire(import.meta.url);
import { findMockRedirect } from '@vitest/mocker/redirect';
import type { Compiler } from 'webpack';

// --- Type Definitions ---

Expand Down Expand Up @@ -131,7 +130,8 @@ export class WebpackMockPlugin {
const mocks = extractMockCalls(
{ previewConfigPath, configDir: dirname(previewConfigPath) },
babelParser,
compiler.context
compiler.context,
findMockRedirect
);

// 2. Resolve each mock call to its absolute path and replacement resource.
Expand All @@ -148,7 +148,7 @@ export class WebpackMockPlugin {
} else {
// No `__mocks__` file found. Use our custom loader to automock the module.
const loaderPath = fileURLToPath(
import.meta.resolve('storybook/webpack/loaders/webpack-automock-loader')
import.meta.resolve('@storybook/builder-webpack5/loaders/webpack-automock-loader')
);
replacementResource = `${loaderPath}?spy=${mock.spy}!${absolutePath}`;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { fileURLToPath } from 'node:url';

import { findConfigFile } from 'storybook/internal/common';
import { logger } from 'storybook/internal/node-logger';
import type { Options, PresetProperty } from 'storybook/internal/types';

Expand All @@ -6,6 +9,8 @@ import { loadCustomWebpackConfig } from '@storybook/core-webpack';
import webpackModule from 'webpack';
import type { Configuration } from 'webpack';

import { WebpackInjectMockerRuntimePlugin } from '../plugins/webpack-inject-mocker-runtime-plugin';
import { WebpackMockPlugin } from '../plugins/webpack-mock-plugin';
import { createDefaultWebpackConfig } from '../preview/base-webpack.config';

export const swc: PresetProperty<'swc'> = (config: Record<string, any>): Record<string, any> => {
Expand All @@ -26,6 +31,38 @@ export const swc: PresetProperty<'swc'> = (config: Record<string, any>): Record<
};
};

export async function webpackFinal(config: Configuration, options: Options) {
const previewConfigPath = findConfigFile('preview', options.configDir);

// If there's no preview file, there's nothing to mock.
if (!previewConfigPath) {
return config;
}

config.plugins = config.plugins || [];

// 1. Add the loader to normalize sb.mock(import(...)) calls.
config.module!.rules!.push({
test: /preview\.(t|j)sx?$/,
use: [
{
loader: fileURLToPath(
import.meta.resolve('@storybook/builder-webpack5/loaders/storybook-mock-transform-loader')
),
},
],
});

// 2. Add the plugin to handle module replacement based on sb.mock() calls.
// This plugin scans the preview file and sets up rules to swap modules.
config.plugins.push(new WebpackMockPlugin({ previewConfigPath }));

// 3. Add the plugin to inject the mocker runtime script into the HTML.
// This ensures the `sb` object is available before any other code runs.
config.plugins.push(new WebpackInjectMockerRuntimePlugin());
return config;
}

export async function webpack(config: Configuration, options: Options) {
const { configDir, configType, presets } = options;

Expand Down
10 changes: 2 additions & 8 deletions code/core/build-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,14 +77,8 @@ const config: BuildEntries = {
exportEntries: ['./internal/cli'],
},
{
entryPoint: './src/core-server/presets/webpack/loaders/webpack-automock-loader.ts',
exportEntries: ['./webpack/loaders/webpack-automock-loader'],
dts: false,
},
{
entryPoint: './src/core-server/presets/webpack/loaders/storybook-mock-transform-loader.ts',
exportEntries: ['./webpack/loaders/storybook-mock-transform-loader'],
dts: false,
exportEntries: ['./internal/mocking-utils'],
entryPoint: './src/mocking-utils/index.ts',
},
],
browser: [
Expand Down
Loading
Loading