Skip to content

Core: Resolve .js imports to .ts files in TypeScript loader#33698

Merged
valentinpalkovic merged 10 commits into
storybookjs:nextfrom
yatishgoel:fix/32757-ts-loader-js-extension-resolution
Mar 20, 2026
Merged

Core: Resolve .js imports to .ts files in TypeScript loader#33698
valentinpalkovic merged 10 commits into
storybookjs:nextfrom
yatishgoel:fix/32757-ts-loader-js-extension-resolution

Conversation

@yatishgoel
Copy link
Copy Markdown
Contributor

@yatishgoel yatishgoel commented Jan 28, 2026

Closes #32757

What I did

Added support for resolving JavaScript extension imports to their TypeScript equivalents in the esbuild loader.

The Problem:
When using TypeScript with moduleResolution: "Node16" or "NodeNext", it's standard practice to use .js extensions in imports even when source files are .ts. The loader was returning the .js path as-is without checking if a .ts file should be used instead, causing "Cannot find module" errors.

The Fix:

  • Added jsToTsExtensionMap to map JS extensions to TS equivalents
  • Modified resolveWithExtension() to check if .js file exists; if not, try TypeScript alternatives
  • Extension mappings: .js.ts/.tsx, .mjs.mts, .cjs.cts, .jsx.tsx

Example:

// .storybook/main.ts                                                                                           
import { config } from './vite-config.js';  // File is actually vite-config.ts                                  
// Before: Error: Cannot find module './vite-config.js'                                                         
// After: Successfully resolves to './vite-config.ts'     

Checklist for Contributors

Testing

The changes in this PR are covered in the following automated tests:

  • stories
  • unit tests
  • integration tests
  • end-to-end tests

Manual testing

Caution

This section is mandatory for all contributions. If you believe no manual test is necessary, please state so explicitly. Thanks!

Documentation

  • Add or update documentation reflecting your changes
  • If you are deprecating/removing a feature, make sure to update
    MIGRATION.MD

Checklist for Maintainers

  • When this PR is ready for testing, make sure to add ci:normal, ci:merged or ci:daily GH label to it to run a specific set of sandboxes. The particular set of sandboxes can be found in code/lib/cli-storybook/src/sandbox-templates.ts

  • Make sure this PR contains one of the labels below:

    Available labels
    • bug: Internal changes that fixes incorrect behavior.
    • maintenance: User-facing maintenance tasks.
    • dependencies: Upgrading (sometimes downgrading) dependencies.
    • build: Internal-facing build tooling & test updates. Will not show up in release changelog.
    • cleanup: Minor cleanup style change. Will not show up in release changelog.
    • documentation: Documentation only changes. Will not show up in release changelog.
    • feature request: Introducing a new feature.
    • BREAKING CHANGE: Changes that break compatibility in some way with current major version.
    • other: Changes that don't fit in the above categories.

🦋 Canary release

This pull request has been released as version 0.0.0-pr-33698-sha-b652f4b7. Try it out in a new sandbox by running npx storybook@0.0.0-pr-33698-sha-b652f4b7 sandbox or in an existing project with npx storybook@0.0.0-pr-33698-sha-b652f4b7 upgrade.

More information
Published version 0.0.0-pr-33698-sha-b652f4b7
Triggered by @valentinpalkovic
Repository yatishgoel/storybook
Branch fix/32757-ts-loader-js-extension-resolution
Commit b652f4b7
Datetime Fri Mar 20 08:36:05 UTC 2026 (1773995765)
Workflow run 23335156160

To request a new release of this pull request, mention the @storybookjs/core team.

core team members can create a new canary release here or locally with gh workflow run --repo storybookjs/storybook publish.yml --field pr=33698

Summary by CodeRabbit

  • Improvements
    • Import resolution now prefers TypeScript alternatives when a corresponding JavaScript-style import exists, falling back to the original when none are found.
    • Extensionless imports are resolved by consulting cached directory listings to select explicit file extensions when available.
    • Directory listings are cached for faster resolution, and a mechanism was added to clear that cache so file changes are picked up promptly.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Jan 28, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Replaces existence checks with a directory-content cache, adds JS→TS extension mapping in resolution logic, introduces clearDirectoryCache(), and updates tests to mock readdirSync and validate TS-vs-JS and extensionless import resolution.

Changes

Cohort / File(s) Summary
Loader core
code/core/src/bin/loader.ts
Added jsToTsExtensionMap, a directoryCache with getDirectoryFiles/clearDirectoryCache, replaced existsSync checks with directory-listing lookups, and updated resolveWithExtension to prefer mapped TypeScript alternatives for JS-like and extensionless imports.
Tests
code/core/src/bin/loader.test.ts
Exported and invoked clearDirectoryCache() in setup, switched test mocks from existsSync to readdirSync directory-content mocks, added/updated tests to cover JS→TS resolution and extensionless import detection, and adjusted deprecation-message assertions.

Sequence Diagram(s)

(Skipped)

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

📝 Coding Plan
  • Generate coding plan for human review comments

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@code/core/src/bin/loader.ts`:
- Around line 47-68: The resolution logic around extImportPath in loader.ts
currently checks for the original JS file first; update the logic in the block
using extImportPath, importPath, currentDir, jsToTsExtensionMap and existsSync
so it first iterates tsExtensions = jsToTsExtensionMap[extImportPath] and checks
each candidate `${basePath}${tsExt}` (resolved with path.resolve) for existence,
returning the first match, and only if none exist fall back to checking the
original importPath (e.g., `${basePath}${extImportPath}`) / returning
importPath; apply the same ordering for .mjs→.mts and .cjs→.cts cases so
TypeScript alternatives are preferred before the JS file.

Comment thread code/core/src/bin/loader.ts
@valentinpalkovic valentinpalkovic changed the title Fix: Resolve .js imports to .ts files in TypeScript loader Core: Resolve .js imports to .ts files in TypeScript loader Feb 2, 2026
@valentinpalkovic valentinpalkovic self-assigned this Feb 2, 2026
@valentinpalkovic valentinpalkovic moved this to Empathy Queue (prioritized) in Core Team Projects Feb 2, 2026
Comment thread code/core/src/bin/loader.ts Outdated
@github-actions github-actions Bot added the Stale label Feb 18, 2026
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (3)
code/core/src/bin/loader.test.ts (3)

14-15: Add spy: true option to vi.mock() calls.

As per coding guidelines, all package and file mocks should use the spy: true option:

-vi.mock('node:fs');
-vi.mock('storybook/internal/node-logger');
+vi.mock('node:fs', { spy: true });
+vi.mock('storybook/internal/node-logger', { spy: true });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@code/core/src/bin/loader.test.ts` around lines 14 - 15, Update the two test
mocks in loader.test.ts to use the spy option: modify the vi.mock calls for
'node:fs' and 'storybook/internal/node-logger' to pass the { spy: true } option
(i.e., vi.mock('node:fs', { spy: true }) and
vi.mock('storybook/internal/node-logger', { spy: true })) so both mocks comply
with the codebase guideline requiring spy: true.

22-85: Consider moving mock setup to beforeEach for consistency.

The resolveWithExtension tests set mock behavior inline within each test case (lines 31, 42, 53, 66, 77), while the addExtensionsToRelativeImports block uses a beforeEach (lines 89-100). For consistency and per coding guidelines recommending mock implementations in beforeEach blocks, consider restructuring:

♻️ Suggested pattern
 describe('resolveWithExtension', () => {
+  beforeEach(() => {
+    vi.mocked(readdirSync).mockReturnValue([] as any);
+  });
+
   it('should return the path as-is if it already has an extension', () => {
     const result = resolveWithExtension('./test.js', '/project/src/file.ts');
     // ...
   });

   it('should resolve extensionless import to .ts extension when file exists', () => {
     vi.mocked(readdirSync).mockReturnValue(['utils.ts'] as any);
     // Override for specific test case
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@code/core/src/bin/loader.test.ts` around lines 22 - 85, Move the inline
readdirSync mocks used in the resolveWithExtension tests into a describe-level
beforeEach: in the describe('resolveWithExtension', ...) block add a beforeEach
that sets a default vi.mocked(readdirSync).mockReturnValue(...) and then remove
the repetitive mockReturnValue calls from individual it(...) tests, only
overriding the mock inside a specific test when needed (so tests like the ones
referencing './utils', './missing', '../utils' still override the default). This
centralizes the mock setup for resolveWithExtension while allowing per-test
overrides and references the resolveWithExtension function and readdirSync mock
used throughout the test cases.

102-117: Update test description for accuracy.

The test name says "non-mapped extensions" but .mjs is now a mapped extension (it maps to .mts per jsToTsExtensionMap). The test actually verifies that when no TypeScript alternative exists, the original import is preserved. Consider updating the description:

-it('should not modify imports that already have non-mapped extensions', () => {
+it('should not modify imports with TypeScript extensions or when no TS alternative exists', () => {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@code/core/src/bin/loader.test.ts` around lines 102 - 117, Update the test
title to accurately describe the behavior being verified: it checks that
addExtensionsToRelativeImports preserves the original import when there is no
TypeScript alternative (even if the original extension appears in
jsToTsExtensionMap), so rename the test string from "should not modify imports
that already have non-mapped extensions" to something like "should not modify
imports when no TypeScript alternative exists"; leave the assertions against
addExtensionsToRelativeImports(...) and expect(deprecate).not.toHaveBeenCalled()
unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@code/core/src/bin/loader.test.ts`:
- Around line 14-15: Update the two test mocks in loader.test.ts to use the spy
option: modify the vi.mock calls for 'node:fs' and
'storybook/internal/node-logger' to pass the { spy: true } option (i.e.,
vi.mock('node:fs', { spy: true }) and vi.mock('storybook/internal/node-logger',
{ spy: true })) so both mocks comply with the codebase guideline requiring spy:
true.
- Around line 22-85: Move the inline readdirSync mocks used in the
resolveWithExtension tests into a describe-level beforeEach: in the
describe('resolveWithExtension', ...) block add a beforeEach that sets a default
vi.mocked(readdirSync).mockReturnValue(...) and then remove the repetitive
mockReturnValue calls from individual it(...) tests, only overriding the mock
inside a specific test when needed (so tests like the ones referencing
'./utils', './missing', '../utils' still override the default). This centralizes
the mock setup for resolveWithExtension while allowing per-test overrides and
references the resolveWithExtension function and readdirSync mock used
throughout the test cases.
- Around line 102-117: Update the test title to accurately describe the behavior
being verified: it checks that addExtensionsToRelativeImports preserves the
original import when there is no TypeScript alternative (even if the original
extension appears in jsToTsExtensionMap), so rename the test string from "should
not modify imports that already have non-mapped extensions" to something like
"should not modify imports when no TypeScript alternative exists"; leave the
assertions against addExtensionsToRelativeImports(...) and
expect(deprecate).not.toHaveBeenCalled() unchanged.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 98b7c14 and 3af8240.

📒 Files selected for processing (2)
  • code/core/src/bin/loader.test.ts
  • code/core/src/bin/loader.ts

@yatishgoel yatishgoel requested a review from ndelangen March 2, 2026 11:21
@valentinpalkovic
Copy link
Copy Markdown
Contributor

Hi @yatishgoel,

Thank you for the follow-up! Could you fix the remaining eslint/prettier violations?

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
code/core/src/bin/loader.ts (1)

43-49: Narrow the catch block to expected directory-miss errors.

Line 47 currently swallows all readdirSync failures and silently treats them as “directory empty,” which can mask real runtime problems (for example permission issues).

Suggested hardening
 function getDirectoryFiles(dir: string): Set<string> {
   if (!directoryCache.has(dir)) {
     try {
       directoryCache.set(dir, new Set(readdirSync(dir)));
-    } catch {
-      directoryCache.set(dir, new Set());
+    } catch (error) {
+      const code = (error as NodeJS.ErrnoException).code;
+      if (code === 'ENOENT' || code === 'ENOTDIR') {
+        directoryCache.set(dir, new Set());
+      } else {
+        throw error;
+      }
     }
   }
   return directoryCache.get(dir)!;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@code/core/src/bin/loader.ts` around lines 43 - 49, The catch in
getDirectoryFiles currently swallows all errors from readdirSync which masks
real failures; change it to catch the error object (e.g., catch (err)) and only
treat missing-directory cases as empty by checking err.code for expected values
(like 'ENOENT' and 'ENOTDIR'), setting directoryCache.set(dir, new Set()) for
those cases, and rethrow or propagate/log all other errors so permission and
unexpected failures are not silently ignored; reference getDirectoryFiles,
directoryCache and the readdirSync call when making the change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@code/core/src/bin/loader.ts`:
- Around line 43-49: The catch in getDirectoryFiles currently swallows all
errors from readdirSync which masks real failures; change it to catch the error
object (e.g., catch (err)) and only treat missing-directory cases as empty by
checking err.code for expected values (like 'ENOENT' and 'ENOTDIR'), setting
directoryCache.set(dir, new Set()) for those cases, and rethrow or propagate/log
all other errors so permission and unexpected failures are not silently ignored;
reference getDirectoryFiles, directoryCache and the readdirSync call when making
the change.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: ebb90741-3d42-4064-ad27-59ac0ff831b1

📥 Commits

Reviewing files that changed from the base of the PR and between 3af8240 and 551e6eb.

📒 Files selected for processing (1)
  • code/core/src/bin/loader.ts

@valentinpalkovic valentinpalkovic moved this from Empathy Queue (prioritized) to In Progress in Core Team Projects Mar 19, 2026
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
code/core/src/bin/loader.ts (1)

96-106: Consider reordering supportedExtensions to prioritize TypeScript files.

For extensionless imports, the current supportedExtensions order checks .js before .ts. In a TypeScript project, users likely expect TypeScript files to be resolved first. While this is a minor concern since extensionless imports are deprecated, reordering to ['.ts', '.tsx', '.mts', '.cts', '.js', '.mjs', '.cjs', '.jsx'] would provide more intuitive behavior during the deprecation period.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@code/core/src/bin/loader.ts` around lines 96 - 106, Change the resolution
order of the supportedExtensions array in loader.ts to prioritize TypeScript
variants before JavaScript so extensionless imports resolve to .ts/.tsx first;
update the supportedExtensions symbol to the suggested order ['.ts', '.tsx',
'.mts', '.cts', '.js', '.mjs', '.cjs', '.jsx'] (affects the logic that iterates
over supportedExtensions in the code block that computes absolutePath, dirFiles,
and baseFileName).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@code/core/src/bin/loader.ts`:
- Around line 96-106: Change the resolution order of the supportedExtensions
array in loader.ts to prioritize TypeScript variants before JavaScript so
extensionless imports resolve to .ts/.tsx first; update the supportedExtensions
symbol to the suggested order ['.ts', '.tsx', '.mts', '.cts', '.js', '.mjs',
'.cjs', '.jsx'] (affects the logic that iterates over supportedExtensions in the
code block that computes absolutePath, dirFiles, and baseFileName).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 81566f51-2ff8-4645-8bc4-3371c766a418

📥 Commits

Reviewing files that changed from the base of the PR and between 551e6eb and 7329ba3.

📒 Files selected for processing (1)
  • code/core/src/bin/loader.ts

@storybook-app-bot
Copy link
Copy Markdown

storybook-app-bot Bot commented Mar 19, 2026

Package Benchmarks

Commit: b652f4b, ran on 20 March 2026 at 12:36:45 UTC

The following packages have significant changes to their size or dependencies:

@storybook/react-native-web-vite

Before After Difference
Dependency count 121 124 🚨 +3 🚨
Self size 30 KB 30 KB 0 B
Dependency size 23.54 MB 23.78 MB 🚨 +240 KB 🚨
Bundle Size Analyzer Link Link

@valentinpalkovic
Copy link
Copy Markdown
Contributor

Tested out the canary successfully on https://stackblitz.com/edit/vitejs-vite-pkn6dr6b?file=package.json.

@valentinpalkovic valentinpalkovic merged commit 346d5d3 into storybookjs:next Mar 20, 2026
123 of 129 checks passed
@github-project-automation github-project-automation Bot moved this from In Progress to Done in Core Team Projects Mar 20, 2026
@github-actions github-actions Bot mentioned this pull request Mar 20, 2026
17 tasks
@coderabbitai coderabbitai Bot mentioned this pull request May 16, 2026
6 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

[Bug]: TypeScript loader: Cannot find module '....js'

3 participants