You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
With "moduleSuffixes": [".web", ""], a fully specified import of a content-mapper extension puts the suffix after the extension: import "./card.foo" probes card.foo.web instead of card.web.foo, so it resolves to card.foo. For a built-in extension, import "./target.ts" probes target.web.ts and resolves to it.
git clone https://gist.github.com/leonidaz/a68ed721ae38e2a90eada142074c24f9.git content-mapper-module-suffixes-repro
cd content-mapper-module-suffixes-repro
pnpm install --ignore-scripts
pnpm run setup
pnpm run check # exit 2: TS2322, './card.foo' resolved to card.foo
pnpm run check:standard # exit 0: './target.ts' resolved to target.web.ts
The mapper is the 47-line identity mapper from #64546: .foo files contain TypeScript, returned unchanged as .ts with an identity span map. setup.mjs only writes the local mapper manifest under node_modules. pnpm run check is exactly tsc --runExternalCode --pretty false -p tsconfig.json.
Which of the following problems are you reporting?
The module specifier resolves, but to the wrong file
Demonstrate the defect described above with a code sample.
--showConfig omits contentMappers in this nightly; tsconfig.json registers identity-foo-mapper for [".foo"].
Run tsc --traceResolution and paste its output here
======== Resolving module './card.foo' from '/repro/main.ts'. ========
Explicitly specified module resolution kind: 'Bundler'.
Resolving in CJS mode with conditions 'import', 'types'.
Loading module as file / folder, candidate module location '/repro/card.foo', target file types: TypeScript, JavaScript, Declaration, JSON.
File name '/repro/card.foo' has a '.foo' extension - stripping it.
File '/repro/card.foo.web' does not exist.
File '/repro/card.foo' exists - use it as a name resolution result.
======== Module name './card.foo' was successfully resolved to '/repro/card.foo'. ========
main.ts(2,7): error TS2322: Type '"default"' is not assignable to type '"web"'.
The control, import "./target.ts" from standard.ts with target.ts and target.web.ts:
======== Resolving module './target.ts' from '/repro/standard.ts'. ========
Explicitly specified module resolution kind: 'Bundler'.
Resolving in CJS mode with conditions 'import', 'types'.
Loading module as file / folder, candidate module location '/repro/target.ts', target file types: TypeScript, JavaScript, Declaration, JSON.
File name '/repro/target.ts' has a '.ts' extension - stripping it.
File '/repro/target.web.ts' exists - use it as a name resolution result.
======== Module name './target.ts' was successfully resolved to '/repro/target.web.ts'. ========
Paste the package.json of the importing module, if it exists
Paste the package.json of the target module, if it exists
Same package: card.foo, card.web.foo, target.ts and target.web.ts sit next to the importers.
Any other comments can go here
Reproduced with typescript@7.1.0-dev.20260929.1 (typescript@next on 2026-09-29, gitHead 0681ef7fa3a2378ccf49645b6d5b7463bdca74bb), macOS 26.6.2 x64, Node.js 24.18.0, pnpm 10.33.4. Verified from a fresh clone of the gist.
There is no runtime step in the demo: the expected placement is TypeScript's own, shown by the .ts control and by the moduleSuffixes docs (foo.ios.ts, not foo.ts.ios).
Cause, from main at b85298b: tryFile takes the extension from tspath.TryGetExtensionFromPath, which only knows built-in extensions. For card.foo it returns "", so the suffix lands after the whole file name. With this change, a local build of main passes the demo, the control is unchanged, and go test ./internal/module/... ./internal/modulespecifiers/... ./internal/contentmapper/... passes:
--- a/tsc/internal/module/resolver.go+++ b/tsc/internal/module/resolver.go@@ -1578,6 +1578,11 @@ func (r *resolutionState) tryFile(fileName string) (string, bool) {
}
ext := tspath.TryGetExtensionFromPath(fileName)
+ if ext == "" {+ // Content-mapper extensions are not built in: keep the suffix in front of them+ // (card.web.foo, not card.foo.web).+ ext = tspath.GetLongestExtensionFromPath(fileName, r.resolver.extraExtensions, false)+ }
fileNameNoExtension := tspath.RemoveExtension(fileName, ext)
for _, suffix := range r.compilerOptions.ModuleSuffixes {
path := fileNameNoExtension + suffix + ext
With
"moduleSuffixes": [".web", ""], a fully specified import of a content-mapper extension puts the suffix after the extension:import "./card.foo"probescard.foo.webinstead ofcard.web.foo, so it resolves tocard.foo. For a built-in extension,import "./target.ts"probestarget.web.tsand resolves to it.Demo Repo
https://gist.github.com/leonidaz/a68ed721ae38e2a90eada142074c24f9
The mapper is the 47-line identity mapper from #64546:
.foofiles contain TypeScript, returned unchanged as.tswith an identity span map.setup.mjsonly writes the local mapper manifest undernode_modules.pnpm run checkis exactlytsc --runExternalCode --pretty false -p tsconfig.json.Which of the following problems are you reporting?
The module specifier resolves, but to the wrong file
Demonstrate the defect described above with a code sample.
card.fooexportsplatform = "default",card.web.fooexportsplatform = "web":Run
tsc --showConfigand paste its output here{ "compilerOptions": { "allowImportingTsExtensions": true, "module": "esnext", "moduleResolution": "bundler", "moduleSuffixes": [ ".web", "" ], "noEmit": true, "strict": true, "target": "es2022", "types": [] }, "files": [ "./main.ts" ] }--showConfigomitscontentMappersin this nightly;tsconfig.jsonregistersidentity-foo-mapperfor[".foo"].Run
tsc --traceResolutionand paste its output hereThe control,
import "./target.ts"fromstandard.tswithtarget.tsandtarget.web.ts:Paste the
package.jsonof the importing module, if it exists{ "name": "typescript-content-mapper-module-suffixes-repro", "private": true, "type": "module", "scripts": { "setup": "node setup.mjs", "check": "tsc --runExternalCode --pretty false -p tsconfig.json", "check:standard": "tsc --runExternalCode --pretty false -p tsconfig.standard.json", "trace": "tsc --runExternalCode --pretty false -p tsconfig.json --traceResolution" }, "devDependencies": { "typescript": "7.1.0-dev.20260929.1" } }Paste the
package.jsonof the target module, if it existsSame package:
card.foo,card.web.foo,target.tsandtarget.web.tssit next to the importers.Any other comments can go here
Reproduced with
typescript@7.1.0-dev.20260929.1(typescript@nexton 2026-09-29, gitHead0681ef7fa3a2378ccf49645b6d5b7463bdca74bb), macOS 26.6.2 x64, Node.js 24.18.0, pnpm 10.33.4. Verified from a fresh clone of the gist.There is no runtime step in the demo: the expected placement is TypeScript's own, shown by the
.tscontrol and by themoduleSuffixesdocs (foo.ios.ts, notfoo.ts.ios).Cause, from main at
b85298b:tryFiletakes the extension fromtspath.TryGetExtensionFromPath, which only knows built-in extensions. Forcard.fooit returns"", so the suffix lands after the whole file name. With this change, a local build of main passes the demo, the control is unchanged, andgo test ./internal/module/... ./internal/modulespecifiers/... ./internal/contentmapper/...passes:Found while looking into Content mappers: registered extensions are not probed for extensionless imports in bundler mode #64546. Platform variants such as
Card.web.tsrx/Card.ios.tsrxin React Native-style projects are the use case.The reproduction and report were prepared with Claude Code; the commands above were run and the results verified.