From d9932edc59f70da9fb67448d13de352345f4f8ff Mon Sep 17 00:00:00 2001 From: James Ross Date: Sun, 4 Oct 2026 22:28:46 +0100 Subject: [PATCH] perf(deps): read each package.json once when resolving dep subpaths --- src/features/deps.ts | 45 ++++++++++++++----- ...-is-checked-for-exports-separately.snap.md | 10 +++++ tests/e2e.test.ts | 38 ++++++++++++++++ 3 files changed, 82 insertions(+), 11 deletions(-) create mode 100644 tests/__snapshots__/resolve-dep-subpath-without-exports-field/each-package-is-checked-for-exports-separately.snap.md diff --git a/src/features/deps.ts b/src/features/deps.ts index 1aa412100..99fb0ebbe 100644 --- a/src/features/deps.ts +++ b/src/features/deps.ts @@ -185,9 +185,15 @@ export function DepsPlugin( tsdownBundle: TsdownBundle, ): Plugin { const deps = pkg && Array.from(getProductionDeps(pkg)) + // Keyed by package.json path. Every subpath import of a package reads the + // same file, so read it once per build. + const hasExportsCache: HasExportsCache = new Map() return { name: 'tsdown:deps', + buildStart() { + hasExportsCache.clear() + }, resolveId: { filter: [include(and(id(/^[^.]/), importerId(/./)))], async handler(id, importer, extraOptions) { @@ -382,7 +388,8 @@ export function DepsPlugin( } if (RE_PACKAGE_SPECIFIER.test(id)) { const resolvedDep = - shouldResolveDepSubpath && (await resolveDepSubpath(id, resolve)) + shouldResolveDepSubpath && + (await resolveDepSubpath(id, resolve, hasExportsCache)) return resolvedDep ? [true, resolvedDep] : true } const resolved = await resolve() @@ -394,7 +401,8 @@ export function DepsPlugin( if (deps) { if (deps.includes(id) || deps.some((dep) => id.startsWith(`${dep}/`))) { const resolvedDep = - shouldResolveDepSubpath && (await resolveDepSubpath(id, resolve)) + shouldResolveDepSubpath && + (await resolveDepSubpath(id, resolve, hasExportsCache)) return resolvedDep ? [true, resolvedDep] : true } @@ -494,7 +502,24 @@ export function getTypesPackageName(id: string): string | undefined { return `@types/${name.replace(/^@/, '').replace('/', '__')}` } -async function resolveDepSubpath(id: string, resolve: ResolveFn) { +// `undefined` when the package.json can't be read or parsed +type HasExportsCache = Map> + +async function readHasExports( + packageJsonPath: string, +): Promise { + try { + return !!JSON.parse(await readFile(packageJsonPath, 'utf8')).exports + } catch { + return undefined + } +} + +async function resolveDepSubpath( + id: string, + resolve: ResolveFn, + hasExportsCache: HasExportsCache, +) { const parts = id.split('/') // ignore scope if (parts[0][0] === '@') parts.shift() @@ -504,15 +529,13 @@ async function resolveDepSubpath(id: string, resolve: ResolveFn) { const resolved = await resolve() if (!resolved?.packageJsonPath) return - let pkgJson: Record - try { - pkgJson = JSON.parse(await readFile(resolved.packageJsonPath, 'utf8')) - } catch { - return + let hasExports = hasExportsCache.get(resolved.packageJsonPath) + if (!hasExports) { + hasExports = readHasExports(resolved.packageJsonPath) + hasExportsCache.set(resolved.packageJsonPath, hasExports) } - - // no `exports` field - if (pkgJson.exports) return + // only rewrite packages that have a readable package.json without `exports` + if ((await hasExports) !== false) return const parsed = parseNodeModulesPath(resolved.id) if (!parsed) return diff --git a/tests/__snapshots__/resolve-dep-subpath-without-exports-field/each-package-is-checked-for-exports-separately.snap.md b/tests/__snapshots__/resolve-dep-subpath-without-exports-field/each-package-is-checked-for-exports-separately.snap.md new file mode 100644 index 000000000..dc3fc24ce --- /dev/null +++ b/tests/__snapshots__/resolve-dep-subpath-without-exports-field/each-package-is-checked-for-exports-separately.snap.md @@ -0,0 +1,10 @@ +## index.mjs + +```mjs +import { lt } from "my-dep/functions/lt.js"; +import { a } from "with-exports/a"; +import { gt } from "my-dep/functions/gt.js"; +import { b } from "with-exports/b"; +export { a, b, gt, lt }; + +``` diff --git a/tests/e2e.test.ts b/tests/e2e.test.ts index af9a97c92..4015f2448 100644 --- a/tests/e2e.test.ts +++ b/tests/e2e.test.ts @@ -1486,6 +1486,44 @@ describe('resolve dep subpath without exports field', () => { expect(fileMap['index.mjs']).toContain('my-dep/folder/index.js') }) + + test('each package is checked for exports separately', async (context) => { + const { fileMap } = await testBuild({ + context, + files: { + 'node_modules/my-dep/package.json': JSON.stringify({ + name: 'my-dep', + main: 'index.js', + }), + 'node_modules/my-dep/functions/lt.js': `export const lt = () => {}`, + 'node_modules/my-dep/functions/gt.js': `export const gt = () => {}`, + 'node_modules/with-exports/package.json': JSON.stringify({ + name: 'with-exports', + exports: { './*': './dist/*.js' }, + }), + 'node_modules/with-exports/dist/a.js': `export const a = 1`, + 'node_modules/with-exports/dist/b.js': `export const b = 2`, + 'index.ts': `export * from './one' +export * from './two'`, + 'one.ts': `export { lt } from 'my-dep/functions/lt' +export { a } from 'with-exports/a'`, + 'two.ts': `export { gt } from 'my-dep/functions/gt' +export { b } from 'with-exports/b'`, + 'package.json': JSON.stringify({ + name: 'test-pkg', + dependencies: { 'my-dep': '^1.0.0', 'with-exports': '^1.0.0' }, + }), + }, + options: { + deps: { resolveDepSubpath: true }, + }, + }) + + expect(fileMap['index.mjs']).toContain('my-dep/functions/lt.js') + expect(fileMap['index.mjs']).toContain('my-dep/functions/gt.js') + expect(fileMap['index.mjs']).toMatch(/from ["']with-exports\/a["']/) + expect(fileMap['index.mjs']).toMatch(/from ["']with-exports\/b["']/) + }) }) describe('neverBundle: true', () => {