Filed by Claude (Anthropic's Claude Code) from Peter Wagenet's account, while investigating a lint slowdown in a large Ember app. The analysis, repro and patch below are Claude's work, not Peter's.
🔎 Search Terms
maxProgramSizeForNonTsFiles, extraFileExtensions, getSupportedExtensions, ScriptKind.Deferred, disableLanguageService, getFilenameForExceededTotalSizeLimitForNonTsFiles
🕗 Version & Regression Information
Reproduced on 5.3.3, 5.7.2 and 5.9.x. Not a regression — this is how the size heuristic has always classified files.
⏯ Playground Link
Not applicable; needs the ts.server.ProjectService API. Standalone repro below.
💻 Code
// npm i typescript && node repro.mjs
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const ts = require('typescript/lib/tsserverlibrary');
// 21MB of .gts -- TypeScript with a template literal element in it, handled by
// a host that transforms the file before TypeScript parses it.
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ts-extfileext-'));
const app = path.join(dir, 'app');
fs.mkdirSync(app, { recursive: true });
const filler = `/*${'x'.repeat(1024 * 1024)}*/\n`;
for (let i = 0; i < 21; i++) {
fs.writeFileSync(path.join(app, `bulk${i}.gts`), `${filler}export const bulk${i}: number = ${i};\n`);
}
fs.writeFileSync(path.join(app, 'entry.ts'), 'export const entry = 1;\n');
fs.writeFileSync(
path.join(dir, 'tsconfig.json'),
JSON.stringify({ compilerOptions: { noEmit: true, skipLibCheck: true }, include: ['app/**/*'] })
);
const doNothing = () => {};
const stubWatcher = () => ({ close: doNothing });
function run(scriptKind) {
const service = new ts.server.ProjectService({
cancellationToken: { isCancellationRequested: () => false },
host: {
...ts.sys, clearImmediate, clearTimeout, setImmediate, setTimeout,
watchDirectory: stubWatcher, watchFile: stubWatcher,
require: () => ({ error: { message: 'no plugins' }, module: undefined }),
},
logger: {
close: doNothing, endGroup: doNothing, getLogFileName: () => undefined,
hasLevel: () => false, info: doNothing, loggingEnabled: () => false,
msg: doNothing, perftrc: doNothing, startGroup: doNothing,
},
session: undefined,
useInferredProjectPerProjectRoot: false,
useSingleInferredProject: false,
});
service.setHostConfiguration({
extraFileExtensions: [{ extension: '.gts', isMixedContent: false, scriptKind }],
});
const probe = path.join(app, 'bulk0.gts');
service.openClientFile(probe, fs.readFileSync(probe, 'utf8'), undefined, dir);
const project = [...service.configuredProjects.values()][0];
return {
registeredAs: ts.ScriptKind[scriptKind],
configuredRoots: project.getRootFiles().length,
gtsMatched: project.getRootFiles().filter((f) => f.endsWith('.gts')).length,
languageServiceEnabled: project.languageServiceEnabled,
programRoots: project.getScriptFileNames().length,
tippedOverBy: path.basename(project.lastFileExceededProgramSize ?? '') || '-',
};
}
console.log(`typescript ${ts.version}`);
console.table([run(ts.ScriptKind.Deferred), run(ts.ScriptKind.TS)]);
fs.rmSync(dir, { recursive: true, force: true });
🙁 Actual behavior
typescript 5.7.2
┌──────────────┬─────────────────┬────────────┬────────────────────────┬──────────────┬──────────────┐
│ registeredAs │ configuredRoots │ gtsMatched │ languageServiceEnabled │ programRoots │ tippedOverBy │
├──────────────┼─────────────────┼────────────┼────────────────────────┼──────────────┼──────────────┤
│ 'Deferred' │ 22 │ 21 │ false │ 1 │ 'bulk8.gts' │
│ 'TS' │ 1 │ 0 │ true │ 1 │ '-' │
└──────────────┴─────────────────┴────────────┴────────────────────────┴──────────────┴──────────────┘
Neither registration gives a usable project, and there is no third option.
Registered as Deferred — the extension is matched, but every .gts file is charged to maxProgramSizeForNonTsFiles, because getFilenameForExceededTotalSizeLimitForNonTsFiles classifies purely on hasTSFileExtension(fileName):
for (const f of fileNames) {
const fileName = propertyReader.getFileName(f);
if (hasTSFileExtension(fileName)) {
continue;
}
totalNonTsFileSize += this.host.getFileSize(fileName);
...
}
Past 20MB the project is demoted, and Project#getScriptFileNames then yields only files the client has open. It never asks propertyReader.getScriptKind(f, this.hostConfiguration.extraFileExtensions), even though it holds both the reader and the configuration and uses exactly that call a few hundred lines away in addFilesToNonInferredProject.
Registered as ScriptKind.TS — the size problem goes away because the files are gone. getSupportedExtensions accepts an extra extension only when it is Deferred, or JS-like with allowJs:
const extensions = [
...builtins,
...mapDefined(extraFileExtensions, x =>
x.scriptKind === ScriptKind.Deferred ||
(needJsExtensions && isJSLike(x.scriptKind) && !flatBuiltins.includes(x.extension))
? [x.extension] : undefined),
];
A TS-like scriptKind falls through to undefined and the extension is silently dropped, so include never matches those files.
🙂 Expected behavior
ExtraFileExtension.scriptKind is typed to accept ScriptKind.TS / ScriptKind.TSX, so a host should be able to say "this extension is TypeScript" and have both the file matching and the size heuristic believe it.
Why this matters
.gts is the file format for Ember's template imports: TypeScript with a <template> element, transformed by the host before TypeScript sees it. It is TypeScript, and today it is weighed against a budget named "non-TS files" whose purpose is to stop the language service choking on large bundled JavaScript.
The effect on a real app (12,735 linted files) going through @typescript-eslint's parserOptions.projectService, which opens one file at a time: against a demoted project every file is new to the program, so every file rebuilds it. 358 new ts.Program instances per 400 files linted; ~230ms/file against ~58ms/file. Setting disableSizeLimit fixes it entirely.
It isn't only a slowdown. A demoted program holds the open file and its imports and nothing else, so ambient declarations nothing imports — declare global, module augmentation, standalone .d.ts — leave the program. In a fixture identical but for total .gts bytes:
|
under 20MB |
over 20MB |
AMBIENT_FLAG (from declare global) |
string |
any |
window.ambientRegistry |
AmbientRegistry |
any |
| semantic diagnostics |
none |
Cannot find name, Property does not exist on type 'Window & typeof globalThis' |
Any lint rule that branches on any reports differently once a project crosses 20MB. Ember apps lean heavily on registry augmentation, so this is a live correctness problem, not a theoretical one.
The same reasoning applies to any host with a TypeScript-flavoured extension — this is not Ember-specific.
Suggested fix
Two hunks. I've applied both to a local 5.7.2 build and confirmed the repro reports languageServiceEnabled: true with all 21 .gts files as roots.
src/compiler/utilities.ts, getSupportedExtensions — accept TS-like extra extensions:
+function isTSLike(scriptKind: ScriptKind | undefined) {
+ return scriptKind === ScriptKind.TS || scriptKind === ScriptKind.TSX;
+}
+
const extensions = [
...builtins,
...mapDefined(extraFileExtensions, x =>
x.scriptKind === ScriptKind.Deferred ||
+ (isTSLike(x.scriptKind) && !flatBuiltins.includes(x.extension)) ||
(needJsExtensions && isJSLike(x.scriptKind) && !flatBuiltins.includes(x.extension))
? [x.extension] : undefined),
];
src/server/editorServices.ts, getFilenameForExceededTotalSizeLimitForNonTsFiles — don't charge them:
for (const f of fileNames) {
const fileName = propertyReader.getFileName(f);
if (hasTSFileExtension(fileName)) {
continue;
}
+ // An extra file extension the host registered as TypeScript is
+ // TypeScript, whatever the extension itself looks like.
+ const scriptKind = propertyReader.getScriptKind(f, this.hostConfiguration.extraFileExtensions);
+ if (scriptKind === ScriptKind.TS || scriptKind === ScriptKind.TSX) {
+ continue;
+ }
totalNonTsFileSize += this.host.getFileSize(fileName);
Deferred extensions keep their current treatment on purpose. Ember's .gjs is JavaScript with a template and should keep counting against a JavaScript budget; only the extensions a host explicitly calls TypeScript are exempted.
The two hunks depend on each other: without the first, a host can't register TS at all, so the second never fires. The first is also the one with real blast radius, since adding an extension to the supported set reaches module resolution and output path derivation too.
The narrower alternative is to leave getSupportedExtensions alone and have the size check skip Deferred extensions instead — arguably defensible on its own terms, since TypeScript doesn't parse a deferred file and its on-disk size is a poor proxy for the work TypeScript will do. But that exempts every deferred extension, including ones that really are JavaScript, so .gts and .gjs become indistinguishable. I'd rather have both hunks and keep the classification honest.
Happy to open a PR with tests if the direction looks right.
🔎 Search Terms
maxProgramSizeForNonTsFiles,extraFileExtensions,getSupportedExtensions,ScriptKind.Deferred,disableLanguageService,getFilenameForExceededTotalSizeLimitForNonTsFiles🕗 Version & Regression Information
Reproduced on 5.3.3, 5.7.2 and 5.9.x. Not a regression — this is how the size heuristic has always classified files.
⏯ Playground Link
Not applicable; needs the
ts.server.ProjectServiceAPI. Standalone repro below.💻 Code
🙁 Actual behavior
Neither registration gives a usable project, and there is no third option.
Registered as
Deferred— the extension is matched, but every.gtsfile is charged tomaxProgramSizeForNonTsFiles, becausegetFilenameForExceededTotalSizeLimitForNonTsFilesclassifies purely onhasTSFileExtension(fileName):Past 20MB the project is demoted, and
Project#getScriptFileNamesthen yields only files the client has open. It never askspropertyReader.getScriptKind(f, this.hostConfiguration.extraFileExtensions), even though it holds both the reader and the configuration and uses exactly that call a few hundred lines away inaddFilesToNonInferredProject.Registered as
ScriptKind.TS— the size problem goes away because the files are gone.getSupportedExtensionsaccepts an extra extension only when it isDeferred, or JS-like withallowJs:A TS-like
scriptKindfalls through toundefinedand the extension is silently dropped, soincludenever matches those files.🙂 Expected behavior
ExtraFileExtension.scriptKindis typed to acceptScriptKind.TS/ScriptKind.TSX, so a host should be able to say "this extension is TypeScript" and have both the file matching and the size heuristic believe it.Why this matters
.gtsis the file format for Ember's template imports: TypeScript with a<template>element, transformed by the host before TypeScript sees it. It is TypeScript, and today it is weighed against a budget named "non-TS files" whose purpose is to stop the language service choking on large bundled JavaScript.The effect on a real app (12,735 linted files) going through
@typescript-eslint'sparserOptions.projectService, which opens one file at a time: against a demoted project every file is new to the program, so every file rebuilds it. 358 newts.Programinstances per 400 files linted; ~230ms/file against ~58ms/file. SettingdisableSizeLimitfixes it entirely.It isn't only a slowdown. A demoted program holds the open file and its imports and nothing else, so ambient declarations nothing imports —
declare global, module augmentation, standalone.d.ts— leave the program. In a fixture identical but for total.gtsbytes:AMBIENT_FLAG(fromdeclare global)stringanywindow.ambientRegistryAmbientRegistryanyCannot find name,Property does not exist on type 'Window & typeof globalThis'Any lint rule that branches on
anyreports differently once a project crosses 20MB. Ember apps lean heavily on registry augmentation, so this is a live correctness problem, not a theoretical one.The same reasoning applies to any host with a TypeScript-flavoured extension — this is not Ember-specific.
Suggested fix
Two hunks. I've applied both to a local 5.7.2 build and confirmed the repro reports
languageServiceEnabled: truewith all 21.gtsfiles as roots.src/compiler/utilities.ts,getSupportedExtensions— accept TS-like extra extensions:src/server/editorServices.ts,getFilenameForExceededTotalSizeLimitForNonTsFiles— don't charge them:for (const f of fileNames) { const fileName = propertyReader.getFileName(f); if (hasTSFileExtension(fileName)) { continue; } + // An extra file extension the host registered as TypeScript is + // TypeScript, whatever the extension itself looks like. + const scriptKind = propertyReader.getScriptKind(f, this.hostConfiguration.extraFileExtensions); + if (scriptKind === ScriptKind.TS || scriptKind === ScriptKind.TSX) { + continue; + } totalNonTsFileSize += this.host.getFileSize(fileName);Deferred extensions keep their current treatment on purpose. Ember's
.gjsis JavaScript with a template and should keep counting against a JavaScript budget; only the extensions a host explicitly calls TypeScript are exempted.The two hunks depend on each other: without the first, a host can't register
TSat all, so the second never fires. The first is also the one with real blast radius, since adding an extension to the supported set reaches module resolution and output path derivation too.The narrower alternative is to leave
getSupportedExtensionsalone and have the size check skipDeferredextensions instead — arguably defensible on its own terms, since TypeScript doesn't parse a deferred file and its on-disk size is a poor proxy for the work TypeScript will do. But that exempts every deferred extension, including ones that really are JavaScript, so.gtsand.gjsbecome indistinguishable. I'd rather have both hunks and keep the classification honest.Happy to open a PR with tests if the direction looks right.