expo-modules-macros 0.32.0 → 0.34.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 (24) hide show
  1. package/.github/workflows/macos.yml +16 -3
  2. package/AGENTS.md +7 -3
  3. package/Package.swift +10 -1
  4. package/README.md +3 -3
  5. package/apple/ExpoModulesMacros +0 -0
  6. package/apple/Sources/ExpoModulesCLI/Commands/ScanExports/ExportedSurface.swift +124 -5
  7. package/apple/Sources/ExpoModulesCLI/Commands/ScanExports/InheritedMembers.swift +2 -1
  8. package/apple/Sources/ExpoModulesCLI/Commands/ScanExports/ResolveRefs.swift +76 -17
  9. package/apple/Sources/ExpoModulesCLI/Commands/ScanExports/ScanExports.swift +15 -8
  10. package/apple/Sources/ExpoModulesCLI/Commands/ScanExports/SurfaceVisitor.swift +206 -21
  11. package/apple/Sources/ExpoModulesCLI/Commands/ScanModules/ScanModules.swift +14 -6
  12. package/apple/Sources/ExpoModulesCLI/Core/Detection.swift +6 -0
  13. package/apple/Sources/ExpoModulesCLI/Core/DetectionVisitor.swift +41 -2
  14. package/apple/Sources/ExpoModulesCLI/Core/SourceScan.swift +3 -2
  15. package/apple/Sources/ExpoModulesMacrosCore/Macros/ExpoViewMacro.swift +64 -30
  16. package/apple/Sources/ExpoModulesMacrosCore/Macros/JSMacro.swift +174 -2
  17. package/apple/Sources/ExpoModulesMacrosCore/Macros/ViewPropsMacro.swift +176 -46
  18. package/apple/Sources/ExpoModulesMacrosCore/ProvidedMacros.swift +1 -1
  19. package/apple/Sources/ExpoModulesMacrosCore/Support/DecorateModuleBuilder.swift +17 -3
  20. package/apple/Sources/ExpoModulesMacrosCore/Support/JSConstructor.swift +11 -2
  21. package/apple/Sources/ExpoModulesMacrosCore/Support/MacroHelpers.swift +36 -5
  22. package/apple/Sources/ExpoModulesMacrosPlugin/ExpoModulesMacrosPlugin.swift +1 -0
  23. package/build/types.d.ts +58 -3
  24. package/package.json +5 -5
@@ -3,11 +3,11 @@ import SwiftSyntax
3
3
  import SwiftSyntaxBuilder
4
4
  import SwiftSyntaxMacros
5
5
 
6
- /// Member macro applied to an `ExpoView` subclass, binding it to its props type:
6
+ /// Member and extension macro applied to an `ExpoNativeView` subclass, binding it to its props type:
7
7
  ///
8
8
  /// @ExpoView<CardProps>
9
- /// final class CardView: ExpoView {
10
- /// override func didUpdateProps(_ diff: CardProps.Diff) {
9
+ /// final class CardView: ExpoNativeView {
10
+ /// func didUpdateProps(_ diff: CardProps.Diff) {
11
11
  /// if diff.changed(.color) {
12
12
  /// backgroundColor = props.color
13
13
  /// }
@@ -21,23 +21,25 @@ import SwiftSyntaxMacros
21
21
  /// compile error at the author's own line ("requires that 'X' conform to 'AnyViewProps'")
22
22
  /// rather than a macro diagnostic. This macro therefore validates nothing about the props type.
23
23
  ///
24
- /// The whole expansion is one line:
24
+ /// The expansion is a typealias and a conformance:
25
25
  ///
26
26
  /// public typealias Props = CardProps
27
+ /// extension CardView: AnyNativeView {}
27
28
  ///
28
- /// That is deliberately all of it. There is **no view definition**: no `ViewDefinition`, no
29
- /// `View(…) { }` builder, no `Props(_:)` or `Events(_:)` element. `_synthesizedViewDefinition()`
30
- /// existed only to hand core a DSL value the old registration path could consume, and modules and
31
- /// shared objects already moved to direct hooks. Core reads the props type from this typealias and
32
- /// the event names from the props type's own `_eventNames` (synthesized by `@ViewProps`), both
33
- /// static, neither needing a builder to be evaluated.
29
+ /// There is **no view definition**: no `ViewDefinition`, no `View(…) { }` builder, no `Props(_:)` or
30
+ /// `Events(_:)` element. Core reaches the view through the `AnyNativeView` conformance: it reads the
31
+ /// props type from the `Props` typealias, which satisfies the protocol's associated type, and the
32
+ /// event names from the props type's own `_eventNames` (synthesized by `@ViewProps`). Both are
33
+ /// static, so no builder has to be evaluated.
34
34
  ///
35
- /// Views also get no `_decorateView` hook in its place: `_decorateModule` and `_decorateSharedObject`
36
- /// exist to bind members into a JS object, and a view has no such object. Props reach a view through
37
- /// Fabric's mounting layer, not a JS call, so a view's runtime entry point is the props update hook.
35
+ /// Views also get no `_decorateView` hook: `_decorateModule` and `_decorateSharedObject` exist to bind
36
+ /// members into a JS object, and a view has no such object. Props reach a view through Fabric's
37
+ /// mounting layer, not a JS call, so a view's runtime entry point is the props update hook.
38
38
  ///
39
- /// `didUpdateProps` is a core-called override, not synthesized here.
40
- public struct ExpoViewMacro: MemberMacro {
39
+ /// `didUpdateProps` is an `AnyNativeView` requirement with a default in core, not synthesized here.
40
+ /// It isn't an `override`: `ExpoNativeView` has no such method, because its parameter type depends on
41
+ /// the props type.
42
+ public struct ExpoViewMacro: MemberMacro, ExtensionMacro {
41
43
  public static func expansion(
42
44
  of node: AttributeSyntax,
43
45
  providingMembersOf declaration: some DeclGroupSyntax,
@@ -87,38 +89,66 @@ public struct ExpoViewMacro: MemberMacro {
87
89
  """
88
90
  ]
89
91
  }
92
+
93
+ public static func expansion(
94
+ of node: AttributeSyntax,
95
+ attachedTo declaration: some DeclGroupSyntax,
96
+ providingExtensionsOf type: some TypeSyntaxProtocol,
97
+ conformingTo protocols: [TypeSyntax],
98
+ in context: some MacroExpansionContext
99
+ ) throws -> [ExtensionDeclSyntax] {
100
+ // A class that the member macro rejects gets no conformance either: the member macro has already
101
+ // reported the error, and a conformance without the `Props` typealias would only add a second
102
+ // error about the associated type.
103
+ guard let classDecl = declaration.as(ClassDeclSyntax.self),
104
+ inheritsFromAny(classDecl, names: expoViewBaseClassNames),
105
+ classDecl.genericParameterClause == nil,
106
+ existingPropsMemberKind(in: classDecl) == nil,
107
+ genericArgument(of: node) != nil,
108
+ !inheritsProtocol(named: nativeViewProtocolName, in: declaration)
109
+ else {
110
+ return []
111
+ }
112
+ let ext: DeclSyntax = """
113
+ extension \(type.trimmed): \(raw: nativeViewProtocolName) {}
114
+ """
115
+ guard let extDecl = ext.as(ExtensionDeclSyntax.self) else {
116
+ return []
117
+ }
118
+ return [extDecl]
119
+ }
90
120
  }
91
121
 
92
122
  // MARK: - Diagnostics
93
123
 
94
- /// The error for a class that doesn't name `ExpoView` in its inheritance clause, carrying a fix-it
124
+ /// The error for a class that doesn't name `ExpoNativeView` in its inheritance clause, carrying a fix-it
95
125
  /// that adds it.
96
126
  ///
97
127
  /// The fix-it only applies where the edit is unambiguous. With no inheritance clause at all it
98
- /// inserts `: ExpoView`; with an existing clause it prepends `ExpoView` as the first entry, since
128
+ /// inserts `: ExpoNativeView`; with an existing clause it prepends `ExpoNativeView` as the first entry, since
99
129
  /// Swift requires the superclass to come before any protocol. A class that already names some other
100
130
  /// superclass gets the diagnostic without a fix-it: replacing that superclass is a decision the macro
101
131
  /// can't make (see the indirect-inheritance note on `expoViewBaseClassNames`).
102
132
  private func missingBaseClassDiagnostic(for classDecl: ClassDeclSyntax) -> Diagnostic {
103
133
  let message = ExpoViewDiagnosticMessage(
104
- "@ExpoView class must inherit from ExpoView. Add `: ExpoView` to the class declaration.",
134
+ "@ExpoView class must inherit from ExpoNativeView. Add `: ExpoNativeView` to the class declaration.",
105
135
  id: "expoview-missing-base-class"
106
136
  )
107
137
  var fixIts: [FixIt] = []
108
138
 
109
139
  if classDecl.inheritanceClause == nil {
110
- // `class CardView {` → `class CardView: ExpoView {`. The name carries the space before `{` as
140
+ // `class CardView {` → `class CardView: ExpoNativeView {`. The name carries the space before `{` as
111
141
  // trailing trivia, so move it onto the clause to keep the spacing.
112
142
  let name = classDecl.name
113
143
  let clause = InheritanceClauseSyntax(
114
144
  colon: .colonToken(trailingTrivia: .space),
115
145
  inheritedTypes: InheritedTypeListSyntax([
116
- InheritedTypeSyntax(type: TypeSyntax(IdentifierTypeSyntax(name: .identifier("ExpoView"))))
146
+ InheritedTypeSyntax(type: TypeSyntax(IdentifierTypeSyntax(name: .identifier("ExpoNativeView"))))
117
147
  ])
118
148
  )
119
149
  fixIts.append(
120
150
  FixIt(
121
- message: ExpoViewFixItMessage("Inherit from 'ExpoView'", id: "expoview-add-base-class"),
151
+ message: ExpoViewFixItMessage("Inherit from 'ExpoNativeView'", id: "expoview-add-base-class"),
122
152
  changes: [
123
153
  .replace(
124
154
  oldNode: Syntax(classDecl),
@@ -132,16 +162,16 @@ private func missingBaseClassDiagnostic(for classDecl: ClassDeclSyntax) -> Diagn
132
162
  )
133
163
  )
134
164
  } else if let clause = classDecl.inheritanceClause, inheritsOnlyProtocolsByConvention(clause) {
135
- // `class CardView: Identifiable {` → `class CardView: ExpoView, Identifiable {`.
165
+ // `class CardView: Identifiable {` → `class CardView: ExpoNativeView, Identifiable {`.
136
166
  var inherited = clause.inheritedTypes
137
167
  if var first = inherited.first {
138
168
  first.trailingComma = .commaToken(trailingTrivia: .space)
139
- first.type = TypeSyntax(IdentifierTypeSyntax(name: .identifier("ExpoView")))
169
+ first.type = TypeSyntax(IdentifierTypeSyntax(name: .identifier("ExpoNativeView")))
140
170
  inherited.insert(first, at: inherited.startIndex)
141
171
  }
142
172
  fixIts.append(
143
173
  FixIt(
144
- message: ExpoViewFixItMessage("Inherit from 'ExpoView'", id: "expoview-add-base-class"),
174
+ message: ExpoViewFixItMessage("Inherit from 'ExpoNativeView'", id: "expoview-add-base-class"),
145
175
  changes: [
146
176
  .replace(
147
177
  oldNode: Syntax(clause),
@@ -157,7 +187,7 @@ private func missingBaseClassDiagnostic(for classDecl: ClassDeclSyntax) -> Diagn
157
187
 
158
188
  /// Whether every entry in the clause looks like a protocol rather than a superclass, by the
159
189
  /// capitalization-free heuristic that a Swift superclass must come first: if the first entry is one
160
- /// the macro would be replacing, prepending `ExpoView` would produce two superclasses. A macro can't
190
+ /// the macro would be replacing, prepending `ExpoNativeView` would produce two superclasses. A macro can't
161
191
  /// resolve names, so this only reports true when the clause is empty of anything that could be a
162
192
  /// base class the author chose deliberately. Conservative by design: a wrong guess here would emit a
163
193
  /// fix-it that doesn't compile.
@@ -223,14 +253,14 @@ private func existingPropsMemberKind(in classDecl: ClassDeclSyntax) -> String? {
223
253
  }
224
254
 
225
255
  /// Base classes an `@ExpoView` may inherit from. The check is syntactic, matching the name in the
226
- /// class's own inheritance clause: both `: ExpoView` and a qualified `: ExpoModulesCore.ExpoView`
256
+ /// class's own inheritance clause: both `: ExpoNativeView` and a qualified `: ExpoModulesCore.ExpoNativeView`
227
257
  /// pass, since `baseIdentifier` reads the trailing name.
228
258
  ///
229
- /// Indirect inheritance does not: a view whose superclass is itself an `ExpoView` subclass
230
- /// (`class Base: ExpoView` then `class CardView: Base`) is rejected, because a macro can't resolve
259
+ /// Indirect inheritance does not: a view whose superclass is itself an `ExpoNativeView` subclass
260
+ /// (`class Base: ExpoNativeView` then `class CardView: Base`) is rejected, because a macro can't resolve
231
261
  /// the superclass to check. `@SharedObject` has the same limitation, through the same helper. The
232
- /// workaround is to apply `@ExpoView` to the class that names `ExpoView` directly.
233
- private let expoViewBaseClassNames: Set<String> = ["ExpoView"]
262
+ /// workaround is to apply `@ExpoView` to the class that names `ExpoNativeView` directly.
263
+ private let expoViewBaseClassNames: Set<String> = ["ExpoNativeView"]
234
264
 
235
265
  /// The attribute's single generic argument, verbatim (`@ExpoView<CardProps>` → `"CardProps"`), or
236
266
  /// `nil` when the attribute carries no generic argument clause.
@@ -257,3 +287,7 @@ private func genericArgument(of attribute: AttributeSyntax) -> String? {
257
287
  }
258
288
  return type.trimmedDescription
259
289
  }
290
+
291
+ /// The core protocol that `@ExpoView` conforms the class to. Core registers the view and decodes its
292
+ /// props through it.
293
+ private let nativeViewProtocolName = "AnyNativeView"
@@ -51,6 +51,7 @@ public struct JSMacro: PeerMacro {
51
51
  diagnoseConcurrentOption(of: node, on: declaration, in: context)
52
52
  diagnoseAsyncVariantOption(of: node, on: declaration, in: context)
53
53
  diagnoseJavaScriptValuesOffThread(of: node, on: declaration, in: context)
54
+ diagnoseRuntimeParameter(of: node, on: declaration, in: context)
54
55
 
55
56
  // In an `@ExpoModule` or `@SharedObject` class, the class macro emits the assertion instead: it sees
56
57
  // the type aliases declared in the class body, which this macro can't (see `TupleTypeAliases`).
@@ -425,6 +426,175 @@ private func diagnoseJavaScriptValuesOffThread(
425
426
  }
426
427
  }
427
428
 
429
+ /// Emits the diagnostics for a parameter that takes the JavaScript runtime (see `isRuntimeParameter`).
430
+ /// The binding passes the runtime of the call, which can be used only on the JavaScript thread. So
431
+ /// the parameter must be:
432
+ /// - in a synchronous function or an initializer. An `async` function can resume on another thread
433
+ /// after an `await`: `@JavaScriptActor` runs a job on the thread that enqueues it, and doesn't move
434
+ /// it to the JavaScript thread. A `.concurrent` function and the variant that `.withAsync` adds run
435
+ /// on the concurrent thread pool;
436
+ /// - `borrowing`, so the body can't keep the runtime for later without an explicit `copy`;
437
+ /// - not optional, since the binding always has a runtime to pass;
438
+ /// - the last parameter, labeled `in` (or `runtime`), so every declaration reads the same and the JavaScript
439
+ /// arguments keep their order.
440
+ private func diagnoseRuntimeParameter(
441
+ of node: AttributeSyntax,
442
+ on declaration: some DeclSyntaxProtocol,
443
+ in context: some MacroExpansionContext
444
+ ) {
445
+ let parameters: FunctionParameterListSyntax
446
+ if let funcDecl = declaration.as(FunctionDeclSyntax.self) {
447
+ parameters = funcDecl.signature.parameterClause.parameters
448
+ // `.concurrent` is checked first: its body never runs on the JavaScript thread, so making the
449
+ // function synchronous doesn't help (a synchronous `.concurrent` function is an error too).
450
+ let offThread: (reason: String, remedy: String)?
451
+ if hasOption(node, named: "concurrent") {
452
+ offThread = ("'.concurrent' runs the body on the concurrent thread pool", "Remove the parameter.")
453
+ } else if funcDecl.signature.effectSpecifiers?.asyncSpecifier != nil {
454
+ offThread = (
455
+ "an 'async' function can resume on another thread after an 'await'",
456
+ "Remove the parameter, or make the function synchronous."
457
+ )
458
+ } else if asyncVariantOption(of: node) != nil {
459
+ offThread = (
460
+ "the variant that '.withAsync' adds runs the function on the concurrent thread pool",
461
+ "Remove the parameter, or remove '.withAsync'."
462
+ )
463
+ } else {
464
+ offThread = nil
465
+ }
466
+ if let offThread {
467
+ for parameter in parameters where isRuntimeParameter(parameter) {
468
+ context.diagnose(
469
+ Diagnostic(
470
+ node: parameter.type,
471
+ message: JSDiagnosticMessage(
472
+ "A @JS function can take the JavaScript runtime only when it's synchronous: \(offThread.reason), and the runtime can be used only on the JavaScript thread. \(offThread.remedy)",
473
+ id: "js-runtime-parameter-async",
474
+ severity: .error
475
+ )
476
+ )
477
+ )
478
+ }
479
+ return
480
+ }
481
+ } else if let initDecl = declaration.as(InitializerDeclSyntax.self) {
482
+ parameters = initDecl.signature.parameterClause.parameters
483
+ } else {
484
+ return
485
+ }
486
+ for (index, parameter) in parameters.enumerated() where isRuntimeParameter(parameter) {
487
+ guard index == parameters.count - 1 else {
488
+ context.diagnose(
489
+ Diagnostic(
490
+ node: parameter,
491
+ message: JSDiagnosticMessage(
492
+ "The JavaScript runtime must be the last parameter of a @JS function or initializer, and only one parameter can take it. Move it to the end as 'in runtime: borrowing JavaScriptRuntime'.",
493
+ id: "js-runtime-parameter-position",
494
+ severity: .error
495
+ )
496
+ )
497
+ )
498
+ continue
499
+ }
500
+ // The errors and the docs name only `in`, which matches the JSI API, such as `encode(_:into:in:)`.
501
+ // An escaped label (`` `in` ``) is the same label.
502
+ if !runtimeParameterLabels.contains(withoutBackticks(parameter.firstName.text)) {
503
+ context.diagnose(
504
+ Diagnostic(
505
+ node: parameter.firstName,
506
+ message: JSDiagnosticMessage(
507
+ "The parameter that takes the JavaScript runtime must be labeled 'in', as in 'in runtime: borrowing JavaScriptRuntime'.",
508
+ id: "js-runtime-parameter-label",
509
+ severity: .error
510
+ ),
511
+ fixIts: [runtimeLabelFixIt(for: parameter)]
512
+ )
513
+ )
514
+ }
515
+ let specified = SpecifiedType(parameter.type)
516
+ let wrapped = optionalWrappedType(specified.type)
517
+ if let wrapped {
518
+ context.diagnose(
519
+ Diagnostic(
520
+ node: specified.type,
521
+ message: JSDiagnosticMessage(
522
+ "The parameter that takes the JavaScript runtime can't be optional: the binding always passes the runtime of the call.",
523
+ id: "js-runtime-parameter-optional",
524
+ severity: .error
525
+ ),
526
+ fixIts: [
527
+ FixIt(
528
+ message: JSFixItMessage("Remove the optional", id: "js-runtime-parameter-remove-optional"),
529
+ changes: [
530
+ .replace(
531
+ oldNode: Syntax(specified.type),
532
+ newNode: Syntax(
533
+ wrapped.trimmed
534
+ .with(\.leadingTrivia, specified.type.leadingTrivia)
535
+ .with(\.trailingTrivia, specified.type.trailingTrivia)
536
+ )
537
+ )
538
+ ]
539
+ )
540
+ ]
541
+ )
542
+ )
543
+ }
544
+ if specified.specifiers != ["borrowing"] {
545
+ context.diagnose(
546
+ Diagnostic(
547
+ node: parameter.type,
548
+ message: JSDiagnosticMessage(
549
+ "The parameter that takes the JavaScript runtime must be 'borrowing': the runtime can be used only on the JavaScript thread, and a borrowed parameter can't be kept for later without an explicit 'copy'.",
550
+ id: "js-runtime-parameter-borrowing",
551
+ severity: .error
552
+ ),
553
+ fixIts: [
554
+ FixIt(
555
+ message: JSFixItMessage("Mark the parameter 'borrowing'", id: "js-runtime-parameter-insert-borrowing"),
556
+ changes: [
557
+ .replace(
558
+ oldNode: Syntax(parameter.type),
559
+ newNode: Syntax(borrowingType(wrapped ?? specified.type, like: parameter.type))
560
+ )
561
+ ]
562
+ )
563
+ ]
564
+ )
565
+ )
566
+ }
567
+ }
568
+ }
569
+
570
+ /// The labels that the runtime parameter can have.
571
+ private let runtimeParameterLabels: Set<String> = ["in", "runtime"]
572
+
573
+ /// The fix-it that labels the runtime parameter `in`. It keeps the parameter name that the body uses, or
574
+ /// names the parameter `runtime` when it has no name.
575
+ private func runtimeLabelFixIt(for parameter: FunctionParameterSyntax) -> FixIt {
576
+ var newParameter = parameter
577
+ let name = parameter.secondName ?? (parameter.firstName.tokenKind == .wildcard ? nil : parameter.firstName)
578
+ newParameter.firstName = .keyword(.in, leadingTrivia: parameter.firstName.leadingTrivia, trailingTrivia: .space)
579
+ newParameter.secondName = name?.trimmed ?? .identifier("runtime")
580
+ return FixIt(
581
+ message: JSFixItMessage("Label the parameter 'in'", id: "js-runtime-parameter-label"),
582
+ changes: [.replace(oldNode: Syntax(parameter), newNode: Syntax(newParameter))]
583
+ )
584
+ }
585
+
586
+ /// The type `type` with the single specifier `borrowing`, with the trivia of `original`.
587
+ private func borrowingType(_ type: TypeSyntax, like original: TypeSyntax) -> TypeSyntax {
588
+ let specifier = SimpleTypeSpecifierSyntax(specifier: .keyword(.borrowing, trailingTrivia: .space))
589
+ let attributed = AttributedTypeSyntax(
590
+ specifiers: [.simpleTypeSpecifier(specifier)],
591
+ baseType: type.trimmed
592
+ )
593
+ return TypeSyntax(attributed)
594
+ .with(\.leadingTrivia, original.leadingTrivia)
595
+ .with(\.trailingTrivia, original.trailingTrivia)
596
+ }
597
+
428
598
  private func diagnoseParameterSpecifiers(
429
599
  in declaration: some DeclSyntaxProtocol,
430
600
  in context: some MacroExpansionContext
@@ -437,7 +607,8 @@ private func diagnoseParameterSpecifiers(
437
607
  } else {
438
608
  return
439
609
  }
440
- for parameter in parameters {
610
+ // The runtime parameter isn't decoded, and `diagnoseRuntimeParameter` requires it to be `borrowing`.
611
+ for parameter in parameters where !isRuntimeParameter(parameter) {
441
612
  guard let specifier = SpecifiedType(parameter.type).unsupportedSpecifier else {
442
613
  continue
443
614
  }
@@ -608,7 +779,8 @@ private func argumentBoundaryTypes(of parameters: FunctionParameterListSyntax) -
608
779
  // A closure in any other position is diagnosed by `diagnoseClosureTypes` and left out here, since
609
780
  // its metatype isn't a valid expression in the assertion.
610
781
  var types: [AssertedType] = []
611
- for parameter in parameters {
782
+ // The runtime parameter takes no JavaScript argument, so nothing decodes into it.
783
+ for parameter in parameters where !isRuntimeParameter(parameter) {
612
784
  let label = parameter.firstName.tokenKind == .wildcard ? nil : parameter.firstName.text
613
785
  // The specifier isn't part of the asserted type, and a parameter with one the binding can't honor
614
786
  // is reported by `diagnoseParameterSpecifiers` and not asserted.