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
49 changes: 49 additions & 0 deletions code/core/src/bin/loader.test.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,73 @@
import { readdirSync } from 'node:fs';
import { readFile } from 'node:fs/promises';
Comment thread
valentinpalkovic marked this conversation as resolved.

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');
Comment thread
valentinpalkovic marked this conversation as resolved.
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 `?<timestamp>` 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');
Expand Down
19 changes: 12 additions & 7 deletions code/core/src/bin/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `?<timestamp>` 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',
Expand Down
10 changes: 9 additions & 1 deletion code/core/src/cli/getStorybookData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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';
Expand Down
8 changes: 6 additions & 2 deletions code/core/src/common/utils/get-storybook-info.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down
4 changes: 2 additions & 2 deletions code/lib/cli-storybook/src/add.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -92,6 +91,7 @@ export async function add(
await getStorybookData({
configDir: userSpecifiedConfigDir,
packageManagerName: pkgMgr,
skipCache: true,
});

if (typeof configDir === 'undefined') {
Expand Down