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
51 changes: 51 additions & 0 deletions packages/bundlecheck/src/__tests__/bundler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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",
Expand Down
176 changes: 161 additions & 15 deletions packages/bundlecheck/src/bundler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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)
Comment thread
aversini marked this conversation as resolved.
});
} 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,
});
Comment thread
aversini marked this conversation as resolved.
} else {
throw error;
}
} else {
throw error;
}
}

// Get raw size.
const bundleContent = result.outputFiles[0].contents;
Expand Down
14 changes: 14 additions & 0 deletions packages/bundlecheck/src/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string[]> = {
react: ["react/jsx-runtime", "react/jsx-dev-runtime"],
"react-dom": ["react-dom/client", "react-dom/server"],
};

export const DEFAULT_REGISTRY = "https://registry.npmjs.org";