Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
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
1 change: 1 addition & 0 deletions code/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,7 @@
"react-textarea-autosize": "^8.3.0",
"react-transition-group": "^4.4.5",
"require-from-string": "^2.0.2",
"resolve": "^1.22.11",
"resolve.exports": "^2.0.3",
"sirv": "^2.0.4",
"slash": "^5.0.0",
Expand Down
1 change: 1 addition & 0 deletions code/core/src/common/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export * from './js-package-manager';
export * from './utils/scan-and-transform-files';
export * from './utils/transform-imports';
export * from '../shared/utils/module';
export * from './utils/utils';

export { versions };

Expand Down
48 changes: 44 additions & 4 deletions code/core/src/common/utils/interpret-files.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,58 @@
import { existsSync } from 'node:fs';
import { extname } from 'node:path';

import resolve from 'resolve';

export const supportedExtensions = [
'.js',
'.mjs',
'.cjs',
'.jsx',
'.ts',
'.jsx',
'.tsx',
'.mjs',
'.mts',
'.mtsx',
'.cjs',
'.cts',
'.tsx',
'.ctsx',
] as const;

export function getInterpretedFile(pathToFile: string) {
return supportedExtensions
.map((ext) => (pathToFile.endsWith(ext) ? pathToFile : `${pathToFile}${ext}`))
.find((candidate) => existsSync(candidate));
}

export function resolveImport(id: string, options: resolve.SyncOpts): string {
const mergedOptions: resolve.SyncOpts = {
extensions: supportedExtensions,
packageFilter(pkg) {
// Prefer 'module' over 'main' if available
if (pkg.module) {
pkg.main = pkg.module;
}
return pkg;
},
...options,
};

try {
return resolve.sync(id, { ...mergedOptions });
} catch (error) {
const ext = extname(id);

// if we try to import a JavaScript file it might be that we are actually pointing to
// a TypeScript file. This can happen in ES modules as TypeScript requires to import other
// TypeScript files with .js extensions
// https://www.typescriptlang.org/docs/handbook/esm-node.html#type-in-packagejson-and-new-extensions
const newId = ['.js', '.mjs', '.cjs'].includes(ext)
? `${id.slice(0, -2)}ts`
: ext === '.jsx'
? `${id.slice(0, -3)}tsx`
: null;

if (!newId) {
throw error;
}
return resolve.sync(newId, { ...mergedOptions, extensions: [] });
}
Comment on lines +47 to +57
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

Fix TypeScript ESM fallback for .tsx components

When stories follow the NodeNext/ESM guidance and import their component as ./Component.js, the source file is often Component.tsx. The new fallback only tries *.ts, so resolution now fails and the component manifest ends up with “Component file could not be read”, which is a functional regression. Please expand the fallback to retry .tsx (or simply drop the extension and let supportedExtensions take over) so .tsx sources keep working.

Apply this diff to cover both .ts and .tsx before giving up:

-    const newId = ['.js', '.mjs', '.cjs'].includes(ext)
-      ? `${id.slice(0, -2)}ts`
-      : ext === '.jsx'
-        ? `${id.slice(0, -3)}tsx`
-        : null;
-
-    if (!newId) {
-      throw error;
-    }
-    return resolve.sync(newId, { ...mergedOptions, extensions: [] });
+    if (['.js', '.mjs', '.cjs', '.jsx'].includes(ext)) {
+      const base = id.slice(0, -ext.length);
+      const candidates =
+        ext === '.jsx'
+          ? [`${base}.tsx`, base]
+          : [`${base}.ts`, `${base}.tsx`, base];
+
+      for (const candidate of candidates) {
+        try {
+          return resolve.sync(candidate, mergedOptions);
+        } catch (_) {
+          // keep trying
+        }
+      }
+    }
+    throw error;
🤖 Prompt for AI Agents
In code/core/src/common/utils/interpret-files.ts around lines 47 to 57, the TS
ESM fallback only rewrites imports like ./Component.js to ./Component.ts and
fails to handle Component.tsx; update the fallback so when ext is one of ['.js',
'.mjs', '.cjs'] you attempt resolution candidates for both the .tsx and .ts
variants (try .tsx first then .ts), and ensure the resolver tries each candidate
before throwing the original error (or alternatively remove the manual extension
rewrite and call resolve.sync with no forced extension so supportedExtensions
handles .ts/.tsx automatically).

}
26 changes: 26 additions & 0 deletions code/core/src/common/utils/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// Object.groupBy polyfill
export const groupBy = <K extends PropertyKey, T>(
items: T[],
keySelector: (item: T, index: number) => K
) => {
return items.reduce<Record<K, T[]>>(
(acc, item, index) => {
const key = keySelector(item, index);
acc[key] ??= [];
acc[key].push(item);
return acc;
},
{} as Record<K, T[]>
);
};

// This invariant allows for lazy evaluation of the message, which we need to avoid excessive computation.
export function invariant(
condition: unknown,
message?: string | (() => string)
): asserts condition {
if (condition) {
return;
}
throw new Error((typeof message === 'function' ? message() : message) ?? 'Invariant failed');
}
7 changes: 6 additions & 1 deletion code/core/src/core-server/build-static.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,15 @@ import {
import { logger } from 'storybook/internal/node-logger';
import { getPrecedingUpgrade, telemetry } from 'storybook/internal/telemetry';
import type { BuilderOptions, CLIOptions, LoadOptions, Options } from 'storybook/internal/types';
import { type ComponentManifestGenerator } from 'storybook/internal/types';
import { type ComponentManifestGenerator, type ComponentsManifest } from 'storybook/internal/types';

import { global } from '@storybook/global';

import { join, relative, resolve } from 'pathe';
import picocolors from 'picocolors';

import { resolvePackageDir } from '../shared/utils/module';
import { renderManifestComponentsPage } from './manifest';
import { StoryIndexGenerator } from './utils/StoryIndexGenerator';
import { buildOrThrow } from './utils/build-or-throw';
import { copyAllStaticFilesRelativeToMain } from './utils/copy-all-static-files';
Expand Down Expand Up @@ -180,6 +181,10 @@ export async function buildStaticStandalone(options: BuildStaticStandaloneOption
join(options.outputDir, 'manifests', 'components.json'),
JSON.stringify(manifests)
);
await writeFile(
join(options.outputDir, 'manifests', 'components.html'),
renderManifestComponentsPage(manifests)
);
} catch (e) {
logger.error('Failed to generate manifests/components.json');
logger.error(e instanceof Error ? e : String(e));
Expand Down
31 changes: 30 additions & 1 deletion code/core/src/core-server/dev-server.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import { logConfig } from 'storybook/internal/common';
import { logger } from 'storybook/internal/node-logger';
import { MissingBuilderError } from 'storybook/internal/server-errors';
import type { Options } from 'storybook/internal/types';
import type { ComponentsManifest, Options } from 'storybook/internal/types';
import { type ComponentManifestGenerator } from 'storybook/internal/types';

import compression from '@polka/compression';
import polka from 'polka';
import invariant from 'tiny-invariant';

import { telemetry } from '../telemetry';
import { renderManifestComponentsPage } from './manifest';
import { type StoryIndexGenerator } from './utils/StoryIndexGenerator';
import { doTelemetry } from './utils/doTelemetry';
import { getManagerBuilder, getPreviewBuilder } from './utils/get-builders';
Expand Down Expand Up @@ -165,6 +166,34 @@ export async function storybookDevServer(options: Options) {
return;
}
});

app.get('/manifests/components.html', async (req, res) => {
try {
const componentManifestGenerator: ComponentManifestGenerator = await options.presets.apply(
'experimental_componentManifestGenerator'
);
const indexGenerator = await initializedStoryIndexGenerator;

if (!componentManifestGenerator || !indexGenerator) {
res.statusCode = 400;
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.end(`<pre>No component manifest generator configured.</pre>`);
return;
}

const manifest = (await componentManifestGenerator(
indexGenerator as unknown as import('storybook/internal/core-server').StoryIndexGenerator
)) as ComponentsManifest;

res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.end(renderManifestComponentsPage(manifest));
} catch (e) {
// logger?.error?.(e instanceof Error ? e : String(e));
res.statusCode = 500;
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.end(`<pre>${e instanceof Error ? e.toString() : String(e)}</pre>`);
}
});
}
// Now the preview has successfully started, we can count this as a 'dev' event.
doTelemetry(app, core, initializedStoryIndexGenerator as Promise<StoryIndexGenerator>, options);
Expand Down
Loading
Loading