diff --git a/code/core/src/bin/loader.test.ts b/code/core/src/bin/loader.test.ts index b0738b3e9ae2..b6a51375f96c 100644 --- a/code/core/src/bin/loader.test.ts +++ b/code/core/src/bin/loader.test.ts @@ -1,24 +1,73 @@ import { readdirSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { deprecate } from 'storybook/internal/node-logger'; +import { transform } from 'esbuild'; + import { addExtensionsToRelativeImports, clearDirectoryCache, + load, resolveWithExtension, } from './loader.ts'; // Mock dependencies vi.mock('node:fs'); +vi.mock('node:fs/promises'); vi.mock('storybook/internal/node-logger'); +vi.mock('esbuild'); describe('loader', () => { beforeEach(() => { clearDirectoryCache(); }); + describe('load', () => { + const nextLoad = vi.fn(); + + beforeEach(() => { + nextLoad.mockReset(); + vi.mocked(readFile).mockResolvedValue('const x: number = 1;'); + vi.mocked(transform).mockResolvedValue({ + code: 'const x = 1;', + map: '', + warnings: [], + } as any); + }); + + it('transforms a plain .ts URL with esbuild', async () => { + const result = await load('file:///project/main.ts', {} as any, nextLoad); + + expect(transform).toHaveBeenCalled(); + expect(nextLoad).not.toHaveBeenCalled(); + expect(result).toMatchObject({ format: 'module', shortCircuit: true }); + }); + + it('still transforms a .ts URL with a cache-busting query string appended', async () => { + // Regression test: importModule appends `?` to bust the module cache when + // skipCache is set. That must not cause this loader to skip the file and fall through to + // Node's native handling, which does not elide type-only named imports the way esbuild does. + const result = await load('file:///project/main.ts?1234567890', {} as any, nextLoad); + + expect(readFile).toHaveBeenCalledWith('/project/main.ts', 'utf-8'); + expect(transform).toHaveBeenCalled(); + expect(nextLoad).not.toHaveBeenCalled(); + expect(result).toMatchObject({ format: 'module', shortCircuit: true }); + }); + + it('delegates non-TS URLs to nextLoad', async () => { + nextLoad.mockResolvedValue({ format: 'module', shortCircuit: true, source: '' }); + + await load('file:///project/main.js', {} as any, nextLoad); + + expect(transform).not.toHaveBeenCalled(); + expect(nextLoad).toHaveBeenCalledWith('file:///project/main.js', {}); + }); + }); + describe('resolveWithExtension', () => { it('should return the path as-is if it already has an extension', () => { const result = resolveWithExtension('./test.js', '/project/src/file.ts'); diff --git a/code/core/src/bin/loader.ts b/code/core/src/bin/loader.ts index a9f44613c6a1..d75fd003d752 100644 --- a/code/core/src/bin/loader.ts +++ b/code/core/src/bin/loader.ts @@ -137,16 +137,21 @@ export function addExtensionsToRelativeImports(source: string, filePath: string) } export const load: LoadHook = async (url, context, nextLoad) => { + // Strip any query string (e.g. the cache-busting `?` importModule appends for + // skipCache) before checking the extension, otherwise a cache-busted URL like + // `file:///main.ts?123` no longer ends with `.ts` and silently skips the esbuild transform below. + const urlWithoutQuery = url.split('?')[0]; + /** Convert TS to ESM using esbuild */ if ( - url.endsWith('.ts') || - url.endsWith('.tsx') || - url.endsWith('.mts') || - url.endsWith('.cts') || - url.endsWith('.mtsx') || - url.endsWith('.ctsx') + urlWithoutQuery.endsWith('.ts') || + urlWithoutQuery.endsWith('.tsx') || + urlWithoutQuery.endsWith('.mts') || + urlWithoutQuery.endsWith('.cts') || + urlWithoutQuery.endsWith('.mtsx') || + urlWithoutQuery.endsWith('.ctsx') ) { - const filePath = fileURLToPath(url); + const filePath = fileURLToPath(urlWithoutQuery); const rawSource = await readFile(filePath, 'utf-8'); const transformedSource = await transform(rawSource, { loader: 'ts', diff --git a/code/core/src/cli/getStorybookData.ts b/code/core/src/cli/getStorybookData.ts index 38f126dcc303..e0972c37498b 100644 --- a/code/core/src/cli/getStorybookData.ts +++ b/code/core/src/cli/getStorybookData.ts @@ -23,9 +23,16 @@ export function getWorkingDir(configDir: string): string { export const getStorybookData = async ({ configDir: userDefinedConfigDir, packageManagerName, + skipCache, }: { configDir?: string; packageManagerName?: PackageManagerName; + /** + * Skip the module cache when reading the main config. Pass `true` when a prior step in the same + * process (e.g. an automigration) may have rewritten the main config on disk, otherwise this + * would read back the module system's cached evaluation from before that rewrite. + */ + skipCache?: boolean; }) => { logger.debug('Getting Storybook info...'); const { @@ -41,7 +48,8 @@ export const getStorybookData = async ({ addons, } = await getStorybookInfo( userDefinedConfigDir, - userDefinedConfigDir ? dirname(userDefinedConfigDir) : undefined + userDefinedConfigDir ? dirname(userDefinedConfigDir) : undefined, + { skipCache } ); const configDir = userDefinedConfigDir || configDirFromScript || '.storybook'; diff --git a/code/core/src/common/utils/get-storybook-info.ts b/code/core/src/common/utils/get-storybook-info.ts index 3652807aba3a..f62b98ed21c5 100644 --- a/code/core/src/common/utils/get-storybook-info.ts +++ b/code/core/src/common/utils/get-storybook-info.ts @@ -1,13 +1,17 @@ import { existsSync, readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; -import { CoreWebpackCompiler, SupportedFramework } from 'storybook/internal/types'; import type { CoreCommon_StorybookInfo, PackageJson, StorybookConfigRaw, } from 'storybook/internal/types'; -import { SupportedBuilder, SupportedRenderer } from 'storybook/internal/types'; +import { + CoreWebpackCompiler, + SupportedBuilder, + SupportedFramework, + SupportedRenderer, +} from 'storybook/internal/types'; import invariant from 'tiny-invariant'; diff --git a/code/lib/cli-storybook/src/add.ts b/code/lib/cli-storybook/src/add.ts index a9fb093cf3e9..2e11eebbbdfb 100644 --- a/code/lib/cli-storybook/src/add.ts +++ b/code/lib/cli-storybook/src/add.ts @@ -1,7 +1,6 @@ import { type PackageManagerName, setupAddonInConfig, versions } from 'storybook/internal/common'; import { readConfig } from 'storybook/internal/csf-tools'; -import { logger as nodeLogger } from 'storybook/internal/node-logger'; -import { prompt } from 'storybook/internal/node-logger'; +import { logger as nodeLogger, prompt } from 'storybook/internal/node-logger'; import type { StorybookConfigRaw } from 'storybook/internal/types'; import SemVer from 'semver'; @@ -92,6 +91,7 @@ export async function add( await getStorybookData({ configDir: userSpecifiedConfigDir, packageManagerName: pkgMgr, + skipCache: true, }); if (typeof configDir === 'undefined') {