Skip to content

Commit 14d9de5

Browse files
committed
fix #2215: lower regexp literals to new RegExp()
1 parent d189b2e commit 14d9de5

File tree

8 files changed

+339
-5
lines changed

8 files changed

+339
-5
lines changed

CHANGELOG.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,25 @@
2929
import k2 from "keep";
3030
```
3131

32+
* Avoid regular expression syntax errors in older browsers ([#2215](https://github.com/evanw/esbuild/issues/2215))
33+
34+
Previously esbuild always passed JavaScript regular expression literals through unmodified from the input to the output. This is undesirable when the regular expression uses newer features that the configured target environment doesn't support. For example, the `d` flag (i.e. the [match indices feature](https://v8.dev/features/regexp-match-indices)) is new in ES2022 and doesn't work in older browsers. If esbuild generated a regular expression literal containing the `d` flag, then older browsers would consider esbuild's output to be a syntax error and none of the code would run.
35+
36+
With this release, esbuild now detects when an unsupported feature is being used and converts the regular expression literal into a `new RegExp()` constructor instead. One consequence of this is that the syntax error is transformed into a run-time error, which allows the output code to run (and to potentially handle the run-time error). Another consequence of this is that it allows you to include a polyfill that overwrites the `RegExp` constructor in older browsers with one that supports modern features. Note that esbuild does not handle polyfills for you, so you will need to include a `RegExp` polyfill yourself if you want one.
37+
38+
```js
39+
// Original code
40+
console.log(/b/d.exec('abc').indices)
41+
42+
// New output (with --target=chrome90)
43+
console.log(/b/d.exec("abc").indices);
44+
45+
// New output (with --target=chrome89)
46+
console.log(new RegExp("b", "d").exec("abc").indices);
47+
```
48+
49+
This is currently done transparently without a warning. If you would like to debug this transformation to see where in your code esbuild is transforming regular expression literals and why, you can pass `--log-level=debug` to esbuild and review the information present in esbuild's debug logs.
50+
3251
* Add Opera to more internal feature compatibility tables ([#2247](https://github.com/evanw/esbuild/issues/2247), [#2252](https://github.com/evanw/esbuild/pull/2252))
3352

3453
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_lower_test.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2280,3 +2280,21 @@ func TestStaticClassBlockES2021(t *testing.T) {
22802280
},
22812281
})
22822282
}
2283+
2284+
func TestLowerRegExpNameCollision(t *testing.T) {
2285+
lower_suite.expectBundled(t, bundled{
2286+
files: map[string]string{
2287+
"/entry.js": `
2288+
export function foo(RegExp) {
2289+
return new RegExp(/./d, 'd')
2290+
}
2291+
`,
2292+
},
2293+
entryPaths: []string{"/entry.js"},
2294+
options: config.Options{
2295+
Mode: config.ModeBundle,
2296+
AbsOutputFile: "/out.js",
2297+
UnsupportedJSFeatures: es(2021),
2298+
},
2299+
})
2300+
}

internal/bundler/snapshots/snapshots_lower.txt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1650,6 +1650,17 @@ export {
16501650
Foo
16511651
};
16521652

1653+
================================================================================
1654+
TestLowerRegExpNameCollision
1655+
---------- /out.js ----------
1656+
// entry.js
1657+
function foo(RegExp2) {
1658+
return new RegExp2(new RegExp(".", "d"), "d");
1659+
}
1660+
export {
1661+
foo
1662+
};
1663+
16531664
================================================================================
16541665
TestLowerStaticAsyncArrowSuperES2016
16551666
---------- /out.js ----------

internal/compat/js_table.go

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,12 @@ const (
8484
ObjectRestSpread
8585
OptionalCatchBinding
8686
OptionalChain
87+
RegExpDotAllFlag
88+
RegExpLookbehindAssertions
89+
RegExpMatchIndices
90+
RegExpNamedCaptureGroups
91+
RegExpStickyAndUnicodeFlags
92+
RegExpUnicodePropertyEscapes
8793
RestArgument
8894
TemplateLiteral
8995
TopLevelAwait
@@ -436,7 +442,7 @@ var jsTable = map[JSFeature]map[Engine][]versionRange{
436442
Firefox: {{start: v{2, 0, 0}}},
437443
IE: {{start: v{9, 0, 0}}},
438444
IOS: {{start: v{6, 0, 0}}},
439-
Node: {{start: v{0, 4, 0}}},
445+
Node: {{start: v{0, 10, 0}}},
440446
Opera: {{start: v{10, 10, 0}}},
441447
Safari: {{start: v{3, 1, 0}}},
442448
},
@@ -477,6 +483,63 @@ var jsTable = map[JSFeature]map[Engine][]versionRange{
477483
Opera: {{start: v{77, 0, 0}}},
478484
Safari: {{start: v{13, 1, 0}}},
479485
},
486+
RegExpDotAllFlag: {
487+
Chrome: {{start: v{62, 0, 0}}},
488+
Edge: {{start: v{79, 0, 0}}},
489+
ES: {{start: v{2018, 0, 0}}},
490+
Firefox: {{start: v{78, 0, 0}}},
491+
IOS: {{start: v{11, 3, 0}}},
492+
Node: {{start: v{8, 10, 0}}},
493+
Opera: {{start: v{49, 0, 0}}},
494+
Safari: {{start: v{11, 1, 0}}},
495+
},
496+
RegExpLookbehindAssertions: {
497+
Chrome: {{start: v{62, 0, 0}}},
498+
Edge: {{start: v{79, 0, 0}}},
499+
ES: {{start: v{2018, 0, 0}}},
500+
Firefox: {{start: v{78, 0, 0}}},
501+
Node: {{start: v{8, 10, 0}}},
502+
Opera: {{start: v{49, 0, 0}}},
503+
},
504+
RegExpMatchIndices: {
505+
Chrome: {{start: v{90, 0, 0}}},
506+
Edge: {{start: v{90, 0, 0}}},
507+
ES: {{start: v{2022, 0, 0}}},
508+
Firefox: {{start: v{88, 0, 0}}},
509+
IOS: {{start: v{15, 0, 0}}},
510+
Opera: {{start: v{76, 0, 0}}},
511+
Safari: {{start: v{15, 0, 0}}},
512+
},
513+
RegExpNamedCaptureGroups: {
514+
Chrome: {{start: v{64, 0, 0}}},
515+
Edge: {{start: v{79, 0, 0}}},
516+
ES: {{start: v{2018, 0, 0}}},
517+
Firefox: {{start: v{78, 0, 0}}},
518+
IOS: {{start: v{11, 3, 0}}},
519+
Node: {{start: v{10, 0, 0}}},
520+
Opera: {{start: v{51, 0, 0}}},
521+
Safari: {{start: v{11, 1, 0}}},
522+
},
523+
RegExpStickyAndUnicodeFlags: {
524+
Chrome: {{start: v{50, 0, 0}}},
525+
Edge: {{start: v{13, 0, 0}}},
526+
ES: {{start: v{2015, 0, 0}}},
527+
Firefox: {{start: v{46, 0, 0}}},
528+
IOS: {{start: v{12, 0, 0}}},
529+
Node: {{start: v{6, 0, 0}}},
530+
Opera: {{start: v{37, 0, 0}}},
531+
Safari: {{start: v{12, 0, 0}}},
532+
},
533+
RegExpUnicodePropertyEscapes: {
534+
Chrome: {{start: v{64, 0, 0}}},
535+
Edge: {{start: v{79, 0, 0}}},
536+
ES: {{start: v{2018, 0, 0}}},
537+
Firefox: {{start: v{78, 0, 0}}},
538+
IOS: {{start: v{11, 3, 0}}},
539+
Node: {{start: v{10, 0, 0}}},
540+
Opera: {{start: v{51, 0, 0}}},
541+
Safari: {{start: v{11, 1, 0}}},
542+
},
480543
RestArgument: {
481544
Chrome: {{start: v{47, 0, 0}}},
482545
Edge: {{start: v{12, 0, 0}}},

internal/js_lexer/js_lexer.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2208,7 +2208,7 @@ func (lexer *Lexer) ScanRegExp() {
22082208
bits := uint32(0)
22092209
for IsIdentifierContinue(lexer.codePoint) {
22102210
switch lexer.codePoint {
2211-
case 'g', 'i', 'm', 's', 'u', 'y':
2211+
case 'd', 'g', 'i', 'm', 's', 'u', 'y':
22122212
bit := uint32(1) << uint32(lexer.codePoint-'a')
22132213
if (bit & bits) != 0 {
22142214
// Reject duplicate flags

internal/js_parser/js_parser.go

Lines changed: 171 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,7 @@ type parser struct {
200200
moduleRef js_ast.Ref
201201
importMetaRef js_ast.Ref
202202
promiseRef js_ast.Ref
203+
regExpRef js_ast.Ref
203204
runtimePublicFieldImport js_ast.Ref
204205
superCtorRef js_ast.Ref
205206

@@ -1510,6 +1511,14 @@ func (p *parser) makePromiseRef() js_ast.Ref {
15101511
return p.promiseRef
15111512
}
15121513

1514+
func (p *parser) makeRegExpRef() js_ast.Ref {
1515+
if p.regExpRef == js_ast.InvalidRef {
1516+
p.regExpRef = p.newSymbol(js_ast.SymbolUnbound, "RegExp")
1517+
p.moduleScope.Generated = append(p.moduleScope.Generated, p.regExpRef)
1518+
}
1519+
return p.regExpRef
1520+
}
1521+
15131522
// The name is temporarily stored in the ref until the scope traversal pass
15141523
// happens, at which point a symbol will be generated and the ref will point
15151524
// to the symbol instead.
@@ -11526,6 +11535,144 @@ func containsClosingScriptTag(text string) bool {
1152611535
return false
1152711536
}
1152811537

11538+
func (p *parser) isUnsupportedRegularExpression(loc logger.Loc, value string) (pattern string, flags string, isUnsupported bool) {
11539+
var feature compat.JSFeature
11540+
var what string
11541+
var r logger.Range
11542+
11543+
end := strings.LastIndexByte(value, '/')
11544+
pattern = value[1:end]
11545+
flags = value[end+1:]
11546+
isUnicode := strings.IndexByte(flags, 'u') >= 0
11547+
parenDepth := 0
11548+
i := 0
11549+
11550+
// Do a simple scan for unsupported features assuming the regular expression
11551+
// is valid. This doesn't do a full validation of the regular expression
11552+
// because regular expression grammar is complicated. If it contains a syntax
11553+
// error that we don't catch, then we will just generate output code with a
11554+
// syntax error. Garbage in, garbage out.
11555+
pattern:
11556+
for i < len(pattern) {
11557+
c := pattern[i]
11558+
i++
11559+
11560+
switch c {
11561+
case '[':
11562+
class:
11563+
for i < len(pattern) {
11564+
c := pattern[i]
11565+
i++
11566+
11567+
switch c {
11568+
case ']':
11569+
break class
11570+
11571+
case '\\':
11572+
i++ // Skip the escaped character
11573+
}
11574+
break
11575+
}
11576+
11577+
case '(':
11578+
tail := pattern[i:]
11579+
11580+
if strings.HasPrefix(tail, "?<=") || strings.HasPrefix(tail, "?<!") {
11581+
if p.options.unsupportedJSFeatures.Has(compat.RegExpLookbehindAssertions) {
11582+
feature = compat.RegExpLookbehindAssertions
11583+
what = "Lookbehind assertions in regular expressions are not available"
11584+
r = logger.Range{Loc: logger.Loc{Start: loc.Start + int32(i) + 1}, Len: 3}
11585+
isUnsupported = true
11586+
break pattern
11587+
}
11588+
} else if strings.HasPrefix(tail, "?<") {
11589+
if p.options.unsupportedJSFeatures.Has(compat.RegExpNamedCaptureGroups) {
11590+
if end := strings.IndexByte(tail, '>'); end >= 0 {
11591+
feature = compat.RegExpNamedCaptureGroups
11592+
what = "Named capture groups in regular expressions are not available"
11593+
r = logger.Range{Loc: logger.Loc{Start: loc.Start + int32(i) + 1}, Len: int32(end) + 1}
11594+
isUnsupported = true
11595+
break pattern
11596+
}
11597+
}
11598+
}
11599+
11600+
parenDepth++
11601+
11602+
case ')':
11603+
if parenDepth == 0 {
11604+
r := logger.Range{Loc: logger.Loc{Start: loc.Start + int32(i)}, Len: 1}
11605+
p.log.Add(logger.Error, &p.tracker, r, "Unexpected \")\" in regular expression")
11606+
return
11607+
}
11608+
11609+
parenDepth--
11610+
11611+
case '\\':
11612+
tail := pattern[i:]
11613+
11614+
if isUnicode && (strings.HasPrefix(tail, "p{") || strings.HasPrefix(tail, "P{")) {
11615+
if p.options.unsupportedJSFeatures.Has(compat.RegExpUnicodePropertyEscapes) {
11616+
if end := strings.IndexByte(tail, '}'); end >= 0 {
11617+
feature = compat.RegExpUnicodePropertyEscapes
11618+
what = "Unicode property escapes in regular expressions are not available"
11619+
r = logger.Range{Loc: logger.Loc{Start: loc.Start + int32(i)}, Len: int32(end) + 2}
11620+
isUnsupported = true
11621+
break pattern
11622+
}
11623+
}
11624+
}
11625+
11626+
i++ // Skip the escaped character
11627+
}
11628+
}
11629+
11630+
if !isUnsupported {
11631+
for i, c := range flags {
11632+
switch c {
11633+
case 'g', 'i', 'm':
11634+
continue // These are part of ES5 and are always supported
11635+
11636+
case 's':
11637+
if !p.options.unsupportedJSFeatures.Has(compat.RegExpDotAllFlag) {
11638+
continue // This is part of ES2018
11639+
}
11640+
feature = compat.RegExpDotAllFlag
11641+
11642+
case 'y', 'u':
11643+
if !p.options.unsupportedJSFeatures.Has(compat.RegExpStickyAndUnicodeFlags) {
11644+
continue // These are part of ES2018
11645+
}
11646+
feature = compat.RegExpStickyAndUnicodeFlags
11647+
11648+
case 'd':
11649+
if !p.options.unsupportedJSFeatures.Has(compat.RegExpMatchIndices) {
11650+
continue // This is part of ES2022
11651+
}
11652+
feature = compat.RegExpMatchIndices
11653+
11654+
default:
11655+
// Unknown flags are never supported
11656+
}
11657+
11658+
r = logger.Range{Loc: logger.Loc{Start: loc.Start + int32(end+1) + int32(i)}, Len: 1}
11659+
what = fmt.Sprintf("The regular expression flag \"%c\" is not available", c)
11660+
isUnsupported = true
11661+
break
11662+
}
11663+
}
11664+
11665+
if isUnsupported {
11666+
where, notes := p.prettyPrintTargetEnvironment(feature)
11667+
p.log.AddWithNotes(logger.Debug, &p.tracker, r, fmt.Sprintf("%s in %s", what, where), append(notes, logger.MsgData{
11668+
Text: "This regular expression literal has been converted to a \"new RegExp()\" constructor " +
11669+
"to avoid generating code with a syntax error. However, you will need to include a " +
11670+
"polyfill for \"RegExp\" for your code to have the correct behavior at run-time."}))
11671+
}
11672+
11673+
return
11674+
}
11675+
1152911676
// This function takes "exprIn" as input from the caller and produces "exprOut"
1153011677
// for the caller to pass along extra data. This is mostly for optional chaining.
1153111678
func (p *parser) visitExprInOut(expr js_ast.Expr, in exprIn) (js_ast.Expr, exprOut) {
@@ -11534,9 +11681,29 @@ func (p *parser) visitExprInOut(expr js_ast.Expr, in exprIn) (js_ast.Expr, exprO
1153411681
}
1153511682

1153611683
switch e := expr.Data.(type) {
11537-
case *js_ast.ENull, *js_ast.ESuper,
11538-
*js_ast.EBoolean, *js_ast.EBigInt,
11539-
*js_ast.ERegExp, *js_ast.EUndefined:
11684+
case *js_ast.ENull, *js_ast.ESuper, *js_ast.EBoolean, *js_ast.EBigInt, *js_ast.EUndefined:
11685+
11686+
case *js_ast.ERegExp:
11687+
// "/pattern/flags" => "new RegExp('pattern', 'flags')"
11688+
if pattern, flags, ok := p.isUnsupportedRegularExpression(expr.Loc, e.Value); ok {
11689+
args := []js_ast.Expr{{
11690+
Loc: logger.Loc{Start: expr.Loc.Start + 1},
11691+
Data: &js_ast.EString{Value: helpers.StringToUTF16(pattern)},
11692+
}}
11693+
if flags != "" {
11694+
args = append(args, js_ast.Expr{
11695+
Loc: logger.Loc{Start: expr.Loc.Start + int32(len(pattern)) + 2},
11696+
Data: &js_ast.EString{Value: helpers.StringToUTF16(flags)},
11697+
})
11698+
}
11699+
regExpRef := p.makeRegExpRef()
11700+
p.recordUsage(regExpRef)
11701+
return js_ast.Expr{Loc: expr.Loc, Data: &js_ast.ENew{
11702+
Target: js_ast.Expr{Loc: expr.Loc, Data: &js_ast.EIdentifier{Ref: regExpRef}},
11703+
Args: args,
11704+
CloseParenLoc: logger.Loc{Start: expr.Loc.Start + int32(len(e.Value))},
11705+
}}, exprOut{}
11706+
}
1154011707

1154111708
case *js_ast.ENewTarget:
1154211709
if !p.fnOnlyDataVisit.isNewTargetAllowed {
@@ -14952,6 +15119,7 @@ func newParser(log logger.Log, source logger.Source, lexer js_lexer.Lexer, optio
1495215119
options: *options,
1495315120
runtimeImports: make(map[string]js_ast.Ref),
1495415121
promiseRef: js_ast.InvalidRef,
15122+
regExpRef: js_ast.InvalidRef,
1495515123
afterArrowBodyLoc: logger.Loc{Start: -1},
1495615124
importMetaRef: js_ast.InvalidRef,
1495715125
runtimePublicFieldImport: js_ast.InvalidRef,

0 commit comments

Comments
 (0)