Skip to content

fix(esbuild): handle browser:false stubs in getContents#611

Open
momomuchu wants to merge 3 commits into
unjs:mainfrom
momomuchu:fix/esbuild-getcontents-enoent-browser-546
Open

fix(esbuild): handle browser:false stubs in getContents#611
momomuchu wants to merge 3 commits into
unjs:mainfrom
momomuchu:fix/esbuild-getcontents-enoent-browser-546

Conversation

@momomuchu

@momomuchu momomuchu commented Jul 4, 2026

Copy link
Copy Markdown

The esbuild adapter's getContents() reads args.path with fs.readFile unconditionally. When esbuild resolves a package.json#browser entry mapped to false (a common way to stub a module out for the browser), unplugin gets a path that does not exist on disk and readFile throws an uncaught ENOENT, crashing the build (#546).

This wraps the read in a try/catch: on ENOENT it returns empty contents (matching how esbuild itself treats these stubs) and re-throws any other error unchanged.

Added an in-process esbuild build test with a fixture whose browser field maps a module to false. It reproduces the ENOENT without the fix and passes with it.

Closes #546

Summary by CodeRabbit

  • Bug Fixes
    • Improved browser builds so modules disabled via package.json browser mappings no longer fail when source files are absent; missing contents are treated as empty.
    • Fixed content caching to correctly reuse empty results across multiple transform operations, avoiding unnecessary filesystem reads and potential ENOENT errors.
  • Tests
    • Added/updated esbuild/Vitest fixtures and tests for browser:false stubs, including coverage for graceful resolution and cached empty-content behavior.

…modules

esbuild resolves package.json#browser entries mapped to false to a
path that does not exist on disk. transform.getContents() called
fs.readFile on that path unconditionally and threw an unhandled
ENOENT, crashing the whole build.

Treat a missing file the same way esbuild treats these stubs: return
empty contents instead of propagating the fs error.

Fixes unjs#546
@coderabbitai

coderabbitai Bot commented Jul 4, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 872ce45d-06ca-4031-bf1b-a223f6a33e9a

📥 Commits

Reviewing files that changed from the base of the PR and between fc41374 and 615bc9a.

📒 Files selected for processing (1)
  • test/unit-tests/esbuild/index.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • test/unit-tests/esbuild/index.test.ts

📝 Walkthrough

Walkthrough

The esbuild plugin now caches empty filesystem reads for browser-disabled files, and the test suite adds a browser:false fixture plus assertions for build success and repeated-load cache reuse.

Changes

ENOENT handling for browser-disabled files

Layer / File(s) Summary
getContents ENOENT handling
src/esbuild/index.ts
Adds a filesystem cache flag, returns cached contents when present, and caches '' on ENOENT while rethrowing other read errors.
browser:false fixture and build tests
test/unit-tests/esbuild/fixtures/browser-false-stub/*, test/unit-tests/esbuild/index.test.ts
Adds a fixture package that disables ./lib/terminal-highlight, wires an entry to load it, and tests that esbuild builds resolve and reuse the cached empty read across transform hooks.

Estimated code review effort: 2 (Simple) | ~10 minutes

Sequence Diagram(s)

sequenceDiagram
  participant esbuild as esbuild
  participant plugin as unplugin esbuild hook
  participant getContents as getContents()
  participant fs as fs.promises

  esbuild->>plugin: onLoad(args.path)
  plugin->>getContents: getContents(args.path)
  getContents->>fs: readFile(args.path, 'utf8')
  alt file exists
    fs-->>getContents: contents
    getContents-->>plugin: return contents
  else ENOENT
    fs-->>getContents: throw ENOENT
    getContents-->>plugin: cache and return ''
  end
  plugin-->>esbuild: loaded result
Loading

Suggested reviewers: sxzz

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: handling browser:false stubs in esbuild getContents.
Linked Issues check ✅ Passed The fix handles missing browser:false stubs as empty content, preserves other fs errors, and adds a reproducing test for #546.
Out of Scope Changes check ✅ Passed The added fixture and tests support the browser:false stub fix and do not introduce unrelated changes.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/esbuild/index.ts (1)

102-133: 🚀 Performance & Scalability | 🟡 Minor | ⚡ Quick win

Cache check breaks for empty-content (stubbed) files.

The cache guard at Line 116 (if (fsContentsCache) return fsContentsCache) is falsy for an empty string, so once getContents() caches '' for an ENOENT/stubbed path (Line 131), every subsequent call within the same onTransformCb loop re-enters the try/catch and re-attempts fs.promises.readFile, re-triggering the ENOENT path each time instead of returning the cached value. This defeats the caching intent specifically for the scenario this PR targets.

🐛 Proposed fix using a defined-check instead of truthiness
-          let fsContentsCache: string | undefined
+          let fsContentsCache: string | undefined
+          let fsContentsCached = false

           for (const { options, onTransformCb } of loaders) {
             if (!checkFilter(options))
               continue

             if (onTransformCb) {
               const newArgs: OnTransformArgs = {
                 ...result,
                 ...args,
                 async getContents() {
                   if (result?.contents)
                     return result.contents as string

-                  if (fsContentsCache)
+                  if (fsContentsCached)
                     return fsContentsCache

                   // caution: 'utf8' assumes the input file is not in binary.
                   // if you want your plugin handle binary files, make sure to
                   // `plugin.load()` them first.
                   try {
-                    return (fsContentsCache = await fs.promises.readFile(args.path, 'utf8'))
+                    fsContentsCache = await fs.promises.readFile(args.path, 'utf8')
+                    fsContentsCached = true
+                    return fsContentsCache
                   }
                   catch (error) {
                     if ((error as NodeJS.ErrnoException)?.code === 'ENOENT')
-                      return (fsContentsCache = '')
+                      { fsContentsCache = ''; fsContentsCached = true; return fsContentsCache }
                     throw error
                   }
                 },
               }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/esbuild/index.ts` around lines 102 - 133, The getContents cache in the
esbuild transform path treats an empty string as uncached, so stubbed or
ENOENT-backed files keep re-reading instead of reusing the cached empty result.
Update the cache guard inside the onTransformCb/getContents flow to check
whether fsContentsCache has been defined rather than relying on truthiness, so
the cached '' value is returned correctly on later calls.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/esbuild/index.ts`:
- Around line 102-133: The getContents cache in the esbuild transform path
treats an empty string as uncached, so stubbed or ENOENT-backed files keep
re-reading instead of reusing the cached empty result. Update the cache guard
inside the onTransformCb/getContents flow to check whether fsContentsCache has
been defined rather than relying on truthiness, so the cached '' value is
returned correctly on later calls.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 75a57b95-668b-4634-9c82-a1604251bf62

📥 Commits

Reviewing files that changed from the base of the PR and between 671bdbc and 2fbf7b6.

📒 Files selected for processing (5)
  • src/esbuild/index.ts
  • test/unit-tests/esbuild/fixtures/browser-false-stub/entry.js
  • test/unit-tests/esbuild/fixtures/browser-false-stub/pkg/lib/index.js
  • test/unit-tests/esbuild/fixtures/browser-false-stub/pkg/package.json
  • test/unit-tests/esbuild/index.test.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
test/unit-tests/esbuild/index.test.ts (2)

57-64: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Duplicated build() configuration across tests.

The build({...}) options here mirror the earlier test's configuration (entryPoints, bundle, platform, write, plugins). Consider extracting a small shared helper (e.g., buildFixture(plugin)) to avoid drift between the two tests.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/unit-tests/esbuild/index.test.ts` around lines 57 - 64, The esbuild test
repeats the same build() options as the earlier test, so the configuration can
drift. Extract the shared build setup into a small helper (for example, a
fixture-specific helper used by plugin.esbuild()) and have both tests call it,
keeping only the differing expectations in each test.

32-32: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Restore spy in a finally to avoid leaking across tests.

If the assertion at Line 67 fails, readFileSpy.mockRestore() on Line 69 never runs, leaving the spy active for subsequent tests in the suite.

🧪 Proposed fix
   it('caches the empty contents from an ENOENT stub so a second transform does not re-read the file', async () => {
     const readFileSpy = vi.spyOn(fs.promises, 'readFile')
 
-    ...
-
-    await expect(build({
-      ...
-    })).resolves.toBeDefined()
-
-    const stubReads = readFileSpy.mock.calls.filter(([path]) => String(path).includes('terminal-highlight'))
-    expect(stubReads).toHaveLength(1)
-
-    readFileSpy.mockRestore()
+    try {
+      await expect(build({
+        ...
+      })).resolves.toBeDefined()
+
+      const stubReads = readFileSpy.mock.calls.filter(([path]) => String(path).includes('terminal-highlight'))
+      expect(stubReads).toHaveLength(1)
+    }
+    finally {
+      readFileSpy.mockRestore()
+    }
   })

Also applies to: 57-70

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/unit-tests/esbuild/index.test.ts` at line 32, The fs.promises.readFile
spy in the esbuild unit test can leak into later tests if an assertion throws
before cleanup. Update the test around readFileSpy in the test suite so the
assertion and any related setup run inside a try block and move
readFileSpy.mockRestore() into a finally block, ensuring the spy is always
restored even when the test fails.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@test/unit-tests/esbuild/index.test.ts`:
- Around line 57-64: The esbuild test repeats the same build() options as the
earlier test, so the configuration can drift. Extract the shared build setup
into a small helper (for example, a fixture-specific helper used by
plugin.esbuild()) and have both tests call it, keeping only the differing
expectations in each test.
- Line 32: The fs.promises.readFile spy in the esbuild unit test can leak into
later tests if an assertion throws before cleanup. Update the test around
readFileSpy in the test suite so the assertion and any related setup run inside
a try block and move readFileSpy.mockRestore() into a finally block, ensuring
the spy is always restored even when the test fails.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ce7a6cac-9927-4de6-bd00-8fed12ea0ce0

📥 Commits

Reviewing files that changed from the base of the PR and between 2fbf7b6 and fc41374.

📒 Files selected for processing (2)
  • src/esbuild/index.ts
  • test/unit-tests/esbuild/index.test.ts

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

esbuild: Error when trying to load a file switched off by the package.json/browser/*: false

1 participant