diff --git a/packages/bundlecheck/src/__tests__/bundler.test.ts b/packages/bundlecheck/src/__tests__/bundler.test.ts index b15dc29d..b273fc2a 100644 --- a/packages/bundlecheck/src/__tests__/bundler.test.ts +++ b/packages/bundlecheck/src/__tests__/bundler.test.ts @@ -5,6 +5,7 @@ import * as esbuild from "esbuild"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { checkBundleSize, + expandExternalsForEsbuild, formatBytes, parsePackageSpecifier, } from "../bundler.js"; @@ -133,6 +134,56 @@ describe("formatBytes", () => { }); }); +describe("expandExternalsForEsbuild", () => { + it("should return empty array for empty input", () => { + expect(expandExternalsForEsbuild([])).toEqual([]); + }); + + it("should expand react to include jsx-runtime subpaths", () => { + const result = expandExternalsForEsbuild(["react"]); + expect(result).toContain("react"); + expect(result).toContain("react/jsx-runtime"); + expect(result).toContain("react/jsx-dev-runtime"); + }); + + it("should expand react-dom to include client and server subpaths", () => { + const result = expandExternalsForEsbuild(["react-dom"]); + expect(result).toContain("react-dom"); + expect(result).toContain("react-dom/client"); + expect(result).toContain("react-dom/server"); + }); + + it("should expand both react and react-dom", () => { + const result = expandExternalsForEsbuild(["react", "react-dom"]); + expect(result).toContain("react"); + expect(result).toContain("react/jsx-runtime"); + expect(result).toContain("react/jsx-dev-runtime"); + expect(result).toContain("react-dom"); + expect(result).toContain("react-dom/client"); + expect(result).toContain("react-dom/server"); + }); + + it("should pass through packages without known subpaths", () => { + const result = expandExternalsForEsbuild(["lodash", "dayjs"]); + expect(result).toEqual(["lodash", "dayjs"]); + }); + + it("should handle mixed packages with and without subpaths", () => { + const result = expandExternalsForEsbuild(["react", "lodash"]); + expect(result).toContain("react"); + expect(result).toContain("react/jsx-runtime"); + expect(result).toContain("react/jsx-dev-runtime"); + expect(result).toContain("lodash"); + expect(result).not.toContain("lodash/fp"); // lodash has no subpath mapping + }); + + it("should deduplicate results", () => { + const result = expandExternalsForEsbuild(["react", "react"]); + const reactCount = result.filter((r) => r === "react").length; + expect(reactCount).toBe(1); + }); +}); + describe("checkBundleSize", () => { const mockPackageJson = { name: "test-package", diff --git a/packages/bundlecheck/src/bundler.ts b/packages/bundlecheck/src/bundler.ts index 79489a99..20890296 100644 --- a/packages/bundlecheck/src/bundler.ts +++ b/packages/bundlecheck/src/bundler.ts @@ -5,7 +5,7 @@ import path from "node:path"; import { promisify } from "node:util"; import zlib from "node:zlib"; import * as esbuild from "esbuild"; -import { DEFAULT_EXTERNALS } from "./defaults.js"; +import { DEFAULT_EXTERNALS, EXTERNAL_SUBPATHS } from "./defaults.js"; const gzipAsync = promisify(zlib.gzip); @@ -272,10 +272,81 @@ function generateEntryContent(options: EntryContentOptions): string { return `import * as pkg from "${packageName}";\nexport default pkg;\n`; } +/** + * Check if an error is an esbuild BuildFailure. + * BuildFailure has an `errors` array with structured error objects. + */ +function isEsbuildBuildFailure( + error: unknown, +): error is { errors: Array<{ text: string; location?: unknown }> } { + return ( + typeof error === "object" && + error !== null && + "errors" in error && + Array.isArray((error as { errors: unknown }).errors) + ); +} + +/** + * Extract unresolved module paths from an esbuild BuildFailure error. + * Returns an object indicating which react-related modules failed to resolve. + * + * Uses esbuild's structured error objects (errors array) for reliable parsing. + * Falls back to string matching if the error format is unexpected. + * + * Tested against esbuild 0.27.x error format. + */ +function parseUnresolvedModules(error: unknown): { + hasUnresolvedReact: boolean; + hasUnresolvedReactDom: boolean; +} { + const result = { hasUnresolvedReact: false, hasUnresolvedReactDom: false }; + + // Pattern to extract module path from "Could not resolve X" errors. + // Matches: Could not resolve "module-name" or Could not resolve 'module-name' + const resolveErrorPattern = /Could not resolve ["']([^"']+)["']/; + + if (isEsbuildBuildFailure(error)) { + // Use structured error objects from esbuild BuildFailure. + for (const err of error.errors) { + const match = resolveErrorPattern.exec(err.text); + if (match) { + const modulePath = match[1]; + // Check if it's a react or react-dom import (including subpaths). + if (modulePath === "react" || modulePath.startsWith("react/")) { + result.hasUnresolvedReact = true; + } + if (modulePath === "react-dom" || modulePath.startsWith("react-dom/")) { + result.hasUnresolvedReactDom = true; + } + } + } + } else { + // Fallback: parse error message string (less reliable). + const errorMessage = String(error); + const matches = errorMessage.matchAll( + /Could not resolve ["']([^"']+)["']/g, + ); + for (const match of matches) { + const modulePath = match[1]; + if (modulePath === "react" || modulePath.startsWith("react/")) { + result.hasUnresolvedReact = true; + } + if (modulePath === "react-dom" || modulePath.startsWith("react-dom/")) { + result.hasUnresolvedReactDom = true; + } + } + } + + return result; +} + /** * Get externals list based on options and package dependencies. react and * react-dom are only marked as external if they are declared in the package's * dependencies or peerDependencies. + * + * Returns the base package names (e.g., ["react", "react-dom"]) for display purposes. */ export function getExternals( packageName: string, @@ -317,6 +388,26 @@ export function getExternals( return result; } +/** + * Expand externals to include subpaths for esbuild. + * For example, "react" expands to ["react", "react/jsx-runtime", "react/jsx-dev-runtime"]. + * This is needed because esbuild doesn't automatically externalize subpaths. + */ +export function expandExternalsForEsbuild(externals: string[]): string[] { + const expanded: string[] = []; + + for (const ext of externals) { + expanded.push(ext); + // Add subpaths if this is a known package with subpath exports. + const subpaths = EXTERNAL_SUBPATHS[ext]; + if (subpaths) { + expanded.push(...subpaths); + } + } + + return [...new Set(expanded)]; +} + export type PackageExports = Record< string, string | { import?: string; types?: string } @@ -645,26 +736,81 @@ export async function checkBundleSize( * Get externals based on package dependencies. Only include react/react-dom * if they're in the package's dependencies or peerDependencies. */ - const externals = getExternals( + let externals = getExternals( packageName, additionalExternals, noExternal, allDependencies, ); - // Bundle with esbuild. - const result = await esbuild.build({ - entryPoints: [entryFile], - bundle: true, - write: false, - format: "esm", - platform, - target: "es2020", - minify: true, - treeShaking: true, - external: externals, - metafile: true, - }); + /** + * Expand externals to include subpaths for esbuild. + * e.g., "react" -> ["react", "react/jsx-runtime", "react/jsx-dev-runtime"] + */ + let esbuildExternals = expandExternalsForEsbuild(externals); + + /** + * Bundle with esbuild. We use a two-phase approach: + * 1. First attempt with logLevel: "silent" to suppress errors + * 2. If it fails due to unresolved react imports, auto-add react to externals and retry + * This handles packages that don't properly declare react as a peer dependency. + */ + let result: esbuild.BuildResult<{ write: false; metafile: true }>; + try { + result = await esbuild.build({ + entryPoints: [entryFile], + bundle: true, + write: false, + format: "esm", + platform, + target: "es2020", + minify: true, + treeShaking: true, + external: esbuildExternals, + metafile: true, + logLevel: "silent", // Suppress errors on first attempt (will retry if needed) + }); + } catch (error) { + /** + * Parse unresolved module errors using esbuild's structured error format. + * This handles packages that don't properly declare react as a peer dependency. + */ + const { hasUnresolvedReact, hasUnresolvedReactDom } = + parseUnresolvedModules(error); + + if (!noExternal && (hasUnresolvedReact || hasUnresolvedReactDom)) { + // Auto-add react and/or react-dom to externals and retry. + const autoExternals: string[] = []; + if (hasUnresolvedReact && !externals.includes("react")) { + autoExternals.push("react"); + } + if (hasUnresolvedReactDom && !externals.includes("react-dom")) { + autoExternals.push("react-dom"); + } + + if (autoExternals.length > 0) { + externals = [...externals, ...autoExternals]; + esbuildExternals = expandExternalsForEsbuild(externals); + + result = await esbuild.build({ + entryPoints: [entryFile], + bundle: true, + write: false, + format: "esm", + platform, + target: "es2020", + minify: true, + treeShaking: true, + external: esbuildExternals, + metafile: true, + }); + } else { + throw error; + } + } else { + throw error; + } + } // Get raw size. const bundleContent = result.outputFiles[0].contents; diff --git a/packages/bundlecheck/src/defaults.ts b/packages/bundlecheck/src/defaults.ts index 07b4c22c..33b86a35 100644 --- a/packages/bundlecheck/src/defaults.ts +++ b/packages/bundlecheck/src/defaults.ts @@ -79,6 +79,20 @@ export function isValidPlatform(platform: string | undefined): boolean { export const TREND_VERSION_COUNT = 5; +/** + * Base packages to auto-detect for externalization. + * These are checked against the package's dependencies/peerDependencies. + */ export const DEFAULT_EXTERNALS = ["react", "react-dom"]; +/** + * Subpath externals to add when a base package is detected. + * When "react" is in dependencies, we also need to externalize "react/jsx-runtime" etc. + * because esbuild doesn't automatically externalize subpaths. + */ +export const EXTERNAL_SUBPATHS: Record = { + react: ["react/jsx-runtime", "react/jsx-dev-runtime"], + "react-dom": ["react-dom/client", "react-dom/server"], +}; + export const DEFAULT_REGISTRY = "https://registry.npmjs.org";