Skip to content

extraFileExtensions cannot declare an extension as TypeScript, so TS-flavoured files are charged to maxProgramSizeForNonTsFiles #63752

Description

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions