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 } }