Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
24f1e61
Refactor: Update main config handling and restore wrapGetAbsolutePath…
ndelangen Sep 25, 2025
4da70a6
add fallback to loadMainConfig by adding migration assistant header
ndelangen Sep 25, 2025
2e9a5c9
Revert "Refactor: Update main config handling and restore wrapGetAbso…
ndelangen Sep 25, 2025
58ca51f
Enhance loadMainConfig: Add temporary fix for ESM validation and impr…
ndelangen Sep 25, 2025
414611f
Add fix for ESM require usage in Storybook configuration
ndelangen Sep 25, 2025
5c96558
Refactor: Simplify ESM usage detection in fix-faux-esm-require
ndelangen Sep 25, 2025
384cbd0
Merge branch 'next' into norbert/sb10-upgrade-with-require-in-main
ndelangen Sep 25, 2025
01c9e29
Refactor: Centralize banner comment for ESM require handling
ndelangen Sep 25, 2025
25ab43b
Merge branch 'norbert/sb10-upgrade-with-require-in-main' of https://g…
ndelangen Sep 25, 2025
aadcab7
Refactor: Enhance require usage detection in mainConfigFile
ndelangen Sep 25, 2025
b8c0048
Remove debug logging from fixFauxEsmRequire utility
ndelangen Sep 25, 2025
b21567f
Apply suggestion from @valentinpalkovic
ndelangen Sep 26, 2025
47c04c6
Apply suggestion from @Copilot
ndelangen Sep 26, 2025
0988bbe
Fix: Update log messages for main config loading and migration assist…
ndelangen Sep 26, 2025
b03194d
Merge branch 'norbert/sb10-upgrade-with-require-in-main' of https://g…
ndelangen Sep 26, 2025
0c1fb28
Refactor: Simplify check method in fixFauxEsmRequire utility
ndelangen Sep 26, 2025
e91cd92
Apply suggestion from @Copilot
ndelangen Sep 26, 2025
f635045
Refactor: Update tests for fixFauxEsmRequire utility
ndelangen Sep 26, 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
37 changes: 36 additions & 1 deletion code/core/src/common/utils/load-main-config.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import { relative, resolve } from 'node:path';
import { readFile, rm, writeFile } from 'node:fs/promises';
import { join, parse, relative, resolve } from 'node:path';

import { logger } from 'storybook/internal/node-logger';
import { MainFileEvaluationError } from 'storybook/internal/server-errors';
import type { StorybookConfig } from 'storybook/internal/types';

import { dedent } from 'ts-dedent';

Comment thread
ndelangen marked this conversation as resolved.
import { importModule } from '../../shared/utils/module';
import { getInterpretedFile } from './interpret-files';
import { validateConfigurationFiles } from './validate-configuration-files';
Expand All @@ -25,6 +29,37 @@ export async function loadMainConfig({
if (!(e instanceof Error)) {
throw e;
}
if (e.message.includes('require is not defined')) {
logger.info(
'Loading main config failed, trying a temporary fix, Please ensure the main config is valid ESM'
);
const comment =
'// end of Storybook 10 migration assistant header, you can delete the above code';
const content = await readFile(mainPath, 'utf-8');

if (!content.includes(comment)) {
const header = dedent`
import { createRequire } from "node:module";
import { dirname } from "node:path";
import { fileURLToPath } from "node:url";

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const require = createRequire(import.meta.url);
`;

const { ext, name, dir } = parse(mainPath);
const modifiedMainPath = join(dir, `${name}.tmp.${ext}`);
Comment thread
ndelangen marked this conversation as resolved.
await writeFile(modifiedMainPath, [header, comment, content].join('\n\n'));
let out;
try {
out = await importModule(modifiedMainPath);
} finally {
await rm(modifiedMainPath);
}
return out;
}
}

throw new MainFileEvaluationError({
location: relative(process.cwd(), mainPath),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { getAddonNames } from 'storybook/internal/common';
import { logger } from 'storybook/internal/node-logger';

import { existsSync, readFileSync, writeFileSync } from 'fs';
import * as jscodeshift from 'jscodeshift';
import path from 'path';
import { dedent } from 'ts-dedent';

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { readFile, writeFile } from 'node:fs/promises';

import { beforeEach, describe, expect, it, vi } from 'vitest';

import { bannerComment } from '../helpers/mainConfigFile';
import { fixFauxEsmRequire } from './fix-faux-esm-require';

vi.mock('node:fs/promises', async (importOriginal) => ({
...(await importOriginal<typeof import('node:fs/promises')>()),
readFile: vi.fn(),
writeFile: vi.fn(),
}));

describe('fix-faux-esm-require', () => {
const mockReadFile = vi.mocked(readFile);
const mockWriteFile = vi.mocked(writeFile);

beforeEach(() => {
vi.clearAllMocks();
});

describe('check', () => {
it('should return null if no mainConfigPath', async () => {
const result = await fixFauxEsmRequire.check({
mainConfigPath: undefined,
} as any);

expect(result).toBeNull();
});

it('should return null if file is not ESM', async () => {
const contentWithoutESM = `
const config = require('./config');
module.exports = config;
`;

mockReadFile.mockResolvedValue(contentWithoutESM);

const result = await fixFauxEsmRequire.check({
mainConfigPath: 'main.js',
} as any);

expect(result).toBeNull();
});

it('should return null if file already has require banner', async () => {
const contentWithBanner = `
import { createRequire } from "node:module";
${bannerComment}
const config = require('./some-config');
`;

mockReadFile.mockResolvedValue(contentWithBanner);

const result = await fixFauxEsmRequire.check({
mainConfigPath: 'main.js',
} as any);

expect(result).toBeNull();
});

it('should return null if file does not contain require usage', async () => {
const contentWithoutRequire = `
import { addons } from '@storybook/addon-essentials';
export default {
addons: ['@storybook/addon-essentials'],
};
`;

mockReadFile.mockResolvedValue(contentWithoutRequire);

const result = await fixFauxEsmRequire.check({
mainConfigPath: 'main.js',
} as any);

expect(result).toBeNull();
});

it('should return true if file is ESM with require usage', async () => {
const contentWithRequire = `
import { addons } from '@storybook/addon-essentials';
const config = require('./some-config');
export default {
addons: ['@storybook/addon-essentials'],
};
`;

mockReadFile.mockResolvedValue(contentWithRequire);

const result = await fixFauxEsmRequire.check({
mainConfigPath: 'main.js',
} as any);

expect(result).toBe(true);
});

it('should detect TypeScript config files', async () => {
const contentWithRequire = `
import { addons } from '@storybook/addon-essentials';
const config = require('./some-config');
export default {
addons: ['@storybook/addon-essentials'],
};
`;

mockReadFile.mockResolvedValue(contentWithRequire);

const result = await fixFauxEsmRequire.check({
mainConfigPath: 'main.ts',
} as any);

expect(result).toBe(true);
});
});

describe('run', () => {
it('should add require banner to file', async () => {
const originalContent = `
import { addons } from '@storybook/addon-essentials';
const config = require('./some-config');
export default {
addons: ['@storybook/addon-essentials'],
};
`;

mockReadFile.mockResolvedValue(originalContent);

await fixFauxEsmRequire.run({
dryRun: false,
mainConfigPath: 'main.js',
} as any);

expect(mockWriteFile).toHaveBeenCalledWith(
'main.js',
expect.stringContaining('import { createRequire } from "node:module"')
);
});

it('should not write file in dry run mode', async () => {
await fixFauxEsmRequire.run({
dryRun: true,
mainConfigPath: 'main.js',
} as any);

expect(mockWriteFile).not.toHaveBeenCalled();
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { readFile, writeFile } from 'node:fs/promises';

import { dedent } from 'ts-dedent';
Comment thread
ndelangen marked this conversation as resolved.

import {
bannerComment,
containsESMUsage,
containsRequireUsage,
getRequireBanner,
hasRequireBanner,
} from '../helpers/mainConfigFile';
Comment thread
ndelangen marked this conversation as resolved.
import type { Fix } from '../types';

export const fixFauxEsmRequire = {
id: 'fix-faux-esm-require',
link: 'https://storybook.js.org/docs/faq#how-do-i-fix-module-resolution-in-special-environments',

async check({ mainConfigPath }) {
if (!mainConfigPath) {
return null;
}

// Read the raw file content to check for ESM syntax and require usage
const content = await readFile(mainConfigPath, 'utf-8');

const isESM = containsESMUsage(content);
const isWithRequire = containsRequireUsage(content);
const isWithBanner = hasRequireBanner(content);

// Check if the file is ESM format based on content
if (!isESM) {
return null;
}

// Check if the file already has the require banner
if (isWithBanner) {
return null;
}

// Check if the file contains require usage
if (!isWithRequire) {
return null;
}

return true;
},

prompt() {
return dedent`Main config is ESM but uses 'require'. This will break in Storybook 10; Adding compatibility banner`;
},

async run({ dryRun, mainConfigPath }) {
if (dryRun) {
return;
}

const content = await readFile(mainConfigPath, 'utf-8');
const banner = getRequireBanner();
const comment = bannerComment;

const newContent = [banner, comment, content].join('\n');

await writeFile(mainConfigPath, newContent);
},
} satisfies Fix<true>;
2 changes: 2 additions & 0 deletions code/lib/cli-storybook/src/automigrate/fixes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { addonMdxGfmRemove } from './addon-mdx-gfm-remove';
import { addonStorysourceCodePanel } from './addon-storysource-code-panel';
import { consolidatedImports } from './consolidated-imports';
import { eslintPlugin } from './eslint-plugin';
import { fixFauxEsmRequire } from './fix-faux-esm-require';
import { initialGlobals } from './initial-globals';
import { migrateAddonConsole } from './migrate-addon-console';
import { removeAddonInteractions } from './remove-addon-interactions';
Expand Down Expand Up @@ -36,6 +37,7 @@ export const allFixes: Fix[] = [
addonA11yParameters,
removeDocsAutodocs,
wrapGetAbsolutePath,
fixFauxEsmRequire,
];

export const initFixes: Fix[] = [eslintPlugin];
Expand Down
46 changes: 46 additions & 0 deletions code/lib/cli-storybook/src/automigrate/helpers/mainConfigFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,3 +228,49 @@ export const updateMainConfig = async (
);
}
};

/** Check if a file is in ESM format based on its content */
export function containsESMUsage(content: string): boolean {
// For .js/.ts files, check the content for ESM syntax
// Check for ESM syntax indicators (multiline aware)
const hasImportStatement =
/^\s*import\s+/m.test(content) ||
/^\s*import\s*{/m.test(content) ||
/^\s*import\s*\(/m.test(content);
const hasExportStatement =
/^\s*export\s+/m.test(content) ||
/^\s*export\s*{/m.test(content) ||
/^\s*export\s*default/m.test(content);
const hasImportMeta = /import\.meta/.test(content);

// If any ESM syntax is found, it's likely an ESM file
return hasImportStatement || hasExportStatement || hasImportMeta;
Comment on lines +236 to +247

Copilot AI Sep 26, 2025

Copy link

Choose a reason for hiding this comment

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

[nitpick] The regex patterns for detecting ESM syntax could be simplified and made more robust. Consider combining them into a single regex with alternation: /^\s*(?:import(?:\s+|\s*[{(])|export(?:\s+|\s*[{]|\s*default))/m to reduce code duplication and improve maintainability.

Suggested change
const hasImportStatement =
/^\s*import\s+/m.test(content) ||
/^\s*import\s*{/m.test(content) ||
/^\s*import\s*\(/m.test(content);
const hasExportStatement =
/^\s*export\s+/m.test(content) ||
/^\s*export\s*{/m.test(content) ||
/^\s*export\s*default/m.test(content);
const hasImportMeta = /import\.meta/.test(content);
// If any ESM syntax is found, it's likely an ESM file
return hasImportStatement || hasExportStatement || hasImportMeta;
const esmRegex = /^\s*(?:import(?:\s+|\s*[{(])|export(?:\s+|\s*[{]|\s*default))/m;
const hasESMSyntax = esmRegex.test(content);
const hasImportMeta = /import\.meta/.test(content);
// If any ESM syntax is found, it's likely an ESM file
return hasESMSyntax || hasImportMeta;

Copilot uses AI. Check for mistakes.
}
Comment thread
ndelangen marked this conversation as resolved.

/** Check if the file content contains require usage */
export function containsRequireUsage(content: string): boolean {
// Check for require() calls
const requireCallRegex = /\brequire\(/;
const requireDotRegex = /\brequire\./;
return requireCallRegex.test(content) || requireDotRegex.test(content);
}

/** Check if the file already has the require banner */
export const bannerComment =
'// end of Storybook 10 migration assistant header, You can delete the above code';
export function hasRequireBanner(content: string): boolean {
return content.includes(bannerComment);
}

/** Generate the require compatibility banner */
export function getRequireBanner(): string {
return dedent`
import { createRequire } from "node:module";
import { dirname } from "node:path";
import { fileURLToPath } from "node:url";

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const require = createRequire(import.meta.url);
`;
}
Comment thread
ndelangen marked this conversation as resolved.
Loading