expo-modules-macros 0.19.1 → 0.21.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.
@@ -122,7 +122,7 @@ private struct EventMember {
122
122
  /// Validates the declaration `@Event` is attached to and reads the event out of it. The checks
123
123
  /// mirror what the expansion relies on: a single-binding instance `var` (the macro synthesizes a
124
124
  /// computed getter, so `let`, accessors, and initializers are all incompatible) whose type is a
125
- /// function type returning `Void` with at most one payload parameter.
125
+ /// function type returning `Void` with at most one payload parameter, which holds no closure.
126
126
  private func validatedEvent(
127
127
  of node: AttributeSyntax,
128
128
  on declaration: some DeclSyntaxProtocol
@@ -169,6 +169,17 @@ private func validatedEvent(
169
169
  throw MacroExpansionErrorMessage(
170
170
  "@Event function type takes at most one payload parameter; combine multiple values into a single record")
171
171
  }
172
+ // The payload is encoded to JavaScript, and a native closure can't be encoded.
173
+ if let payloadClosure = functionType.parameters.first.flatMap({ firstFunctionType(in: $0.type) }) {
174
+ throw DiagnosticsError(diagnostics: [
175
+ Diagnostic(
176
+ node: payloadClosure,
177
+ message: EventDiagnosticMessage(
178
+ "@Event payload can't hold a closure: the payload is encoded to JavaScript, and a native closure can't be encoded.",
179
+ id: "event-closure-payload"
180
+ ))
181
+ ])
182
+ }
172
183
 
173
184
  let swiftName = identifier.identifier.text
174
185
  return EventMember(
@@ -73,6 +73,8 @@ public struct ExpoModuleMacro: MemberMacro {
73
73
  }
74
74
  }
75
75
 
76
+ diagnoseJSNameCollisions(functions: functions, properties: properties, in: context)
77
+
76
78
  let body: String
77
79
  if entries.isEmpty {
78
80
  body = " return []"
@@ -0,0 +1,64 @@
1
+ import SwiftDiagnostics
2
+ import SwiftSyntax
3
+ import SwiftSyntaxMacros
4
+
5
+ /// Reports each `@JS` member whose JavaScript name an earlier `@JS` member already binds on the same
6
+ /// JavaScript object. Pass the members of one object only: a shared object's static members bind onto
7
+ /// the constructor and its instance members onto the prototype, so the two sets are checked separately.
8
+ internal func diagnoseJSNameCollisions(
9
+ functions: [JSFunction],
10
+ properties: [JSProperty],
11
+ in context: some MacroExpansionContext
12
+ ) {
13
+ // Each member binds with its own `setProperty` or `defineProperty` call, so of two members with one
14
+ // name, JavaScript would reach only one. Members are compared in source order, so the error lands on
15
+ // every member after the first, whichever kind each one is.
16
+ let members =
17
+ functions.map { BoundMember(jsName: $0.jsName, kind: "function", nameToken: $0.nameToken) }
18
+ + properties.map { BoundMember(jsName: $0.jsName, kind: "property", nameToken: $0.nameToken) }
19
+ var firstMembers: [String: BoundMember] = [:]
20
+
21
+ for member in members.sorted(by: { $0.nameToken.position < $1.nameToken.position }) {
22
+ guard let first = firstMembers[member.jsName] else {
23
+ firstMembers[member.jsName] = member
24
+ continue
25
+ }
26
+ context.diagnose(
27
+ Diagnostic(
28
+ node: member.nameToken,
29
+ message: JSNameCollisionMessage(
30
+ "The JavaScript name '\(member.jsName)' is already bound by an earlier @JS \(first.kind). A JavaScript object holds one member per name, so JavaScript could reach only one of them. Give one of them another name with @JS(\"...\")."
31
+ ),
32
+ notes: [
33
+ Note(
34
+ node: Syntax(first.nameToken),
35
+ message: JSNameCollisionNote("'\(member.jsName)' is first bound here"))
36
+ ]
37
+ ))
38
+ }
39
+ }
40
+
41
+ private struct BoundMember {
42
+ let jsName: String
43
+ let kind: String
44
+ let nameToken: TokenSyntax
45
+ }
46
+
47
+ private struct JSNameCollisionMessage: DiagnosticMessage {
48
+ let message: String
49
+ let diagnosticID = MessageID(domain: "ExpoModulesMacros", id: "js-name-collision")
50
+ let severity: DiagnosticSeverity = .error
51
+
52
+ init(_ message: String) {
53
+ self.message = message
54
+ }
55
+ }
56
+
57
+ private struct JSNameCollisionNote: NoteMessage {
58
+ let message: String
59
+ let noteID = MessageID(domain: "ExpoModulesMacros", id: "js-name-collision-first")
60
+
61
+ init(_ message: String) {
62
+ self.message = message
63
+ }
64
+ }
@@ -441,7 +441,8 @@ internal func collectProperties(
441
441
  valueType: valueType,
442
442
  // A literal-inferred type never mentions `Any`, so only an explicit annotation can be free-form.
443
443
  freeForm: binding.typeAnnotation.flatMap { freeFormConversion(of: $0.type) },
444
- isSettable: isVar && bindingIsSettable(binding)
444
+ isSettable: isVar && bindingIsSettable(binding),
445
+ nameToken: ident.identifier
445
446
  )
446
447
  }
447
448
  }
@@ -1,3 +1,4 @@
1
+ import SwiftDiagnostics
1
2
  import SwiftSyntax
2
3
  import SwiftSyntaxBuilder
3
4
  import SwiftSyntaxMacros
@@ -8,14 +9,16 @@ import SwiftSyntaxMacros
8
9
  needed — and synthesizes the conversion surface from the property's static type:
9
10
 
10
11
  - an explicit memberwise `init` (so the static factories have a single construction point),
11
- - `from(object:appContext:)` — the fast path, reading each property straight off a
12
- `JavaScriptObject`,
13
- - `from(dictionary:appContext:)` — reading each property from a `[String: Any]` dictionary,
14
- - `toDictionary(appContext:)` and `toObject(appContext:)` for the write side.
12
+ - `decode(_:in:)` and `encode(_:in:)` — the fast paths, reading each property straight off a JS
13
+ object and writing it straight into one,
14
+ - `from(object:appContext:)` and `toObject(appContext:)`, which forward to `decode` and `encode`,
15
+ - `from(dictionary:appContext:)` and `toDictionary(appContext:)` — converting each property from and
16
+ to a `[String: Any]` dictionary.
15
17
 
16
- The type is auto-conformed to `Record` (a core protocol whose requirements are exactly this
17
- surface); the synthesized methods override `Record`'s reflection-based defaults. Author-facing
18
- shape — no conformance to spell out:
18
+ The type is auto-conformed to `Record` (a core protocol whose requirements are this surface); the
19
+ synthesized methods override `Record`'s reflection-based defaults, and the synthesized `decode` and
20
+ `encode` replace its default `JavaScriptCodable` witnesses. Author-facing shape — no conformance to
21
+ spell out:
19
22
 
20
23
  @Record
21
24
  struct Options {
@@ -30,7 +33,7 @@ import SwiftSyntaxMacros
30
33
  default is required (the factories throw when the source omits it).
31
34
 
32
35
  The JS-value paths convert through `JavaScriptDecodable.decode` / `JavaScriptEncodable.encode`
33
- (`from(object:)` / `toObject(appContext:)`); the native-`Any` dictionary paths still go through the
36
+ (`decode(_:in:)` / `encode(_:in:)`); the native-`Any` dictionary paths still go through the
34
37
  public dynamic-type API — `T.getDynamicType()` plus `cast(_:appContext:)` / `convertToJS(_:appContext:)`
35
38
  (`from(dictionary:)` / `toDictionary(appContext:)`), since `JavaScriptCodable` only converts JS
36
39
  values, not native `Any`. Both spell types as `public` symbols so the synthesized code compiles inside
@@ -54,10 +57,15 @@ public struct RecordMacro: MemberMacro, ExtensionMacro {
54
57
  conformingTo protocols: [TypeSyntax],
55
58
  in context: some MacroExpansionContext
56
59
  ) throws -> [DeclSyntax] {
57
- let isClass = declaration.is(ClassDeclSyntax.self)
58
- guard declaration.is(StructDeclSyntax.self) || isClass else {
60
+ let typeName: String
61
+ if let structDecl = declaration.as(StructDeclSyntax.self) {
62
+ typeName = structDecl.name.text
63
+ } else if let classDecl = declaration.as(ClassDeclSyntax.self) {
64
+ typeName = classDecl.name.text
65
+ } else {
59
66
  throw MacroExpansionErrorMessage("@Record can only be applied to a struct or class")
60
67
  }
68
+ let isClass = declaration.is(ClassDeclSyntax.self)
61
69
 
62
70
  let properties = try recordProperties(of: declaration)
63
71
  let inheritsRecord = isClass && classHasInheritance(declaration)
@@ -98,10 +106,13 @@ public struct RecordMacro: MemberMacro, ExtensionMacro {
98
106
  if !existingInitLabels.contains(properties.map { $0.key }) {
99
107
  members.append(memberwiseInit(properties: properties))
100
108
  }
101
- members.append(fromJSObjectFactory(properties: properties))
109
+ members.append(fromJSObjectFactory(properties: properties, inheritsRecord: inheritsRecord))
102
110
  members.append(fromDictionaryFactory(properties: properties))
103
111
  members.append(toDictionaryMethod(properties: properties, inheritsRecord: inheritsRecord))
104
112
  members.append(toObjectMethod(properties: properties, inheritsRecord: inheritsRecord))
113
+ if !inheritsRecord {
114
+ members.append(contentsOf: javaScriptCodableWitnesses(properties: properties, typeName: typeName))
115
+ }
105
116
  return members
106
117
  }
107
118
 
@@ -226,8 +237,21 @@ private func recordProperties(
226
237
  "@Record properties must declare an explicit type — '\(ident.identifier.text)' has none"
227
238
  )
228
239
  }
229
- // A literal-inferred type never mentions `Any`, so only an explicit annotation can be free-form.
240
+ // A literal-inferred type never mentions `Any` or a closure, so only an explicit annotation can.
230
241
  let annotatedType = binding.typeAnnotation?.type
242
+ // A closure has no `decode`, `encode` or dynamic type, and the record converts every property both
243
+ // ways. A JavaScript function decodes into a native closure only as a `@JS` argument; a native
244
+ // closure can't be encoded.
245
+ if let functionType = annotatedType.flatMap(firstFunctionType(in:)) {
246
+ throw DiagnosticsError(diagnostics: [
247
+ Diagnostic(
248
+ node: functionType,
249
+ message: RecordDiagnosticMessage(
250
+ "@Record property '\(ident.identifier.text)' can't hold a closure: a record property is also encoded to JavaScript, and a native closure can't be encoded",
251
+ id: "record-closure-property"
252
+ ))
253
+ ])
254
+ }
231
255
  let freeForm = try annotatedType.flatMap { annotatedType -> FreeFormConversion? in
232
256
  guard mentionsFreeFormAny(annotatedType) else {
233
257
  return nil
@@ -312,15 +336,24 @@ private func memberwiseInit(properties: [RecordProperty]) -> DeclSyntax {
312
336
  }
313
337
 
314
338
  /**
315
- `from(object:appContext:)` — reads each property off the `JavaScriptObject` into a local,
316
- then constructs the record through the memberwise init. Required properties throw when
317
- undefined; defaulted properties fall back to the property's declared default (inlined) when
318
- undefined; optional properties become `nil` when undefined/null. Each property is decoded with
319
- `JavaScriptDecodable.decode`, so the factory binds the `runtime` from the app context once and
320
- threads it to every read. A free-form property decodes through the `JavaScriptValue` free-form
321
- methods, which take the runtime too.
339
+ `from(object:appContext:)`. A record forwards to its own unowned `decode(_:in:)` witness (see
340
+ `javaScriptCodableWitnesses`), which holds the property reads. A subclass, which gets no witnesses,
341
+ reads the properties here, in the app context's runtime.
322
342
  */
323
- private func fromJSObjectFactory(properties: [RecordProperty]) -> DeclSyntax {
343
+ private func fromJSObjectFactory(properties: [RecordProperty], inheritsRecord: Bool) -> DeclSyntax {
344
+ guard inheritsRecord else {
345
+ // The record declares only the unowned `decode`, so the call lends the value unowned. With an owning
346
+ // value it would reach an owning `decode` default, and `Record`'s converts through this factory.
347
+ return """
348
+ @JavaScriptActor
349
+ public static func from(object: borrowing JavaScriptObject, appContext: AppContext) throws -> Self {
350
+ let runtime = try appContext.runtime
351
+ return try object.asValue().withUnownedValue(in: runtime) { unownedValue in
352
+ return try decode(unownedValue, in: runtime)
353
+ }
354
+ }
355
+ """
356
+ }
324
357
  var lines: [String] = []
325
358
  // Only bind the runtime when there's a property to decode — an empty record's factory would
326
359
  // otherwise leave it unused.
@@ -328,11 +361,10 @@ private func fromJSObjectFactory(properties: [RecordProperty]) -> DeclSyntax {
328
361
  lines.append(" let runtime = try appContext.runtime")
329
362
  }
330
363
  lines.append(factoryBody(properties: properties, readLines: jsObjectReadLines(properties: properties)))
331
- let body = lines.joined(separator: "\n")
332
364
  return """
333
365
  @JavaScriptActor
334
366
  public static func from(object: borrowing JavaScriptObject, appContext: AppContext) throws -> Self {
335
- \(raw: body)
367
+ \(raw: lines.joined(separator: "\n"))
336
368
  }
337
369
  """
338
370
  }
@@ -359,69 +391,68 @@ private func fromDictionaryFactory(properties: [RecordProperty]) -> DeclSyntax {
359
391
  private func factoryBody(properties: [RecordProperty], readLines: [String]) -> String {
360
392
  var lines: [String] = []
361
393
  lines.append(contentsOf: readLines)
362
- let initArgs = properties.map { "\($0.name): \($0.name)" }.joined(separator: ", ")
394
+ let initArgs = properties.enumerated().map { "\($1.name): \(fieldLocal($0))" }.joined(separator: ", ")
363
395
  lines.append(" return Self(\(initArgs))")
364
396
  return lines.joined(separator: "\n")
365
397
  }
366
398
 
367
- /// Per-property read statements for the JS-object factory, each producing a `let <name>` by decoding
368
- /// the JS value with `JavaScriptDecodable.decode` (recovering the app context from `runtime` itself).
369
- /// A free-form property decodes through the `JavaScriptValue` free-form methods instead; for an optional
370
- /// free-form shape, `undefined`/`null` map to `nil` around the call, just like `Optional.decode`.
399
+ /// The local that holds the value of the property at `index` in a factory. It's numbered rather than
400
+ /// named after the property, so no property can collide with it or with the factory's other parameters
401
+ /// and locals (`object`, `runtime`, `dictionary`, `appContext`).
402
+ private func fieldLocal(_ index: Int) -> String {
403
+ return "field\(index)"
404
+ }
405
+
406
+ /// Per-property read statements, each producing a `let field<index>` from the property of the JS object
407
+ /// named `object`. The property is lent to a closure as a `JavaScriptUnownedValue` and decoded there the
408
+ /// same way as a `@JS` argument (see `decodeCall`), so the read doesn't allocate a `JavaScriptValue`.
409
+ /// Only the generic free-form decode copies the value into one.
371
410
  private func jsObjectReadLines(properties: [RecordProperty]) -> [String] {
372
411
  var lines: [String] = []
373
- for property in properties {
412
+ for (index, property) in properties.enumerated() {
374
413
  let valueVar = derivedIdentifier(from: property.name, suffix: "JSValue")
375
- let exprType = expressionType(property.type)
376
- let decode: String
377
- switch property.freeForm {
378
- case .shape(let decodeMethod, _, let isOptional, _):
379
- let call = "try JavaScriptValue.\(decodeMethod)(\(valueVar), in: runtime)"
380
- decode = isOptional ? "\(valueVar).isUndefined() || \(valueVar).isNull() ? nil : \(call)" : call
381
- case .generic:
382
- decode = "try JavaScriptValue.decodeAny(\(valueVar), as: \(exprType).self, in: runtime)"
383
- case nil:
384
- decode = "try \(exprType).decode(\(valueVar), in: runtime)"
385
- }
386
- lines.append(" let \(valueVar) = object.getProperty(\"\(property.key)\")")
414
+ let decode = "try \(decodeCall(property.type, freeForm: property.freeForm, from: valueVar))"
415
+ lines.append(" let \(fieldLocal(index)) = try object.withUnownedProperty(\"\(property.key)\") { \(valueVar) in")
387
416
  if property.isRequired {
388
- lines.append(" guard !\(valueVar).isUndefined() else {")
389
- lines.append(" throw RecordPropertyRequiredException(\"\(property.key)\")")
390
- lines.append(" }")
391
- lines.append(" let \(property.name) = \(decode)")
417
+ lines.append(" guard !\(valueVar).isUndefined() else {")
418
+ lines.append(" throw RecordPropertyRequiredException(\"\(property.key)\")")
419
+ lines.append(" }")
420
+ lines.append(" return \(decode)")
392
421
  } else if property.isOptional {
393
- // `Optional.decode` (and the free-form reads above) already map `undefined`/`null` to `nil`, so
394
- // the read is a plain decode.
395
- lines.append(" let \(property.name) = \(decode)")
422
+ // `Optional.decode` (and the free-form decodes of optional shapes) already map `undefined` and
423
+ // `null` to `nil`, so the read is a plain decode.
424
+ lines.append(" return \(decode)")
396
425
  } else {
397
- lines.append(" let \(property.name) = \(valueVar).isUndefined() ? \(property.defaultValue!) : \(decode)")
426
+ lines.append(" return \(valueVar).isUndefined() ? \(property.defaultValue!) : \(decode)")
398
427
  }
428
+ lines.append(" }")
399
429
  }
400
430
  return lines
401
431
  }
402
432
 
403
- /// Per-property read statements for the dictionary factory, each producing a `let <name>`. A bare `Any`
404
- /// property has no dynamic type to cast through, and the dictionary value already is an `Any`, so it's
405
- /// read unchanged.
433
+ /// Per-property read statements for the dictionary factory, each producing a `let field<index>` from the
434
+ /// raw dictionary value in `entry<index>`. A bare `Any` property has no dynamic type to cast through, and
435
+ /// the dictionary value already is an `Any`, so it's read unchanged.
406
436
  private func dictionaryReadLines(properties: [RecordProperty]) -> [String] {
407
437
  var lines: [String] = []
408
- for property in properties {
409
- let valueVar = derivedIdentifier(from: property.name, suffix: "Value")
438
+ for (index, property) in properties.enumerated() {
439
+ let field = fieldLocal(index)
440
+ let entry = "entry\(index)"
410
441
  let exprType = expressionType(property.type)
411
442
  let isBareAny = property.freeForm?.isBareAny == true
412
443
  let cast = isBareAny
413
- ? valueVar
414
- : "try \(exprType).getDynamicType().cast(\(valueVar), appContext: appContext) as! \(exprType)"
415
- lines.append(" let \(valueVar) = dictionary[\"\(property.key)\"]")
444
+ ? entry
445
+ : "try \(exprType).getDynamicType().cast(\(entry), appContext: appContext) as! \(exprType)"
446
+ lines.append(" let \(entry) = dictionary[\"\(property.key)\"]")
416
447
  if property.isRequired {
417
- lines.append(" guard let \(valueVar) else {")
448
+ lines.append(" guard let \(entry) else {")
418
449
  lines.append(" throw RecordPropertyRequiredException(\"\(property.key)\")")
419
450
  lines.append(" }")
420
- lines.append(" let \(property.name) = \(cast)")
451
+ lines.append(" let \(field) = \(cast)")
421
452
  } else if property.isOptional {
422
- lines.append(" let \(property.name): \(property.type) = (\(valueVar) == nil || \(valueVar)! is NSNull) ? nil : \(cast)")
453
+ lines.append(" let \(field): \(property.type) = (\(entry) == nil || \(entry)! is NSNull) ? nil : \(cast)")
423
454
  } else {
424
- lines.append(" let \(property.name) = \(valueVar) == nil ? \(property.defaultValue!) : \(isBareAny ? valueVar + "!" : cast)")
455
+ lines.append(" let \(field) = \(entry) == nil ? \(property.defaultValue!) : \(isBareAny ? entry + "!" : cast)")
425
456
  }
426
457
  }
427
458
  return lines
@@ -460,51 +491,94 @@ private func toDictionaryMethod(properties: [RecordProperty], inheritsRecord: Bo
460
491
  }
461
492
 
462
493
  /**
463
- `toObject(appContext:)` — builds a `JavaScriptObject` directly, encoding each property with
464
- `JavaScriptEncodable.encode` (or, for a free-form property, the matching `JavaScriptValue` free-form
465
- method). The fast write path mirroring `from(object:)`. The `runtime` is bound
466
- from the app context once and threaded to every write. Subclasses chain to `super` so inherited
467
- properties are written first.
494
+ `toObject(appContext:)`. A record forwards to its own `encode(_:in:)` witness (see
495
+ `javaScriptCodableWitnesses`), which holds the property writes. A subclass, which gets no witnesses,
496
+ overrides it to chain to `super`, so inherited properties are written first, and writes its own
497
+ properties in the app context's runtime.
468
498
  */
469
499
  private func toObjectMethod(properties: [RecordProperty], inheritsRecord: Bool) -> DeclSyntax {
470
- let overrideKeyword = inheritsRecord ? "override " : ""
500
+ guard inheritsRecord else {
501
+ return """
502
+ @JavaScriptActor
503
+ public func toObject(appContext: AppContext) throws -> JavaScriptObject {
504
+ return try Self.encode(self, in: appContext.runtime).asObject()
505
+ }
506
+ """
507
+ }
471
508
  var lines: [String] = []
472
- // The base case needs the runtime to create the object; the inheriting case only needs it when
473
- // there's a property to encode (it chains to `super` for the object itself). Binding it when unused
474
- // would warn.
475
- if !inheritsRecord || !properties.isEmpty {
509
+ // The runtime is only needed when there's a property to encode (the object itself comes from
510
+ // `super`). Binding it when unused would warn.
511
+ if !properties.isEmpty {
476
512
  lines.append(" let runtime = try appContext.runtime")
477
513
  }
478
- if inheritsRecord {
479
- lines.append(" let object = try super.toObject(appContext: appContext)")
480
- } else {
481
- lines.append(" let object = runtime.createObject()")
482
- }
483
- for property in properties {
484
- let exprType = expressionType(property.type)
514
+ lines.append(" let object = try super.toObject(appContext: appContext)")
515
+ lines.append(contentsOf: objectWriteLines(properties: properties, of: "self"))
516
+ lines.append(" return object")
517
+ return """
518
+ @JavaScriptActor
519
+ public override func toObject(appContext: AppContext) throws -> JavaScriptObject {
520
+ \(raw: lines.joined(separator: "\n"))
521
+ }
522
+ """
523
+ }
524
+
525
+ /**
526
+ The record's own `JavaScriptDecodable` and `JavaScriptEncodable` witnesses, which convert in the
527
+ runtime they're given. Without them the record would convert through `Record`'s default witnesses,
528
+ which recover the app context from the runtime and convert in the app context's runtime.
529
+
530
+ `decode` reads each property off the JS object into a local, then constructs the record through the
531
+ memberwise init. Required properties throw when undefined; defaulted properties fall back to the
532
+ property's declared default (inlined) when undefined; optional properties become `nil` when
533
+ undefined/null. It's the unowned overload: an owning value decodes through `JavaScriptDecodable`'s
534
+ default, which forwards to it. `encode` builds the JS object, writing each property. Its parameter
535
+ names the type, because a class can't take `Self` as a parameter type.
536
+ */
537
+ private func javaScriptCodableWitnesses(properties: [RecordProperty], typeName: String) -> [DeclSyntax] {
538
+ // An empty record still decodes only from an object, but has no property to read off it.
539
+ let objectLine = properties.isEmpty
540
+ ? " _ = try value.asObject(in: runtime)"
541
+ : " let object = try value.asObject(in: runtime)"
542
+ let decodeBody = objectLine + "\n" + factoryBody(properties: properties, readLines: jsObjectReadLines(properties: properties))
543
+ let encodeLines = [" let object = runtime.createObject()"]
544
+ + objectWriteLines(properties: properties, of: "value")
545
+ + [" return object.asValue()"]
546
+ return [
547
+ """
548
+ @JavaScriptActor
549
+ public static func decode(_ value: borrowing JavaScriptUnownedValue, in runtime: borrowing JavaScriptRuntime) throws -> Self {
550
+ \(raw: decodeBody)
551
+ }
552
+ """,
553
+ """
554
+ @JavaScriptActor
555
+ public static func encode(_ value: \(raw: typeName), in runtime: borrowing JavaScriptRuntime) throws -> JavaScriptValue {
556
+ \(raw: encodeLines.joined(separator: "\n"))
557
+ }
558
+ """,
559
+ ]
560
+ }
561
+
562
+ /// The statements that write each property of `record` (`self` or the `encode` parameter) into the
563
+ /// `object` local, encoding it with `JavaScriptEncodable.encode` or, for a free-form property, the
564
+ /// matching `JavaScriptValue` free-form method.
565
+ private func objectWriteLines(properties: [RecordProperty], of record: String) -> [String] {
566
+ return properties.map { property in
567
+ let value = "\(record).\(property.name)"
485
568
  let encode: String
486
569
  switch property.freeForm {
487
570
  case .shape(_, let encodeMethod, let isOptional, _):
488
571
  encode = isOptional
489
- ? "self.\(property.name) == nil ? .null() : try JavaScriptValue.\(encodeMethod)(self.\(property.name)!, in: runtime)"
490
- : "try JavaScriptValue.\(encodeMethod)(self.\(property.name), in: runtime)"
572
+ ? "\(value) == nil ? .null() : try JavaScriptValue.\(encodeMethod)(\(value)!, in: runtime)"
573
+ : "try JavaScriptValue.\(encodeMethod)(\(value), in: runtime)"
491
574
  case .generic:
492
575
  // The explicit `as Any` keeps an optional property from warning about its implicit coercion.
493
- encode = "try JavaScriptValue.encodeAny(self.\(property.name) as Any, in: runtime)"
576
+ encode = "try JavaScriptValue.encodeAny(\(value) as Any, in: runtime)"
494
577
  case nil:
495
- encode = "try \(exprType).encode(self.\(property.name), in: runtime)"
578
+ encode = "try \(expressionType(property.type)).encode(\(value), in: runtime)"
496
579
  }
497
- lines.append(" object.setProperty(\"\(property.key)\", value: \(encode))")
580
+ return " object.setProperty(\"\(property.key)\", value: \(encode))"
498
581
  }
499
- lines.append(" return object")
500
- let body = lines.joined(separator: "\n")
501
-
502
- return """
503
- @JavaScriptActor
504
- public \(raw: overrideKeyword)func toObject(appContext: AppContext) throws -> JavaScriptObject {
505
- \(raw: body)
506
- }
507
- """
508
582
  }
509
583
 
510
584
  // MARK: - Helpers
@@ -545,3 +619,14 @@ private func classHasInheritance(_ declaration: some DeclGroupSyntax) -> Bool {
545
619
  }
546
620
  return true
547
621
  }
622
+
623
+ private struct RecordDiagnosticMessage: DiagnosticMessage {
624
+ let message: String
625
+ let diagnosticID: MessageID
626
+ let severity: DiagnosticSeverity = .error
627
+
628
+ init(_ message: String, id: String) {
629
+ self.message = message
630
+ self.diagnosticID = MessageID(domain: "ExpoModulesMacros", id: id)
631
+ }
632
+ }
@@ -93,6 +93,9 @@ public struct SharedObjectMacro: MemberMacro {
93
93
  }
94
94
  }
95
95
 
96
+ diagnoseJSNameCollisions(functions: instanceFunctions, properties: instanceProperties, in: context)
97
+ diagnoseJSNameCollisions(functions: staticFunctions, properties: staticProperties, in: context)
98
+
96
99
  let lines = entries.map { " \($0)" }.joined(separator: "\n")
97
100
  let body = entries.isEmpty
98
101
  ? " return Class(\"\(jsName)\", \(typeName).self) {\n }"
@@ -141,7 +141,8 @@ private struct UnionCase {
141
141
 
142
142
  /// Reads and validates the union off the attached declaration. Every check is a compile error located
143
143
  /// on the offending node: the macro must be on a non-generic `enum` with at least one case, and every
144
- /// case must carry exactly one associated value, its payload type distinct from every earlier case's.
144
+ /// case must carry exactly one associated value that holds no closure, its payload type distinct from
145
+ /// every earlier case's.
145
146
  private func validatedUnion(of declaration: some DeclGroupSyntax) throws -> UnionType {
146
147
  guard let enumDecl = declaration.as(EnumDeclSyntax.self) else {
147
148
  throw MacroExpansionErrorMessage("@Union can only be applied to an enum")
@@ -197,6 +198,18 @@ private func validatedUnion(of declaration: some DeclGroupSyntax) throws -> Unio
197
198
  continue
198
199
  }
199
200
 
201
+ // A payload is decoded from and encoded to JavaScript, and a native closure can't be encoded.
202
+ if let payloadClosure = firstFunctionType(in: parameter.type) {
203
+ diagnostics.append(
204
+ Diagnostic(
205
+ node: payloadClosure,
206
+ message: UnionDiagnosticMessage(
207
+ "@Union case '\(name)' can't carry a closure: the payload is also encoded to JavaScript, and a native closure can't be encoded.",
208
+ id: "union-closure-payload"
209
+ )))
210
+ continue
211
+ }
212
+
200
213
  let payloadType = parameter.type.trimmedDescription
201
214
  // Alternatives decode in declaration order and the first success wins, so a case whose payload
202
215
  // type repeats an earlier case's is unreachable: an exact-spelling duplicate is an error. (An