diff --git a/CHANGELOG.md b/CHANGELOG.md index e13c8132a0b..12408def1cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,45 @@ ## Unreleased +* Implement class static blocks ([#1558](https://github.com/evanw/esbuild/issues/1558)) + + This release adds support for a new upcoming JavaScript feature called [class static blocks](https://github.com/tc39/proposal-class-static-block) that lets you evaluate code inside of a class body. It looks like this: + + ```js + class Foo { + static { + this.foo = 123 + } + } + ``` + + This can be useful when you want to use `try`/`catch` or access private `#name` fields during class initialization. Doing that without this feature is quite hacky and basically involves creating temporary static fields containing immediately-invoked functions and then deleting the fields after class initialization. Static blocks are much more ergonomic and avoid performance loss due to `delete` changing the object shape. + + Static blocks are transformed for older browsers by moving the static block outside of the class body and into an immediately invoked arrow function after the class definition: + + ```js + // The transformed version of the example code above + const _Foo = class { + }; + let Foo = _Foo; + (() => { + _Foo.foo = 123; + })(); + ``` + + In case you're wondering, the additional `let` variable is to guard against the potential reassignment of `Foo` during evaluation such as what happens below. The value of `this` must be bound to the original class, not to the current value of `Foo`: + + ```js + let bar + class Foo { + static { + bar = () => this + } + } + Foo = null + console.log(bar()) // This should not be "null" + ``` + * Fix issues with `super` property accesses Code containing `super` property accesses may need to be transformed even when they are supported. For example, in ES6 `async` methods are unsupported while `super` properties are supported. An `async` method containing `super` property accesses requires those uses of `super` to be transformed (the `async` function is transformed into a nested generator function and the `super` keyword cannot be used inside nested functions). diff --git a/internal/bundler/bundler_lower_test.go b/internal/bundler/bundler_lower_test.go index ca9057968dc..1bd819dfc50 100644 --- a/internal/bundler/bundler_lower_test.go +++ b/internal/bundler/bundler_lower_test.go @@ -1877,3 +1877,66 @@ func TestLowerNullishCoalescingAssignmentIssue1493(t *testing.T) { }, }) } + +func TestStaticClassBlockESNext(t *testing.T) { + lower_suite.expectBundled(t, bundled{ + files: map[string]string{ + "/entry.js": ` + class A { + static {} + static { + this.thisField++ + A.classField++ + super.superField = super.superField + 1 + super.superField++ + } + } + let B = class { + static {} + static { + this.thisField++ + super.superField = super.superField + 1 + super.superField++ + } + } + `, + }, + entryPaths: []string{"/entry.js"}, + options: config.Options{ + Mode: config.ModeBundle, + AbsOutputFile: "/out.js", + }, + }) +} + +func TestStaticClassBlockES2021(t *testing.T) { + lower_suite.expectBundled(t, bundled{ + files: map[string]string{ + "/entry.js": ` + class A { + static {} + static { + this.thisField++ + A.classField++ + super.superField = super.superField + 1 + super.superField++ + } + } + let B = class { + static {} + static { + this.thisField++ + super.superField = super.superField + 1 + super.superField++ + } + } + `, + }, + entryPaths: []string{"/entry.js"}, + options: config.Options{ + Mode: config.ModeBundle, + AbsOutputFile: "/out.js", + UnsupportedJSFeatures: es(2021), + }, + }) +} diff --git a/internal/bundler/snapshots/snapshots_lower.txt b/internal/bundler/snapshots/snapshots_lower.txt index 29c64c25a7f..8cbb68fcebb 100644 --- a/internal/bundler/snapshots/snapshots_lower.txt +++ b/internal/bundler/snapshots/snapshots_lower.txt @@ -1255,6 +1255,51 @@ y = () => [ tag(_h || (_h = __template(["x", void 0], ["x", "\\u"])), y) ]; +================================================================================ +TestStaticClassBlockES2021 +---------- /out.js ---------- +// entry.js +var _A = class { +}; +var A = _A; +(() => { + _A.thisField++; + _A.classField++; + __superStaticSet(_A, "superField", __superStaticGet(_A, "superField") + 1); + __superStaticWrapper(_A, "superField")._++; +})(); +var _a; +var B = (_a = class { +}, (() => { + _a.thisField++; + __superStaticSet(_a, "superField", __superStaticGet(_a, "superField") + 1); + __superStaticWrapper(_a, "superField")._++; +})(), _a); + +================================================================================ +TestStaticClassBlockESNext +---------- /out.js ---------- +// entry.js +var A = class { + static { + } + static { + this.thisField++; + A.classField++; + super.superField = super.superField + 1; + super.superField++; + } +}; +var B = class { + static { + } + static { + this.thisField++; + super.superField = super.superField + 1; + super.superField++; + } +}; + ================================================================================ TestTSLowerClassField2020NoBundle ---------- /out.js ---------- diff --git a/internal/compat/js_table.go b/internal/compat/js_table.go index 84b4849d0da..d67cffd65c3 100644 --- a/internal/compat/js_table.go +++ b/internal/compat/js_table.go @@ -52,6 +52,7 @@ const ( ClassPrivateStaticAccessor ClassPrivateStaticField ClassPrivateStaticMethod + ClassStaticBlocks ClassStaticField Const DefaultArgument @@ -209,6 +210,10 @@ var jsTable = map[JSFeature]map[Engine][]int{ Node: {14, 6}, Safari: {15}, }, + ClassStaticBlocks: { + Chrome: {91}, + Node: {16, 11}, + }, ClassStaticField: { Chrome: {73}, Edge: {79}, diff --git a/internal/js_ast/js_ast.go b/internal/js_ast/js_ast.go index d8183217b48..56d3620fe17 100644 --- a/internal/js_ast/js_ast.go +++ b/internal/js_ast/js_ast.go @@ -261,11 +261,19 @@ const ( PropertySet PropertySpread PropertyDeclare + PropertyClassStaticBlock ) +type ClassStaticBlock struct { + Loc logger.Loc + Stmts []Stmt +} + type Property struct { - TSDecorators []Expr - Key Expr + TSDecorators []Expr + ClassStaticBlock *ClassStaticBlock + + Key Expr // This is omitted for class fields ValueOrNil Expr diff --git a/internal/js_parser/js_parser.go b/internal/js_parser/js_parser.go index 04806dbde42..de8982404ba 100644 --- a/internal/js_parser/js_parser.go +++ b/internal/js_parser/js_parser.go @@ -1979,25 +1979,31 @@ func (p *parser) parseProperty(kind js_ast.PropertyKind, opts propertyOpts, erro } } } else if p.lexer.Token == js_lexer.TOpenBrace && name == "static" { - p.log.AddRangeError(&p.tracker, p.lexer.Range(), "Class static blocks are not supported yet") - loc := p.lexer.Loc() p.lexer.Next() - oldIsClassStaticInit := p.fnOrArrowDataParse.isClassStaticInit - oldAwait := p.fnOrArrowDataParse.await - p.fnOrArrowDataParse.isClassStaticInit = true - p.fnOrArrowDataParse.await = forbidAll + oldFnOrArrowDataParse := p.fnOrArrowDataParse + p.fnOrArrowDataParse = fnOrArrowDataParse{ + isClassStaticInit: true, + allowSuperProperty: true, + await: forbidAll, + yield: allowIdent, + } - scopeIndex := p.pushScopeForParsePass(js_ast.ScopeClassStaticInit, loc) - p.parseStmtsUpTo(js_lexer.TCloseBrace, parseStmtOpts{}) - p.popAndDiscardScope(scopeIndex) + p.pushScopeForParsePass(js_ast.ScopeClassStaticInit, loc) + stmts := p.parseStmtsUpTo(js_lexer.TCloseBrace, parseStmtOpts{}) + p.popScope() - p.fnOrArrowDataParse.isClassStaticInit = oldIsClassStaticInit - p.fnOrArrowDataParse.await = oldAwait + p.fnOrArrowDataParse = oldFnOrArrowDataParse p.lexer.Expect(js_lexer.TCloseBrace) - + return js_ast.Property{ + Kind: js_ast.PropertyClassStaticBlock, + ClassStaticBlock: &js_ast.ClassStaticBlock{ + Loc: loc, + Stmts: stmts, + }, + }, true } } @@ -9777,8 +9783,47 @@ func (p *parser) visitClass(nameScopeLoc logger.Loc, class *js_ast.Class) js_ast p.pushScopeForVisitPass(js_ast.ScopeClassBody, class.BodyLoc) defer p.popScope() + end := 0 + for i := range class.Properties { property := &class.Properties[i] + + if property.Kind == js_ast.PropertyClassStaticBlock { + oldFnOrArrowData := p.fnOrArrowDataVisit + oldFnOnlyDataVisit := p.fnOnlyDataVisit + + p.fnOrArrowDataVisit = fnOrArrowDataVisit{} + p.fnOnlyDataVisit = fnOnlyDataVisit{ + isThisNested: true, + isNewTargetAllowed: true, + } + + if classLoweringInfo.lowerAllStaticFields { + // Replace "this" with the class name inside static class blocks + p.fnOnlyDataVisit.thisClassStaticRef = &shadowRef + + // Need to lower "super" since it won't be valid outside the class body + p.fnOnlyDataVisit.shouldLowerSuper = true + } + + p.pushScopeForVisitPass(js_ast.ScopeClassStaticInit, property.ClassStaticBlock.Loc) + property.ClassStaticBlock.Stmts = p.visitStmts(property.ClassStaticBlock.Stmts, stmtsFnBody) + p.popScope() + + p.fnOrArrowDataVisit = oldFnOrArrowData + p.fnOnlyDataVisit = oldFnOnlyDataVisit + + // "class { static {} }" => "class {}" + if p.options.mangleSyntax && len(property.ClassStaticBlock.Stmts) == 0 { + continue + } + + // Keep this property + class.Properties[end] = *property + end++ + continue + } + property.TSDecorators = p.visitTSDecorators(property.TSDecorators) private, isPrivate := property.Key.Data.(*js_ast.EPrivateIdentifier) @@ -9869,8 +9914,15 @@ func (p *parser) visitClass(nameScopeLoc logger.Loc, class *js_ast.Class) js_ast // Restore the ability to use "arguments" in decorators and computed properties p.currentScope.ForbidArguments = false + + // Keep this property + class.Properties[end] = *property + end++ } + // Finish the filtering operation + class.Properties = class.Properties[:end] + p.enclosingClassKeyword = oldEnclosingClassKeyword p.popScope() diff --git a/internal/js_parser/js_parser_lower.go b/internal/js_parser/js_parser_lower.go index 9da07f51636..a9f5881cec1 100644 --- a/internal/js_parser/js_parser_lower.go +++ b/internal/js_parser/js_parser_lower.go @@ -1820,6 +1820,13 @@ func (p *parser) computeClassLoweringInfo(class *js_ast.Class) (result classLowe // _foo = new WeakMap(); // for _, prop := range class.Properties { + if prop.Kind == js_ast.PropertyClassStaticBlock { + if p.options.unsupportedJSFeatures.Has(compat.ClassStaticBlocks) && len(prop.ClassStaticBlock.Stmts) > 0 { + result.lowerAllStaticFields = true + } + continue + } + if private, ok := prop.Key.Data.(*js_ast.EPrivateIdentifier); ok { if prop.IsStatic { if p.privateSymbolNeedsToBeLowered(private) { @@ -2105,6 +2112,24 @@ func (p *parser) lowerClass(stmt js_ast.Stmt, expr js_ast.Expr, shadowRef js_ast classLoweringInfo := p.computeClassLoweringInfo(class) for _, prop := range class.Properties { + if prop.Kind == js_ast.PropertyClassStaticBlock { + if p.options.unsupportedJSFeatures.Has(compat.ClassStaticBlocks) { + if block := *prop.ClassStaticBlock; len(block.Stmts) > 0 { + staticMembers = append(staticMembers, js_ast.Expr{Loc: block.Loc, Data: &js_ast.ECall{ + Target: js_ast.Expr{Loc: block.Loc, Data: &js_ast.EArrow{Body: js_ast.FnBody{ + Stmts: block.Stmts, + }}}, + }}) + } + continue + } + + // Keep this property + class.Properties[end] = prop + end++ + continue + } + // Merge parameter decorators with method decorators if p.options.ts.Parse && prop.IsMethod { if fn, ok := prop.ValueOrNil.Data.(*js_ast.EFunction); ok { diff --git a/internal/js_parser/js_parser_test.go b/internal/js_parser/js_parser_test.go index d28879ce15c..46cf66654cf 100644 --- a/internal/js_parser/js_parser_test.go +++ b/internal/js_parser/js_parser_test.go @@ -1673,6 +1673,22 @@ func TestClassFields(t *testing.T) { expectPrinted(t, "class Foo { static ['prototype'] = 1 }", "class Foo {\n static [\"prototype\"] = 1;\n}\n") } +func TestClassStaticBlocks(t *testing.T) { + expectPrinted(t, "class Foo { static {} }", "class Foo {\n static {\n }\n}\n") + expectPrinted(t, "class Foo { static {} x = 1 }", "class Foo {\n static {\n }\n x = 1;\n}\n") + expectPrinted(t, "class Foo { static { this.foo() } }", "class Foo {\n static {\n this.foo();\n }\n}\n") + + expectParseError(t, "class Foo { static { yield } }", + ": error: \"yield\" is a reserved word and cannot be used in strict mode\n"+ + ": note: All code inside a class is implicitly in strict mode\n") + expectParseError(t, "class Foo { static { await } }", ": error: The keyword \"await\" cannot be used here\n") + expectParseError(t, "class Foo { static { return } }", ": error: A return statement cannot be used inside a class static block\n") + expectParseError(t, "class Foo { static { break } }", ": error: Cannot use \"break\" here\n") + expectParseError(t, "class Foo { static { continue } }", ": error: Cannot use \"continue\" here\n") + expectParseError(t, "x: { class Foo { static { break x } } }", ": error: There is no containing label named \"x\"\n") + expectParseError(t, "x: { class Foo { static { continue x } } }", ": error: There is no containing label named \"x\"\n") +} + func TestGenerator(t *testing.T) { expectParseError(t, "(class { * foo })", ": error: Expected \"(\" but found \"}\"\n") expectParseError(t, "(class { * *foo() {} })", ": error: Unexpected \"*\"\n") diff --git a/internal/js_printer/js_printer.go b/internal/js_printer/js_printer.go index 43ee68bfe11..dd79bb0431f 100644 --- a/internal/js_printer/js_printer.go +++ b/internal/js_printer/js_printer.go @@ -819,6 +819,15 @@ func (p *printer) printClass(class js_ast.Class) { for _, item := range class.Properties { p.printSemicolonIfNeeded() p.printIndent() + + if item.Kind == js_ast.PropertyClassStaticBlock { + p.print("static") + p.printSpace() + p.printBlock(item.ClassStaticBlock.Loc, item.ClassStaticBlock.Stmts) + p.printNewline() + continue + } + p.printProperty(item) // Need semicolons after class fields diff --git a/scripts/compat-table.js b/scripts/compat-table.js index d8be5266eaa..933c7c2c11a 100644 --- a/scripts/compat-table.js +++ b/scripts/compat-table.js @@ -157,6 +157,7 @@ mergeVersions('LogicalAssignment', { es2021: true }) mergeVersions('TopLevelAwait', {}) mergeVersions('ArbitraryModuleNamespaceNames', {}) mergeVersions('ImportAssertions', {}) +mergeVersions('ClassStaticBlocks', {}) // Manually copied from https://caniuse.com/?search=export%20*%20as mergeVersions('ExportStarAs', { @@ -218,6 +219,17 @@ mergeVersions('ImportAssertions', { // Not yet in Firefox: https://bugzilla.mozilla.org/show_bug.cgi?id=1668330 }) +mergeVersions('ClassStaticBlocks', { + // From https://www.chromestatus.com/feature/6482797915013120 + chrome91: true, + + // https://github.com/nodejs/node/blob/master/doc/changelogs/CHANGELOG_V16.md + // combined with https://v8.dev/blog/v8-release-94 + node16_11: true, + + // Not yet in Firefox: https://bugzilla.mozilla.org/show_bug.cgi?id=1670018 +}) + for (const test of [...es5.tests, ...es6.tests, ...stage4.tests, ...stage1to3.tests]) { const feature = features[test.name] if (feature) {