expo-modules-macros 0.21.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/.github/workflows/linux.yml +8 -5
  2. package/.github/workflows/publish.yml +26 -11
  3. package/.github/workflows/swift-format.yml +79 -0
  4. package/.github/workflows/swift.yml +7 -4
  5. package/.github/workflows/windows.yml +8 -5
  6. package/AGENTS.md +16 -9
  7. package/README.md +20 -5
  8. package/apple/ExpoModulesMacros +0 -0
  9. package/apple/Package.resolved +3 -3
  10. package/apple/Package.swift +1 -2
  11. package/apple/Sources/ExpoModulesMacros/ClosureArguments.swift +39 -14
  12. package/apple/Sources/ExpoModulesMacros/DecorateModuleBuilder.swift +144 -30
  13. package/apple/Sources/ExpoModulesMacros/EventMacro.swift +29 -15
  14. package/apple/Sources/ExpoModulesMacros/ExpoModuleMacro.swift +63 -68
  15. package/apple/Sources/ExpoModulesMacros/ExpoModulesOptimizedMacro.swift +2 -1
  16. package/apple/Sources/ExpoModulesMacros/FreeFormTypes.swift +2 -61
  17. package/apple/Sources/ExpoModulesMacros/JSConstructor.swift +6 -3
  18. package/apple/Sources/ExpoModulesMacros/JSMacro.swift +184 -48
  19. package/apple/Sources/ExpoModulesMacros/JSNameCollisions.swift +26 -6
  20. package/apple/Sources/ExpoModulesMacros/MacroHelpers.swift +172 -43
  21. package/apple/Sources/ExpoModulesMacros/RecordMacro.swift +286 -222
  22. package/apple/Sources/ExpoModulesMacros/SharedObjectMacro.swift +49 -56
  23. package/apple/Sources/ExpoModulesMacros/TypeConformanceAssertion.swift +57 -27
  24. package/apple/Sources/ExpoModulesMacros/UnionMacro.swift +23 -9
  25. package/apple/Sources/ExpoModulesMacros/ViewPropsMacro.swift +10 -5
  26. package/apple/Sources/ExpoModulesScanner/CLI.swift +3 -1
  27. package/apple/Sources/ExpoModulesScanner/Core/DetectionVisitor.swift +11 -7
  28. package/apple/Sources/ExpoModulesScanner/Core/ScanBuildConfiguration.swift +2 -1
  29. package/apple/Sources/ExpoModulesScanner/Core/SourceScan.swift +10 -7
  30. package/apple/Sources/ExpoModulesScanner/Exports/Availability.swift +6 -3
  31. package/apple/Sources/ExpoModulesScanner/Exports/ExportedSurface.swift +20 -4
  32. package/apple/Sources/ExpoModulesScanner/Exports/ResolveRefs.swift +18 -5
  33. package/apple/Sources/ExpoModulesScanner/Exports/ScanExports.swift +8 -3
  34. package/apple/Sources/ExpoModulesScanner/Exports/SurfaceVisitor.swift +179 -46
  35. package/apple/Sources/ExpoModulesScanner/Exports/TypeNode.swift +28 -8
  36. package/apple/Sources/ExpoModulesScanner/Modules/ScanModules.swift +8 -2
  37. package/build/index.d.ts +1 -1
  38. package/build/index.js +3 -3
  39. package/build/types.d.ts +32 -2
  40. package/build/types.js +1 -1
  41. package/package.json +8 -5
@@ -13,18 +13,27 @@ import SwiftSyntax
13
13
  /// unwrap, arity guard, argument decode, all while `this` and the arguments are still valid)
14
14
  /// and returns the async body (call and result encode), so nothing JSI-owned crosses the asynchronous
15
15
  /// boundary and JS gets a promise.
16
+ ///
17
+ /// A synchronous function marked `@JS(.withAsync("name"))` also binds a promise-returning variant
18
+ /// under that name (see `asyncVariant`). It decodes and encodes like an `async` function, and calls
19
+ /// the function on the concurrent pool.
16
20
  internal struct JSFunction {
17
21
  let swiftName: String
18
- let jsName: String
22
+ private(set) var jsName: String
19
23
  let parameters: [FunctionParameterSyntax]
20
24
  /// The declared return type as written, or `nil` when the function returns `Void`/nothing.
21
25
  let returnType: String?
22
26
  /// How the return value encodes when its type mentions `Any`, or `nil` for any other type.
23
27
  let returnFreeForm: FreeFormConversion?
24
28
  let isThrowing: Bool
25
- let isAsync: Bool
29
+ private(set) var isAsync: Bool
26
30
  /// The function's name in the source, where a JavaScript name collision is reported.
27
- let nameToken: TokenSyntax
31
+ private(set) var nameToken: TokenSyntax
32
+ /// The name from `.withAsync("name")` and its token, or `nil` when the attribute doesn't list it.
33
+ private var asyncVariantName: (name: String, token: TokenSyntax)?
34
+ /// True for the binding `asyncVariant` returns: it calls the synchronous function on the concurrent
35
+ /// pool instead of on the JavaScript thread.
36
+ private(set) var isAsyncVariant = false
28
37
 
29
38
  init(funcDecl: FunctionDeclSyntax, attribute: AttributeSyntax) {
30
39
  self.swiftName = funcDecl.name.text
@@ -39,6 +48,23 @@ internal struct JSFunction {
39
48
  let effectSpecifiers = funcDecl.signature.effectSpecifiers
40
49
  self.isThrowing = effectSpecifiers?.throwsClause?.throwsSpecifier != nil
41
50
  self.isAsync = effectSpecifiers?.asyncSpecifier != nil
51
+ self.asyncVariantName = asyncVariantOption(of: attribute).flatMap(asyncVariantName(of:))
52
+ }
53
+
54
+ /// The promise-returning binding that `.withAsync("name")` adds next to a synchronous function, or
55
+ /// `nil` when the attribute doesn't list it. An `async` function gets none: `@JS` reports the option
56
+ /// there.
57
+ var asyncVariant: JSFunction? {
58
+ guard let asyncVariantName, !isAsync else {
59
+ return nil
60
+ }
61
+ var variant = self
62
+ variant.jsName = asyncVariantName.name
63
+ variant.nameToken = asyncVariantName.token
64
+ variant.isAsync = true
65
+ variant.isAsyncVariant = true
66
+ variant.asyncVariantName = nil
67
+ return variant
42
68
  }
43
69
 
44
70
  /// The number of leading parameters that must always be supplied: the total minus the maximal
@@ -79,14 +105,16 @@ internal struct JSFunction {
79
105
  guard arguments.count == \(maximum) else {
80
106
  throw Exceptions.ArgumentsRangeMismatch((functionName: "\(jsName)", received: arguments.count, required: \(required), maximum: \(maximum)))
81
107
  }
82
- """)
108
+ """
109
+ )
83
110
  } else {
84
111
  lines.append(
85
112
  """
86
113
  guard arguments.count >= \(required) && arguments.count <= \(maximum) else {
87
114
  throw Exceptions.ArgumentsRangeMismatch((functionName: "\(jsName)", received: arguments.count, required: \(required), maximum: \(maximum)))
88
115
  }
89
- """)
116
+ """
117
+ )
90
118
  }
91
119
 
92
120
  // Decode the required prefix once — these slots are present in every accepted arity, so the
@@ -142,7 +170,8 @@ internal struct JSFunction {
142
170
  lines.append(contentsOf: encodeResultLines())
143
171
  }
144
172
 
145
- return lines
173
+ return
174
+ lines
146
175
  .flatMap { $0.split(separator: "\n", omittingEmptySubsequences: false) }
147
176
  .map { indent + $0 }
148
177
  .joined(separator: "\n")
@@ -152,14 +181,22 @@ internal struct JSFunction {
152
181
  /// the function's async body (`AsyncFunctionBody`), awaiting the call and encoding the result. It
153
182
  /// captures only what the call needs — the decoded `arg<i>` locals, the receiver, and `runtime`
154
183
  /// for the encode — never `this` or the arguments buffer, which are only valid for the duration
155
- /// of the host call.
184
+ /// of the host call. The `asyncVariant` binding awaits its call on the concurrent pool instead.
156
185
  private func asyncBodyLines(receiver: Receiver, arity: Int) -> [String] {
157
186
  var lines: [String] = ["return {"]
187
+ let call =
188
+ isAsyncVariant
189
+ ? concurrentCallExpression(receiver: receiver, arity: arity)
190
+ : callExpression(receiver: receiver, arity: arity)
191
+ // The concurrent call spans several lines, so each of its lines is indented into the body.
192
+ let indented = { (statement: String) in
193
+ statement.split(separator: "\n", omittingEmptySubsequences: false).map { " " + $0 }
194
+ }
158
195
  if returnType != nil {
159
- lines.append(" let result = \(callExpression(receiver: receiver, arity: arity))")
196
+ lines.append(contentsOf: indented("let result = \(call)"))
160
197
  lines.append(contentsOf: encodeResultLines().map { " " + $0 })
161
198
  } else {
162
- lines.append(" \(callExpression(receiver: receiver, arity: arity))")
199
+ lines.append(contentsOf: indented(call))
163
200
  lines.append(" return .undefined()")
164
201
  }
165
202
  lines.append("}")
@@ -179,10 +216,36 @@ internal struct JSFunction {
179
216
  )
180
217
  }
181
218
 
182
- /// The `<callee>.<name>(...)` call for the given arity. Slots `0..<arity` are passed their decoded
219
+ /// The `<callee>.<name>(...)` call for the given arity, with the arguments from `callArguments`.
220
+ private func callExpression(receiver: Receiver, arity: Int) -> String {
221
+ // `try` only for a throwing function. An `async` call that doesn't throw must not get one, or the
222
+ // expansion warns that no throwing call occurs within the `try` expression.
223
+ let tryKeyword = isThrowing ? "try " : ""
224
+ let awaitKeyword = isAsync ? "await " : ""
225
+ return "\(tryKeyword)\(awaitKeyword)\(receiver.callee).\(swiftName)(\(callArguments(arity: arity)))"
226
+ }
227
+
228
+ /// The call of the `asyncVariant` binding for the given arity: the synchronous function runs in a
229
+ /// `@concurrent` closure, so it leaves the JavaScript thread, and the body continues once it returns.
230
+ private func concurrentCallExpression(receiver: Receiver, arity: Int) -> String {
231
+ // The closure captures the receiver and the decoded arguments, so Swift 6 requires them to be
232
+ // `Sendable`, as it does for the receiver and arguments of a `.concurrent` call. The signature is
233
+ // spelled out because `@concurrent` needs an `async` closure, and a body without `await` would
234
+ // infer a synchronous one.
235
+ let tryKeyword = isThrowing ? "try " : ""
236
+ let effects = isThrowing ? "async throws" : "async"
237
+ let returnClause = returnType.map { " -> \(expressionType($0))" } ?? ""
238
+ return """
239
+ \(tryKeyword)await { @concurrent () \(effects)\(returnClause) in
240
+ \(tryKeyword)\(receiver.callee).\(swiftName)(\(callArguments(arity: arity)))
241
+ }()
242
+ """
243
+ }
244
+
245
+ /// The argument list of a call for the given arity. Slots `0..<arity` are passed their decoded
183
246
  /// `arg<i>`; a trailing optional-without-default slot that this arity omits is passed `nil`; a
184
247
  /// trailing defaulted slot that this arity omits is dropped entirely so Swift applies its default.
185
- private func callExpression(receiver: Receiver, arity: Int) -> String {
248
+ private func callArguments(arity: Int) -> String {
186
249
  var callArguments: [String] = []
187
250
  for (index, parameter) in parameters.enumerated() {
188
251
  let label = parameter.firstName.text
@@ -201,11 +264,7 @@ internal struct JSFunction {
201
264
  }
202
265
  callArguments.append(label == "_" ? value : "\(label): \(value)")
203
266
  }
204
- // `try` only for a throwing function. An `async` call that doesn't throw must not get one, or the
205
- // expansion warns that no throwing call occurs within the `try` expression.
206
- let tryKeyword = isThrowing ? "try " : ""
207
- let awaitKeyword = isAsync ? "await " : ""
208
- return "\(tryKeyword)\(awaitKeyword)\(receiver.callee).\(swiftName)(\(callArguments.joined(separator: ", ")))"
267
+ return callArguments.joined(separator: ", ")
209
268
  }
210
269
 
211
270
  /// The flat (single-arity) call-and-encode lines used when no trailing parameter is omittable:
@@ -304,6 +363,13 @@ internal struct JSFunction {
304
363
  /// writes `<callee>.<name> = …`. Decode/encode of the value go through `JavaScriptDecodable.decode` /
305
364
  /// `JavaScriptEncodable.encode` (or the free-form methods for a type that mentions `Any`), the same
306
365
  /// path as functions.
366
+ ///
367
+ /// A `lazy var` binds a getter only, on a configurable accessor. On the first read the getter defines
368
+ /// the encoded value as a data property on the JS `this` with `defineProperty`, so later reads don't
369
+ /// call into native code. The data property goes on `this`, not on the object that holds the accessor,
370
+ /// so each shared object instance stores its own value. `defineProperty` leaves a frozen object
371
+ /// unchanged, so there the property stays an accessor. A `this` that isn't an object doesn't fail the read
372
+ /// either: the getter still returns the value.
307
373
  internal struct JSProperty {
308
374
  let swiftName: String
309
375
  let jsName: String
@@ -314,8 +380,10 @@ internal struct JSProperty {
314
380
  /// How the value converts when its type mentions `Any`, or `nil` for any other type.
315
381
  let freeForm: FreeFormConversion?
316
382
  /// Whether the property is settable from JS: `true` for a stored `var` or a computed `var` with an
317
- /// explicit `set` accessor; `false` for a getter-only computed `var` or a `let`.
383
+ /// explicit `set` accessor; `false` for a getter-only computed `var`, a `lazy var` or a `let`.
318
384
  let isSettable: Bool
385
+ /// Whether the property is a `lazy var`, whose getter replaces the accessor with the first value.
386
+ let isLazy: Bool
319
387
  /// The property's name in the source, where a JavaScript name collision is reported.
320
388
  let nameToken: TokenSyntax
321
389
 
@@ -336,18 +404,37 @@ internal struct JSProperty {
336
404
 
337
405
  lines.append("let \(descriptorName) = runtime.createObject()")
338
406
  lines.append("\(descriptorName).setProperty(\"enumerable\", value: true)")
407
+ if isLazy {
408
+ // The getter redefines the property on `this`, which needs a configurable accessor when `this`
409
+ // is the object that holds it (a module).
410
+ lines.append("\(descriptorName).setProperty(\"configurable\", value: true)")
411
+ }
339
412
 
340
413
  // Getter: read `<callee>.<name>` and encode the result back to JS through `encode`. When the value
341
414
  // type couldn't be inferred (no annotation and no literal default, rare for a stored var) there's
342
415
  // no static type to call `encode` on, so the value's own `toJavaScriptValue(in:)` is the fallback.
343
- let getEncode: String
416
+ let encode: String
344
417
  if let valueType {
345
- getEncode = "return try \(encodeCall(valueType, freeForm: freeForm, of: "\(callee).\(swiftName)"))"
418
+ encode = "try \(encodeCall(valueType, freeForm: freeForm, of: "\(callee).\(swiftName)"))"
346
419
  } else {
347
- getEncode = "return \(callee).\(swiftName).toJavaScriptValue(in: runtime)"
420
+ encode = "\(callee).\(swiftName).toJavaScriptValue(in: runtime)"
421
+ }
422
+ let getBody: String
423
+ if isLazy {
424
+ let define = "this.getObject(in: runtime).defineProperty(\"\(jsName)\", value: value, options: [.enumerable])"
425
+ // A module getter ignores `this` otherwise, so it can be anything, and only an object can hold the
426
+ // value. A shared object's unwrap has already checked that `this` is an object.
427
+ let defineStatement =
428
+ receiver.unwrapStatement == nil
429
+ ? "if this.isObject() {\n \(define)\n}"
430
+ : define
431
+ getBody = "let value = \(encode)\n\(defineStatement)\nreturn value"
432
+ } else {
433
+ getBody = "return \(encode)"
348
434
  }
349
435
  lines.append(
350
- accessorClosure(descriptorName, "get", receiver: receiver, body: "\(unwrap)\(getEncode)"))
436
+ accessorClosure(descriptorName, "get", receiver: receiver, body: "\(unwrap)\(getBody)")
437
+ )
351
438
 
352
439
  // Setter: decode argument 0 by the static type through `decode` and write `<callee>.<name>`. A
353
440
  // typed setter needs a known value type; when the type couldn't be inferred the property is bound
@@ -358,12 +445,19 @@ internal struct JSProperty {
358
445
  let setDecode = "\(callee).\(swiftName) = try \(decode)"
359
446
  lines.append(
360
447
  accessorClosure(
361
- descriptorName, "set", receiver: receiver, returnsVoid: true, body: "\(unwrap)\(setDecode)"))
448
+ descriptorName,
449
+ "set",
450
+ receiver: receiver,
451
+ returnsVoid: true,
452
+ body: "\(unwrap)\(setDecode)"
453
+ )
454
+ )
362
455
  }
363
456
 
364
457
  lines.append("\(object).defineProperty(\"\(jsName)\", descriptor: \(descriptorName))")
365
458
 
366
- return lines
459
+ return
460
+ lines
367
461
  .flatMap { $0.split(separator: "\n", omittingEmptySubsequences: false) }
368
462
  .map { " " + $0 }
369
463
  .joined(separator: "\n")
@@ -374,12 +468,17 @@ internal struct JSProperty {
374
468
  /// A setter passes `returnsVoid`, so its closure declares `-> Void` and binds through the `Void`
375
469
  /// overload, like a function that returns nothing.
376
470
  private func accessorClosure(
377
- _ descriptorName: String, _ key: String, receiver: Receiver, returnsVoid: Bool = false, body: String
471
+ _ descriptorName: String,
472
+ _ key: String,
473
+ receiver: Receiver,
474
+ returnsVoid: Bool = false,
475
+ body: String
378
476
  ) -> String {
379
477
  let captures = receiver.captureClause
380
478
  // Indent each line of a (possibly multi-line) body to sit one level inside the closure; a bare
381
479
  // `\(body)` interpolation would only indent the first line.
382
- let indentedBody = body
480
+ let indentedBody =
481
+ body
383
482
  .split(separator: "\n", omittingEmptySubsequences: false)
384
483
  .map { " \($0)" }
385
484
  .joined(separator: "\n")
@@ -404,7 +503,10 @@ internal struct JSProperty {
404
503
  /// how each binding reaches its Swift value (module `self`, shared-object instance `_self`, or the
405
504
  /// metatype for a static member).
406
505
  private func decorateBody(
407
- functions: [JSFunction], properties: [JSProperty], object: String, receiver: Receiver
506
+ functions: [JSFunction],
507
+ properties: [JSProperty],
508
+ object: String,
509
+ receiver: Receiver
408
510
  ) -> String {
409
511
  let functionBody = functions.map { $0.decorateStatements(object: object, receiver: receiver) }
410
512
  let propertyBody = properties.map { $0.decorateStatements(object: object, receiver: receiver) }
@@ -421,7 +523,11 @@ private func decorateBody(
421
523
  /// receiver; the shared-object counterpart is `buildDecorateSharedObjectPhase`.
422
524
  internal func buildDecorateJavaScriptObject(functions: [JSFunction], properties: [JSProperty]) -> DeclSyntax {
423
525
  let body = decorateBody(
424
- functions: functions, properties: properties, object: Phase.object.rawValue, receiver: .module)
526
+ functions: functions,
527
+ properties: properties,
528
+ object: Phase.object.rawValue,
529
+ receiver: .module
530
+ )
425
531
  return """
426
532
  @JavaScriptActor
427
533
  public func _decorateModule(object: borrowing JavaScriptObject, in runtime: JavaScriptRuntime) throws {
@@ -442,13 +548,21 @@ internal func buildDecorateJavaScriptObject(functions: [JSFunction], properties:
442
548
  /// which builds an instance and is emitted separately (see `JSConstructor.buildConstructor`). Each phase
443
549
  /// is emitted only when the type has a member for it.
444
550
  internal func buildDecorateSharedObjectPhase(
445
- phase: Phase, functions: [JSFunction], properties: [JSProperty], typeName: String
551
+ phase: Phase,
552
+ functions: [JSFunction],
553
+ properties: [JSProperty],
554
+ typeName: String
446
555
  ) -> DeclSyntax {
447
- let receiver: Receiver = phase == .constructor
556
+ let receiver: Receiver =
557
+ phase == .constructor
448
558
  ? .staticMember(typeName: typeName)
449
559
  : .sharedObject(typeName: typeName)
450
560
  let body = decorateBody(
451
- functions: functions, properties: properties, object: phase.rawValue, receiver: receiver)
561
+ functions: functions,
562
+ properties: properties,
563
+ object: phase.rawValue,
564
+ receiver: receiver
565
+ )
452
566
  return """
453
567
  @JavaScriptActor
454
568
  public override class func _decorateSharedObject(\(raw: phase.rawValue): borrowing JavaScriptObject, in runtime: JavaScriptRuntime) throws {
@@ -54,7 +54,8 @@ public struct EventMacro: AccessorMacro {
54
54
  // through the getter, so the body never has to spell the payload type. A sync event calls
55
55
  // `emitSync` (inline dispatch, JS thread only); the default calls the scheduling `emit`.
56
56
  let emitMethod = event.isSync ? "emitSync" : "emit"
57
- let closure = event.hasPayload
57
+ let closure =
58
+ event.hasPayload
58
59
  ? "{ [weak self] payload in self?.\(emitMethod)(event: \"\(event.jsName)\", payload: payload) }"
59
60
  : "{ [weak self] in self?.\(emitMethod)(event: \"\(event.jsName)\") }"
60
61
  return [
@@ -85,9 +86,10 @@ extension EventMacro: PeerMacro {
85
86
  // and asserting the enclosing type by its spelled name so the conformance error names the
86
87
  // user's type rather than 'Self'.
87
88
  let name = event.swiftName
88
- let payload = event.payloadType.flatMap(assertableBoundaryType)
89
+ let payload = event.payloadType.flatMap { assertableBoundaryType($0, constraint: jsConvertibleProtocolName) }
89
90
  let owner = enclosingTypeName(in: context) ?? "Self"
90
- let helper = payload != nil
91
+ let helper =
92
+ payload != nil
91
93
  ? "func \(name)<P: \(jsConvertibleProtocolName), E: \(eventEmitterProtocolName)>(_: P.Type, _: E.Type) {}"
92
94
  : "func \(name)<E: \(eventEmitterProtocolName)>(_: E.Type) {}"
93
95
  let call = payload.map { "\(name)(\($0).self, \(owner).self)" } ?? "\(name)(\(owner).self)"
@@ -132,7 +134,8 @@ private func validatedEvent(
132
134
  }
133
135
  if varDecl.attributes.firstAttribute(named: "JS") != nil {
134
136
  throw MacroExpansionErrorMessage(
135
- "@Event and @JS cannot be combined on the same property; an event is exposed to JS on its own, so remove one of the attributes")
137
+ "@Event and @JS cannot be combined on the same property; an event is exposed to JS on its own, so remove one of the attributes"
138
+ )
136
139
  }
137
140
  // The compiler also rejects accessor macros on a `let`, but with a generic message; this one says
138
141
  // what to do instead and carries the fix-it doing it. The synthesized property is getter-only, so
@@ -142,32 +145,40 @@ private func validatedEvent(
142
145
  }
143
146
  if varDecl.modifiers.contains(where: isTypeLevelModifier) {
144
147
  throw MacroExpansionErrorMessage(
145
- "@Event must be an instance property; events are emitted from a module or shared object instance.")
148
+ "@Event must be an instance property; events are emitted from a module or shared object instance."
149
+ )
146
150
  }
147
151
  guard varDecl.bindings.count == 1, let binding = varDecl.bindings.first,
148
- let identifier = binding.pattern.as(IdentifierPatternSyntax.self) else {
152
+ let identifier = binding.pattern.as(IdentifierPatternSyntax.self)
153
+ else {
149
154
  throw MacroExpansionErrorMessage(
150
- "@Event must be applied to a single named property; declare each event separately")
155
+ "@Event must be applied to a single named property; declare each event separately"
156
+ )
151
157
  }
152
158
  if binding.initializer != nil {
153
159
  throw MacroExpansionErrorMessage(
154
- "@Event property cannot have an initial value; the macro synthesizes the closure")
160
+ "@Event property cannot have an initial value; the macro synthesizes the closure"
161
+ )
155
162
  }
156
163
  if binding.accessorBlock != nil {
157
164
  throw MacroExpansionErrorMessage(
158
- "@Event property cannot declare its own accessors; the macro synthesizes the getter")
165
+ "@Event property cannot declare its own accessors; the macro synthesizes the getter"
166
+ )
159
167
  }
160
168
  guard let functionType = underlyingFunctionType(of: binding.typeAnnotation?.type) else {
161
169
  throw MacroExpansionErrorMessage(
162
- "@Event property must declare a function type, such as '(Payload) -> Void' or '() -> Void'")
170
+ "@Event property must declare a function type, such as '(Payload) -> Void' or '() -> Void'"
171
+ )
163
172
  }
164
173
  guard isVoidReturn(functionType.returnClause.type) else {
165
174
  throw MacroExpansionErrorMessage(
166
- "@Event function type must return 'Void'; an event dispatches to JS and has no return value")
175
+ "@Event function type must return 'Void'; an event dispatches to JS and has no return value"
176
+ )
167
177
  }
168
178
  guard functionType.parameters.count <= 1 else {
169
179
  throw MacroExpansionErrorMessage(
170
- "@Event function type takes at most one payload parameter; combine multiple values into a single record")
180
+ "@Event function type takes at most one payload parameter; combine multiple values into a single record"
181
+ )
171
182
  }
172
183
  // The payload is encoded to JavaScript, and a native closure can't be encoded.
173
184
  if let payloadClosure = functionType.parameters.first.flatMap({ firstFunctionType(in: $0.type) }) {
@@ -177,7 +188,8 @@ private func validatedEvent(
177
188
  message: EventDiagnosticMessage(
178
189
  "@Event payload can't hold a closure: the payload is encoded to JavaScript, and a native closure can't be encoded.",
179
190
  id: "event-closure-payload"
180
- ))
191
+ )
192
+ )
181
193
  ])
182
194
  }
183
195
 
@@ -321,7 +333,8 @@ private func underlyingFunctionType(of type: TypeSyntax?) -> FunctionTypeSyntax?
321
333
  return underlyingFunctionType(of: attributed.baseType)
322
334
  }
323
335
  if let tuple = type.as(TupleTypeSyntax.self),
324
- tuple.elements.count == 1, let element = tuple.elements.first, element.firstName == nil {
336
+ tuple.elements.count == 1, let element = tuple.elements.first, element.firstName == nil
337
+ {
325
338
  return underlyingFunctionType(of: element.type)
326
339
  }
327
340
  return type.as(FunctionTypeSyntax.self)
@@ -334,7 +347,8 @@ private func underlyingFunctionType(of type: TypeSyntax?) -> FunctionTypeSyntax?
334
347
  private func isVoidReturn(_ type: TypeSyntax) -> Bool {
335
348
  // Peel single-element, unlabeled parentheses: `(Void)` and `(())` are the same type as their content.
336
349
  if let tuple = type.as(TupleTypeSyntax.self), tuple.elements.count == 1,
337
- let element = tuple.elements.first, element.firstName == nil {
350
+ let element = tuple.elements.first, element.firstName == nil
351
+ {
338
352
  return isVoidReturn(element.type)
339
353
  }
340
354
  let text = type.trimmedDescription
@@ -2,34 +2,31 @@ import SwiftSyntax
2
2
  import SwiftSyntaxBuilder
3
3
  import SwiftSyntaxMacros
4
4
 
5
- /**
6
- Macro applied to a module class. It plays three roles, each implemented in its own
7
- extension below:
8
-
9
- - `MemberMacro`: binds `@JS`-marked members directly into the module's JS object via the
10
- synthesized `_decorateModule`, and emits the resolved module name as a non-optional
11
- `_jsName` static (no `Name(…)` DSL element). It also synthesizes a
12
- framework-internal `_synthesizedDefinition()` returning `[AnyDefinition]` (now carrying
13
- only nested `classes` entries) that `expo-modules-core` merges into the module's
14
- definition. When the class doesn't already inherit `Module`/`BaseModule`, it also
15
- synthesizes the `appContext` storage and `init(appContext:)` those base classes provide.
16
- - `MemberAttributeMacro`: stamps `@JavaScriptActor` on `@JS` sync members and
17
- `@ModuleDefinitionBuilder` on a `definition()` method.
18
- - `ExtensionMacro`: adds the `AnyModule` conformance when the class doesn't inherit it.
19
-
20
- Inheriting from `Module` is therefore optional — a class can carry any superclass (or
21
- none) and still be a module, because everything inheritance used to provide is synthesized.
22
-
23
- @ExpoModule
24
- public final class MyModule {
25
- public func definition() -> ModuleDefinition {
26
- Name("MyModule")
27
- }
28
-
29
- @JS
30
- func greet(name: String) -> String { "Hi, \(name)" }
31
- }
32
- */
5
+ /// Macro applied to a module class. It plays three roles, each implemented in its own
6
+ /// extension below:
7
+ ///
8
+ /// - `MemberMacro`: binds `@JS`-marked members directly into the module's JS object via the
9
+ /// synthesized `_decorateModule`, and emits the resolved module name as a non-optional
10
+ /// `_jsName` static (no `Name(…)` DSL element). The `classes:` and `views:` arguments become
11
+ /// `_classTypes` and `_viewTypes` statics that `expo-modules-core` registers by type.
12
+ /// When the class doesn't already inherit `Module`/`BaseModule`, it also
13
+ /// synthesizes the `appContext` storage and `init(appContext:)` those base classes provide.
14
+ /// - `MemberAttributeMacro`: stamps `@JavaScriptActor` on `@JS` sync members and
15
+ /// `@ModuleDefinitionBuilder` on a `definition()` method.
16
+ /// - `ExtensionMacro`: adds the `AnyModule` conformance when the class doesn't inherit it.
17
+ ///
18
+ /// Inheriting from `Module` is therefore optional — a class can carry any superclass (or
19
+ /// none) and still be a module, because everything inheritance used to provide is synthesized.
20
+ ///
21
+ /// @ExpoModule
22
+ /// public final class MyModule {
23
+ /// public func definition() -> ModuleDefinition {
24
+ /// Name("MyModule")
25
+ /// }
26
+ ///
27
+ /// @JS
28
+ /// func greet(name: String) -> String { "Hi, \(name)" }
29
+ /// }
33
30
  public struct ExpoModuleMacro: MemberMacro {
34
31
  public static func expansion(
35
32
  of node: AttributeSyntax,
@@ -46,43 +43,36 @@ public struct ExpoModuleMacro: MemberMacro {
46
43
  // macro no longer emits a `Name(…)` DSL entry. Core reads the static for the module name,
47
44
  // ahead of its type-name fallback (which then only applies to non-macro DSL modules).
48
45
  let moduleName = jsNameArgument(of: node) ?? withoutBackticks(classDecl.name.text)
49
- var entries: [String] = []
50
46
 
51
47
  // `@JS func`s (sync and async) and `@JS var`s are bound directly into the JS object by the
52
48
  // synthesized `_decorateModule` rather than described with a `Function(...)` / `Property(...)`
53
- // DSL entry, so they're collected here instead of appended to `entries`.
49
+ // DSL entry.
54
50
  var functions: [JSFunction] = []
55
51
  var properties: [JSProperty] = []
56
52
 
57
- for typeName in classListArgument(of: node, label: "classes") {
58
- entries.append("\(typeName)._synthesizedClassDefinition()")
59
- }
60
-
61
53
  for member in classDecl.memberBlock.members {
62
54
  let decl = member.decl
63
55
 
64
56
  if let funcDecl = decl.as(FunctionDeclSyntax.self),
65
- let attribute = funcDecl.attributes.firstAttribute(named: "JS") {
66
- functions.append(JSFunction(funcDecl: funcDecl, attribute: attribute))
57
+ let attribute = funcDecl.attributes.firstAttribute(named: "JS")
58
+ {
59
+ let function = JSFunction(funcDecl: funcDecl, attribute: attribute)
60
+ functions.append(function)
61
+ if let variant = function.asyncVariant {
62
+ functions.append(variant)
63
+ }
67
64
  continue
68
65
  }
69
66
 
70
67
  if let varDecl = decl.as(VariableDeclSyntax.self),
71
- let attribute = varDecl.attributes.firstAttribute(named: "JS") {
68
+ let attribute = varDecl.attributes.firstAttribute(named: "JS")
69
+ {
72
70
  properties.append(contentsOf: collectProperties(varDecl: varDecl, attribute: attribute))
73
71
  }
74
72
  }
75
73
 
76
74
  diagnoseJSNameCollisions(functions: functions, properties: properties, in: context)
77
75
 
78
- let body: String
79
- if entries.isEmpty {
80
- body = " return []"
81
- } else {
82
- let lines = entries.map { " \($0)" }.joined(separator: ",\n")
83
- body = " return [\n\(lines)\n ]"
84
- }
85
-
86
76
  var emitted: [DeclSyntax] = []
87
77
 
88
78
  // The fully-resolved module name as a non-optional stored constant. Core reads this
@@ -90,6 +80,16 @@ public struct ExpoModuleMacro: MemberMacro {
90
80
  // JS object name, so they can't diverge.
91
81
  emitted.append("public static let _jsName = \"\(raw: moduleName)\"")
92
82
 
83
+ // Shared object classes are registered by type: core builds each JavaScript class from the
84
+ // `@SharedObject` hooks, with the name from the class's `_jsName`. The element type makes the
85
+ // compiler reject a class that isn't a `SharedObject` subclass. Emitted only when the argument is
86
+ // present, so a module without classes gains nothing.
87
+ let classTypes = classListArgument(of: node, label: "classes")
88
+ if !classTypes.isEmpty {
89
+ let entries = classTypes.map { "\($0).self" }.joined(separator: ", ")
90
+ emitted.append("public static let _classTypes: [SharedObject.Type] = [\(raw: entries)]")
91
+ }
92
+
93
93
  // View classes are registered by type, not spliced as definitions: an `@ExpoView` emits no
94
94
  // definition function, so there is nothing to call on each entry. Core reads a view's props
95
95
  // type from its `Props` typealias and its event names from that type's `_eventNames`, both
@@ -119,15 +119,6 @@ public struct ExpoModuleMacro: MemberMacro {
119
119
  }
120
120
  }
121
121
 
122
- // Always emitted: the definition is derived from the class name and `@JS` members,
123
- // independent of any `definition()` the user wrote.
124
- let method: DeclSyntax = """
125
- public func _synthesizedDefinition() -> [AnyDefinition] {
126
- \(raw: body)
127
- }
128
- """
129
- emitted.append(method)
130
-
131
122
  // Direct JSI binding: one `_decorateModule` that binds each `@JS func` (inlined `setProperty`
132
123
  // closure) and each `@JS var` (a `defineProperty` get/set accessor) into the module's JS object.
133
124
  // Only emitted when there's at least one member to bind.
@@ -154,12 +145,14 @@ extension ExpoModuleMacro: MemberAttributeMacro {
154
145
 
155
146
  // `@JS` sync members run on the JS thread; stamp `@JavaScriptActor` so isolation is
156
147
  // checked at compile time. Skipped when the member already chose an isolation
157
- // (`async`, `nonisolated`, or another global actor) — see `shouldStampJavaScriptActor`.
148
+ // (`nonisolated` or another global actor), or when `.concurrent` or `.withAsync("name")` runs
149
+ // it on the concurrent pool. See `shouldStampJavaScriptActor`.
158
150
  // `@Event(sync: true)` members get the stamp too: a sync event dispatches inline, so the
159
151
  // isolation forces its call site onto the JS thread. Async events (the default) are
160
152
  // deliberately left unstamped — their `emit` schedules onto the JS thread itself.
161
153
  if memberHasJSAttribute(member) || isSyncEventMember(member),
162
- shouldStampJavaScriptActor(on: member, enclosedBy: declaration) {
154
+ shouldStampJavaScriptActor(on: member, enclosedBy: declaration)
155
+ {
163
156
  attributes.append("@JavaScriptActor")
164
157
  }
165
158
 
@@ -174,7 +167,8 @@ extension ExpoModuleMacro: MemberAttributeMacro {
174
167
  // they already wrote `@ModuleDefinitionBuilder` themselves, which would otherwise be
175
168
  // a duplicate attribute.
176
169
  if isModuleDefinitionFunction(member),
177
- !memberAttributes(of: member).contains(where: { hasAttributeName($0, "ModuleDefinitionBuilder") }) {
170
+ !memberAttributes(of: member).contains(where: { hasAttributeName($0, "ModuleDefinitionBuilder") })
171
+ {
178
172
  attributes.append("@ModuleDefinitionBuilder")
179
173
  }
180
174
 
@@ -186,7 +180,8 @@ private func isModuleDefinitionFunction(_ member: some DeclSyntaxProtocol) -> Bo
186
180
  guard let funcDecl = member.as(FunctionDeclSyntax.self),
187
181
  funcDecl.name.text == "definition",
188
182
  funcDecl.signature.parameterClause.parameters.isEmpty,
189
- let returnType = funcDecl.signature.returnClause?.type else {
183
+ let returnType = funcDecl.signature.returnClause?.type
184
+ else {
190
185
  return false
191
186
  }
192
187
  return baseIdentifier(of: returnType) == "ModuleDefinition"
@@ -215,7 +210,8 @@ extension ExpoModuleMacro: ExtensionMacro {
215
210
  // These base types already supply `AnyModule`; emitting a second conformance here
216
211
  // would be redundant and error.
217
212
  if let classDecl = declaration.as(ClassDeclSyntax.self),
218
- inheritsFromAny(classDecl, names: ["Module", "BaseModule", "AnyModule"]) {
213
+ inheritsFromAny(classDecl, names: ["Module", "BaseModule", "AnyModule"])
214
+ {
219
215
  return []
220
216
  }
221
217
  let conformance: DeclSyntax = "extension \(type.trimmed): AnyModule {}"
@@ -232,7 +228,8 @@ private func hasStoredProperty(_ classDecl: ClassDeclSyntax, named name: String)
232
228
  }
233
229
  for binding in varDecl.bindings {
234
230
  if let identifier = binding.pattern.as(IdentifierPatternSyntax.self),
235
- identifier.identifier.text == name {
231
+ identifier.identifier.text == name
232
+ {
236
233
  return true
237
234
  }
238
235
  }
@@ -240,14 +237,12 @@ private func hasStoredProperty(_ classDecl: ClassDeclSyntax, named name: String)
240
237
  return false
241
238
  }
242
239
 
243
- /**
244
- True if the class declares an initializer that satisfies the `init(appContext:)` protocol
245
- requirement: a single parameter labeled `appContext` whose type is written as `AppContext`
246
- (or `…​.AppContext`). Both must match — the label is part of the requirement, so
247
- `init(c: AppContext)` does *not* satisfy it (we still synthesize ours), and the type guards
248
- against a same-labeled init of an unrelated type. A macro can't resolve types, so this is a
249
- syntactic match on the spelled name.
250
- */
240
+ /// True if the class declares an initializer that satisfies the `init(appContext:)` protocol
241
+ /// requirement: a single parameter labeled `appContext` whose type is written as `AppContext`
242
+ /// (or `…​.AppContext`). Both must match — the label is part of the requirement, so
243
+ /// `init(c: AppContext)` does *not* satisfy it (we still synthesize ours), and the type guards
244
+ /// against a same-labeled init of an unrelated type. A macro can't resolve types, so this is a
245
+ /// syntactic match on the spelled name.
251
246
  private func hasAppContextInitializer(_ classDecl: ClassDeclSyntax) -> Bool {
252
247
  for member in classDecl.memberBlock.members {
253
248
  guard let initDecl = member.decl.as(InitializerDeclSyntax.self) else {