From 476667d548d7822d2408de64df03fa441d449d54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arnaud=20Barr=C3=A9?= Date: Fri, 13 Oct 2023 01:10:25 +0200 Subject: [PATCH] Add Fast Refresh support --- cmd/esbuild/main.go | 1 + internal/config/config.go | 11 +- internal/js_parser/js_parser.go | 386 ++++++++++++++++++++++++++++++++ lib/shared/common.ts | 2 + lib/shared/types.ts | 2 + pkg/api/api.go | 2 + pkg/api/api_impl.go | 2 + pkg/cli/cli_impl.go | 10 + scripts/esbuild.js | 2 +- scripts/js-api-tests.js | 338 ++++++++++++++++++++++++++++ 10 files changed, 750 insertions(+), 6 deletions(-) diff --git a/cmd/esbuild/main.go b/cmd/esbuild/main.go index 94671d6d5d3..62a37fe8138 100644 --- a/cmd/esbuild/main.go +++ b/cmd/esbuild/main.go @@ -114,6 +114,7 @@ var helpText = func(colors logger.Colors) string { --preserve-symlinks Disable symlink resolution for module lookup --public-path=... Set the base URL for the "file" loader --pure:N Mark the name N as a pure function for tree shaking + --react-refresh Enable React Fast Refresh transformation --reserve-props=... Do not mangle these properties --resolve-extensions=... A comma-separated list of implicit extensions (default ".tsx,.ts,.jsx,.js,.css,.json") diff --git a/internal/config/config.go b/internal/config/config.go index 9c1c64003b1..13c28d7e71b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -434,11 +434,12 @@ type Options struct { ChunkPathTemplate []PathTemplate AssetPathTemplate []PathTemplate - Plugins []Plugin - SourceRoot string - Stdin *StdinInfo - JSX JSXOptions - LineLimit int + Plugins []Plugin + SourceRoot string + Stdin *StdinInfo + JSX JSXOptions + ReactRefresh bool + LineLimit int CSSPrefixData map[css_ast.D]compat.CSSPrefix UnsupportedJSFeatures compat.JSFeature diff --git a/internal/js_parser/js_parser.go b/internal/js_parser/js_parser.go index f006d5f18a4..66b0f179a52 100644 --- a/internal/js_parser/js_parser.go +++ b/internal/js_parser/js_parser.go @@ -5,6 +5,7 @@ import ( "math" "regexp" "sort" + "strconv" "strings" "unicode/utf8" @@ -230,6 +231,11 @@ type parser struct { jsxRuntimeImports map[string]ast.LocRef jsxLegacyImports map[string]ast.LocRef + reactRefreshReg ast.Ref + reactRefreshSig ast.Ref + reactRefreshHandles []reactRefreshHandle + reactRefreshSigCount int + // For lowering private methods weakMapRef ast.Ref weakSetRef ast.Ref @@ -481,6 +487,7 @@ type optionsThatSupportStructuralEquality struct { treeShaking bool dropDebugger bool mangleQuoted bool + reactRefresh bool // This is an internal-only option used for the implementation of Yarn PnP decodeHydrateRuntimeStateYarnPnP bool @@ -525,6 +532,7 @@ func OptionsFromConfig(options *config.Options) Options { treeShaking: options.TreeShaking, dropDebugger: options.DropDebugger, mangleQuoted: options.MangleQuoted, + reactRefresh: options.ReactRefresh, }, } } @@ -1733,6 +1741,297 @@ func (p *parser) importJSXSymbol(loc logger.Loc, jsx JSXImport) js_ast.Expr { }) } +func isValidReactComponentName(candidate string) bool { + return len(candidate) > 0 && candidate[0] >= 'A' && candidate[0] <= 'Z' +} + +func isReactCustomHook(candidate string) bool { + return len(candidate) >= 4 && + candidate[0] == 'u' && + candidate[1] == 's' && + candidate[2] == 'e' && + candidate[3] >= 'A' && + candidate[3] <= 'Z' +} + +var reactBuiltInHooks = map[string]bool{ + "useState": true, + "useReducer": true, + "useEffect": true, + "useLayoutEffect": true, + "useMemo": true, + "useCallback": true, + "useRef": true, + "useContext": true, + "useImperativeHandle": true, + "useDebugValue": true, +} + +type reactRefreshHandle struct { + handle ast.Ref + id string +} + +func (p *parser) reactRefreshRegisterHandle(target ast.Ref, id string) js_ast.Stmt { + var handleName = "_c" + if len(p.reactRefreshHandles) > 0 { + handleName += strconv.Itoa(len(p.reactRefreshHandles) + 1) + } + var handle = p.newSymbol(ast.SymbolOther, handleName) + p.reactRefreshHandles = append(p.reactRefreshHandles, reactRefreshHandle{ + handle: handle, + id: id, + }) + var loc = p.lexer.Loc() + return js_ast.AssignStmt( + js_ast.Expr{Loc: loc, Data: &js_ast.EIdentifier{Ref: handle}}, + js_ast.Expr{Loc: loc, Data: &js_ast.EIdentifier{Ref: target}}, + ) +} + +func (p *parser) reactRefreshGetHookSignature(name string, call *js_ast.ECall) string { + if name == "useState" && len(call.Args) > 0 { + return "useState{" + p.source.Contents[call.Args[0].Loc.Start:call.CloseParenLoc.Start] + "}" + } else if name == "useReducer" && len(call.Args) > 1 { + return "useReducer{" + p.source.Contents[call.Args[1].Loc.Start:call.CloseParenLoc.Start] + "}" + } else { + return name + "{}" + } +} + +type ReactRefreshVisitFunctionBodyOutput struct { + invalidReturnType bool + registerSignatureStmts []js_ast.Stmt +} + +func (p *parser) reactRefreshVisitFunctionBody(target ast.Ref, body *js_ast.FnBody, isComponent bool) ReactRefreshVisitFunctionBodyOutput { + var hooksSignatures []string + var customHooks []string + var output ReactRefreshVisitFunctionBodyOutput + + for _, stmt := range body.Block.Stmts { + // const [state, setState] = (React.)useState() + if local, ok := stmt.Data.(*js_ast.SLocal); ok { + for _, decl := range local.Decls { + if call, ok := decl.ValueOrNil.Data.(*js_ast.ECall); ok { + // useState() + if importId, ok := call.Target.Data.(*js_ast.EImportIdentifier); ok { + var name = p.symbols[importId.Ref.InnerIndex].OriginalName + if reactBuiltInHooks[name] { + hooksSignatures = append(hooksSignatures, p.reactRefreshGetHookSignature(name, call)) + } + } + // React.useState() + if member, ok := call.Target.Data.(*js_ast.EDot); ok { + if id, ok := member.Target.Data.(*js_ast.EImportIdentifier); ok { + var targetName = p.symbols[id.Ref.InnerIndex].OriginalName + if targetName == "React" && reactBuiltInHooks[member.Name] { + hooksSignatures = append(hooksSignatures, p.reactRefreshGetHookSignature(member.Name, call)) + } else if isReactCustomHook(member.Name) { + // SomeLib.useCustomState() + hooksSignatures = append(hooksSignatures, p.reactRefreshGetHookSignature(member.Name, call)) + customHooks = append(customHooks, targetName+"."+member.Name) + } + } + } + // useCustomState() (local) + if id, ok := call.Target.Data.(*js_ast.EIdentifier); ok { + var name = p.symbols[id.Ref.InnerIndex].OriginalName + if isReactCustomHook(name) { + hooksSignatures = append(hooksSignatures, p.reactRefreshGetHookSignature(name, call)) + customHooks = append(customHooks, name) + } + } + } + } + } + // (React.)useEffect(()=> {}) + if expr, ok := stmt.Data.(*js_ast.SExpr); ok { + if call, ok := expr.Value.Data.(*js_ast.ECall); ok { + // use(Custom)Effect(()=> {}) (import) + if id, ok := call.Target.Data.(*js_ast.EImportIdentifier); ok { + var name = p.symbols[id.Ref.InnerIndex].OriginalName + if reactBuiltInHooks[name] { + hooksSignatures = append(hooksSignatures, p.reactRefreshGetHookSignature(name, call)) + } else if isReactCustomHook(name) { + hooksSignatures = append(hooksSignatures, p.reactRefreshGetHookSignature(name, call)) + customHooks = append(customHooks, name) + } + } + if member, ok := call.Target.Data.(*js_ast.EDot); ok { + // React.useEffect(()=> {}) + if id, ok := member.Target.Data.(*js_ast.EImportIdentifier); ok { + var targetName = p.symbols[id.Ref.InnerIndex].OriginalName + if targetName == "React" && reactBuiltInHooks[member.Name] { + hooksSignatures = append(hooksSignatures, p.reactRefreshGetHookSignature(member.Name, call)) + } else if isReactCustomHook(member.Name) { + // SomeLib.useCustomEffect() + hooksSignatures = append(hooksSignatures, p.reactRefreshGetHookSignature(member.Name, call)) + customHooks = append(customHooks, targetName+"."+member.Name) + } + } + } + // useCustomEffect() (local) + if id, ok := call.Target.Data.(*js_ast.EIdentifier); ok { + var name = p.symbols[id.Ref.InnerIndex].OriginalName + if isReactCustomHook(name) { + hooksSignatures = append(hooksSignatures, p.reactRefreshGetHookSignature(name, call)) + customHooks = append(customHooks, name) + } + } + } + } + if isComponent { + if sReturn, ok := stmt.Data.(*js_ast.SReturn); ok { + if _, ok := sReturn.ValueOrNil.Data.(*js_ast.EArrow); ok { + output.invalidReturnType = true + } + } + } + } + + if output.invalidReturnType { + return output + } + + if len(hooksSignatures) > 0 { + p.reactRefreshSigCount++ + var sigHandleName = "_s" + if p.reactRefreshSigCount > 1 { + sigHandleName += strconv.Itoa(p.reactRefreshSigCount) + } + var sigHandle = p.newSymbol(ast.SymbolOther, sigHandleName) + if p.reactRefreshSig == ast.InvalidRef { + p.reactRefreshSig = p.newSymbol(ast.SymbolUnbound, "$RefreshSig$") + } + // add _s() at the start of the body + body.Block.Stmts = append([]js_ast.Stmt{{Data: &js_ast.SExpr{ + Value: js_ast.Expr{Data: &js_ast.ECall{ + Target: js_ast.Expr{Data: &js_ast.EIdentifier{Ref: sigHandle}}, + }}, + }}}, body.Block.Stmts...) + // Comp, "signature", resetBool, function() { return [useA, useB] } + var signatureArgs = []js_ast.Expr{ + {Data: &js_ast.EIdentifier{Ref: target}}, + {Data: &js_ast.EString{Value: helpers.StringToUTF16(strings.Join(hooksSignatures, " "))}}, + } + var hasRefreshReset = strings.Contains(p.source.Contents, "@refresh reset") + if len(customHooks) > 0 || hasRefreshReset { + signatureArgs = append(signatureArgs, js_ast.Expr{Data: &js_ast.EBoolean{Value: hasRefreshReset}}) + } + if len(customHooks) > 0 { + var customHooksArgs []js_ast.Expr + for _, customHook := range customHooks { + customHooksArgs = append(customHooksArgs, js_ast.Expr{ + Data: &js_ast.EIdentifier{Ref: p.newSymbol(ast.SymbolOther, customHook)}, + }) + } + signatureArgs = append(signatureArgs, + js_ast.Expr{Data: &js_ast.EFunction{ + Fn: js_ast.Fn{ + Body: js_ast.FnBody{ + Block: js_ast.SBlock{ + Stmts: []js_ast.Stmt{{ + Data: &js_ast.SReturn{ + ValueOrNil: js_ast.Expr{Data: &js_ast.EArray{ + IsSingleLine: true, + Items: customHooksArgs, + }}, + }, + }}, + }, + }, + }, + }}, + ) + } + + output.registerSignatureStmts = append( + output.registerSignatureStmts, + // var _s = $RefreshSig$() + js_ast.Stmt{Data: &js_ast.SLocal{ + Kind: js_ast.LocalVar, + Decls: []js_ast.Decl{{ + Binding: js_ast.Binding{Data: &js_ast.BIdentifier{Ref: sigHandle}}, + ValueOrNil: js_ast.Expr{Data: &js_ast.ECall{ + Target: js_ast.Expr{Data: &js_ast.EIdentifier{Ref: p.reactRefreshSig}}, + }}, + }}, + }}, + // _s(...signatureArgs) + js_ast.Stmt{Data: &js_ast.SExpr{ + Value: js_ast.Expr{Data: &js_ast.ECall{ + Target: js_ast.Expr{Data: &js_ast.EIdentifier{Ref: sigHandle}}, + Args: signatureArgs, + }}, + }}, + ) + } + + return output +} + +func (p *parser) reactRefreshVisitComponentBody(target ast.Ref, id string, body *js_ast.FnBody) []js_ast.Stmt { + var stmts []js_ast.Stmt + + var output = p.reactRefreshVisitFunctionBody(target, body, true) + if output.invalidReturnType { + return stmts + } + + if len(output.registerSignatureStmts) > 0 { + stmts = append(stmts, output.registerSignatureStmts...) + } + + stmts = append(stmts, p.reactRefreshRegisterHandle(target, id)) + return stmts +} + +func (p *parser) reactRefreshVisitFunction(fn *js_ast.Fn) []js_ast.Stmt { + var name = p.symbols[fn.Name.Ref.InnerIndex].OriginalName + if isValidReactComponentName(name) { + return p.reactRefreshVisitComponentBody(fn.Name.Ref, name, &fn.Body) + } else if isReactCustomHook(name) { + return p.reactRefreshVisitFunctionBody(fn.Name.Ref, &fn.Body, false).registerSignatureStmts + } + return []js_ast.Stmt{} +} + +func (p *parser) reactRefreshVisitExpression(target ast.Ref, id string, expr *js_ast.Expr) []js_ast.Stmt { + var fnBody *js_ast.FnBody + // Arrow & expression functions + if eArrow, ok := expr.Data.(*js_ast.EArrow); ok { + fnBody = &eArrow.Body + } + if eFn, ok := expr.Data.(*js_ast.EFunction); ok { + fnBody = &eFn.Fn.Body + } + if fnBody != nil { + return p.reactRefreshVisitComponentBody(target, id, fnBody) + } + if eCall, ok := expr.Data.(*js_ast.ECall); ok { + if member, ok := eCall.Target.Data.(*js_ast.EDot); ok { + if importId, ok := member.Target.Data.(*js_ast.EImportIdentifier); ok { + if p.symbols[importId.Ref.InnerIndex].OriginalName == "React" { + if member.Name == "forwardRef" || member.Name == "memo" { + if len(eCall.Args) == 1 { + return p.reactRefreshVisitExpression(target, id, &eCall.Args[0]) + } + } + } + } + } else if importId, ok := eCall.Target.Data.(*js_ast.EImportIdentifier); ok { + var fnName = p.symbols[importId.Ref.InnerIndex].OriginalName + if fnName == "forwardRef" || fnName == "memo" { + if len(eCall.Args) == 1 { + return p.reactRefreshVisitExpression(target, id, &eCall.Args[0]) + } + } + } + } + return []js_ast.Stmt{} +} + func (p *parser) valueToSubstituteForRequire(loc logger.Loc) js_ast.Expr { if p.source.Index != runtime.SourceIndex && config.ShouldCallRuntimeRequire(p.options.mode, p.options.outputFormat) { @@ -8408,6 +8707,31 @@ func (p *parser) visitStmtsAndPrependTempRefs(stmts []js_ast.Stmt, opts prependT p.tempRefsToDeclare = oldTempRefs p.tempRefCount = oldTempRefCount + + if p.options.reactRefresh && p.currentScope == p.moduleScope && len(p.reactRefreshHandles) > 0 { + // Append to the end of the module: + // var _c, _c2; + // $RefreshReg$(_c, "Hello"); + // $RefreshReg$(_c2, "Bar"); + if p.reactRefreshReg == ast.InvalidRef { + p.reactRefreshReg = p.newSymbol(ast.SymbolUnbound, "$RefreshReg$") + } + var decls []js_ast.Decl + for _, handle := range p.reactRefreshHandles { + decls = append(decls, js_ast.Decl{Binding: js_ast.Binding{Data: &js_ast.BIdentifier{Ref: handle.handle}}}) + } + stmts = append(stmts, js_ast.Stmt{Data: &js_ast.SLocal{Kind: js_ast.LocalVar, Decls: decls}}) + for _, handle := range p.reactRefreshHandles { + stmts = append(stmts, js_ast.Stmt{Data: &js_ast.SExpr{Value: js_ast.Expr{Data: &js_ast.ECall{ + Target: js_ast.Expr{Data: &js_ast.EIdentifier{Ref: p.reactRefreshReg}}, + Args: []js_ast.Expr{ + {Data: &js_ast.EIdentifier{Ref: handle.handle}}, + {Data: &js_ast.EString{Value: helpers.StringToUTF16(handle.id)}}, + }, + }}}}) + } + } + return stmts } @@ -10006,6 +10330,13 @@ func (p *parser) visitAndAppendStmt(stmts []js_ast.Stmt, stmt js_ast.Stmt) []js_ p.visitFn(&s2.Fn, s2.Fn.OpenParenLoc, visitFnOpts{}) stmts = append(stmts, stmt) + if p.options.reactRefresh && s2.Fn.Name != nil { + var reactRefreshStmts = p.reactRefreshVisitFunction(&s2.Fn) + if len(reactRefreshStmts) > 0 { + stmts = append(stmts, reactRefreshStmts...) + } + } + // Optionally preserve the name if p.options.keepNames { p.symbols[s2.Fn.Name.Ref.InnerIndex].Flags |= ast.DidKeepName @@ -10157,6 +10488,7 @@ func (p *parser) visitAndAppendStmt(stmts []js_ast.Stmt, stmt js_ast.Stmt) []js_ // Local statements do not end the const local prefix p.currentScope.IsAfterConstLocalPrefix = wasAfterAfterConstLocalPrefix + var registerRefreshStmts []js_ast.Stmt for i := range s.Decls { d := &s.Decls[i] p.visitBinding(d.Binding, bindingOpts{}) @@ -10174,9 +10506,47 @@ func (p *parser) visitAndAppendStmt(stmts []js_ast.Stmt, stmt js_ast.Stmt) []js_ p.nameToKeepIsFor = d.ValueOrNil.Data } } + if p.options.reactRefresh && p.currentScope == p.moduleScope { + // This case is done before visitExpr because loadNameFromRef throws invalid symbol reference otherwise + if id, ok := d.Binding.Data.(*js_ast.BIdentifier); ok { + var name = p.symbols[id.Ref.InnerIndex].OriginalName + if isValidReactComponentName(name) { + if eCall, ok := d.ValueOrNil.Data.(*js_ast.ECall); ok { + // HOC calls (e.g. const Foo = connect(Bar)) + if len(eCall.Args) == 1 { + if argId, ok := eCall.Args[0].Data.(*js_ast.EIdentifier); ok { + if isValidReactComponentName(p.loadNameFromRef(argId.Ref)) { + registerRefreshStmts = append(registerRefreshStmts, p.reactRefreshRegisterHandle(id.Ref, name)) + } + } + } + } + } + } + } d.ValueOrNil = p.visitExpr(d.ValueOrNil) + if p.options.reactRefresh && p.currentScope == p.moduleScope { + if id, ok := d.Binding.Data.(*js_ast.BIdentifier); ok { + var name = p.symbols[id.Ref.InnerIndex].OriginalName + if isValidReactComponentName(name) { + var reactRefreshStmts = p.reactRefreshVisitExpression(id.Ref, name, &d.ValueOrNil) + if len(reactRefreshStmts) > 0 { + registerRefreshStmts = append(registerRefreshStmts, reactRefreshStmts...) + } + } + if isReactCustomHook(name) { + if eArrow, ok := d.ValueOrNil.Data.(*js_ast.EArrow); ok { + var data = p.reactRefreshVisitFunctionBody(id.Ref, &eArrow.Body, false) + if len(data.registerSignatureStmts) > 0 { + registerRefreshStmts = append(registerRefreshStmts, data.registerSignatureStmts...) + } + } + } + } + } + p.shouldFoldTypeScriptConstantExpressions = oldShouldFoldTypeScriptConstantExpressions // Initializing to undefined is implicit, but be careful to not @@ -10287,6 +10657,12 @@ func (p *parser) visitAndAppendStmt(stmts []js_ast.Stmt, stmt js_ast.Stmt) []js_ } } + if len(registerRefreshStmts) > 0 { + stmts = append(stmts, stmt) + stmts = append(stmts, registerRefreshStmts...) + return stmts + } + case *js_ast.SExpr: shouldTrimUnsightlyPrimitives := !p.options.minifySyntax && !isUnsightlyPrimitive(s.Value.Data) p.stmtExprValue = s.Value.Data @@ -10688,6 +11064,13 @@ func (p *parser) visitAndAppendStmt(stmts []js_ast.Stmt, stmt js_ast.Stmt) []js_ stmts = append(stmts, stmt) } + if p.options.reactRefresh && p.currentScope == p.moduleScope && s.Fn.Name != nil { + var reactRefreshStmts = p.reactRefreshVisitFunction(&s.Fn) + if len(reactRefreshStmts) > 0 { + stmts = append(stmts, reactRefreshStmts...) + } + } + // Optionally preserve the name if p.options.keepNames { symbol := &p.symbols[s.Fn.Name.Ref.InnerIndex] @@ -16542,6 +16925,9 @@ func newParser(log logger.Log, source logger.Source, lexer js_lexer.Lexer, optio jsxRuntimeImports: make(map[string]ast.LocRef), jsxLegacyImports: make(map[string]ast.LocRef), + reactRefreshReg: ast.InvalidRef, + reactRefreshSig: ast.InvalidRef, + suppressWarningsAboutWeirdCode: helpers.IsInsideNodeModules(source.KeyPath.Text), } diff --git a/lib/shared/common.ts b/lib/shared/common.ts index d57ec310f0e..9c2f9f76453 100644 --- a/lib/shared/common.ts +++ b/lib/shared/common.ts @@ -157,6 +157,7 @@ function pushCommonFlags(flags: string[], options: CommonOptions, keys: OptionKe let jsxImportSource = getFlag(options, keys, 'jsxImportSource', mustBeString) let jsxDev = getFlag(options, keys, 'jsxDev', mustBeBoolean) let jsxSideEffects = getFlag(options, keys, 'jsxSideEffects', mustBeBoolean) + let reactRefresh = getFlag(options, keys, 'reactRefresh', mustBeBoolean) let define = getFlag(options, keys, 'define', mustBeObject) let logOverride = getFlag(options, keys, 'logOverride', mustBeObject) let supported = getFlag(options, keys, 'supported', mustBeObject) @@ -197,6 +198,7 @@ function pushCommonFlags(flags: string[], options: CommonOptions, keys: OptionKe if (jsxImportSource) flags.push(`--jsx-import-source=${jsxImportSource}`) if (jsxDev) flags.push(`--jsx-dev`) if (jsxSideEffects) flags.push(`--jsx-side-effects`) + if (reactRefresh) flags.push(`--react-refresh`) if (define) { for (let key in define) { diff --git a/lib/shared/types.ts b/lib/shared/types.ts index d938a6df626..aa1b3a05bbd 100644 --- a/lib/shared/types.ts +++ b/lib/shared/types.ts @@ -67,6 +67,8 @@ interface CommonOptions { jsxDev?: boolean /** Documentation: https://esbuild.github.io/api/#jsx-side-effects */ jsxSideEffects?: boolean + /** Documentation: https://esbuild.github.io/api/#react-refresh */ + reactRefresh?: boolean /** Documentation: https://esbuild.github.io/api/#define */ define?: { [key: string]: string } diff --git a/pkg/api/api.go b/pkg/api/api.go index 155e1843655..83809a76cc8 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -300,6 +300,7 @@ type BuildOptions struct { JSXImportSource string // Documentation: https://esbuild.github.io/api/#jsx-import-source JSXDev bool // Documentation: https://esbuild.github.io/api/#jsx-dev JSXSideEffects bool // Documentation: https://esbuild.github.io/api/#jsx-side-effects + ReactRefresh bool // Documentation: https://esbuild.github.io/api/#react-refresh Define map[string]string // Documentation: https://esbuild.github.io/api/#define Pure []string // Documentation: https://esbuild.github.io/api/#pure @@ -435,6 +436,7 @@ type TransformOptions struct { JSXImportSource string // Documentation: https://esbuild.github.io/api/#jsx-import-source JSXDev bool // Documentation: https://esbuild.github.io/api/#jsx-dev JSXSideEffects bool // Documentation: https://esbuild.github.io/api/#jsx-side-effects + ReactRefresh bool // Documentation: https://esbuild.github.io/api/#react-refresh TsconfigRaw string // Documentation: https://esbuild.github.io/api/#tsconfig-raw Banner string // Documentation: https://esbuild.github.io/api/#banner diff --git a/pkg/api/api_impl.go b/pkg/api/api_impl.go index d8b919758ee..809cd3336ac 100644 --- a/pkg/api/api_impl.go +++ b/pkg/api/api_impl.go @@ -1271,6 +1271,7 @@ func validateBuildOptions( ImportSource: buildOpts.JSXImportSource, SideEffects: buildOpts.JSXSideEffects, }, + ReactRefresh: buildOpts.ReactRefresh, Defines: defines, InjectedDefines: injectedDefines, Platform: platform, @@ -1726,6 +1727,7 @@ func transformImpl(input string, transformOpts TransformOptions) TransformResult ImportSource: transformOpts.JSXImportSource, SideEffects: transformOpts.JSXSideEffects, }, + ReactRefresh: transformOpts.ReactRefresh, Defines: defines, InjectedDefines: injectedDefines, Platform: platform, diff --git a/pkg/cli/cli_impl.go b/pkg/cli/cli_impl.go index 8e292cc315b..cf256b8748c 100644 --- a/pkg/cli/cli_impl.go +++ b/pkg/cli/cli_impl.go @@ -708,6 +708,15 @@ func parseOptionsImpl( transformOpts.JSXSideEffects = value } + case isBoolFlag(arg, "--react-refresh"): + if value, err := parseBoolFlag(arg, true); err != nil { + return parseOptionsExtras{}, err + } else if buildOpts != nil { + buildOpts.ReactRefresh = value + } else { + transformOpts.ReactRefresh = value + } + case strings.HasPrefix(arg, "--banner=") && transformOpts != nil: transformOpts.Banner = arg[len("--banner="):] @@ -827,6 +836,7 @@ func parseOptionsImpl( "minify-whitespace": true, "minify": true, "preserve-symlinks": true, + "react-refresh": true, "sourcemap": true, "splitting": true, "watch": true, diff --git a/scripts/esbuild.js b/scripts/esbuild.js index 415b5d34bb3..7a0d89c9f1a 100644 --- a/scripts/esbuild.js +++ b/scripts/esbuild.js @@ -329,7 +329,7 @@ exports.installForTests = () => { fs.mkdirSync(installDir) fs.writeFileSync(path.join(installDir, 'package.json'), '{}') childProcess.execSync(`npm pack --silent "${npmDir}"`, { cwd: installDir, stdio: 'inherit' }) - childProcess.execSync(`npm install --silent --no-audit --no-optional --ignore-scripts=false --progress=false esbuild-${version}.tgz`, { cwd: installDir, env, stdio: 'inherit' }) + childProcess.execSync(`npm install --silent --prefer-offline --no-audit --no-optional --ignore-scripts=false --progress=false esbuild-${version}.tgz`, { cwd: installDir, env, stdio: 'inherit' }) // Evaluate the code const ESBUILD_PACKAGE_PATH = path.join(installDir, 'node_modules', 'esbuild') diff --git a/scripts/js-api-tests.js b/scripts/js-api-tests.js index 3c5d822c2a1..b9e033f0076 100644 --- a/scripts/js-api-tests.js +++ b/scripts/js-api-tests.js @@ -7336,6 +7336,343 @@ let childProcessTests = { }, } +let refreshTests = [ + { + name: "registers top-level function declarations", + input: ` +export function Hello() { + function handleClick() {} + return

Hi

; +} +export default function Bar() { + return ; +} +function Baz() { + return

OK

; +} + +const NotAComp = 'hi'; +export { Baz, NotAComp }; + +export function sum() {} +export const Bad = 42; +`, + output: ` +export function Hello() { + function handleClick() { + } + return

Hi

; +} +_c = Hello; +export default function Bar() { + return ; +} +_c2 = Bar; +function Baz() { + return

OK

; +} +_c3 = Baz; +const NotAComp = "hi"; +export { Baz, NotAComp }; +export function sum() { +} +export const Bad = 42; +var _c, _c2, _c3; +$RefreshReg$(_c, "Hello"); +$RefreshReg$(_c2, "Bar"); +$RefreshReg$(_c3, "Baz"); +`.trimStart(), + }, + { + name: "registers top-level named arrow functions", + input: ` +export const Hello = () => { + function handleClick() {} + return

Hi

; +}; +export let Bar = (props) => ; +export default () => { + // This one should be ignored. + // You should name your components. + return ; +}; +const Baz = () => { + return ; +}; +`, + output: ` +export const Hello = () => { + function handleClick() { + } + return

Hi

; +}; +_c = Hello; +export let Bar = (props) => ; +_c2 = Bar; +export default () => { + return ; +}; +const Baz = () => { + return ; +}; +_c3 = Baz; +var _c, _c2, _c3; +$RefreshReg$(_c, "Hello"); +$RefreshReg$(_c2, "Bar"); +$RefreshReg$(_c3, "Baz"); +`.trimStart(), + }, + { + name: "registers memo & forwardRef", + input: ` +import React, { memo, forwardRef } from "react"; +const A = React.forwardRef(function() { + return

Foo

; +}); +const B = memo(forwardRef(() => { + return

Foo

; +})); +`, + output: ` +import React, { memo, forwardRef } from "react"; +const A = React.forwardRef(function() { + return

Foo

; +}); +_c = A; +const B = memo(forwardRef(() => { + return

Foo

; +})); +_c2 = B; +var _c, _c2; +$RefreshReg$(_c, "A"); +$RefreshReg$(_c2, "B"); +`.trimStart(), + }, + { + name: "ignores complex definitions", + input: ` +let A = foo ? () => { + return

Hi

; +} : null +const B = (function Foo() { + return

Hi

; +})(); +let C = () => () => { + return

Hi

; +}; +let D = bar && (() => { + return

Hi

; +}); +`, + output: ` +let A = foo ? () => { + return

Hi

; +} : null; +const B = function Foo() { + return

Hi

; +}(); +let C = () => () => { + return

Hi

; +}; +let D = bar && (() => { + return

Hi

; +}); +`.trimStart(), + }, + { + name: "registers capitalized identifiers in HOC calls", + input: ` +function Foo() { + return

Hi

; +} +export default hoc(Foo); // Will be registered at runtime +export const A = hoc(Foo); +const B = hoc(Foo); +`, + output: ` +function Foo() { + return

Hi

; +} +_c = Foo; +export default hoc(Foo); +export const A = hoc(Foo); +_c2 = A; +const B = hoc(Foo); +_c3 = B; +var _c, _c2, _c3; +$RefreshReg$(_c, "Foo"); +$RefreshReg$(_c2, "A"); +$RefreshReg$(_c3, "B"); +`.trimStart(), + }, + { + name: "generates signatures for function declarations calling hooks", + input: ` +import React, { useState, useReducer, useCallback } from "react"; +export function App() { + const [foo, setFoo] = useState(0); + const [state, dispatch] = useReducer(reducer, createInitialState(username)); + const reducerV2 = useReducer(reducer, username, createInitialState); + useCallback(() => {}); + React.useEffect(() => {}); + return

{foo}

; +} +`, + output: ` +import React, { useState, useReducer, useCallback } from "react"; +export function App() { + _s(); + const [foo, setFoo] = useState(0); + const [state, dispatch] = useReducer(reducer, createInitialState(username)); + const reducerV2 = useReducer(reducer, username, createInitialState); + useCallback(() => { + }); + React.useEffect(() => { + }); + return

{foo}

; +} +var _s = $RefreshSig$(); +_s(App, "useState{0} useReducer{createInitialState(username)} useReducer{username, createInitialState} useCallback{} useEffect{}"); +_c = App; +var _c; +$RefreshReg$(_c, "App"); +`.trimStart(), + }, + { + name: "generates signatures for memo & forwardRef", + input: + ` +import React, { useState, forwardRef, memo } from "react"; +export const A = React.memo(React.forwardRef((props, ref) => { + const [foo, setFoo] = useState(0); + React.useEffect(() => {}); + return

{foo}

; +})); + +export const B = forwardRef(memo(function(props, ref) { + const [foo, setFoo] = useState(0); + React.useEffect(() => {}); + return

{foo}

; +})); +`, + output: ` +import React, { useState, forwardRef, memo } from "react"; +export const A = React.memo(React.forwardRef((props, ref) => { + _s(); + const [foo, setFoo] = useState(0); + React.useEffect(() => { + }); + return

{foo}

; +})); +var _s = $RefreshSig$(); +_s(A, "useState{0} useEffect{}"); +_c = A; +export const B = forwardRef(memo(function(props, ref) { + _s2(); + const [foo, setFoo] = useState(0); + React.useEffect(() => { + }); + return

{foo}

; +})); +var _s2 = $RefreshSig$(); +_s2(B, "useState{0} useEffect{}"); +_c2 = B; +var _c, _c2; +$RefreshReg$(_c, "A"); +$RefreshReg$(_c2, "B"); +`.trimStart(), + }, + { + name: "includes custom hooks into the signatures", + input: ` +import React from "react"; +import FancyHooks from "fancy"; + +function useFancyState() { + const [foo, setFoo] = React.useState(0); + useFancyEffect(); + return foo; +} + +const useFancyEffect = () => { + React.useEffect(() => {}); +}; + +export default function App() { + const bar = FancyHooks.useThing(); + const baz = useFancyState(); + React.useState(); + FancyHooks.useThePlatform(); + useFancyEffect(); + return

{bar}

; +} +`, + output: ` +import React from "react"; +import FancyHooks from "fancy"; +function useFancyState() { + _s(); + const [foo, setFoo] = React.useState(0); + useFancyEffect(); + return foo; +} +var _s = $RefreshSig$(); +_s(useFancyState, "useState{0} useFancyEffect{}", false, function() { + return [useFancyEffect]; +}); +const useFancyEffect = () => { + _s2(); + React.useEffect(() => { + }); +}; +var _s2 = $RefreshSig$(); +_s2(useFancyEffect, "useEffect{}"); +export default function App() { + _s3(); + const bar = FancyHooks.useThing(); + const baz = useFancyState(); + React.useState(); + FancyHooks.useThePlatform(); + useFancyEffect(); + return

{bar}

; +} +var _s3 = $RefreshSig$(); +_s3(App, "useThing{} useFancyState{} useState{} useThePlatform{} useFancyEffect{}", false, function() { + return [FancyHooks.useThing, useFancyState, FancyHooks.useThePlatform, useFancyEffect]; +}); +_c = App; +var _c; +$RefreshReg$(_c, "App"); +`.trimStart(), + }, + { + name: "handle @refresh reset comments", + input: ` +import { useState } from "react"; +/* @refresh reset */ +function useFancyState() { + const [foo, setFoo] = useState(0); + return foo; +}`, + output: ` +import { useState } from "react"; +function useFancyState() { + _s(); + const [foo, setFoo] = useState(0); + return foo; +} +var _s = $RefreshSig$(); +_s(useFancyState, "useState{0}", true); +`.trimStart(), + }, +].map(t => [ + t.name, + async ({ esbuild }) => assert.strictEqual( + (await esbuild.transform(t.input, { loader: 'jsx', reactRefresh: true, jsx: 'preserve' })).code, + t.output + ) +]); + async function assertSourceMap(jsSourceMap, source) { jsSourceMap = JSON.parse(jsSourceMap) assert.deepStrictEqual(jsSourceMap.version, 3) @@ -7381,6 +7718,7 @@ async function main() { ...Object.entries(analyzeTests), ...Object.entries(syncTests), ...Object.entries(childProcessTests), + ...refreshTests, ] const allTestsPassed = (await Promise.all(tests.map(([name, fn]) => {