diff --git a/.cspell.json b/.cspell.json index 1a754cf9b96d..a994be7d5639 100644 --- a/.cspell.json +++ b/.cspell.json @@ -88,6 +88,7 @@ "esquery", "esrecurse", "estree", + "extrafileextensions", "falsiness", "globby", "IDE's", @@ -120,6 +121,7 @@ "preact", "Premade", "prettier's", + "projectservice", "quasis", "Quickstart", "recurse", diff --git a/docs/packages/Parser.mdx b/docs/packages/Parser.mdx index c337e5655967..cf8ac653ac30 100644 --- a/docs/packages/Parser.mdx +++ b/docs/packages/Parser.mdx @@ -152,6 +152,10 @@ This option allows you to provide one or more additional file extensions which s The default extensions are `['.js', '.mjs', '.cjs', '.jsx', '.ts', '.mts', '.cts', '.tsx']`. Add extensions starting with `.`, followed by the file extension. E.g. for a `.vue` file use `"extraFileExtensions": [".vue"]`. +:::note +See [Changes to `extraFileExtensions` with `projectService`](../troubleshooting/Performance.mdx#changes-to-extrafileextensions-with-projectservice) to avoid performance issues. +::: + ### `jsDocParsingMode` > Default if `parserOptions.project` is set, then `'all'`, otherwise `'none'` diff --git a/docs/packages/TypeScript_ESTree.mdx b/docs/packages/TypeScript_ESTree.mdx index 1ac35da35cd5..7e70ed1624c8 100644 --- a/docs/packages/TypeScript_ESTree.mdx +++ b/docs/packages/TypeScript_ESTree.mdx @@ -203,6 +203,8 @@ interface ParseAndGenerateServicesOptions extends ParseOptions { /** * When `project` is provided, this controls the non-standard file extensions which will be parsed. * It accepts an array of file extensions, each preceded by a `.`. + * + * NOTE: When used with {@link projectService}, full project reloads may occur. */ extraFileExtensions?: string[]; diff --git a/docs/troubleshooting/FAQ.mdx b/docs/troubleshooting/FAQ.mdx index 164ee17c32bc..8a8b3af7feb1 100644 --- a/docs/troubleshooting/FAQ.mdx +++ b/docs/troubleshooting/FAQ.mdx @@ -243,6 +243,10 @@ We don't recommend using `--cache`. You can use `parserOptions.extraFileExtensions` to specify an array of non-TypeScript extensions to allow, for example: +:::note +See [Changes to `extraFileExtensions` with `projectService`](../troubleshooting/Performance.mdx#changes-to-extrafileextensions-with-projectservice) to avoid performance issues. +::: + diff --git a/docs/troubleshooting/Performance.mdx b/docs/troubleshooting/Performance.mdx index 71a63da234b2..68cafa2b3253 100644 --- a/docs/troubleshooting/Performance.mdx +++ b/docs/troubleshooting/Performance.mdx @@ -79,6 +79,106 @@ module.exports = { See [Glob pattern in parser's option "project" slows down linting](https://github.com/typescript-eslint/typescript-eslint/issues/2611) for more details. +## Changes to `extraFileExtensions` with `projectService` + +Using a different [`extraFileExtensions`](../packages/Parser.mdx#extrafileextensions) between files in the same project with +the [`projectService`](../packages/Parser.mdx#projectservice) option may cause performance degradations. +For every file linted, we update the `projectService` whenever `extraFileExtensions` changes. +This causes the underlying TypeScript server to perform a full project reload. + + + + +```js title="eslint.config.js" +// @ts-check + +import tseslint from 'typescript-eslint'; +import vueParser from 'vue-eslint-parser'; + +// Add this line +const extraFileExtensions = ['.vue']; +export default [ + { + files: ['*.ts'], + languageOptions: { + parser: tseslint.parser, + parserOptions: { + projectService: true, + // Add this line + extraFileExtensions, + }, + }, + }, + { + files: ['*.vue'], + languageOptions: { + parser: vueParser, + parserOptions: { + projectService: true, + parser: tseslint.parser, + // Remove this line + extraFileExtensions: ['.vue'], + // Add this line + extraFileExtensions, + }, + }, + }, +]; +``` + + + + +```js title=".eslintrc.js" +// Add this line +const extraFileExtensions = ['.vue']; +module.exports = { + files: ['*.ts'], + parser: '@typescript-eslint/parser', + parserOptions: { + projectService: true, + // Add this line + extraFileExtensions, + }, + overrides: [ + { + files: ['*.vue'], + parser: 'vue-eslint-parser', + parserOptions: { + parser: '@typescript-eslint/parser', + projectService: true, + // Remove this line + extraFileExtensions: ['.vue'], + // Add this line + extraFileExtensions, + }, + }, + ], +}; +``` + + + + +Project reloads can be observed using the [debug environment variable](../packages/typescript-estree/#debugging): `DEBUG='typescript-eslint:typescript-estree:*'`. + +``` +typescript-estree:useProgramFromProjectService Updating extra file extensions: before=[]: after=[ '.vue' ] +typescript-estree:tsserver:info reload projects. +typescript-estree:useProgramFromProjectService Extra file extensions updated: [ '.vue' ] +... +typescript-estree:useProgramFromProjectService Updating extra file extensions: before=[ '.vue' ]: after=[] +typescript-estree:tsserver:info reload projects. +typescript-estree:useProgramFromProjectService Extra file extensions updated: [] +... +typescript-estree:tsserver:info Scheduled: /path/to/tsconfig.src.json, Cancelled earlier one +0ms +typescript-estree:tsserver:info Scheduled: *ensureProjectForOpenFiles*, Cancelled earlier one +0ms +... +typescript-estree:useProgramFromProjectService Updating extra file extensions: before=[]: after=[ '.vue' ] +typescript-estree:tsserver:info reload projects. +typescript-estree:useProgramFromProjectService Extra file extensions updated: [ '.vue' ] +``` + ## The `indent` / `@typescript-eslint/indent` rules This rule helps ensure your codebase follows a consistent indentation pattern. diff --git a/packages/typescript-estree/src/parser-options.ts b/packages/typescript-estree/src/parser-options.ts index b3f7794133d4..8b4496526d5c 100644 --- a/packages/typescript-estree/src/parser-options.ts +++ b/packages/typescript-estree/src/parser-options.ts @@ -151,6 +151,8 @@ interface ParseAndGenerateServicesOptions extends ParseOptions { /** * When `project` is provided, this controls the non-standard file extensions which will be parsed. * It accepts an array of file extensions, each preceded by a `.`. + * + * NOTE: When used with {@link projectService}, full project reloads may occur. */ extraFileExtensions?: string[]; diff --git a/packages/typescript-estree/src/useProgramFromProjectService.ts b/packages/typescript-estree/src/useProgramFromProjectService.ts index b0606902b079..516ad0dbcdb5 100644 --- a/packages/typescript-estree/src/useProgramFromProjectService.ts +++ b/packages/typescript-estree/src/useProgramFromProjectService.ts @@ -1,7 +1,9 @@ +import path from 'node:path'; +import util from 'node:util'; + import debug from 'debug'; import { minimatch } from 'minimatch'; -import path from 'path'; -import { ScriptKind } from 'typescript'; +import * as ts from 'typescript'; import { createProjectProgram } from './create-program/createProjectProgram'; import type { ProjectServiceSettings } from './create-program/createProjectService'; @@ -13,6 +15,33 @@ const log = debug( 'typescript-eslint:typescript-estree:useProgramFromProjectService', ); +const serviceFileExtensions = new WeakMap(); + +const updateExtraFileExtensions = ( + service: ts.server.ProjectService, + extraFileExtensions: string[], +): void => { + const currentServiceFileExtensions = serviceFileExtensions.get(service) ?? []; + if ( + !util.isDeepStrictEqual(currentServiceFileExtensions, extraFileExtensions) + ) { + log( + 'Updating extra file extensions: before=%s: after=%s', + currentServiceFileExtensions, + extraFileExtensions, + ); + service.setHostConfiguration({ + extraFileExtensions: extraFileExtensions.map(extension => ({ + extension, + isMixedContent: false, + scriptKind: ts.ScriptKind.Deferred, + })), + }); + serviceFileExtensions.set(service, extraFileExtensions); + log('Extra file extensions updated: %o', extraFileExtensions); + } +}; + export function useProgramFromProjectService( { allowDefaultProject, @@ -23,6 +52,9 @@ export function useProgramFromProjectService( hasFullTypeInformation: boolean, defaultProjectMatchedFiles: Set, ): ASTAndDefiniteProgram | undefined { + // NOTE: triggers a full project reload when changes are detected + updateExtraFileExtensions(service, parseSettings.extraFileExtensions); + // We don't canonicalize the filename because it caused a performance regression. // See https://github.com/typescript-eslint/typescript-eslint/issues/8519 const filePathAbsolute = absolutify(parseSettings.filePath); @@ -32,16 +64,6 @@ export function useProgramFromProjectService( filePathAbsolute, ); - if (parseSettings.extraFileExtensions.length) { - service.setHostConfiguration({ - extraFileExtensions: parseSettings.extraFileExtensions.map(extension => ({ - extension, - isMixedContent: false, - scriptKind: ScriptKind.Deferred, - })), - }); - } - const opened = service.openClientFile( filePathAbsolute, parseSettings.codeFullText, diff --git a/packages/typescript-estree/tests/lib/useProgramFromProjectService.test.ts b/packages/typescript-estree/tests/lib/useProgramFromProjectService.test.ts index 3ca8ae53aea4..b368355a2c04 100644 --- a/packages/typescript-estree/tests/lib/useProgramFromProjectService.test.ts +++ b/packages/typescript-estree/tests/lib/useProgramFromProjectService.test.ts @@ -302,7 +302,7 @@ If you absolutely need more files included, set parserOptions.projectService.max expect(actual).toBe(program); }); - it('does not call setHostConfiguration if extraFileExtensions are not provided', () => { + it('does not call setHostConfiguration on the service with default extensions if extraFileExtensions are not provided', () => { const { service } = createMockProjectService(); useProgramFromProjectService( @@ -318,7 +318,7 @@ If you absolutely need more files included, set parserOptions.projectService.max expect(service.setHostConfiguration).not.toHaveBeenCalled(); }); - it('calls setHostConfiguration on the service to use extraFileExtensions when it is provided', () => { + it('does not call setHostConfiguration on the service with default extensions if extraFileExtensions is empty', () => { const { service } = createMockProjectService(); useProgramFromProjectService( @@ -326,6 +326,93 @@ If you absolutely need more files included, set parserOptions.projectService.max allowDefaultProject: [mockParseSettings.filePath], service, }), + { + ...mockParseSettings, + extraFileExtensions: [], + }, + false, + new Set(), + ); + + expect(service.setHostConfiguration).not.toHaveBeenCalled(); + }); + + it('calls setHostConfiguration on the service with default extensions to use extraFileExtensions when it is provided', () => { + const { service } = createMockProjectService(); + + useProgramFromProjectService( + createProjectServiceSettings({ + allowDefaultProject: [mockParseSettings.filePath], + service, + }), + { + ...mockParseSettings, + extraFileExtensions: ['.vue'], + }, + false, + new Set(), + ); + + expect(service.setHostConfiguration).toHaveBeenCalledWith({ + extraFileExtensions: [ + { + extension: '.vue', + isMixedContent: false, + scriptKind: ScriptKind.Deferred, + }, + ], + }); + }); + + it('does not call setHostConfiguration on the service to use extraFileExtensions when unchanged', () => { + const { service } = createMockProjectService(); + const settings = createProjectServiceSettings({ + allowDefaultProject: [mockParseSettings.filePath], + service, + }); + + useProgramFromProjectService( + settings, + { + ...mockParseSettings, + extraFileExtensions: ['.vue'], + }, + false, + new Set(), + ); + + expect(service.setHostConfiguration).toHaveBeenCalledTimes(1); + expect(service.setHostConfiguration).toHaveBeenCalledWith({ + extraFileExtensions: [ + { + extension: '.vue', + isMixedContent: false, + scriptKind: ScriptKind.Deferred, + }, + ], + }); + + useProgramFromProjectService( + settings, + { + ...mockParseSettings, + extraFileExtensions: ['.vue'], + }, + false, + new Set(), + ); + expect(service.setHostConfiguration).toHaveBeenCalledTimes(1); + }); + + it('calls setHostConfiguration on the service to use extraFileExtensions when changed', () => { + const { service } = createMockProjectService(); + const settings = createProjectServiceSettings({ + allowDefaultProject: [mockParseSettings.filePath], + service, + }); + + useProgramFromProjectService( + settings, { ...mockParseSettings, extraFileExtensions: ['.vue'], @@ -334,6 +421,7 @@ If you absolutely need more files included, set parserOptions.projectService.max new Set(), ); + expect(service.setHostConfiguration).toHaveBeenCalledTimes(1); expect(service.setHostConfiguration).toHaveBeenCalledWith({ extraFileExtensions: [ { @@ -343,5 +431,56 @@ If you absolutely need more files included, set parserOptions.projectService.max }, ], }); + + useProgramFromProjectService( + settings, + { + ...mockParseSettings, + extraFileExtensions: [], + }, + false, + new Set(), + ); + + expect(service.setHostConfiguration).toHaveBeenCalledTimes(2); + expect(service.setHostConfiguration).toHaveBeenCalledWith({ + extraFileExtensions: [], + }); + }); + + it('calls setHostConfiguration on the service with non-default extensions to use defaults when extraFileExtensions are not provided', () => { + const { service } = createMockProjectService(); + const settings = createProjectServiceSettings({ + allowDefaultProject: [mockParseSettings.filePath], + service, + }); + + useProgramFromProjectService( + settings, + { + ...mockParseSettings, + extraFileExtensions: ['.vue'], + }, + false, + new Set(), + ); + + expect(service.setHostConfiguration).toHaveBeenCalledTimes(1); + expect(service.setHostConfiguration).toHaveBeenCalledWith({ + extraFileExtensions: [ + { + extension: '.vue', + isMixedContent: false, + scriptKind: ScriptKind.Deferred, + }, + ], + }); + + useProgramFromProjectService(settings, mockParseSettings, false, new Set()); + + expect(service.setHostConfiguration).toHaveBeenCalledTimes(2); + expect(service.setHostConfiguration).toHaveBeenCalledWith({ + extraFileExtensions: [], + }); }); });