From 8248a43f122e0f46277ba31f01ec7198f90bce4b Mon Sep 17 00:00:00 2001 From: James Ross Date: Sun, 4 Oct 2026 22:28:46 +0100 Subject: [PATCH 1/2] 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 0487425e6..f4297c687 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) { @@ -383,7 +389,8 @@ export function DepsPlugin( } if (RE_PACKAGE_SPECIFIER.test(id)) { const resolvedDep = - shouldResolveDepSubpath && (await resolveDepSubpath(id, resolve)) + shouldResolveDepSubpath && + (await resolveDepSubpath(id, resolve, hasExportsCache)) // eslint-disable-next-line unicorn/prefer-logical-operator-over-ternary return resolvedDep ? [true, resolvedDep] : true } @@ -396,7 +403,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)) // eslint-disable-next-line unicorn/prefer-logical-operator-over-ternary return resolvedDep ? [true, resolvedDep] : true } @@ -497,7 +505,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() @@ -507,15 +532,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', () => { From a7ff27899c9a48748d4e249476b5f659202ce733 Mon Sep 17 00:00:00 2001 From: James Ross Date: Sun, 11 Oct 2026 21:42:55 +0100 Subject: [PATCH 2/2] test(deps): cover exports cache reuse and rebuild invalidation --- src/features/deps.test.ts | 49 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/src/features/deps.test.ts b/src/features/deps.test.ts index f3836b07d..f201c88b2 100644 --- a/src/features/deps.test.ts +++ b/src/features/deps.test.ts @@ -1,4 +1,8 @@ +import { mkdtemp, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' import { describe, expect, it, vi } from 'vitest' +import { fsRemove } from '../utils/fs.ts' import { DepsPlugin, getTypesPackageName, @@ -146,6 +150,51 @@ describe('DepsPlugin', () => { }) expect(resolve).toHaveBeenCalledTimes(1) }) + + it('caches the exports check per build', async () => { + const dir = await mkdtemp(path.join(tmpdir(), 'tsdown-deps-cache-')) + const packageJsonPath = path.join(dir, 'package.json') + try { + await writeFile(packageJsonPath, JSON.stringify({ name: 'my-dep' })) + + const plugin = DepsPlugin( + { + pkg: { dependencies: { 'my-dep': '^1.0.0' } }, + deps: resolveDepsConfig({ deps: { resolveDepSubpath: true } }), + } as any, + { inlinedDeps: new Map() } as any, + ) + const handler = (plugin.resolveId as any).handler + const buildStart = plugin.buildStart as () => void + const resolve = vi.fn(() => + Promise.resolve({ + id: '/project/node_modules/my-dep/sub/index.js', + packageJsonPath, + }), + ) + const resolveSub = () => + handler.call({ resolve }, 'my-dep/sub', '/project/src/index.ts', {}) + + await expect(resolveSub()).resolves.toMatchObject({ + id: 'my-dep/sub/index.js', + }) + + // within the same build the first read wins + await writeFile( + packageJsonPath, + JSON.stringify({ name: 'my-dep', exports: './index.js' }), + ) + await expect(resolveSub()).resolves.toMatchObject({ + id: 'my-dep/sub/index.js', + }) + + // a rebuild in watch mode must pick up the new `exports` field + buildStart() + await expect(resolveSub()).resolves.toMatchObject({ id: 'my-dep/sub' }) + } finally { + await fsRemove(dir) + } + }) }) describe('DepsPlugin with neverBundle: true', () => {