Skip to content

Commit 254ea3d

Browse files
authored
fix(unplugin-dts): stabilize Vue program lifecycle (#488)
* perf(unplugin-dts): optimize watch rebuilds * chore(ci): diagnose Windows worker exits * test(unplugin-dts): canonicalize Windows watch paths * fix(unplugin-dts): stabilize Vue program lifecycle
1 parent 349e1b4 commit 254ea3d

9 files changed

Lines changed: 858 additions & 19 deletions

File tree

packages/unplugin-dts/src/core/processor/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ export interface ProgramProcessor {
2020
) => ts.ParsedCommandLine,
2121
createProgram: typeof ts.createProgram,
2222
createCompilerHost?: (options: ts.CompilerOptions) => VersionedCompilerHost,
23+
needsModuleResolutionFallback?: boolean,
24+
isInternalSourceFile?: (sourceFile: ts.SourceFile) => boolean,
25+
releaseSourceFile?: (program: ts.Program, fileName: string) => void,
2326
}
2427

2528
export async function loadProgramProcessor(type: 'vue' | 'ts' = 'ts'): Promise<ProgramProcessor> {

packages/unplugin-dts/src/core/processor/vue.ts

Lines changed: 159 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { dirname } from 'node:path'
1+
import { dirname, parse as pathParse, relative } from 'node:path'
22

33
import {
44
createVueLanguagePlugin,
@@ -8,7 +8,44 @@ import {
88

99
import { proxyCreateProgram } from '@volar/typescript'
1010
import ts from '../ts-loader.cjs'
11-
import { slash } from '../utils'
11+
import { normalizePath, resolve, slash } from '../utils'
12+
13+
const internalSourceFiles = new WeakSet<ts.SourceFile>()
14+
const programSourceFileReleasers = new WeakMap<ts.Program, (fileName: string) => void>()
15+
interface VueLanguage {
16+
scripts: { delete(fileName: string): void },
17+
}
18+
const programLanguages = new WeakMap<ts.Program, VueLanguage>()
19+
let activeLanguage: VueLanguage | undefined
20+
21+
export const needsModuleResolutionFallback = true
22+
23+
interface VueRootPathApi {
24+
parse: typeof import('node:path').parse,
25+
relative: typeof import('node:path').relative,
26+
resolve: typeof import('node:path').resolve,
27+
}
28+
29+
export function groupVueRootNames(
30+
vueRootNames: readonly string[],
31+
projectDirectory: string,
32+
pathApi: VueRootPathApi = { parse: pathParse, relative, resolve },
33+
) {
34+
projectDirectory = pathApi.resolve(projectDirectory)
35+
const projectVolume = pathApi.parse(projectDirectory).root
36+
const groups = new Map<string, string[]>()
37+
38+
for (const rootName of vueRootNames) {
39+
const fileName = pathApi.resolve(projectDirectory, rootName)
40+
const fileVolume = pathApi.parse(fileName).root
41+
const directory = pathApi.relative(projectVolume, fileVolume) ? fileVolume : projectDirectory
42+
const files = groups.get(directory) ?? []
43+
files.push(fileName)
44+
groups.set(directory, files)
45+
}
46+
47+
return [...groups].map(([directory, rootNames]) => ({ directory, rootNames }))
48+
}
1249

1350
export function createParsedCommandLine(
1451
_ts: typeof ts,
@@ -42,7 +79,101 @@ export function createParsedCommandLine(
4279
}
4380
}
4481

45-
export const createProgram = proxyCreateProgram(ts, ts.createProgram, (ts, options) => {
82+
function prepareVueProgramOptions(options: ts.CreateProgramOptions) {
83+
const originalHost = options.host
84+
if (!originalHost) return { options }
85+
86+
const vueRootNames = options.rootNames.filter(fileName => fileName.endsWith('.vue'))
87+
88+
const projectDirectory =
89+
typeof options.options.configFilePath === 'string'
90+
? dirname(options.options.configFilePath)
91+
: originalHost.getCurrentDirectory()
92+
const syntheticRoots = new Map<
93+
string,
94+
{ fileName: string, text: string, sourceFile?: ts.SourceFile }
95+
>()
96+
for (const group of groupVueRootNames(vueRootNames, projectDirectory)) {
97+
let sequence = 0
98+
let syntheticFileName = resolve(group.directory, '__unplugin_dts_vue_root__.d.ts')
99+
while (originalHost.fileExists(syntheticFileName)) {
100+
syntheticFileName = resolve(group.directory, `__unplugin_dts_vue_root_${++sequence}__.d.ts`)
101+
}
102+
103+
const text = group.rootNames
104+
.map(fileName => {
105+
const path = slash(relative(group.directory, fileName))
106+
const moduleName = path.startsWith('.') ? path : `./${path}`
107+
return `import ${JSON.stringify(moduleName)}`
108+
})
109+
.join('\n')
110+
const canonicalFileName = normalizePath(originalHost.getCanonicalFileName(syntheticFileName))
111+
syntheticRoots.set(canonicalFileName, { fileName: syntheticFileName, text })
112+
}
113+
114+
const getSourceFile = originalHost.getSourceFile.bind(originalHost)
115+
const fileExists = originalHost.fileExists.bind(originalHost)
116+
const readFile = originalHost.readFile.bind(originalHost)
117+
const releasedSourceFiles = new Set<string>()
118+
const getCanonicalFileName = (fileName: string) =>
119+
normalizePath(originalHost.getCanonicalFileName(fileName))
120+
const getSyntheticRoot = (fileName: string) => syntheticRoots.get(getCanonicalFileName(fileName))
121+
const host: ts.CompilerHost = {
122+
...originalHost,
123+
fileExists(fileName) {
124+
return !!getSyntheticRoot(fileName) || fileExists(fileName)
125+
},
126+
readFile(fileName) {
127+
return getSyntheticRoot(fileName)?.text ?? readFile(fileName)
128+
},
129+
getSourceFile(fileName, languageVersionOrOptions, onError, shouldCreateNewSourceFile) {
130+
if (releasedSourceFiles.has(getCanonicalFileName(fileName))) return undefined
131+
132+
const syntheticRoot = getSyntheticRoot(fileName)
133+
if (!syntheticRoot) {
134+
return getSourceFile(fileName, languageVersionOrOptions, onError, shouldCreateNewSourceFile)
135+
}
136+
137+
if (!syntheticRoot.sourceFile || shouldCreateNewSourceFile) {
138+
syntheticRoot.sourceFile = ts.createSourceFile(
139+
syntheticRoot.fileName,
140+
syntheticRoot.text,
141+
languageVersionOrOptions,
142+
true,
143+
ts.ScriptKind.TS,
144+
)
145+
internalSourceFiles.add(syntheticRoot.sourceFile)
146+
}
147+
return syntheticRoot.sourceFile
148+
},
149+
}
150+
151+
const preparedOptions: ts.CreateProgramOptions = {
152+
...options,
153+
host,
154+
rootNames: [...options.rootNames, ...[...syntheticRoots.values()].map(root => root.fileName)],
155+
}
156+
return {
157+
options: preparedOptions,
158+
releaseSourceFile(fileName: string) {
159+
const canonicalFileName = getCanonicalFileName(fileName)
160+
releasedSourceFiles.add(canonicalFileName)
161+
try {
162+
preparedOptions.host?.getSourceFile(fileName, ts.ScriptTarget.Latest)
163+
} finally {
164+
releasedSourceFiles.delete(canonicalFileName)
165+
}
166+
},
167+
}
168+
}
169+
170+
/**
171+
* 通过仅存在于 CompilerHost 的声明 root 引入未引用 SFC。
172+
*
173+
* TypeScript 会在 rootNames 中保留任意扩展文件,却不会主动为它们创建 SourceFile;
174+
* 虚拟声明 root 让 Volar 继续使用自身的模块解析和 service-script 生成路径。
175+
*/
176+
const createVueProgram = proxyCreateProgram(ts, ts.createProgram, (ts, options) => {
46177
const { configFilePath } = options.options
47178
const vueOptions =
48179
typeof configFilePath === 'string'
@@ -55,5 +186,29 @@ export const createProgram = proxyCreateProgram(ts, ts.createProgram, (ts, optio
55186
vueOptions,
56187
id => id,
57188
)
58-
return { languagePlugins: [vueLanguagePlugin] }
189+
return {
190+
languagePlugins: [vueLanguagePlugin],
191+
setup(instance) {
192+
activeLanguage = instance
193+
},
194+
}
59195
})
196+
197+
export const createProgram = ((options: ts.CreateProgramOptions) => {
198+
const prepared = prepareVueProgramOptions(options)
199+
const program = createVueProgram(prepared.options)
200+
if (activeLanguage) programLanguages.set(program, activeLanguage)
201+
if (prepared.releaseSourceFile) {
202+
programSourceFileReleasers.set(program, prepared.releaseSourceFile)
203+
}
204+
return program
205+
}) as typeof ts.createProgram
206+
207+
export function isInternalSourceFile(sourceFile: ts.SourceFile) {
208+
return internalSourceFiles.has(sourceFile)
209+
}
210+
211+
export function releaseSourceFile(program: ts.Program, fileName: string) {
212+
programLanguages.get(program)?.scripts.delete(fileName)
213+
programSourceFileReleasers.get(program)?.(fileName)
214+
}

packages/unplugin-dts/src/core/runtime.ts

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -274,10 +274,14 @@ export class Runtime {
274274
}
275275
const rawCompilerOptions = content?.raw.compilerOptions || {}
276276

277-
if (content?.fileNames.find(name => name.endsWith('.vue'))) {
277+
if (
278+
programProcessor.needsModuleResolutionFallback ||
279+
content?.fileNames.find(name => name.endsWith('.vue'))
280+
) {
278281
// (#277) A patch for Vue
279-
// If user don't specify `moduleResolution` in top config file,
280-
// declaration of Vue files will be inferred to `any` type.
282+
// If user doesn't specify `moduleResolution` in the top config file,
283+
// declarations of Vue files can be inferred as `any`, including Vue files
284+
// that are reached only through TypeScript roots.
281285
setModuleResolution(compilerOptions)
282286
}
283287

@@ -578,6 +582,9 @@ export class Runtime {
578582

579583
private rebuildFresh(previousProgram: ts.Program, reason: string) {
580584
const internals = getRuntimeInternals(this)
585+
const previousHost = this.host
586+
const previousLogger = this.logger
587+
const preserveVueHost = !!internals.programProcessor.releaseSourceFile && reason !== 'config'
581588
if (
582589
reason === 'create' ||
583590
reason === 'delete' ||
@@ -589,7 +596,15 @@ export class Runtime {
589596
internals.cleanStaleOutputs = true
590597
}
591598
const rebuildProgram = this.rebuildProgram
592-
const fresh = new Runtime(internals.runtimeOptions, internals.programProcessor)
599+
const fresh = new Runtime(
600+
preserveVueHost
601+
? {
602+
...internals.runtimeOptions,
603+
logger: { info() {}, warn() {}, error() {} },
604+
}
605+
: internals.runtimeOptions,
606+
internals.programProcessor,
607+
)
593608
const freshInternals = getRuntimeInternals(fresh)
594609
Object.assign(this, fresh)
595610
Object.defineProperty(this, 'rebuildProgram', {
@@ -600,6 +615,21 @@ export class Runtime {
600615
})
601616
internals.projectReferences = freshInternals.projectReferences
602617
internals.configWatchDirectories = freshInternals.configWatchDirectories
618+
if (preserveVueHost) {
619+
this.host = previousHost
620+
this.logger = previousLogger
621+
this.program = internals.programProcessor.createProgram({
622+
host: previousHost,
623+
rootNames: this.rootNames,
624+
options: this.compilerOptions,
625+
projectReferences: internals.projectReferences,
626+
})
627+
this.programSourceFilesByCanonicalName = undefined
628+
this.watchDirectoryNames = undefined
629+
this.watchTargets = undefined
630+
this.refreshDiagnostics()
631+
}
632+
this.releaseRemovedProgramSourceFiles(previousProgram)
603633
this.measureProgramReuse(previousProgram, 'fresh', reason, {
604634
entries: 0,
605635
hits: 0,
@@ -608,6 +638,22 @@ export class Runtime {
608638
})
609639
}
610640

641+
private releaseRemovedProgramSourceFiles(previousProgram: ts.Program) {
642+
const { releaseSourceFile } = getRuntimeInternals(this).programProcessor
643+
if (!releaseSourceFile) return
644+
645+
const currentSourceFiles = new Set(
646+
this.program
647+
.getSourceFiles()
648+
.map(sourceFile => this.getCanonicalFileName(sourceFile.fileName)),
649+
)
650+
for (const sourceFile of previousProgram.getSourceFiles()) {
651+
if (!currentSourceFiles.has(this.getCanonicalFileName(sourceFile.fileName))) {
652+
releaseSourceFile(previousProgram, sourceFile.fileName)
653+
}
654+
}
655+
}
656+
611657
private rebuildProgramWithCurrentHost(previousProgram: ts.Program, reason: string) {
612658
const internals = getRuntimeInternals(this)
613659
this.program = internals.programProcessor.createProgram({
@@ -620,6 +666,7 @@ export class Runtime {
620666
this.watchDirectoryNames = undefined
621667
this.watchTargets = undefined
622668
this.refreshDiagnostics()
669+
this.releaseRemovedProgramSourceFiles(previousProgram)
623670
this.measureProgramReuse(previousProgram, 'fresh', reason, {
624671
entries: 0,
625672
hits: 0,
@@ -748,7 +795,10 @@ export class Runtime {
748795
}
749796
if (this.configPath) files.add(normalizePath(this.configPath))
750797

798+
const { programProcessor } = getRuntimeInternals(this)
751799
for (const sourceFile of this.program.getSourceFiles()) {
800+
if (programProcessor.isInternalSourceFile?.(sourceFile)) continue
801+
752802
if (!this.program.isSourceFileDefaultLibrary(sourceFile)) {
753803
files.add(normalizePath(sourceFile.fileName))
754804

@@ -1024,9 +1074,11 @@ export class Runtime {
10241074
record && emittedFiles.set(path, content)
10251075
}
10261076

1077+
const { programProcessor } = getRuntimeInternals(this)
10271078
const sourceFiles = this.program.getSourceFiles()
10281079

10291080
for (const sourceFile of sourceFiles) {
1081+
if (programProcessor.isInternalSourceFile?.(sourceFile)) continue
10301082
if (!this.filter(sourceFile.fileName)) continue
10311083

10321084
if ((copyDtsFiles || bundleTypes) && dtsRE.test(sourceFile.fileName)) {

packages/unplugin-dts/src/core/utils.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -433,15 +433,20 @@ export function setModuleResolution(options: CompilerOptions) {
433433
moduleResolution = ts.ModuleResolutionKind.NodeNext
434434
break
435435
default:
436-
moduleResolution = ts.version.startsWith('5')
437-
? ts.ModuleResolutionKind.Bundler
438-
: ts.ModuleResolutionKind.Classic
436+
moduleResolution = resolveESModuleResolution(ts.ModuleResolutionKind)
439437
break
440438
}
441439

442440
options.moduleResolution = moduleResolution
443441
}
444442

443+
export function resolveESModuleResolution(moduleResolutionKind: {
444+
Classic: ts.ModuleResolutionKind,
445+
Bundler?: ts.ModuleResolutionKind,
446+
}) {
447+
return moduleResolutionKind.Bundler ?? moduleResolutionKind.Classic
448+
}
449+
445450
export function editSourceMapDir(content: string, fromDir: string, toDir: string) {
446451
const relativeOutDir = relative(fromDir, toDir)
447452

packages/unplugin-dts/tests/runtime-ts6.spec.ts

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
1+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
22
import { tmpdir } from 'node:os'
3-
import { resolve } from 'node:path'
3+
import { dirname, resolve } from 'node:path'
4+
import { fileURLToPath } from 'node:url'
45
import { afterEach, describe, expect, it, vi } from 'vitest'
56

67
// typescript-v6 is "npm:typescript@^6" — provides real TS 6 compilation behaviour
@@ -104,4 +105,48 @@ describe('runtime tests (TypeScript 6)', () => {
104105
expect(content).toContain('foo')
105106
expect(content.trim()).not.toBe('export { }')
106107
}, 20_000)
108+
109+
it('should preserve Vue declarations reached only from a TypeScript root', async () => {
110+
tempDir = mkdtempSync(resolve(tmpdir(), 'unplugin-dts-'))
111+
mkdirSync(resolve(tempDir, 'src'))
112+
symlinkSync(
113+
resolve(dirname(fileURLToPath(import.meta.url)), '../../../playground/vue-vite/node_modules'),
114+
resolve(tempDir, 'node_modules'),
115+
process.platform === 'win32' ? 'junction' : 'dir',
116+
)
117+
writeFileSync(
118+
resolve(tempDir, 'tsconfig.json'),
119+
JSON.stringify({
120+
compilerOptions: {
121+
target: 'ESNext',
122+
module: 'ESNext',
123+
strict: true,
124+
declaration: true,
125+
},
126+
files: ['src/index.ts'],
127+
}),
128+
)
129+
writeFileSync(
130+
resolve(tempDir, 'src/App.vue'),
131+
'<script setup lang="ts">\nconst value = 1\ndefineExpose({ value })\n</script>\n',
132+
)
133+
writeFileSync(resolve(tempDir, 'src/index.ts'), "export { default as App } from './App.vue'\n")
134+
135+
const runtime = await Runtime.toInstance({
136+
processor: 'vue',
137+
root: tempDir,
138+
tsconfigPath: 'tsconfig.json',
139+
})
140+
const declarations = new Map<string, string>()
141+
runtime
142+
.getProgram()
143+
.emit(undefined, (fileName, content) => declarations.set(fileName, content), undefined, true)
144+
const vueDeclaration = [...declarations].find(([fileName]) =>
145+
fileName.endsWith('App.vue.d.ts'),
146+
)?.[1]
147+
148+
expect(runtime.getDiagnostics()).toEqual([])
149+
expect(vueDeclaration).toContain('DefineComponent')
150+
expect(vueDeclaration).not.toContain('__VLS_export: any')
151+
})
107152
})

0 commit comments

Comments
 (0)