Skip to content

Commit 5c49904

Browse files
[next-ts-plugin] fix: language service crashes / metadata plugin not working (#77213)
### Why? This PR tackles two issues: 1. The Next.js TS plugin for metadata wasn't working. 2. The language service occasionally crashes in large projects. #### The Next.js TS plugin for metadata wasn't working The plugin wasn't working due to the position mismatch of `optionalReplacementSpan` since VSCode [v1.86](https://code.visualstudio.com/updates/v1_86) due to the behavior change of mapping the `replacementSpan` with `optionalReplacementSpan` if it exists. x-ref: https://github.com/microsoft/vscode/pull/200945/files#diff-9027dfcf905374dcada6a75144ed94935254a5a6bf325a20d557201cd7fbe704R766 #### The language service occasionally crashes in large projects Since we spun up another language service and a custom language service host for metadata rules, it occasionally crashed on large projects. ### How? - Synced the `optionalReplacementSpan` to the new position. - Removed creating another language service and relying on `@typescript/vfs` for the virtual file system, which is recommended: "where files on disk aren't the source of truth". https://github.com/user-attachments/assets/b5b325a4-fd2f-4210-be68-1aa5e40229c2 ### Success Criteria - [x] Does it do completions with `export const metadata = { a }`? - [x] Does it disable the plugin with `{ enabled: false }` in the tsconfig.json? - [x] Does the language service not break when used in large projects? ### Follow Up This fix is a partial port from the commit [071d839](071d839) and will continue to follow up on the refactors from PR #77206, including a proper test to prevent regression. --------- Co-authored-by: Zack Tanner <[email protected]>
1 parent 6ed3b01 commit 5c49904

File tree

11 files changed

+162
-133
lines changed

11 files changed

+162
-133
lines changed

packages/next/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,7 @@
218218
"@types/ua-parser-js": "0.7.36",
219219
"@types/webpack-sources1": "npm:@types/[email protected]",
220220
"@types/ws": "8.2.0",
221+
"@typescript/vfs": "1.6.1",
221222
"@vercel/ncc": "0.34.0",
222223
"@vercel/nft": "0.27.1",
223224
"@vercel/turbopack-ecmascript-runtime": "*",
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
The MIT License (MIT)
2+
Copyright (c) Microsoft Corporation
3+
4+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
5+
associated documentation files (the "Software"), to deal in the Software without restriction,
6+
including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense,
7+
and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so,
8+
subject to the following conditions:
9+
10+
The above copyright notice and this permission notice shall be included in all copies or substantial
11+
portions of the Software.
12+
13+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
14+
NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
15+
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
16+
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
17+
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

packages/next/src/compiled/@typescript/vfs/index.js

+1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"name":"@typescript/vfs","main":"index.js","author":"TypeScript team","license":"MIT"}

packages/next/src/compiled/@typescript/vfs/typescript.js

+21
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/next/src/server/typescript/index.ts

+14-13
Original file line numberDiff line numberDiff line change
@@ -32,18 +32,6 @@ export const createTSPlugin: tsModule.server.PluginModuleFactory = ({
3232
typescript: ts,
3333
}) => {
3434
function create(info: tsModule.server.PluginCreateInfo) {
35-
init({
36-
ts,
37-
info,
38-
})
39-
40-
// Set up decorator object
41-
const proxy = Object.create(null)
42-
for (let k of Object.keys(info.languageService)) {
43-
const x = (info.languageService as any)[k]
44-
proxy[k] = (...args: Array<{}>) => x.apply(info.languageService, args)
45-
}
46-
4735
// Get plugin options
4836
// config is the plugin options from the user's tsconfig.json
4937
// e.g. { "plugins": [{ "name": "next", "enabled": true }] }
@@ -52,9 +40,22 @@ export const createTSPlugin: tsModule.server.PluginModuleFactory = ({
5240
const isPluginEnabled = info.config.enabled ?? true
5341

5442
if (!isPluginEnabled) {
55-
return proxy
43+
return info.languageService
5644
}
5745

46+
// Set up decorator object
47+
const proxy: tsModule.LanguageService = Object.create(null)
48+
for (let k of Object.keys(info.languageService)) {
49+
const x = info.languageService[k as keyof tsModule.LanguageService]
50+
// @ts-expect-error - JS runtime trickery which is tricky to type tersely
51+
proxy[k] = (...args: Array<{}>) => x.apply(info.languageService, args)
52+
}
53+
54+
init({
55+
ts,
56+
info,
57+
})
58+
5859
// Auto completion
5960
proxy.getCompletionsAtPosition = (
6061
fileName: string,

packages/next/src/server/typescript/rules/metadata.ts

+30-112
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import { NEXT_TS_ERRORS } from '../constant'
22
import {
3-
getInfo,
43
getSource,
4+
getSourceFromVirtualTsEnv,
55
getTs,
66
getTypeChecker,
77
isPositionInsideNode,
8+
log,
9+
virtualTsEnv,
810
} from '../utils'
911

1012
import type tsModule from 'typescript/lib/tsserverlibrary'
@@ -49,101 +51,6 @@ function getMetadataExport(fileName: string, position: number) {
4951
return metadataExport
5052
}
5153

52-
let cachedProxiedLanguageService: tsModule.LanguageService | undefined
53-
let cachedProxiedLanguageServiceHost: tsModule.LanguageServiceHost | undefined
54-
function getProxiedLanguageService() {
55-
if (cachedProxiedLanguageService)
56-
return {
57-
languageService: cachedProxiedLanguageService as tsModule.LanguageService,
58-
languageServiceHost:
59-
cachedProxiedLanguageServiceHost as tsModule.LanguageServiceHost & {
60-
addFile: (fileName: string, body: string) => void
61-
},
62-
}
63-
64-
const languageServiceHost = getInfo().languageServiceHost
65-
66-
const ts = getTs()
67-
class ProxiedLanguageServiceHost implements tsModule.LanguageServiceHost {
68-
files: {
69-
[fileName: string]: { file: tsModule.IScriptSnapshot; ver: number }
70-
} = {}
71-
72-
log = () => {}
73-
trace = () => {}
74-
error = () => {}
75-
getCompilationSettings = () => languageServiceHost.getCompilationSettings()
76-
getScriptIsOpen = () => true
77-
getCurrentDirectory = () => languageServiceHost.getCurrentDirectory()
78-
getDefaultLibFileName = (o: any) =>
79-
languageServiceHost.getDefaultLibFileName(o)
80-
81-
getScriptVersion = (fileName: string) => {
82-
const file = this.files[fileName]
83-
if (!file) return languageServiceHost.getScriptVersion(fileName)
84-
return file.ver.toString()
85-
}
86-
87-
getScriptSnapshot = (fileName: string) => {
88-
const file = this.files[fileName]
89-
if (!file) return languageServiceHost.getScriptSnapshot(fileName)
90-
return file.file
91-
}
92-
93-
getScriptFileNames(): string[] {
94-
const names: Set<string> = new Set()
95-
for (var name in this.files) {
96-
if (this.files.hasOwnProperty(name)) {
97-
names.add(name)
98-
}
99-
}
100-
const files = languageServiceHost.getScriptFileNames()
101-
for (const file of files) {
102-
names.add(file)
103-
}
104-
return [...names]
105-
}
106-
107-
addFile(fileName: string, body: string) {
108-
const snap = ts.ScriptSnapshot.fromString(body)
109-
snap.getChangeRange = (_) => undefined
110-
const existing = this.files[fileName]
111-
if (existing) {
112-
this.files[fileName].ver++
113-
this.files[fileName].file = snap
114-
} else {
115-
this.files[fileName] = { ver: 1, file: snap }
116-
}
117-
}
118-
119-
readFile(fileName: string) {
120-
const file = this.files[fileName]
121-
return file
122-
? file.file.getText(0, file.file.getLength())
123-
: languageServiceHost.readFile(fileName)
124-
}
125-
fileExists(fileName: string) {
126-
return (
127-
this.files[fileName] !== undefined ||
128-
languageServiceHost.fileExists(fileName)
129-
)
130-
}
131-
}
132-
133-
cachedProxiedLanguageServiceHost = new ProxiedLanguageServiceHost()
134-
cachedProxiedLanguageService = ts.createLanguageService(
135-
cachedProxiedLanguageServiceHost,
136-
ts.createDocumentRegistry()
137-
)
138-
return {
139-
languageService: cachedProxiedLanguageService as tsModule.LanguageService,
140-
languageServiceHost:
141-
cachedProxiedLanguageServiceHost as tsModule.LanguageServiceHost & {
142-
addFile: (fileName: string, body: string) => void
143-
},
144-
}
145-
}
146-
14754
function updateVirtualFileWithType(
14855
fileName: string,
14956
node: tsModule.VariableDeclaration | tsModule.FunctionDeclaration,
@@ -178,8 +85,17 @@ function updateVirtualFileWithType(
17885
annotation +
17986
sourceText.slice(nodeEnd) +
18087
TYPE_IMPORT
181-
const { languageServiceHost } = getProxiedLanguageService()
182-
languageServiceHost.addFile(fileName, newSource)
88+
89+
if (virtualTsEnv.sys.fileExists(fileName)) {
90+
log('Updating file: ' + fileName)
91+
// FIXME: updateFile() breaks as the file doesn't exists, which is weird.
92+
// virtualTsEnv.updateFile(fileName, newSource)
93+
virtualTsEnv.deleteFile(fileName)
94+
virtualTsEnv.createFile(fileName, newSource)
95+
} else {
96+
log('Creating file: ' + fileName)
97+
virtualTsEnv.createFile(fileName, newSource)
98+
}
18399

184100
return [nodeEnd, annotation.length]
185101
}
@@ -196,9 +112,9 @@ function proxyDiagnostics(
196112
n: tsModule.VariableDeclaration | tsModule.FunctionDeclaration
197113
) {
198114
// Get diagnostics
199-
const { languageService } = getProxiedLanguageService()
200-
const diagnostics = languageService.getSemanticDiagnostics(fileName)
201-
const source = getSource(fileName)
115+
const diagnostics =
116+
virtualTsEnv.languageService.getSemanticDiagnostics(fileName)
117+
const source = getSourceFromVirtualTsEnv(fileName)
202118

203119
// Filter and map the results
204120
return diagnostics
@@ -232,24 +148,26 @@ const metadata = {
232148
if (!node) return prior
233149
if (isTyped(node)) return prior
234150

235-
const ts = getTs()
236-
237151
// We annotate with the type in a virtual language service
238152
const pos = updateVirtualFileWithType(fileName, node)
239153
if (pos === undefined) return prior
240154

241155
// Get completions
242-
const { languageService } = getProxiedLanguageService()
243156
const newPos = position <= pos[0] ? position : position + pos[1]
244-
const completions = languageService.getCompletionsAtPosition(
157+
const completions = virtualTsEnv.languageService.getCompletionsAtPosition(
245158
fileName,
246159
newPos,
247160
undefined
248161
)
249162

250163
if (completions) {
164+
const ts = getTs()
251165
completions.isIncomplete = true
252-
166+
// https://github.com/microsoft/TypeScript/blob/4dc677b292354f4b9162452b2e00f4d7dd118221/src/services/types.ts#L1428-L1433
167+
if (completions.optionalReplacementSpan) {
168+
// Adjust the start position of the text span to original source.
169+
completions.optionalReplacementSpan.start -= newPos - position
170+
}
253171
completions.entries = completions.entries
254172
.filter((e) => {
255173
return [
@@ -465,10 +383,9 @@ const metadata = {
465383
const pos = updateVirtualFileWithType(fileName, node)
466384
if (pos === undefined) return
467385

468-
const { languageService } = getProxiedLanguageService()
469386
const newPos = position <= pos[0] ? position : position + pos[1]
470387

471-
const details = languageService.getCompletionEntryDetails(
388+
const details = virtualTsEnv.languageService.getCompletionEntryDetails(
472389
fileName,
473390
newPos,
474391
entryName,
@@ -489,9 +406,11 @@ const metadata = {
489406
const pos = updateVirtualFileWithType(fileName, node)
490407
if (pos === undefined) return
491408

492-
const { languageService } = getProxiedLanguageService()
493409
const newPos = position <= pos[0] ? position : position + pos[1]
494-
const insight = languageService.getQuickInfoAtPosition(fileName, newPos)
410+
const insight = virtualTsEnv.languageService.getQuickInfoAtPosition(
411+
fileName,
412+
newPos
413+
)
495414
return insight
496415
},
497416

@@ -503,11 +422,10 @@ const metadata = {
503422
// We annotate with the type in a virtual language service
504423
const pos = updateVirtualFileWithType(fileName, node)
505424
if (pos === undefined) return
506-
const { languageService } = getProxiedLanguageService()
507425
const newPos = position <= pos[0] ? position : position + pos[1]
508426

509427
const definitionInfoAndBoundSpan =
510-
languageService.getDefinitionAndBoundSpan(fileName, newPos)
428+
virtualTsEnv.languageService.getDefinitionAndBoundSpan(fileName, newPos)
511429

512430
if (definitionInfoAndBoundSpan) {
513431
// Adjust the start position of the text span

packages/next/src/server/typescript/utils.ts

+44-4
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,61 @@
1-
import path from 'path'
1+
import type { VirtualTypeScriptEnvironment } from 'next/dist/compiled/@typescript/vfs'
2+
import {
3+
createFSBackedSystem,
4+
createDefaultMapFromNodeModules,
5+
createVirtualTypeScriptEnvironment,
6+
} from 'next/dist/compiled/@typescript/vfs'
7+
8+
import path, { join } from 'path'
29

310
import type tsModule from 'typescript/lib/tsserverlibrary'
411
type TypeScript = typeof import('typescript/lib/tsserverlibrary')
512

613
let ts: TypeScript
714
let info: tsModule.server.PluginCreateInfo
815
let appDirRegExp: RegExp
16+
export let virtualTsEnv: VirtualTypeScriptEnvironment
917

1018
export function log(message: string) {
11-
info.project.projectService.logger.info(message)
19+
info.project.projectService.logger.info('[next] ' + message)
1220
}
1321

1422
// This function has to be called initially.
1523
export function init(opts: {
1624
ts: TypeScript
1725
info: tsModule.server.PluginCreateInfo
1826
}) {
27+
const projectDir = opts.info.project.getCurrentDirectory()
1928
ts = opts.ts
2029
info = opts.info
21-
const projectDir = info.project.getCurrentDirectory()
2230
appDirRegExp = new RegExp(
2331
'^' + (projectDir + '(/src)?/app').replace(/[\\/]/g, '[\\/]')
2432
)
25-
log('Starting Next.js TypeScript plugin: ' + projectDir)
33+
34+
log('Initializing Next.js TypeScript plugin: ' + projectDir)
35+
36+
const compilerOptions = info.project.getCompilerOptions()
37+
const fsMap = createDefaultMapFromNodeModules(
38+
compilerOptions,
39+
ts,
40+
join(projectDir, 'node_modules/typescript/lib')
41+
)
42+
const system = createFSBackedSystem(fsMap, projectDir, ts)
43+
44+
virtualTsEnv = createVirtualTypeScriptEnvironment(
45+
system,
46+
[],
47+
ts,
48+
compilerOptions
49+
)
50+
51+
if (virtualTsEnv) {
52+
log(
53+
'Failed to create virtual TypeScript environment. This is a bug in Next.js TypeScript plugin. Please report it by opening an issue at https://github.com/vercel/next.js/issues.'
54+
)
55+
return
56+
}
57+
58+
log('Successfully initialized Next.js TypeScript plugin!')
2659
}
2760

2861
export function getTs() {
@@ -41,6 +74,13 @@ export function getSource(fileName: string) {
4174
return info.languageService.getProgram()?.getSourceFile(fileName)
4275
}
4376

77+
export function getSourceFromVirtualTsEnv(fileName: string) {
78+
if (virtualTsEnv.sys.fileExists(fileName)) {
79+
return virtualTsEnv.getSourceFile(fileName)
80+
}
81+
return getSource(fileName)
82+
}
83+
4484
export function removeStringQuotes(str: string): string {
4585
return str.replace(/^['"`]|['"`]$/g, '')
4686
}

packages/next/taskfile.js

+9
Original file line numberDiff line numberDiff line change
@@ -2234,6 +2234,14 @@ export async function ncc_https_proxy_agent(task, opts) {
22342234
.target('src/compiled/https-proxy-agent')
22352235
}
22362236

2237+
externals['@typescript/vfs'] = 'next/dist/compiled/@typescript/vfs'
2238+
export async function ncc_typescript_vfs(task, opts) {
2239+
await task
2240+
.source(relative(__dirname, require.resolve('@typescript/vfs')))
2241+
.ncc({ packageName: '@typescript/vfs', externals })
2242+
.target('src/compiled/@typescript/vfs')
2243+
}
2244+
22372245
export async function precompile(task, opts) {
22382246
await task.parallel(
22392247
['browser_polyfills', 'copy_ncced', 'copy_styled_jsx_assets'],
@@ -2381,6 +2389,7 @@ export async function ncc(task, opts) {
23812389
'ncc_opentelemetry_api',
23822390
'ncc_http_proxy_agent',
23832391
'ncc_https_proxy_agent',
2392+
'ncc_typescript_vfs',
23842393
'ncc_mini_css_extract_plugin',
23852394
],
23862395
opts

0 commit comments

Comments
 (0)