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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const PLUGIN_NAME = 'WebpackInjectMockerRuntimePlugin';
* Storybook preview bundle, are executed.
*/
export class WebpackInjectMockerRuntimePlugin {
private cachedRuntime: string | null = null;
// We need to lazy-require HtmlWebpackPlugin because it's an optional peer dependency.
private getHtmlWebpackPlugin(compiler: Compiler): typeof HtmlWebpackPlugin | null {
try {
Expand Down Expand Up @@ -52,20 +53,23 @@ export class WebpackInjectMockerRuntimePlugin {
PLUGIN_NAME,
(data, cb) => {
try {
const runtimeScriptContent = getMockerRuntime();
const runtimeScriptContent =
this.cachedRuntime ?? (this.cachedRuntime = getMockerRuntime());
const runtimeAssetName = 'mocker-runtime-injected.js';

// Use the documented `emitAsset` method to add the pre-bundled runtime script
// to the compilation's assets. This is the standard Webpack way.
compilation.emitAsset(
runtimeAssetName,
new compiler.webpack.sources.RawSource(runtimeScriptContent)
);
if (!compilation.getAsset(runtimeAssetName)) {
compilation.emitAsset(
runtimeAssetName,
new compiler.webpack.sources.RawSource(runtimeScriptContent)
);
data.assets.js.unshift(runtimeAssetName);
}

// Prepend the name of our new asset to the list of JavaScript files.
// Prepend the name of our new asset to the list of JavaScript files, once.
// HtmlWebpackPlugin will automatically create a <script> tag for it
// and place it at the beginning of the body scripts.
data.assets.js.unshift(runtimeAssetName);
cb(null, data);
} catch (error) {
// In case of an error (e.g., file not found), pass it to Webpack's compilation.
Expand Down
43 changes: 36 additions & 7 deletions code/builders/builder-webpack5/src/plugins/webpack-mock-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
resolveExternalModule,
resolveWithExtensions,
} from 'storybook/internal/mocking-utils';
import { logger } from 'storybook/internal/node-logger';

import type { Compiler } from 'webpack';

Expand Down Expand Up @@ -49,6 +50,8 @@ const PLUGIN_NAME = 'storybook-mock-plugin';
export class WebpackMockPlugin {
private readonly options: WebpackMockPluginOptions;
private mockMap: Map<string, ResolvedMock> = new Map();
private candidateSpecifiers: Set<string> = new Set();
private lastPreviewMtime: number | undefined;

constructor(options: WebpackMockPluginOptions) {
if (!options.previewConfigPath) {
Expand All @@ -63,20 +66,29 @@ export class WebpackMockPlugin {
* @param {Compiler} compiler The Webpack compiler instance.
*/
public apply(compiler: Compiler): void {
const logger = compiler.getInfrastructureLogger(PLUGIN_NAME);

// This function will be called to update the mock map before each compilation.
const updateMocks = () => {
const mTimePreviewConfig = this.getPreviewConfigMtime(compiler);
if (
this.lastPreviewMtime &&
mTimePreviewConfig &&
mTimePreviewConfig <= this.lastPreviewMtime
) {
return; // unchanged
}
const resolved = this.extractAndResolveMocks(compiler);
this.mockMap = new Map(
this.extractAndResolveMocks(compiler).flatMap((mock) => [
// first one, full path
resolved.flatMap((mock) => [
[mock.absolutePath, mock],
// second one, without the extension
[mock.absolutePath.replace(/\.[^.]+$/, ''), mock],
])
);
// divide by 2 because we add both the full path and the path without the extension
logger.info(`Mock map updated with ${this.mockMap.size / 2} mocks.`);
this.candidateSpecifiers = new Set(resolved.map((m) => m.path));
this.lastPreviewMtime = mTimePreviewConfig;

if (resolved.length > 0) {
logger.info(`Mock map updated with ${resolved.length} mocks.`);
}
};

compiler.hooks.beforeRun.tap(PLUGIN_NAME, updateMocks); // for build
Expand All @@ -85,10 +97,17 @@ export class WebpackMockPlugin {
// Apply the replacement plugin. Its callback will now use the dynamically updated mockMap.
new compiler.webpack.NormalModuleReplacementPlugin(/.*/, (resource) => {
try {
if (this.mockMap.size === 0) {
return;
}
const path = resource.request;
const importer = resource.context;

const isExternal = getIsExternal(path, importer);
// Early filter only for external specifiers. Relative/local specifiers need resolution
if (isExternal && !this.candidateSpecifiers.has(path)) {
return;
}
const absolutePath = isExternal
? resolveExternalModule(path, importer)
: resolveWithExtensions(path, importer);
Expand All @@ -115,6 +134,16 @@ export class WebpackMockPlugin {
});
}

private getPreviewConfigMtime(compiler: Compiler): number | undefined {
try {
const fs = compiler.inputFileSystem as any;
const stat = fs.statSync?.(this.options.previewConfigPath);
return stat?.mtime?.getTime?.();
} catch {
return undefined;
}
}

/**
* Reads the preview config, parses it to find all `sb.mock()` calls, and resolves their
* corresponding mock implementations.
Expand Down
Loading