Skip to content

Support TypeScript 7.1 API - #1704

Draft
johnnyreilly with Copilot wants to merge 111 commits into
mainfrom
copilot/implement-new-tsgo-api-support
Draft

Support TypeScript 7.1 API#1704
johnnyreilly with Copilot wants to merge 111 commits into
mainfrom
copilot/implement-new-tsgo-api-support

Conversation

Copilot AI commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Copilot didn't write this - I did! The below can also be found in the CHANGELOG.md.

This is a ground-up rewrite of ts-loader's compilation engine. Instead of driving TypeScript's classic LanguageService / Program / watch APIs, ts-loader now compiles exclusively through TypeScript's new native typescript/unstable/sync API (the tsgo-powered engine) - the legacy compiler API integration has been removed entirely.

Not supported yet

  • getCustomTransformers, resolveModuleName and resolveTypeReferenceDirective are still accepted for backwards compatibility but are now inert - the native API doesn't expose equivalent extension points, so custom transformers and custom module/type-reference resolution are no longer applied. It is possible that the API will support these in future, and so the options have been left in place for now, but they will be removed if the API never exposes them.
  • Project references are not yet supported under the native API. If / when support is added, ts-loader will need to be updated to support it.

Breaking changes:

  • Minimum supported TypeScript version is now 7.1+ (up from 3.6.3+). ts-loader currently depends on a TypeScript next prerelease that exposes this native API ahead of a stable 7.1 release.
  • Minimum supported Node.js version is now 22.x+ (up from 12.x+).
  • The compiler option must now resolve to a package exposing a <compiler>/unstable/sync entry point (the TypeScript native API). Drop-in classic-API compilers (e.g. ttypescript) are no longer supported.
  • Removed the compilerOptions loader option; the native API resolves a project's compiler options purely from its on-disk tsconfig.json, with no per-loader-instance override hook. Set compiler options in tsconfig.json instead.
  • Removed the context loader option; the native API always resolves relative paths against the config file's own directory, with no basePath override exposed to let a tsconfig live outside the project root.
  • Removed the happyPackMode loader option - HappyPack / thread-loader based parallelisation is no longer supported this way.
  • Removed the experimentalFileCaching loader option; there's no equivalent under the native API's own caching model.
  • Removed the experimentalWatchApi loader option, now that the native API supersedes TypeScript's classic watch API.
  • Removed the onlyCompileBundledFiles loader option; the native API always resolves a project's own root files and offers no hook to restrict them to what webpack actually bundles.
  • errorFormatter's colors argument is now a small picocolors-backed helper object instead of a chalk instance; chalk has been dropped as a runtime dependency in favour of picocolors.

@johnnyreilly

Copy link
Copy Markdown
Member

@copilot update

ts: [5.6.2, 5.7.2, 5.8.2, 5.9.2, 6.0.2] # next excluded for now
and
ts: [5.6.2, 5.7.2, 5.8.2, 5.9.2, 6.0.2] # next excluded for now
to only cater for: 7.1.0-dev.20260725.1 - the existing versions specified do not contain the new API.

We expect execution-tests to pass after this change

Copilot AI commented Jul 26, 2026

Copy link
Copy Markdown
Contributor Author

@copilot update

ts: [5.6.2, 5.7.2, 5.8.2, 5.9.2, 6.0.2] # next excluded for now
and https://github.com/TypeStrong/ts-loader/blob/53082fbe1cdeb560b6...

Updated in fcc8a21 to run the execution-test matrix only against 7.1.0-dev.20260725.1. I also verified the native path locally with yarn build && yarn execution-tests -- --single-test nativeApiTranspile.

@johnnyreilly

Copy link
Copy Markdown
Member

@copilot great work! We would like the following to succeed:

yarn build && yarn execution-tests

Remember backwards compatibility is not required - feel free to make changes

Copilot AI commented Jul 26, 2026

Copy link
Copy Markdown
Contributor Author

@copilot great work! We would like the following to succeed:

yarn build && yarn execution-tests
...

Verified on fcc8a21: yarn build && yarn execution-tests now succeeds locally (30 execution suites passed).

@johnnyreilly

Copy link
Copy Markdown
Member

@copilot we have explictly removed the historic typescript version from dependencies and moved to use 7.1 directly. This has broken compilation. Please fix it. You will likely need to use new APIs in TypeScript 7 to achieve this. No cheating.

Copilot AI commented Jul 27, 2026

Copy link
Copy Markdown
Contributor Author

@copilot we have explictly removed the historic typescript version from dependencies and moved to use 7.1 directly. This has broken compilation. Please fix it. You will likely need to use new APIs i...

Updated in 4706cfe. yarn build now passes against direct TypeScript 7 by routing the loader through typescript/unstable/sync instead of the removed legacy compiler API.

@johnnyreilly

Copy link
Copy Markdown
Member

@copilot make experimentalNativeApi: true the default behaviour and ensure execution-tests pass. We are happy to ignore loaderOptions test for now

Copilot AI commented Jul 27, 2026

Copy link
Copy Markdown
Contributor Author

@copilot make experimentalNativeApi: true the default behaviour and ensure execution-tests pass. We are happy to ignore loaderOptions test for now

Addressed in 048c5ce. The native execution test now relies on the default native API path, loaderOptions is skipped for now, and yarn build && yarn execution-tests passes locally.

johnnyreilly and others added 30 commits August 29, 2026 15:47
The tsgo-backed native API resolves compiler options purely from the
on-disk tsconfig.json, with no per-loader-instance override hook, so
compilerOptions has been inert since the 10.0.0 rewrite. Remove it as
a valid loader option (matching how onlyCompileBundledFiles/context
were removed) instead of leaving it silently accepted but ignored.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…t removal

The comparison-test harness injected compilerOptions: { newLine: 'LF' }
into every test's loader options via its own default options object,
independent of each fixture's own webpack.config.js. Removing
compilerOptions as a valid loader option meant that default had to go
too (test/comparison-tests/create-and-execute-test.js), which shifts
the computed options-hash for every test that doesn't set an explicit
instance name. A number of snapshots embed that hash literally as
ts-loader-default_<hash>; regenerate them via --save-output.

Verified the full diff contains only options-hash changes, dist/index.js
stack-line normalization noise, and the couple-byte bundle size shift
from the shortened validateLoaderOptionNames error message - no actual
compiled-output changes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
relativeSpecifierExtensions (used by resolveRelativeSpecifier to match
a relative import specifier against the program's source files for
webpack dependency registration and transitive-dependant rechecking)
only tried .ts/.tsx/.d.ts/.js/.jsx, unlike the neighbouring
toDeclarationFileName which already handles .mts/.cts. A relative
import resolving to a .mts/.cts file was silently treated as
unresolved, so editing it neither registered a webpack dependency on
the importer nor triggered a diagnostics recheck of it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
updateSnapshot passed { invalidateAll: true } unconditionally on every
single file compiled, forcing the native API to discard its caches and
rescan disk once per file rather than once per build - real watch-mode
wiring (a hook tied to webpack's own build/rebuild boundary) didn't
exist anywhere in the tsgo rewrite.

Add TypeScriptInstance.pendingInvalidation, consumed by the first
updateSnapshot call of a build and re-armed via a compiler.hooks.compile
tap (fires once per build/watch-rebuild in both webpack 4 and 5), so
only that first call forces the rescan; every other file compiled in
the same build reuses the snapshot it already refreshed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…heck

findTransitiveDependants rebuilt a fresh O(n) Set of every project
file's name inside getDirectResolvedImports on every single call, and
called it once per project file for every file compiled - roughly
O(n^3) for a full build. The currently-compiling file's own imports
were also recomputed a second time (once via
registerResolvedImportDependencies, again inside the BFS).

- comparableSourceFileNames is now built once per file compiled
  (passed down to callers) instead of once per candidate file inside
  the BFS.
- Each project file's resolved-imports list is memoized in a new
  TypeScriptInstance.directImportsCache, reused across every file
  compiled in the same build via getCachedDirectResolvedImports. The
  currently-compiling file always recomputes fresh (never trusts a
  stale entry) and writes its result back, so other files' dependant
  searches - and the file's own subsequent BFS lookup - see it too,
  incidentally also fixing the double computation.
- The cache is cleared whenever pendingInvalidation forces a real
  rescan (see the prior watch-mode fix), keeping it correct across
  builds.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
registerResolvedImportDependencies wrote cache entries keyed by
apiFileName (OS-native, from webpack), while
findTransitiveDependants/getCachedDirectResolvedImports read/wrote
entries keyed by program.getSourceFileNames() (forward-slash-
normalized). On Windows those are different strings for the same
file, so an entry written by one path could never be found by the
other - silently defeating the memoization exactly where it mattered
(the currently-compiled file's own entry).

Key by FilePathKey via each instance's ResolvedPathCache instead,
matching how instance.files/pendingDiagnostics/pendingDeclarationFiles
already canonicalize path spelling and case-sensitivity.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
registerTypeScriptDependencies iterated program.getSourceFileNames()
(every file in the program) on every single compiled file just to
find .d.ts files to register as dependencies - O(n) per file, O(n^2)
over a build, even though that set only changes when the program's
file set actually does.

Add getProjectDtsFileNames, memoized per project (primary or a
synthetic one-off project for an orphan file) in a new
TypeScriptInstance.projectDtsFileNamesCache, cleared alongside
directImportsCache whenever pendingInvalidation forces a real rescan.
Keyed by FilePathKey (via ResolvedPathCache) for consistency with
directImportsCache, though project config paths are already stable,
single-sourced strings in this codebase.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
ensureSyntheticConfigForFile created and permanently tracked one
synthetic tsconfig + open project per distinct orphan file (e.g. every
allowTsInNodeModules file ever compiled), with no eviction - each held
its own ref-counted project open on the API side, so a long watch
session touching many distinct orphan files leaked memory/state
proportional to all of them, unbounded.

Cap it at 20 (maxOrphanFileProjects), evicting the least-recently-used
entry - tracked via Map insertion order, bumped on reuse - once the cap
is hit. The evicted project's closeProjects is threaded through
updateSnapshot to actually release its ref-counted open on the API
side, and removed from openedProjectPaths so revisiting that file later
reopens it fresh rather than treating it as still-open.

Verified with a standalone scratch reproduction (22 distinct orphan
files under allowTsInNodeModules): exactly 2 evictions occurred against
the cap of 20, the build succeeded with no errors, and editing a
previously-evicted file was correctly picked up on the next rebuild.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Two real bugs, same class as the earlier directImportsCache fix:

- syntheticConfigContents's readFile/fileExists lookups used
  toComparablePath (slash-only normalization), while the sibling
  files Map right next to it already used resolvedPathCache (slash
  *and* case normalization) - a case-insensitive-filesystem spelling
  mismatch would silently miss.
- syntheticConfigFiles was keyed by raw fileName. Two importers
  spelling/casing the same orphan file differently would be treated
  as distinct files, each spawning its own redundant synthetic
  project - undermining the LRU cap added previously.

configFilePath and openedProjectPaths are converted too for
consistency, though their values were already single-sourced and
stable in practice.

Added TypeScriptInstance.resolvedPathCache so functions that only
receive TypeScriptInstance (not the outer TSInstance) - openPrimaryProject,
prepareSnapshotForFile, ensureSyntheticConfigForFile, updateSnapshot -
can canonicalize a path without threading it through as a separate
parameter.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Use interface instead of type for the plain object shape
  TypeScriptApiModule, matching AGENTS.md's convention.
- Extract reportConfigFileParsingErrors, deduplicating the ~20-line
  broken-tsconfig error-reporting block shared by getTypeScriptEmit
  and getTranspileOnlyEmit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
TypeScriptInstance.resolvedFilePathCache duplicated the exact same
function reference already stored on the owning TSInstance's own
resolvedPathCache field.

Drop the stored field; thread it as an explicit parameter through
prepareSnapshotForFile/ensureSyntheticConfigForFile instead, sourced
from instance.resolvedPathCache at the getTypeScriptEmit call site -
matching the convention already used by getProjectDtsFileNames,
registerResolvedImportDependencies, getCachedDirectResolvedImports,
and findTransitiveDependants.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants