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
1 change: 1 addition & 0 deletions .changepacks/changepack_log_resolver_memo.json
Original file line number Diff line number Diff line change
@@ -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"}
43 changes: 43 additions & 0 deletions packages/plugin-utils/src/import-graph.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
mkdirSync,
mkdtempSync,
readFileSync,
realpathSync,
rmSync,
writeFileSync,
} from 'node:fs'
Expand Down Expand Up @@ -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(
Expand Down
64 changes: 60 additions & 4 deletions packages/plugin-utils/src/import-graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1080,6 +1080,43 @@ export function createModuleResolver({
importer: string,
) => ResolvedModule | undefined {
const { aliases, baseDir } = readPathAliases(tsconfigPath)
const packages = new Map<string, CachedPackage>()
const sources = new Map<string, CachedSource>()
/**
* `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('.')
Expand All @@ -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
}
}

Expand Down
Loading