-
Notifications
You must be signed in to change notification settings - Fork 83
/
ReactComponent.fs
222 lines (195 loc) · 11.2 KB
/
ReactComponent.fs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
namespace Feliz
open Fable
open Fable.AST
open Fable.AST.Fable
// Tell Fable to scan for plugins in this assembly
[<assembly:ScanForPlugins>]
do()
module internal ReactComponentHelpers =
let (|ReactMemo|_|) = function
| Import({ Selector = "memo"; Path = "react" },_,_) as e -> Some e
| Get(Import({ Path = "react" },_,_),kind,_,_) as e ->
match kind with
| ExprGet(Value(StringConstant "memo",_)) -> Some e
| FieldGet i when i.Name = "memo" -> Some e
| _ -> None
| _ -> None
let injectReactImport body =
let body = match body with Sequential body -> body | _ -> [body]
Sequential [
AstUtils.makeImport "default as React" "react"
yield! body
]
let applyImportOrMemo import from memo (decl: MemberDecl) =
match import, from, memo with
| Some _, Some _, _ ->
let reactElType = decl.Body.Type
// imported component doesn't need to emit any JS
let tags = "remove-declaration" :: decl.Tags
{ decl with Body = AstUtils.emptyReactElement reactElType; Tags = tags }
| _, _, Some true ->
let memoFn = AstUtils.makeImport "memo" "react"
let body =
decl.Body
|> injectReactImport
|> fun body -> [Delegate(decl.Args, body, None, Tags.empty)]
|> AstUtils.makeCall memoFn
// Change declaration kind from function to value
let info =
AstUtils.memberName decl.MemberRef
|> AstUtils.makeMemberInfo false body.Type
|> GeneratedValue
|> GeneratedMemberRef
{ decl with MemberRef = info; Args = []; Body = body }
| _ -> { decl with Body = injectReactImport decl.Body }
open ReactComponentHelpers
/// <summary>Transforms a function into a React function component. Make sure the function is defined at the module level</summary>
type ReactComponentAttribute(?exportDefault: bool, ?import: string, ?from:string, ?memo: bool) =
inherit MemberDeclarationPluginAttribute()
override _.FableMinimumVersion = "4.0"
new() = ReactComponentAttribute(exportDefault=false)
new(exportDefault: bool) = ReactComponentAttribute(exportDefault=exportDefault,?import=None, ?from=None)
new(import: string, from: string) = ReactComponentAttribute(exportDefault=false,import=import, from=from)
/// <summary>Transforms call-site into createElement calls</summary>
override this.TransformCall(compiler, memb, expr) =
let reactElType = expr.Type
let membArgs = memb.CurriedParameterGroups |> List.concat
match expr with
| Call(callee, info, _typeInfo, _range) ->
let reactComponent =
match import, from with
| Some importedMember, Some externalModule ->
AstUtils.makeImport importedMember externalModule
| _ ->
callee
if List.length membArgs = info.Args.Length && info.Args.Length = 1 && AstUtils.isRecord compiler info.Args[0].Type then
// F# Component { Value = 1 }
// JSX <Component Value={1} />
// JS createElement(Component, { Value: 1 })
if AstUtils.recordHasField "Key" compiler info.Args[0].Type then
// When the key property is upper-case (which is common in record fields)
// then we should rewrite it
let modifiedRecord = AstUtils.emitJs "(($value) => { $value.key = $value.Key; return $value; })($0)" [info.Args[0]]
AstUtils.createElement reactElType [reactComponent; modifiedRecord]
else
AstUtils.createElement reactElType [reactComponent; info.Args[0]]
elif info.Args.Length = 1 && info.Args[0].Type = Type.Unit then
// F# Component()
// JSX <Component />
// JS createElement(Component, null)
AstUtils.createElement reactElType [reactComponent; AstUtils.nullValue]
else
let mutable keyBinding = None
let propsObj =
List.zip (List.take info.Args.Length membArgs) info.Args
|> List.collect (fun (arg, expr) ->
match arg.Name, expr with
| Some "key", IdentExpr _ -> ["key", expr; "$key", expr]
| Some "key", _ ->
let keyIdent = AstUtils.makeUniqueIdent "key"
keyBinding <- Some(keyIdent, expr)
["key", IdentExpr keyIdent; "$key", IdentExpr keyIdent]
| Some name, _ -> [name, expr]
| None, _ -> [])
|> AstUtils.objExpr
let reactEl = AstUtils.createElement reactElType [reactComponent; propsObj]
let expr =
match keyBinding with
| None -> reactEl
| Some(ident, value) -> Let(ident, value, reactEl)
match [|memo, callee|] with
// If the call is memo and the function has an identifier, we can set the displayName
| [|(Some true), (IdentExpr i)|] ->
Sequential [
(AstUtils.makeSet (IdentExpr(i)) "displayName" (AstUtils.makeStrConst i.Name))
expr
]
| _ -> expr
| _ ->
// return expression as is when it is not a call expression
expr
override this.Transform(compiler, file, decl) =
let info = compiler.GetMember(decl.MemberRef)
if info.IsValue || info.IsGetter || info.IsSetter then
// Invalid attribute usage
let errorMessage = sprintf "Expecting a function declaration for %s when using [<ReactComponent>]" decl.Name
compiler.LogWarning(errorMessage, ?range=decl.Body.Range)
decl
else if not (AstUtils.isReactElement decl.Body.Type) then
// output of a React function component must be a ReactElement
let errorMessage = sprintf "Expected function %s to return a ReactElement when using [<ReactComponent>]. Instead it returns %A" decl.Name decl.Body.Type
compiler.LogWarning(errorMessage, ?range=decl.Body.Range)
decl
else
if (AstUtils.isCamelCase decl.Name) then
compiler.LogWarning(sprintf "React function component '%s' is written in camelCase format. Please consider declaring it in PascalCase (i.e. '%s') to follow conventions of React applications and allow tools such as react-refresh to pick it up." decl.Name (AstUtils.capitalize decl.Name))
let decl =
match exportDefault with
| Some true -> { decl with Tags = "export-default"::decl.Tags }
| Some false | None -> decl
// do not rewrite components accepting records as input
if decl.Args.Length = 1 && AstUtils.isRecord compiler decl.Args[0].Type then
// check whether the record type is defined in this file
// trigger warning if that is case
let definedInThisFile =
file.Declarations
|> List.tryPick (fun declaration ->
match declaration with
| Declaration.ClassDeclaration classDecl ->
let classEntity = compiler.GetEntity(classDecl.Entity)
match decl.Args[0].Type with
| Type.DeclaredType (entity, _genericArgs) ->
let declaredEntity = compiler.GetEntity(entity)
if classEntity.IsFSharpRecord && declaredEntity.FullName = classEntity.FullName
then Some declaredEntity.FullName
else None
| _ -> None
| Declaration.ActionDeclaration _action ->
None
| _ ->
None
)
match definedInThisFile with
| Some recordTypeName ->
let errorMsg = String.concat "" [
sprintf "Function component '%s' is using a record type '%s' as an input parameter. " decl.Name recordTypeName
"This happens to break React tooling like react-refresh and hot module reloading. "
"To fix this issue, consider using an anonymous record instead or multiple simpler values as input parameters (can be tupled). "
"Future versions of [<ReactComponent>] might not emit this warning anymore, in which case you can assume that the issue is fixed. "
"To learn more about the issue, see https://github.com/pmmmwh/react-refresh-webpack-plugin/issues/258"
]
compiler.LogWarning(errorMsg, ?range=decl.Body.Range)
| None ->
// nothing to report
()
decl
|> applyImportOrMemo import from memo
else if decl.Args.Length = 1 && decl.Args[0].Type = Type.Unit then
// remove arguments from functions requiring unit as input
{ decl with Args = [ ] }
|> applyImportOrMemo import from memo
else
// rewrite all other arguments into getters of a single props object
// TODO: transform any callback into into useCallback(callback) to stabilize reference
let propsArg = AstUtils.makeIdent (sprintf "%sInputProps" (AstUtils.camelCase decl.Name))
let propBindings =
([], decl.Args) ||> List.fold (fun bindings arg ->
let getterKey = if arg.DisplayName = "key" then "$key" else arg.DisplayName
let getterKind = ExprGet(AstUtils.makeStrConst getterKey)
let getter = Get(IdentExpr propsArg, getterKind, Any, None)
(arg, getter)::bindings)
|> List.rev
let body =
match decl.Body with
// If the body is surrounded by a memo call we put the bindings within the call
// because Fable will later move the surrounding function into memo
| Call(ReactMemo reactMemo, ({ Args = arg::restArgs } as callInfo), t, r) ->
let arg = propBindings |> List.fold (fun body (k,v) -> Let(k, v, body)) arg
Call(reactMemo, { callInfo with Args = arg::restArgs }, t, r)
| _ ->
propBindings |> List.fold (fun body (k,v) -> Let(k, v, body)) decl.Body
{ decl with Args = [propsArg]; Body = body }
|> applyImportOrMemo import from memo
type ReactMemoComponentAttribute(?exportDefault: bool) =
inherit ReactComponentAttribute(?exportDefault=exportDefault, ?import=None, ?from=None, memo=true)
new() = ReactMemoComponentAttribute(exportDefault=false)