From 4e3b1303ea4cd8ecea76164f45b464fee23c3544 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 15 Nov 2026 02:28:28 +0900 Subject: [PATCH] perf(plugin-utils): remember package resolutions and module code in the resolver The extractor asks the resolver again for every file that imports a module, so a shared token file or package was found and read once per importer. Package resolutions are kept per importing directory until the manifest or the entry changes, and module code until the file's stamp changes. Misses are not kept, so a package installed later is still found. Co-Authored-By: Claude Opus 5.5 --- .../changepack_log_resolver_memo.json | 1 + .../plugin-utils/src/import-graph.test.ts | 43 +++++++++++++ packages/plugin-utils/src/import-graph.ts | 64 +++++++++++++++++-- 3 files changed, 104 insertions(+), 4 deletions(-) create mode 100644 .changepacks/changepack_log_resolver_memo.json diff --git a/.changepacks/changepack_log_resolver_memo.json b/.changepacks/changepack_log_resolver_memo.json new file mode 100644 index 000000000..670eaedec --- /dev/null +++ b/.changepacks/changepack_log_resolver_memo.json @@ -0,0 +1 @@ +{"changes":{"packages/plugin-utils/package.json":"Patch"},"note":"Remember package resolutions and module code in the module resolver, so a module imported by many files is read once until it changes","date":"2026-11-15T00:00:00.000Z"} diff --git a/packages/plugin-utils/src/import-graph.test.ts b/packages/plugin-utils/src/import-graph.test.ts index 9736af4d1..7f0069c24 100644 --- a/packages/plugin-utils/src/import-graph.test.ts +++ b/packages/plugin-utils/src/import-graph.test.ts @@ -2,6 +2,7 @@ import { mkdirSync, mkdtempSync, readFileSync, + realpathSync, rmSync, writeFileSync, } from 'node:fs' @@ -1466,6 +1467,48 @@ describe('createModuleResolver', () => { expect(code('number-pkg')).toBeUndefined() expect(code('not-installed')).toBeUndefined() }) + it('reads a module again only after it changes', () => { + const resolveModule = createModuleResolver({ cwd: root }) + expect(resolveModule('./tokens', 'src/App.tsx')?.code).toBe( + 'export const PRIMARY = "red"', + ) + expect(resolveModule('@/tokens', 'src/App.tsx')?.code).toBe( + 'export const PRIMARY = "red"', + ) + writeFileSync(join(root, 'src/tokens.ts'), 'export const PRIMARY = "blue!"') + expect(resolveModule('./tokens', 'src/App.tsx')?.code).toBe( + 'export const PRIMARY = "blue!"', + ) + }) + + it('remembers packages until the manifest or the entry changes', () => { + const manifestPath = join(root, 'node_modules/pkg/package.json') + mkdirSync(dirname(manifestPath), { recursive: true }) + writeFileSync(manifestPath, JSON.stringify({ main: 'a.js' })) + writeFileSync(join(root, 'node_modules/pkg/a.js'), 'a') + writeFileSync(join(root, 'node_modules/pkg/other.js'), 'b') + const manifests: string[] = [] + const resolveModule = createModuleResolver({ + cwd: root, + onPackageManifest: (path) => manifests.push(path), + }) + const code = () => resolveModule('pkg', 'src/App.tsx')?.code + expect(code()).toBe('a') + expect(code()).toBe('a') + expect(manifests).toEqual([ + realpathSync(manifestPath), + realpathSync(manifestPath), + ]) + writeFileSync(manifestPath, JSON.stringify({ main: 'other.js' })) + expect(code()).toBe('b') + rmSync(join(root, 'node_modules/pkg/other.js')) + expect(code()).toBeUndefined() + writeFileSync(join(root, 'node_modules/pkg/other.js'), 'b again') + expect(code()).toBe('b again') + rmSync(dirname(manifestPath), { recursive: true }) + expect(code()).toBeUndefined() + }) + it('defaults to the working directory and the path itself', () => { const resolveModule = createModuleResolver() expect(resolveModule('./tokens', join(root, 'src/App.tsx'))?.path).toBe( diff --git a/packages/plugin-utils/src/import-graph.ts b/packages/plugin-utils/src/import-graph.ts index 13197350d..fcc0d7238 100644 --- a/packages/plugin-utils/src/import-graph.ts +++ b/packages/plugin-utils/src/import-graph.ts @@ -1080,6 +1080,43 @@ export function createModuleResolver({ importer: string, ) => ResolvedModule | undefined { const { aliases, baseDir } = readPathAliases(tsconfigPath) + const packages = new Map() + const sources = new Map() + /** + * `resolvePackage`, remembered per importing directory while the manifest + * and the entry stay unchanged. A miss is not remembered: the package may + * be installed later. + */ + const resolveCachedPackage = (specifier: string, from: string) => { + const key = `${dirname(from)}\0${specifier}` + const cached = packages.get(key) + if ( + cached && + fileStamp(cached.manifest) === cached.stamp && + isFile(cached.path) + ) { + onPackageManifest?.(cached.manifest) + return cached.path + } + let manifest = '' + const path = resolvePackage(specifier, from, (found) => { + manifest = found + onPackageManifest?.(found) + }) + if (path) { + packages.set(key, { manifest, stamp: fileStamp(manifest), path }) + } + return path + } + /** The file's code, read again only once its stamp changes. */ + const readSource = (path: string) => { + const stamp = fileStamp(path) + const cached = sources.get(path) + if (cached && cached.stamp === stamp) return cached.code + const code = readFileSync(path, 'utf-8') + sources.set(path, { stamp, code }) + return code + } return (specifier, importer) => { const from = resolve(cwd, importer) const path = specifier.startsWith('.') @@ -1093,11 +1130,30 @@ export function createModuleResolver({ .map(resolveSourceFile) .find(Boolean) ?? ((allowPackage?.(specifier) ?? true) - ? resolvePackage(specifier, from, onPackageManifest) + ? resolveCachedPackage(specifier, from) : undefined)) - return path - ? { path: toId(path), code: readFileSync(path, 'utf-8') } - : undefined + return path ? { path: toId(path), code: readSource(path) } : undefined + } +} + +interface CachedPackage { + manifest: string + stamp: string | undefined + path: string +} + +interface CachedSource { + stamp: string | undefined + code: string +} + +/** Changes whenever the file is written; `undefined` once it is gone. */ +function fileStamp(path: string): string | undefined { + try { + const { mtimeNs, ctimeNs, size } = statSync(path, { bigint: true }) + return `${mtimeNs}:${ctimeNs}:${size}` + } catch { + return undefined } }