Skip to content

Commit 4b34056

Browse files
committed
fix #2268: bug with "preserveValueImports": true
1 parent 71a2f8d commit 4b34056

File tree

8 files changed

+174
-38
lines changed

8 files changed

+174
-38
lines changed

CHANGELOG.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,33 @@
22

33
## Unreleased
44

5+
* Correct esbuild's implementation of `"preserveValueImports": true` ([#2268](https://github.com/evanw/esbuild/issues/2268))
6+
7+
TypeScript's [`preserveValueImports` setting](https://www.typescriptlang.org/tsconfig#preserveValueImports) tells the compiler to preserve unused imports, which can sometimes be necessary because otherwise TypeScript will remove unused imports as it assumes they are type annotations. This setting is useful for programming environments that strip TypeScript types as part of a larger code transformation where additional code is appended later that will then make use of those unused imports, such as with [Svelte](https://svelte.dev/) or [Vue](https://vuejs.org/).
8+
9+
This release fixes an issue where esbuild's implementation of `preserveValueImports` diverged from the official TypeScript compiler. If the import clause is present but empty of values (even if it contains types), then the import clause should be considered a type-only import clause. This was an oversight, and has now been fixed:
10+
11+
```ts
12+
// Original code
13+
import "keep"
14+
import { k1 } from "keep"
15+
import k2, { type t1 } from "keep"
16+
import {} from "remove"
17+
import { type t2 } from "remove"
18+
19+
// Old output under "preserveValueImports": true
20+
import "keep";
21+
import { k1 } from "keep";
22+
import k2, {} from "keep";
23+
import {} from "remove";
24+
import {} from "remove";
25+
26+
// New output under "preserveValueImports": true (matches the TypeScript compiler)
27+
import "keep";
28+
import { k1 } from "keep";
29+
import k2 from "keep";
30+
```
31+
532
* Add Opera to more internal feature compatibility tables ([#2247](https://github.com/evanw/esbuild/issues/2247), [#2252](https://github.com/evanw/esbuild/pull/2252))
633

734
The internal compatibility tables that esbuild uses to determine which environments support which features are derived from multiple sources. Most of it is automatically derived from [these ECMAScript compatibility tables](https://kangax.github.io/compat-table/), but missing information is manually copied from [MDN](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/), GitHub PR comments, and various other websites. Version 0.14.35 of esbuild introduced Opera as a possible target environment which was automatically picked up by the compatibility table script, but the manually-copied information wasn't updated to include Opera. This release fixes this omission so Opera feature compatibility should now be accurate.

internal/bundler/bundler.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1185,8 +1185,8 @@ func (s *scanner) maybeParseFile(
11851185
if resolveResult.UseDefineForClassFieldsTS != config.Unspecified {
11861186
optionsClone.UseDefineForClassFields = resolveResult.UseDefineForClassFieldsTS
11871187
}
1188-
if resolveResult.UnusedImportsTS != config.UnusedImportsRemoveStmt {
1189-
optionsClone.UnusedImportsTS = resolveResult.UnusedImportsTS
1188+
if resolveResult.UnusedImportFlagsTS != 0 {
1189+
optionsClone.UnusedImportFlagsTS = resolveResult.UnusedImportFlagsTS
11901190
}
11911191
optionsClone.TSTarget = resolveResult.TSTarget
11921192

internal/bundler/bundler_tsconfig_test.go

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1162,13 +1162,55 @@ func TestTsconfigPreserveValueImports(t *testing.T) {
11621162
tsconfig_suite.expectBundled(t, bundled{
11631163
files: map[string]string{
11641164
"/Users/user/project/src/entry.ts": `
1165-
import {x, y} from "./foo"
1166-
import z from "./foo"
1167-
import * as ns from "./foo"
1168-
console.log(1 as x, 2 as z, 3 as ns.y)
1165+
import {} from "a"
1166+
import {b1} from "b"
1167+
import {c1, type c2} from "c"
1168+
import {d1, d2, type d3} from "d"
1169+
import {type e1, type e2} from "e"
1170+
import f1, {} from "f"
1171+
import g1, {g2} from "g"
1172+
import h1, {type h2} from "h"
1173+
import * as i1 from "i"
1174+
import "j"
1175+
`,
1176+
"/Users/user/project/src/tsconfig.json": `{
1177+
"compilerOptions": {
1178+
"preserveValueImports": true
1179+
}
1180+
}`,
1181+
},
1182+
entryPaths: []string{"/Users/user/project/src/entry.ts"},
1183+
options: config.Options{
1184+
Mode: config.ModeConvertFormat,
1185+
OutputFormat: config.FormatESModule,
1186+
AbsOutputFile: "/Users/user/project/out.js",
1187+
ExternalSettings: config.ExternalSettings{
1188+
PostResolve: config.ExternalMatchers{Exact: map[string]bool{
1189+
"/Users/user/project/src/foo": true,
1190+
}},
1191+
},
1192+
},
1193+
})
1194+
}
1195+
1196+
func TestTsconfigPreserveValueImportsAndImportsNotUsedAsValuesPreserve(t *testing.T) {
1197+
tsconfig_suite.expectBundled(t, bundled{
1198+
files: map[string]string{
1199+
"/Users/user/project/src/entry.ts": `
1200+
import {} from "a"
1201+
import {b1} from "b"
1202+
import {c1, type c2} from "c"
1203+
import {d1, d2, type d3} from "d"
1204+
import {type e1, type e2} from "e"
1205+
import f1, {} from "f"
1206+
import g1, {g2} from "g"
1207+
import h1, {type h2} from "h"
1208+
import * as i1 from "i"
1209+
import "j"
11691210
`,
11701211
"/Users/user/project/src/tsconfig.json": `{
11711212
"compilerOptions": {
1213+
"importsNotUsedAsValues": "preserve",
11721214
"preserveValueImports": true
11731215
}
11741216
}`,

internal/bundler/snapshots/snapshots_tsconfig.txt

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -393,10 +393,28 @@ console.log(1);
393393
================================================================================
394394
TestTsconfigPreserveValueImports
395395
---------- /Users/user/project/out.js ----------
396-
import { x, y } from "./foo";
397-
import z from "./foo";
398-
import * as ns from "./foo";
399-
console.log(1, 2, 3);
396+
import { b1 } from "b";
397+
import { c1 } from "c";
398+
import { d1, d2 } from "d";
399+
import f1 from "f";
400+
import g1, { g2 } from "g";
401+
import h1 from "h";
402+
import * as i1 from "i";
403+
import "j";
404+
405+
================================================================================
406+
TestTsconfigPreserveValueImportsAndImportsNotUsedAsValuesPreserve
407+
---------- /Users/user/project/out.js ----------
408+
import {} from "a";
409+
import { b1 } from "b";
410+
import { c1 } from "c";
411+
import { d1, d2 } from "d";
412+
import {} from "e";
413+
import f1, {} from "f";
414+
import g1, { g2 } from "g";
415+
import h1, {} from "h";
416+
import * as i1 from "i";
417+
import "j";
400418

401419
================================================================================
402420
TestTsconfigRemoveUnusedImports

internal/config/config.go

Lines changed: 37 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -274,7 +274,7 @@ type Options struct {
274274
WriteToStdout bool
275275

276276
OmitRuntimeForTests bool
277-
UnusedImportsTS UnusedImportsTS
277+
UnusedImportFlagsTS UnusedImportFlagsTS
278278
UseDefineForClassFields MaybeBool
279279
ASCIIOnly bool
280280
KeepNames bool
@@ -303,27 +303,49 @@ const (
303303
TargetWasConfiguredAndAtLeastES2022
304304
)
305305

306-
type UnusedImportsTS uint8
307-
306+
type UnusedImportFlagsTS uint8
307+
308+
// With !UnusedImportKeepStmt && !UnusedImportKeepValues:
309+
//
310+
// "import 'foo'" => "import 'foo'"
311+
// "import * as unused from 'foo'" => ""
312+
// "import { unused } from 'foo'" => ""
313+
// "import { type unused } from 'foo'" => ""
314+
//
315+
// With UnusedImportKeepStmt && !UnusedImportKeepValues:
316+
//
317+
// "import 'foo'" => "import 'foo'"
318+
// "import * as unused from 'foo'" => "import 'foo'"
319+
// "import { unused } from 'foo'" => "import 'foo'"
320+
// "import { type unused } from 'foo'" => "import 'foo'"
321+
//
322+
// With !UnusedImportKeepStmt && UnusedImportKeepValues:
323+
//
324+
// "import 'foo'" => "import 'foo'"
325+
// "import * as unused from 'foo'" => "import * as unused from 'foo'"
326+
// "import { unused } from 'foo'" => "import { unused } from 'foo'"
327+
// "import { type unused } from 'foo'" => ""
328+
//
329+
// With UnusedImportKeepStmt && UnusedImportKeepValues:
330+
//
331+
// "import 'foo'" => "import 'foo'"
332+
// "import * as unused from 'foo'" => "import * as unused from 'foo'"
333+
// "import { unused } from 'foo'" => "import { unused } from 'foo'"
334+
// "import { type unused } from 'foo'" => "import {} from 'foo'"
335+
//
308336
const (
309-
// "import { unused } from 'foo'" => "" (TypeScript's default behavior)
310-
UnusedImportsRemoveStmt UnusedImportsTS = iota
311-
312-
// "import { unused } from 'foo'" => "import 'foo'" ("importsNotUsedAsValues" != "remove")
313-
UnusedImportsKeepStmtRemoveValues
314-
315-
// "import { unused } from 'foo'" => "import { unused } from 'foo'" ("preserveValueImports" == true)
316-
UnusedImportsKeepValues
337+
UnusedImportKeepStmt UnusedImportFlagsTS = 1 << iota // "importsNotUsedAsValues" != "remove"
338+
UnusedImportKeepValues // "preserveValueImports" == true
317339
)
318340

319-
func UnusedImportsFromTsconfigValues(preserveImportsNotUsedAsValues bool, preserveValueImports bool) UnusedImportsTS {
341+
func UnusedImportFlagsFromTsconfigValues(preserveImportsNotUsedAsValues bool, preserveValueImports bool) (flags UnusedImportFlagsTS) {
320342
if preserveValueImports {
321-
return UnusedImportsKeepValues
343+
flags |= UnusedImportKeepValues
322344
}
323345
if preserveImportsNotUsedAsValues {
324-
return UnusedImportsKeepStmtRemoveValues
346+
flags |= UnusedImportKeepStmt
325347
}
326-
return UnusedImportsRemoveStmt
348+
return
327349
}
328350

329351
type TSTarget struct {

internal/js_parser/js_parser.go

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -395,7 +395,7 @@ type optionsThatSupportStructuralEquality struct {
395395
treeShaking bool
396396
dropDebugger bool
397397
mangleQuoted bool
398-
unusedImportsTS config.UnusedImportsTS
398+
unusedImportFlagsTS config.UnusedImportFlagsTS
399399
useDefineForClassFields config.MaybeBool
400400
}
401401

@@ -426,7 +426,7 @@ func OptionsFromConfig(options *config.Options) Options {
426426
treeShaking: options.TreeShaking,
427427
dropDebugger: options.DropDebugger,
428428
mangleQuoted: options.MangleQuoted,
429-
unusedImportsTS: options.UnusedImportsTS,
429+
unusedImportFlagsTS: options.UnusedImportFlagsTS,
430430
useDefineForClassFields: options.UseDefineForClassFields,
431431
},
432432
}
@@ -6628,11 +6628,38 @@ func (p *parser) parseStmt(opts parseStmtOpts) js_ast.Stmt {
66286628
}
66296629

66306630
pathLoc, pathText, assertions := p.parsePath()
6631+
p.lexer.ExpectOrInsertSemicolon()
6632+
6633+
// If TypeScript's "preserveValueImports": true setting is active, TypeScript's
6634+
// "importsNotUsedAsValues": "preserve" setting is NOT active, and the import
6635+
// clause is present and empty (or is non-empty but filled with type-only
6636+
// items), then the import statement should still be removed entirely to match
6637+
// the behavior of the TypeScript compiler:
6638+
//
6639+
// // Keep these
6640+
// import 'x'
6641+
// import { y } from 'x'
6642+
// import { y, type z } from 'x'
6643+
//
6644+
// // Remove these
6645+
// import {} from 'x'
6646+
// import { type y } from 'x'
6647+
//
6648+
// // Remove the items from these
6649+
// import d, {} from 'x'
6650+
// import d, { type y } from 'x'
6651+
//
6652+
if p.options.ts.Parse && p.options.unusedImportFlagsTS == config.UnusedImportKeepValues && stmt.Items != nil && len(*stmt.Items) == 0 {
6653+
if stmt.DefaultName == nil {
6654+
return js_ast.Stmt{Loc: loc, Data: &js_ast.STypeScript{}}
6655+
}
6656+
stmt.Items = nil
6657+
}
6658+
66316659
stmt.ImportRecordIndex = p.addImportRecord(ast.ImportStmt, pathLoc, pathText, assertions)
66326660
if wasOriginallyBareImport {
66336661
p.importRecords[stmt.ImportRecordIndex].Flags |= ast.WasOriginallyBareImport
66346662
}
6635-
p.lexer.ExpectOrInsertSemicolon()
66366663

66376664
if stmt.StarNameLoc != nil {
66386665
name := p.loadNameFromRef(stmt.NamespaceRef)
@@ -14164,7 +14191,7 @@ func (p *parser) scanForImportsAndExports(stmts []js_ast.Stmt) (result importsEx
1416414191
// user is expecting the output to be as small as possible. So we
1416514192
// should omit unused imports.
1416614193
//
14167-
keepUnusedImports := p.options.ts.Parse && p.options.unusedImportsTS == config.UnusedImportsKeepValues &&
14194+
keepUnusedImports := p.options.ts.Parse && (p.options.unusedImportFlagsTS&config.UnusedImportKeepValues) != 0 &&
1416814195
p.options.mode != config.ModeBundle && !p.options.minifyIdentifiers
1416914196

1417014197
// TypeScript always trims unused imports. This is important for
@@ -14180,7 +14207,7 @@ func (p *parser) scanForImportsAndExports(stmts []js_ast.Stmt) (result importsEx
1418014207
symbol := p.symbols[s.DefaultName.Ref.InnerIndex]
1418114208

1418214209
// TypeScript has a separate definition of unused
14183-
if p.options.ts.Parse && p.tsUseCounts[s.DefaultName.Ref.InnerIndex] != 0 {
14210+
if p.options.ts.Parse && (p.tsUseCounts[s.DefaultName.Ref.InnerIndex] != 0 || (p.options.unusedImportFlagsTS&config.UnusedImportKeepValues) != 0) {
1418414211
isUnusedInTypeScript = false
1418514212
}
1418614213

@@ -14196,7 +14223,7 @@ func (p *parser) scanForImportsAndExports(stmts []js_ast.Stmt) (result importsEx
1419614223
symbol := p.symbols[s.NamespaceRef.InnerIndex]
1419714224

1419814225
// TypeScript has a separate definition of unused
14199-
if p.options.ts.Parse && p.tsUseCounts[s.NamespaceRef.InnerIndex] != 0 {
14226+
if p.options.ts.Parse && (p.tsUseCounts[s.NamespaceRef.InnerIndex] != 0 || (p.options.unusedImportFlagsTS&config.UnusedImportKeepValues) != 0) {
1420014227
isUnusedInTypeScript = false
1420114228
}
1420214229

@@ -14219,7 +14246,7 @@ func (p *parser) scanForImportsAndExports(stmts []js_ast.Stmt) (result importsEx
1421914246
symbol := p.symbols[item.Name.Ref.InnerIndex]
1422014247

1422114248
// TypeScript has a separate definition of unused
14222-
if p.options.ts.Parse && p.tsUseCounts[item.Name.Ref.InnerIndex] != 0 {
14249+
if p.options.ts.Parse && (p.tsUseCounts[item.Name.Ref.InnerIndex] != 0 || (p.options.unusedImportFlagsTS&config.UnusedImportKeepValues) != 0) {
1422314250
isUnusedInTypeScript = false
1422414251
}
1422514252

@@ -14251,7 +14278,7 @@ func (p *parser) scanForImportsAndExports(stmts []js_ast.Stmt) (result importsEx
1425114278
//
1425214279
// We do not want to do this culling in JavaScript though because the
1425314280
// module may have side effects even if all imports are unused.
14254-
if p.options.ts.Parse && foundImports && isUnusedInTypeScript && p.options.unusedImportsTS == config.UnusedImportsRemoveStmt {
14281+
if p.options.ts.Parse && foundImports && isUnusedInTypeScript && (p.options.unusedImportFlagsTS&config.UnusedImportKeepStmt) == 0 {
1425514282
// Ignore import records with a pre-filled source index. These are
1425614283
// for injected files and we definitely do not want to trim these.
1425714284
if !record.SourceIndex.IsValid() {

internal/resolver/resolver.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ type ResolveResult struct {
122122
UseDefineForClassFieldsTS config.MaybeBool
123123

124124
// This is the "importsNotUsedAsValues" and "preserveValueImports" fields from "package.json"
125-
UnusedImportsTS config.UnusedImportsTS
125+
UnusedImportFlagsTS config.UnusedImportFlagsTS
126126
}
127127

128128
type DebugMeta struct {
@@ -626,7 +626,7 @@ func (r resolverQuery) finalizeResolve(result *ResolveResult) {
626626
result.JSXFactory = dirInfo.enclosingTSConfigJSON.JSXFactory
627627
result.JSXFragment = dirInfo.enclosingTSConfigJSON.JSXFragmentFactory
628628
result.UseDefineForClassFieldsTS = dirInfo.enclosingTSConfigJSON.UseDefineForClassFields
629-
result.UnusedImportsTS = config.UnusedImportsFromTsconfigValues(
629+
result.UnusedImportFlagsTS = config.UnusedImportFlagsFromTsconfigValues(
630630
dirInfo.enclosingTSConfigJSON.PreserveImportsNotUsedAsValues,
631631
dirInfo.enclosingTSConfigJSON.PreserveValueImports,
632632
)

pkg/api/api_impl.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1361,7 +1361,7 @@ func transformImpl(input string, transformOpts TransformOptions) TransformResult
13611361
})
13621362

13631363
// Settings from the user come first
1364-
unusedImportsTS := config.UnusedImportsRemoveStmt
1364+
var unusedImportFlagsTS config.UnusedImportFlagsTS
13651365
useDefineForClassFieldsTS := config.Unspecified
13661366
jsx := config.JSXOptions{
13671367
Preserve: transformOpts.JSXMode == JSXModePreserve,
@@ -1388,7 +1388,7 @@ func transformImpl(input string, transformOpts TransformOptions) TransformResult
13881388
if result.UseDefineForClassFields != config.Unspecified {
13891389
useDefineForClassFieldsTS = result.UseDefineForClassFields
13901390
}
1391-
unusedImportsTS = config.UnusedImportsFromTsconfigValues(
1391+
unusedImportFlagsTS = config.UnusedImportFlagsFromTsconfigValues(
13921392
result.PreserveImportsNotUsedAsValues,
13931393
result.PreserveValueImports,
13941394
)
@@ -1436,7 +1436,7 @@ func transformImpl(input string, transformOpts TransformOptions) TransformResult
14361436
AbsOutputFile: transformOpts.Sourcefile + "-out",
14371437
KeepNames: transformOpts.KeepNames,
14381438
UseDefineForClassFields: useDefineForClassFieldsTS,
1439-
UnusedImportsTS: unusedImportsTS,
1439+
UnusedImportFlagsTS: unusedImportFlagsTS,
14401440
Stdin: &config.StdinInfo{
14411441
Loader: validateLoader(transformOpts.Loader),
14421442
Contents: input,

0 commit comments

Comments
 (0)