expo-modules-macros 0.32.0 → 0.33.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.
package/AGENTS.md CHANGED
@@ -4,9 +4,10 @@
4
4
 
5
5
  ## Layout
6
6
 
7
- - `Package.swift`: the SwiftPM package, at the root so that its identity is `expo-modules-macros` (an `apple/` manifest would have the identity `apple`, which conflicts with other packages that keep theirs in `apple/`). The targets name their paths under `apple/`. The `ExpoModulesMacrosPlugin` library product vends the macro target to Swift packages, and its name must differ from the target's. The test targets are declared only when `apple/Tests` exists, because the published npm package leaves it out.
7
+ - `Package.swift`: the SwiftPM package, at the root so that its identity is `expo-modules-macros` (an `apple/` manifest would have the identity `apple`, which conflicts with other packages that keep theirs in `apple/`). The targets name their paths under `apple/`. The `ExpoModulesMacrosPlugin` library product vends the macro target to Swift packages through a target of the same name, and its name must differ from the macro target's. The test targets are declared only when `apple/Tests` exists, because the published npm package leaves it out.
8
8
  - `apple/Sources/ExpoModulesMacrosCore`: the macro implementations, in a library target so that both executables below can link them. `Macros/` holds one file per macro, and `Support/` the code that several macros share. `ProvidedMacros.swift` lists every macro with its attribute name and conformances in `ProvidedMacro.all`. `PluginServer.swift` runs the compiler plugin protocol with a provider that resolves the macros of `ProvidedMacro.all` by type name, because the default `CompilerPlugin` lookup expects them in a module named `ExpoModulesMacros`. It uses the `@_spi(PluginMessage)` API of swift-syntax.
9
9
  - `apple/Sources/ExpoModulesMacros`: the `.macro` target that the `ExpoModulesMacrosPlugin` product vends to Swift packages. It only runs the plugin server, so Swift packages don't compile the CLI.
10
+ - `apple/Sources/ExpoModulesMacrosPlugin`: an empty target that the `ExpoModulesMacrosPlugin` product holds in place of the macro target, which it depends on. Xcode fails an iOS build when the product holds the macro target directly (see `Package.swift`).
10
11
  - `apple/Sources/ExpoModulesMacrosTool`: the executable that ships as `apple/ExpoModulesMacros` and in the Windows and Linux packages. `main.swift` runs the plugin server without arguments and sends any invocation with arguments to the CLI.
11
12
  - `apple/Sources/ExpoModulesCLI`: the library behind the command-line tool. `CLI.swift` parses the arguments, `Commands/` holds one directory per subcommand (`ScanModules/` for autolinking, `ScanExports/` for type generation, `Expand/` for `expand`, with `MacroExpander.swift`, which runs the macros in process, and `Fix/` for `fix`, which applies the Fix-Its that `expand` reports), and `Core/` holds the shared parsing, `#if` evaluation and output.
12
13
  - `apple/Sources/ExpoModulesOptimized`: declarations for the `@OptimizedFunction` macro.
package/Package.swift CHANGED
@@ -14,7 +14,7 @@ let package = Package(
14
14
  // `#externalMacro(module: "ExpoModulesMacros", ...)` depends on this product, and the targets that
15
15
  // depend on it get the plugin too. CocoaPods builds keep loading the prebuilt binary. The name
16
16
  // differs from the macro target's, because SwiftPM already has a product of that name for it.
17
- .library(name: "ExpoModulesMacrosPlugin", targets: ["ExpoModulesMacros"]),
17
+ .library(name: "ExpoModulesMacrosPlugin", targets: ["ExpoModulesMacrosPlugin"]),
18
18
  .executable(name: "ExpoModulesMacrosTool", targets: ["ExpoModulesMacrosTool"]),
19
19
  ],
20
20
  dependencies: [
@@ -51,6 +51,15 @@ let package = Package(
51
51
  ],
52
52
  path: "apple/Sources/ExpoModulesMacros"
53
53
  ),
54
+ // The target of the `ExpoModulesMacrosPlugin` product. The product must not hold the macro target
55
+ // itself: Xcode then passes the module map of `ExpoModulesMacrosCore`, which it builds for the
56
+ // host only, to the iOS target that depends on the product, and the build fails with
57
+ // `ExpoModulesMacrosCore.modulemap not found`.
58
+ .target(
59
+ name: "ExpoModulesMacrosPlugin",
60
+ dependencies: ["ExpoModulesMacros"],
61
+ path: "apple/Sources/ExpoModulesMacrosPlugin"
62
+ ),
54
63
  // The shipped binary, `apple/ExpoModulesMacros` and the Windows and Linux binaries. It is both the
55
64
  // compiler plugin and the scanner CLI: the compiler launches it without arguments and speaks the
56
65
  // plugin protocol over stdin, while an invocation with arguments dispatches into `ScannerCLI`
package/README.md CHANGED
@@ -180,7 +180,7 @@ to `OTHER_SWIFT_FLAGS` for `ExpoModulesCore`, every pod that depends on it, and
180
180
 
181
181
  ## Swift packages
182
182
 
183
- The package manifest is `Package.swift` at the root of this package, so a Swift package can depend on it by its path in `node_modules` or by the URL of this repository. Its identity is then `expo-modules-macros`. The `ExpoModulesMacrosPlugin` library product holds the `ExpoModulesMacros` macro target:
183
+ The package manifest is `Package.swift` at the root of this package, so a Swift package can depend on it by its path in `node_modules` or by the URL of this repository. Its identity is then `expo-modules-macros`. The `ExpoModulesMacrosPlugin` library product gives the `ExpoModulesMacros` macro target to the targets that depend on it:
184
184
 
185
185
  ```swift
186
186
  dependencies: [
Binary file
@@ -1,7 +1,8 @@
1
- /// The deep-scan surface: the full JS-exported shape of every `@ExpoModule`, `@SharedObject`, and
2
- /// `@Record` type, with the per-member detail a TypeScript type generator needs. Read syntactically
3
- /// (like the macros): each boundary type becomes a structured `TypeNode` tree so the consumer walks a
4
- /// tagged tree rather than re-parsing Swift type syntax.
1
+ /// The deep-scan surface: the full JS-exported shape of every `@ExpoModule`, `@SharedObject`,
2
+ /// `@Record`, `@Union`, and `@ExpoView` type and every `Enumerable` enum, with the per-member detail a
3
+ /// TypeScript type generator needs. Read syntactically (like the macros): each boundary type becomes a
4
+ /// structured `TypeNode` tree so the consumer walks a tagged tree rather than re-parsing Swift type
5
+ /// syntax.
5
6
  ///
6
7
  /// Every declaration and member carries the `platforms` that build it, and the ones that can be
7
8
  /// written with an access modifier carry the `accessLevel` they spell. A declaration no platform
@@ -460,6 +461,122 @@ struct ExportedUnion: Encodable, Equatable {
460
461
  let file: String
461
462
  }
462
463
 
464
+ /// An `@ExpoView` class with the props and events of the `@ViewProps` struct in its generic argument.
465
+ /// It is reported on its own rather than under a module, because core reaches it through its
466
+ /// `AnyNativeView` conformance.
467
+ struct ExportedView: Encodable, Equatable {
468
+ /// The Swift class name. Only a view declared at file scope is reported, as in `scan-modules`.
469
+ let name: String
470
+
471
+ /// The name the view registers under. `@ExpoView` takes no name, so it is always the class name.
472
+ let jsName: String
473
+
474
+ /// The props type: the generic argument of `@ExpoView<CardProps>`, as written without backticks, or
475
+ /// the qualified name of the scanned `@ViewProps` struct it names (`CardView.Props`). A class without
476
+ /// the generic argument is not reported, because the macro rejects it.
477
+ var propsType: String
478
+
479
+ /// The name `propsType` is looked up under: its spelling without generic arguments, or `nil` when it
480
+ /// isn't a plain or qualified type name. Not encoded.
481
+ var propsTypeLookupName: String? = nil
482
+
483
+ /// The value props of the `@ViewProps` struct, or `nil` when the scan declares no struct named
484
+ /// `propsType` on a platform of the view. A struct shared by several views is copied into each of
485
+ /// them, and a struct declared in several `#if` branches contributes the props of each declaration
486
+ /// whose platforms overlap the view's.
487
+ var props: [ExportedViewProp]? = nil
488
+
489
+ /// The events of the `@ViewProps` struct, or `nil` when `props` is `nil`.
490
+ var events: [ExportedViewEvent]? = nil
491
+
492
+ /// The spelled access modifier, or `nil` when none is written.
493
+ let accessLevel: String?
494
+
495
+ let platforms: [ExportedPlatform]
496
+
497
+ let file: String
498
+
499
+ private enum CodingKeys: String, CodingKey {
500
+ case name, jsName, propsType, props, events, accessLevel, platforms, file
501
+ }
502
+ }
503
+
504
+ /// One value prop of a `@ViewProps` struct: a stored property that isn't function-typed. Like
505
+ /// `ExportedRecordProperty`, but with no `required`: core resets a prop that React leaves out to its
506
+ /// default, so JS can always omit one.
507
+ struct ExportedViewProp: Encodable, Equatable {
508
+ let name: String
509
+
510
+ /// The annotated type, or the type of a literal initial value. A property with neither is not
511
+ /// reported, because the macro rejects it.
512
+ var type: TypeNode
513
+
514
+ /// The initial value as source text, or `nil` when none is written.
515
+ let defaultValue: String?
516
+
517
+ /// The spelled access modifier, or `nil` when none is written. Never `private` or `fileprivate`,
518
+ /// since those exclude a property from the props.
519
+ let accessLevel: String?
520
+
521
+ let platforms: [ExportedPlatform]
522
+
523
+ /// Optional-typed, read from `type`, so every spelling of an optional counts (`T?`, `T!`,
524
+ /// `Optional<T>`, `(T?)`). Encoded as `optional`.
525
+ var isOptional: Bool {
526
+ if case .optional = type {
527
+ return true
528
+ }
529
+ return false
530
+ }
531
+
532
+ private enum CodingKeys: String, CodingKey {
533
+ case name, type, defaultValue, accessLevel, platforms
534
+ case isOptional = "optional"
535
+ }
536
+
537
+ func encode(to encoder: Encoder) throws {
538
+ var container = encoder.container(keyedBy: CodingKeys.self)
539
+ try container.encode(name, forKey: .name)
540
+ try container.encode(type, forKey: .type)
541
+ try container.encode(isOptional, forKey: .isOptional)
542
+ try container.encodeIfPresent(defaultValue, forKey: .defaultValue)
543
+ try container.encodeIfPresent(accessLevel, forKey: .accessLevel)
544
+ try container.encode(platforms, forKey: .platforms)
545
+ }
546
+ }
547
+
548
+ /// One event of a `@ViewProps` struct: a function-typed stored property. Its name is the React prop
549
+ /// name as written, with no `on` prefix stripped, so it carries no `jsName`.
550
+ struct ExportedViewEvent: Encodable, Equatable {
551
+ /// The Swift property name, e.g. `onTap`.
552
+ let name: String
553
+
554
+ /// The type of the function's single parameter, or `nil` when it takes none (`() -> Void`) or more
555
+ /// than one.
556
+ var payload: TypeNode?
557
+
558
+ /// The spelled access modifier, or `nil` when none is written.
559
+ let accessLevel: String?
560
+
561
+ let platforms: [ExportedPlatform]
562
+ }
563
+
564
+ /// A `@ViewProps` struct the scan found. Not encoded: `ExportedSurface.joiningViewProps(_:)` copies its
565
+ /// props and events into each view that names it, and a struct that no view names is not reported.
566
+ struct ViewPropsDeclaration: Equatable {
567
+ /// The Swift struct name, qualified with the types it is nested in (`CardView.Props`).
568
+ let name: String
569
+
570
+ /// The value props, in declaration order.
571
+ let props: [ExportedViewProp]
572
+
573
+ /// The events, in declaration order.
574
+ let events: [ExportedViewEvent]
575
+
576
+ /// The platforms that build the struct, to match it with the views that build on them.
577
+ let platforms: [ExportedPlatform]
578
+ }
579
+
463
580
  /// The exported types grouped by kind, nested under `exports` in the result so the surface is one
464
581
  /// self-contained object separate from `stats`.
465
582
  struct ExportedSurface: Encodable, Equatable {
@@ -468,6 +585,7 @@ struct ExportedSurface: Encodable, Equatable {
468
585
  let records: [ExportedRecord]
469
586
  let enums: [ExportedEnum]
470
587
  let unions: [ExportedUnion]
588
+ let views: [ExportedView]
471
589
  }
472
590
 
473
591
  /// How an `@Event` is dispatched to JavaScript, from its option. Mirrors `EventDispatch` in the macros.
@@ -488,7 +606,8 @@ enum ExportedEventDispatch: String, Encodable, Equatable {
488
606
  /// `defaultValue`, and replaces `constructorParameters` with `initializer`; version 9 adds the
489
607
  /// `sharedRef` type kind and the `sharedRef` of a shared object; version 10 adds the `tuple` and `object` type kinds (named after a tuple alias)
490
608
  /// and resolves the tuple type aliases declared in `@ExpoModule` and `@SharedObject` classes; version 11
491
- /// replaces the `sync` flag of an event with `dispatch`.
609
+ /// replaces the `sync` flag of an event with `dispatch`. A new field that older consumers can ignore,
610
+ /// such as `views`, doesn't change the version.
492
611
  let scanExportsSchemaVersion = 11
493
612
 
494
613
  /// The `scan-exports` result: the surface plus the run's stats. A distinct envelope from
@@ -83,7 +83,8 @@ extension ExportedSurface {
83
83
  sharedObjects: sharedObjects.map { merged($0, chain: []) },
84
84
  records: records,
85
85
  enums: enums,
86
- unions: unions
86
+ unions: unions,
87
+ views: views
87
88
  )
88
89
  }
89
90
  }
@@ -74,26 +74,32 @@ struct RefIndex {
74
74
  /// dropped one at a time (`Media.Status` -> `Status`): a use site may qualify a name with a module
75
75
  /// the scan doesn't see, which is how the conformance and raw-type checks already match names.
76
76
  func lookup(_ name: String, in scope: String) -> Entry? {
77
- let name = name.hasPrefix("Self.") ? String(name.dropFirst("Self.".count)) : name
78
- var components = scope.split(separator: ".").map(String.init)
79
- while true {
80
- if let entry = entries[(components + [name]).joined(separator: ".")] {
81
- return entry
82
- }
83
- if components.isEmpty {
84
- break
85
- }
86
- components.removeLast()
77
+ return scopedLookup(name, in: scope, entries: entries)
78
+ }
79
+ }
80
+
81
+ /// The entry that a use of `name` written inside the type `scope` names, from `entries` keyed by
82
+ /// qualified name. Swift's lookup rules, as `RefIndex.lookup(_:in:)` documents them.
83
+ private func scopedLookup<Entry>(_ name: String, in scope: String, entries: [String: Entry]) -> Entry? {
84
+ let name = name.hasPrefix("Self.") ? String(name.dropFirst("Self.".count)) : name
85
+ var components = scope.split(separator: ".").map(String.init)
86
+ while true {
87
+ if let entry = entries[(components + [name]).joined(separator: ".")] {
88
+ return entry
87
89
  }
88
- var suffix = name.split(separator: ".").map(String.init).dropFirst()
89
- while !suffix.isEmpty {
90
- if let entry = entries[suffix.joined(separator: ".")] {
91
- return entry
92
- }
93
- suffix = suffix.dropFirst()
90
+ if components.isEmpty {
91
+ break
92
+ }
93
+ components.removeLast()
94
+ }
95
+ var suffix = name.split(separator: ".").map(String.init).dropFirst()
96
+ while !suffix.isEmpty {
97
+ if let entry = entries[suffix.joined(separator: ".")] {
98
+ return entry
94
99
  }
95
- return nil
100
+ suffix = suffix.dropFirst()
96
101
  }
102
+ return nil
97
103
  }
98
104
 
99
105
  /// The JS category a raw-value enum crosses as: its raw value's. A bare `Enumerable` conformance with
@@ -214,6 +220,59 @@ extension ExportedSurface {
214
220
  return resolvedMember
215
221
  }
216
222
  return resolved
223
+ },
224
+ // The props and events are written in the `@ViewProps` struct, so their refs are looked up from
225
+ // it. `joiningViewProps(_:)` runs first and sets `propsType` to the struct's qualified name.
226
+ views: views.map { view in
227
+ var resolved = view
228
+ resolved.props = view.props?.map { prop in
229
+ var resolvedProp = prop
230
+ resolvedProp.type = prop.type.resolvingRefs(using: index, in: view.propsType)
231
+ return resolvedProp
232
+ }
233
+ resolved.events = view.events?.map { event in
234
+ var resolvedEvent = event
235
+ resolvedEvent.payload = event.payload?.resolvingRefs(using: index, in: view.propsType)
236
+ return resolvedEvent
237
+ }
238
+ return resolved
239
+ }
240
+ )
241
+ }
242
+
243
+ /// This surface with the props and events of each view's `@ViewProps` struct copied into the view.
244
+ /// The struct is looked up from the view like a ref, by its name without generic arguments, so it
245
+ /// may be declared in another file or nested (`CardView.Props`). A found struct gives the view its
246
+ /// qualified name as `propsType`. A view whose struct the scan doesn't declare keeps `props` and
247
+ /// `events` at `nil`, and `propsType` as written.
248
+ ///
249
+ /// A struct declared more than once, in `#if` branches, contributes the props and events of each
250
+ /// declaration whose platforms overlap the platforms of the view, as a superclass does in
251
+ /// `mergingInheritedMembers()`. Each prop and event keeps its own `platforms`. A view that builds on
252
+ /// none of those platforms is treated as one whose struct the scan doesn't declare.
253
+ func joiningViewProps(_ declarations: [ViewPropsDeclaration]) -> ExportedSurface {
254
+ let entries = Dictionary(grouping: declarations, by: \.name)
255
+ return ExportedSurface(
256
+ modules: modules,
257
+ sharedObjects: sharedObjects,
258
+ records: records,
259
+ enums: enums,
260
+ unions: unions,
261
+ views: views.map { view in
262
+ let platforms = Set(view.platforms.map(\.name))
263
+ guard let name = view.propsTypeLookupName,
264
+ let variants = scopedLookup(name, in: view.name, entries: entries)?.filter({ declaration in
265
+ declaration.platforms.contains { platforms.contains($0.name) }
266
+ }),
267
+ let first = variants.first
268
+ else {
269
+ return view
270
+ }
271
+ var joined = view
272
+ joined.propsType = first.name
273
+ joined.props = variants.flatMap(\.props)
274
+ joined.events = variants.flatMap(\.events)
275
+ return joined
217
276
  }
218
277
  )
219
278
  }
@@ -26,11 +26,11 @@ extension Scanner {
26
26
  }
27
27
  }
28
28
 
29
- /// Scans `paths` for `@ExpoModule`, `@SharedObject`, `@Record`, and `@Union` types plus `Enumerable`
30
- /// enums, and returns their exported surface plus the run's stats. Separate from the public entry so
31
- /// tests can drive it without argv/stdout.
29
+ /// Scans `paths` for `@ExpoModule`, `@SharedObject`, `@Record`, `@Union`, `@ExpoView`, and
30
+ /// `@ViewProps` types plus `Enumerable` enums, and returns their exported surface plus the run's stats.
31
+ /// Separate from the public entry so tests can drive it without argv/stdout.
32
32
  ///
33
- /// The pre-filter omits `@Event`: an event only declares a member of one of these three types, so a
33
+ /// The pre-filter omits `@Event`: an event only declares a member of a module or shared object, so a
34
34
  /// file containing one already matches on its enclosing type. It does include `Enumerable`, which is a
35
35
  /// conformance rather than an attribute: an enum is commonly declared in a file of its own, which no
36
36
  /// macro attribute would match.
@@ -40,10 +40,12 @@ func scanExports(paths: [String]) -> ScanExportsResult {
40
40
  var records: [ExportedRecord] = []
41
41
  var enums: [ExportedEnum] = []
42
42
  var unions: [ExportedUnion] = []
43
+ var views: [ExportedView] = []
44
+ var viewProps: [ViewPropsDeclaration] = []
43
45
 
44
46
  let stats = scanFiles(
45
47
  paths: paths,
46
- macros: [.expoModule, .sharedObject, .record, .union],
48
+ macros: [.expoModule, .sharedObject, .record, .union, .expoView, .viewProps],
47
49
  conformances: [enumerableConformanceName]
48
50
  ) { source, file in
49
51
  let tree = Parser.parse(source: source)
@@ -54,18 +56,23 @@ func scanExports(paths: [String]) -> ScanExportsResult {
54
56
  records.append(contentsOf: visitor.records)
55
57
  enums.append(contentsOf: visitor.enums)
56
58
  unions.append(contentsOf: visitor.unions)
59
+ views.append(contentsOf: visitor.views)
60
+ viewProps.append(contentsOf: visitor.viewProps)
57
61
  }
58
62
 
59
63
  // Refs resolve only once every file has been walked: a type parsed in one file may name a type
60
64
  // declared in another, so the full set of declarations has to exist before any lookup is valid. The
61
- // same holds for a superclass, so the inherited members are merged after that.
65
+ // same holds for a view's `@ViewProps` struct, which is joined before the refs resolve so that the
66
+ // refs in its props resolve from the struct, and for a superclass, so the inherited members are
67
+ // merged last.
62
68
  let surface = ExportedSurface(
63
69
  modules: modules,
64
70
  sharedObjects: sharedObjects,
65
71
  records: records,
66
72
  enums: enums,
67
- unions: unions
68
- ).resolvingRefs().mergingInheritedMembers()
73
+ unions: unions,
74
+ views: views
75
+ ).joiningViewProps(viewProps).resolvingRefs().mergingInheritedMembers()
69
76
 
70
77
  return ScanExportsResult(
71
78
  schemaVersion: scanExportsSchemaVersion,
@@ -1,10 +1,10 @@
1
1
  import SwiftSyntax
2
2
 
3
- /// Extracts the full JS-exported surface of every `@ExpoModule`, `@SharedObject`, and `@Record` type:
4
- /// their `@JS` members and record properties. The deep counterpart to `DetectionVisitor`.
5
- /// Recognition is purely syntactic and re-reads what the macros read (the macro target can't be
6
- /// imported), so it stays in step with `JSFunction` / `JSProperty` / `JSConstructor` /
7
- /// `RecordProperty`.
3
+ /// Extracts the full JS-exported surface of every `@ExpoModule`, `@SharedObject`, `@Record`,
4
+ /// `@ExpoView`, and `@ViewProps` type: their `@JS` members, record properties, view props, and view
5
+ /// events. The deep counterpart to `DetectionVisitor`. Recognition is purely syntactic and re-reads
6
+ /// what the macros read (the macro target can't be imported), so it stays in step with `JSFunction` /
7
+ /// `JSProperty` / `JSConstructor` / `RecordProperty` / `validatedViewProps`.
8
8
  ///
9
9
  /// Types nested in another type's body, or in a file-scope `extension`, are reported under their
10
10
  /// qualified Swift name (`Options.Encoding`), so a ref to one can be resolved from the scope it is
@@ -12,10 +12,10 @@ import SwiftSyntax
12
12
  ///
13
13
  /// Each declaration and member reports the platforms that build it (see `PlatformResolver`), and one
14
14
  /// that no platform builds is not reported. `#if` is read only where the runtime sees what it holds:
15
- /// around types, `@Event` members, and enum cases. `@ExpoModule`, `@SharedObject`, `@Record`, and
16
- /// `@Union` read only the members written directly in the type body, so `@JS` members, record
17
- /// properties, and union cases in a `#if` clause are never bound and are not reported. An `@Event`
18
- /// expands on its own declaration, and the compiler reads enum cases itself.
15
+ /// around types, `@Event` members, and enum cases. `@ExpoModule`, `@SharedObject`, `@Record`,
16
+ /// `@Union`, and `@ViewProps` read only the members written directly in the type body, so `@JS`
17
+ /// members, record properties, union cases, and view props in a `#if` clause are never bound and are
18
+ /// not reported. An `@Event` expands on its own declaration, and the compiler reads enum cases itself.
19
19
  final class SurfaceVisitor: SyntaxVisitor {
20
20
  private let file: String
21
21
  private let platformResolver: PlatformResolver
@@ -24,6 +24,10 @@ final class SurfaceVisitor: SyntaxVisitor {
24
24
  private(set) var records: [ExportedRecord] = []
25
25
  private(set) var enums: [ExportedEnum] = []
26
26
  private(set) var unions: [ExportedUnion] = []
27
+ private(set) var views: [ExportedView] = []
28
+ /// The `@ViewProps` structs, which `ExportedSurface.joiningViewProps(_:)` copies into the views once
29
+ /// every file has been walked.
30
+ private(set) var viewProps: [ViewPropsDeclaration] = []
27
31
 
28
32
  init(file: String, tree: SourceFileSyntax) {
29
33
  self.file = file
@@ -120,8 +124,8 @@ final class SurfaceVisitor: SyntaxVisitor {
120
124
  }
121
125
 
122
126
  /// Routes a class or struct to the right collector based on which Expo macro it carries. A type
123
- /// carrying none of them is ignored. `@Record` and `@ExpoModule`/`@SharedObject` are mutually
124
- /// exclusive in practice, so the first match wins.
127
+ /// carrying none of them is ignored. The macros are mutually exclusive in practice, so the first
128
+ /// match wins.
125
129
  ///
126
130
  /// `name` is the declared name and `scope` the names of the types enclosing it. The reported `name`
127
131
  /// is qualified with the scope, while a default `jsName` is the declared name alone, as the macros
@@ -201,6 +205,38 @@ final class SurfaceVisitor: SyntaxVisitor {
201
205
  file: file
202
206
  )
203
207
  )
208
+ return
209
+ }
210
+
211
+ // `@ExpoView` applies only to a class, and needs the props type as its generic argument.
212
+ if node.is(ClassDeclSyntax.self),
213
+ let attribute = attributes.firstAttribute(named: DetectedMacro.expoView.rawValue),
214
+ let propsType = genericArgumentType(of: attribute)
215
+ {
216
+ // Only a file-scope view is reported, as `scan-modules` reports only those.
217
+ guard scope.isEmpty else {
218
+ return
219
+ }
220
+ // `props` and `events` are filled in by `ExportedSurface.joiningViewProps(_:)`, since the
221
+ // `@ViewProps` struct can be declared in another file.
222
+ views.append(
223
+ ExportedView(
224
+ name: qualified,
225
+ jsName: name,
226
+ propsType: String(propsType.trimmedDescription.filter { $0 != "`" }),
227
+ propsTypeLookupName: spellingWithoutGenericArguments(of: propsType),
228
+ accessLevel: accessLevel,
229
+ platforms: platforms,
230
+ file: file
231
+ )
232
+ )
233
+ return
234
+ }
235
+
236
+ // `@ViewProps` applies only to a struct.
237
+ if node.is(StructDeclSyntax.self), attributes.firstAttribute(named: DetectedMacro.viewProps.rawValue) != nil {
238
+ let (props, events) = collectViewProps(members)
239
+ viewProps.append(ViewPropsDeclaration(name: qualified, props: props, events: events, platforms: platforms))
204
240
  }
205
241
  }
206
242
 
@@ -425,7 +461,7 @@ final class SurfaceVisitor: SyntaxVisitor {
425
461
  // `@Record` reads only the properties written directly in the body, so `#if` is not looked into.
426
462
  for member in members {
427
463
  guard let varDecl = member.decl.as(VariableDeclSyntax.self),
428
- !isExcludedRecordModifier(varDecl.modifiers)
464
+ !isExcludedByModifier(varDecl.modifiers)
429
465
  else {
430
466
  continue
431
467
  }
@@ -459,6 +495,76 @@ final class SurfaceVisitor: SyntaxVisitor {
459
495
  return properties
460
496
  }
461
497
 
498
+ /// The value props and events of a `@ViewProps` struct, mirroring `validatedViewProps` in the macro:
499
+ /// every binding of a `var` or `let` that no modifier excludes, except a computed one. A property
500
+ /// with `willSet`/`didSet` or a `set` counts, as in the macro. A function-typed property is an event,
501
+ /// and every other one a value prop.
502
+ ///
503
+ /// The scan stays lenient where the macro reports an error, and leaves out only what can't be
504
+ /// described: a property whose type can't be determined, an optional function type, and an event
505
+ /// that doesn't return `Void`. A value prop the macro rejects for another reason, such as a `let` or
506
+ /// a missing default, is still reported.
507
+ private func collectViewProps(
508
+ _ members: MemberBlockItemListSyntax
509
+ ) -> (props: [ExportedViewProp], events: [ExportedViewEvent]) {
510
+ var props: [ExportedViewProp] = []
511
+ var events: [ExportedViewEvent] = []
512
+
513
+ // `@ViewProps` reads only the properties written directly in the body, so `#if` is not looked into.
514
+ for member in members {
515
+ guard let varDecl = member.decl.as(VariableDeclSyntax.self),
516
+ !isExcludedByModifier(varDecl.modifiers)
517
+ else {
518
+ continue
519
+ }
520
+ let platforms = platformResolver.platforms(of: varDecl)
521
+ guard !platforms.isEmpty else {
522
+ continue
523
+ }
524
+ for binding in varDecl.bindings {
525
+ if binding.accessorBlock != nil && !isSettable(binding: binding, isLet: false) {
526
+ continue
527
+ }
528
+ guard let ident = binding.pattern.as(IdentifierPatternSyntax.self) else {
529
+ continue
530
+ }
531
+ let name = ident.identifier.identifierName
532
+ let annotation = binding.typeAnnotation?.type
533
+
534
+ if let annotation, let functionType = viewEventFunctionType(of: annotation) {
535
+ // An optional function type and a non-`Void` result are errors in the macro.
536
+ guard !isOptionalEventType(annotation), isVoidEventReturn(functionType.returnClause.type) else {
537
+ continue
538
+ }
539
+ let parameters = functionType.parameters
540
+ events.append(
541
+ ExportedViewEvent(
542
+ name: name,
543
+ payload: parameters.count == 1 ? parameters.first.map { typeNode(from: $0.type) } : nil,
544
+ accessLevel: spelledAccessLevel(of: varDecl.modifiers),
545
+ platforms: platforms
546
+ )
547
+ )
548
+ continue
549
+ }
550
+
551
+ guard let type = valueTypeNode(annotation: annotation, initializer: binding.initializer?.value) else {
552
+ continue
553
+ }
554
+ props.append(
555
+ ExportedViewProp(
556
+ name: name,
557
+ type: type,
558
+ defaultValue: binding.initializer?.value.trimmedDescription,
559
+ accessLevel: spelledAccessLevel(of: varDecl.modifiers),
560
+ platforms: platforms
561
+ )
562
+ )
563
+ }
564
+ }
565
+ return (props, events)
566
+ }
567
+
462
568
  /// The declared cases of an enum, in source order. One `case` declaration can introduce several
463
569
  /// cases (`case a, b`), so each element is read separately. A case carrying associated values is
464
570
  /// skipped: it has no raw value, so it can't cross the boundary as one.
@@ -706,6 +812,44 @@ private func isVoidEventReturn(_ type: TypeSyntax) -> Bool {
706
812
  return text == "Void" || text == "()" || text == "Swift.Void"
707
813
  }
708
814
 
815
+ /// The function type a `@ViewProps` property declares, or `nil` when the type isn't a function.
816
+ /// Unwraps parentheses, attributes (`@Sendable`), and the optional spellings `T?`, `T!`, and
817
+ /// `Optional<T>`, like `underlyingFunctionType` in `ViewPropsMacro`.
818
+ private func viewEventFunctionType(of type: TypeSyntax) -> FunctionTypeSyntax? {
819
+ if let functionType = type.as(FunctionTypeSyntax.self) {
820
+ return functionType
821
+ }
822
+ if let tuple = type.as(TupleTypeSyntax.self), tuple.elements.count == 1, let only = tuple.elements.first {
823
+ return viewEventFunctionType(of: only.type)
824
+ }
825
+ if let attributed = type.as(AttributedTypeSyntax.self) {
826
+ return viewEventFunctionType(of: attributed.baseType)
827
+ }
828
+ if let optional = type.as(OptionalTypeSyntax.self) {
829
+ return viewEventFunctionType(of: optional.wrappedType)
830
+ }
831
+ if let implicitlyUnwrapped = type.as(ImplicitlyUnwrappedOptionalTypeSyntax.self) {
832
+ return viewEventFunctionType(of: implicitlyUnwrapped.wrappedType)
833
+ }
834
+ if let identifier = type.as(IdentifierTypeSyntax.self),
835
+ identifier.name.text == "Optional",
836
+ let argument = identifier.genericArgumentClause?.arguments.first,
837
+ case .type(let wrapped) = argument.argument
838
+ {
839
+ return viewEventFunctionType(of: wrapped)
840
+ }
841
+ return nil
842
+ }
843
+
844
+ /// True when a `@ViewProps` event is written as an optional (`(() -> Void)?`, also after attributes),
845
+ /// which the macro rejects. Like the macros' `isOptionalType`, it reads only the outermost type.
846
+ private func isOptionalEventType(_ type: TypeSyntax) -> Bool {
847
+ if let attributed = type.as(AttributedTypeSyntax.self) {
848
+ return isOptionalEventType(attributed.baseType)
849
+ }
850
+ return isOptionalType(type)
851
+ }
852
+
709
853
  /// True if an `@Event` attribute lists the given option member (`@Event(.inline)` or
710
854
  /// `@Event("name", EventOptions.inline)`). Like `hasOption` in the macros, but without the array-literal
711
855
  /// branch, which `EventOptions...` doesn't accept.
@@ -836,9 +980,10 @@ private func isTypeLevel(_ modifiers: DeclModifierListSyntax) -> Bool {
836
980
  }
837
981
  }
838
982
 
839
- /// True when a modifier excludes a property from being a `@Record` field (`static`, `class`,
840
- /// `private`, `fileprivate`, `lazy`), mirroring `RecordMacro.isExcludedByModifier`.
841
- private func isExcludedRecordModifier(_ modifiers: DeclModifierListSyntax) -> Bool {
983
+ /// True when a modifier excludes a property from being a `@Record` field or a `@ViewProps` prop
984
+ /// (`static`, `class`, `private`, `fileprivate`, `lazy`), mirroring `isExcludedByModifier` in the
985
+ /// macros.
986
+ private func isExcludedByModifier(_ modifiers: DeclModifierListSyntax) -> Bool {
842
987
  modifiers.contains { modifier in
843
988
  switch modifier.name.tokenKind {
844
989
  case .keyword(.static), .keyword(.class), .keyword(.private), .keyword(.fileprivate), .keyword(.lazy):
@@ -951,9 +1096,17 @@ func rawValueType(of clause: InheritanceClauseSyntax?) -> TypeNode? {
951
1096
  return typeNode(from: first)
952
1097
  }
953
1098
 
954
- /// True when a type is written as an optional: `T?`, `T!`, or `Optional<T>`, mirroring the macros'
955
- /// `isOptionalType`.
1099
+ /// True when a type is written as an optional: `T?`, `T!`, or `Optional<T>`, also in parentheses
1100
+ /// (`(T?)`) or behind attributes, mirroring the macros' `isOptionalType`.
956
1101
  private func isOptionalType(_ type: TypeSyntax) -> Bool {
1102
+ if let tuple = type.as(TupleTypeSyntax.self), tuple.elements.count == 1, let only = tuple.elements.first,
1103
+ only.firstName == nil
1104
+ {
1105
+ return isOptionalType(only.type)
1106
+ }
1107
+ if let attributed = type.as(AttributedTypeSyntax.self) {
1108
+ return isOptionalType(attributed.baseType)
1109
+ }
957
1110
  if type.is(OptionalTypeSyntax.self) || type.is(ImplicitlyUnwrappedOptionalTypeSyntax.self) {
958
1111
  return true
959
1112
  }
@@ -963,13 +1116,14 @@ private func isOptionalType(_ type: TypeSyntax) -> Bool {
963
1116
  return false
964
1117
  }
965
1118
 
966
- /// The first attribute whose spelled name matches `name`. A local copy of the macros' helper (the
967
- /// macro target can't be imported here).
1119
+ /// The first attribute whose name matches `name` as `attributeName(of:)` reads it, so
1120
+ /// `@ExpoView<CardProps>` and `@ExpoModulesCore.ExpoView<CardProps>` match `ExpoView`. A local copy of
1121
+ /// the macros' helper (the macro target can't be imported here).
968
1122
  extension AttributeListSyntax {
969
1123
  fileprivate func firstAttribute(named name: String) -> AttributeSyntax? {
970
1124
  for element in self {
971
1125
  if let attribute = element.as(AttributeSyntax.self),
972
- attribute.attributeName.trimmedDescription == name
1126
+ attributeName(of: attribute) == name
973
1127
  {
974
1128
  return attribute
975
1129
  }
@@ -34,8 +34,8 @@ public enum Scanner {
34
34
  }
35
35
  }
36
36
 
37
- /// One class in the `scan-modules` output: an `@ExpoModule` class in `modules`, or a `@SharedObject`
38
- /// class in `sharedObjects`. Trimmed to what `expo-modules-autolinking` needs to register the class:
37
+ /// One class in the `scan-modules` output: an `@ExpoModule` class in `modules`, a `@SharedObject`
38
+ /// class in `sharedObjects`, or an `@ExpoView` class in `views`. Trimmed to what `expo-modules-autolinking` needs to register the class:
39
39
  /// the Swift class name, the JS name it registers under, the platforms that include it, and the file
40
40
  /// it's in. The richer fields the visitor captures (declaration kind, raw macro arguments,
41
41
  /// line/column) are dropped here, because this command doesn't need them (the macro is always on a
@@ -45,7 +45,7 @@ struct ScannedClass: Codable, Equatable {
45
45
  let name: String
46
46
 
47
47
  /// The fully-resolved JS name: the `@ExpoModule("Foo")` or `@SharedObject("Foo")` override when
48
- /// present, otherwise the class name. Resolved here (rather than left `nil`) so it matches how the
48
+ /// present, otherwise the class name. `@ExpoView` takes no name, so a view's JS name is its class name. Resolved here (rather than left `nil`) so it matches how the
49
49
  /// macro derives the name and the consumer never has to apply the fallback itself.
50
50
  let jsName: String
51
51
 
@@ -70,7 +70,7 @@ struct ScannedClass: Codable, Equatable {
70
70
  /// `ScannedClass`, so `expo-modules-autolinking` can verify it understands the output before
71
71
  /// trusting it (and fall back to config-declared modules when it doesn't). Version 2 added the
72
72
  /// per-module `platforms` list and made the module list platform-agnostic. A new field that older
73
- /// consumers can ignore, such as `sharedObjects`, doesn't change the version.
73
+ /// consumers can ignore, such as `sharedObjects` or `views`, doesn't change the version.
74
74
  let scanModulesSchemaVersion = 2
75
75
 
76
76
  /// The `scan-modules` result: the detected modules plus the stats describing the run. Encoded as the
@@ -84,6 +84,10 @@ struct ScanModulesResult: Codable, Equatable {
84
84
  /// `modules`.
85
85
  let sharedObjects: [ScannedClass]
86
86
 
87
+ /// The top-level `@ExpoView` classes, in the same order and with the same platform rules as
88
+ /// `modules`.
89
+ let views: [ScannedClass]
90
+
87
91
  /// Warnings for `#if` conditions the scan couldn't answer statically (see `ScanWarning`). Carried
88
92
  /// in the report rather than on stderr so the consumer can attach them to its own output.
89
93
  let warnings: [ScanWarning]
@@ -102,7 +106,7 @@ private let platformUniverse: [(condition: String, reported: String)] = [
102
106
  (condition: "visionOS", reported: "visionOS"),
103
107
  ]
104
108
 
105
- /// Scans the given paths for top-level `@ExpoModule` and `@SharedObject` types and returns every one
109
+ /// Scans the given paths for top-level `@ExpoModule`, `@SharedObject` and `@ExpoView` types and returns every one
106
110
  /// found in any
107
111
  /// `#if` branch (in file then source order), each with the platforms whose builds include it, plus
108
112
  /// the stats for the run — the `scan-modules` command. Kept separate from the public entry (and
@@ -115,10 +119,11 @@ private let platformUniverse: [(condition: String, reported: String)] = [
115
119
  func scanModules(paths: [String], defines: Set<String> = []) -> ScanModulesResult {
116
120
  var modules: [ScannedClass] = []
117
121
  var sharedObjects: [ScannedClass] = []
122
+ var views: [ScannedClass] = []
118
123
  var warnings: [ScanWarning] = []
119
124
  var seenWarnings = Set<ScanWarning>()
120
125
 
121
- let macros: Set<DetectedMacro> = [.expoModule, .sharedObject]
126
+ let macros: Set<DetectedMacro> = [.expoModule, .sharedObject, .expoView]
122
127
  let stats = scanFiles(paths: paths, macros: macros) { source, file in
123
128
  let tree = Parser.parse(source: source)
124
129
 
@@ -162,6 +167,8 @@ func scanModules(paths: [String], defines: Set<String> = []) -> ScanModulesResul
162
167
  switch detection.macro {
163
168
  case .sharedObject:
164
169
  sharedObjects.append(scanned)
170
+ case .expoView:
171
+ views.append(scanned)
165
172
  default:
166
173
  modules.append(scanned)
167
174
  }
@@ -172,6 +179,7 @@ func scanModules(paths: [String], defines: Set<String> = []) -> ScanModulesResul
172
179
  schemaVersion: scanModulesSchemaVersion,
173
180
  modules: modules,
174
181
  sharedObjects: sharedObjects,
182
+ views: views,
175
183
  warnings: warnings,
176
184
  stats: stats
177
185
  )
@@ -7,6 +7,8 @@ enum DetectedMacro: String, Codable, CaseIterable {
7
7
  case sharedObject = "SharedObject"
8
8
  case record = "Record"
9
9
  case union = "Union"
10
+ case expoView = "ExpoView"
11
+ case viewProps = "ViewProps"
10
12
  }
11
13
 
12
14
  /// A single argument passed to a macro, e.g. `"Foo"` or `classes: [Bar.self]`. The label is `nil`
@@ -43,6 +45,10 @@ struct Detection: Codable, Equatable {
43
45
  /// `@SharedObject("Baz")`, otherwise `nil` (the name defaults to `name` at expansion time).
44
46
  let jsName: String?
45
47
 
48
+ /// The generic argument of the attribute as written, which is the props type of `@ExpoView<CardProps>`,
49
+ /// otherwise `nil`.
50
+ var genericArgument: String? = nil
51
+
46
52
  /// Every argument passed to the macro, in source order, e.g. `@ExpoModule("Foo", classes: [Bar.self])`
47
53
  /// yields a positional `"Foo"` and a `classes:` argument. Empty when the macro is written bare.
48
54
  let arguments: [MacroArgument]
@@ -3,7 +3,7 @@ import SwiftIfConfig
3
3
  import SwiftSyntax
4
4
 
5
5
  /// Walks a parsed source file and records top-level declarations carrying `@ExpoModule`, `@JS`,
6
- /// `@SharedObject`, or `@Record`. Only file-scope declarations are considered: these macros apply to
6
+ /// `@SharedObject`, `@Record`, `@Union`, `@ExpoView`, or `@ViewProps`. Only file-scope declarations are considered: these macros apply to
7
7
  /// top-level types, so descending into type and function bodies would only surface false positives.
8
8
  /// Recognition mirrors the macros themselves — a purely syntactic match on the spelled attribute
9
9
  /// name — so it sees the same declarations the compiler would hand the plugin, without compiling
@@ -119,7 +119,7 @@ final class DetectionVisitor: SyntaxVisitor {
119
119
  ) {
120
120
  for element in attributes {
121
121
  guard let attribute = element.as(AttributeSyntax.self),
122
- let macro = DetectedMacro(rawValue: attribute.attributeName.trimmedDescription),
122
+ let macro = DetectedMacro(rawValue: attributeName(of: attribute)),
123
123
  detectedMacros.contains(macro)
124
124
  else {
125
125
  continue
@@ -132,6 +132,7 @@ final class DetectionVisitor: SyntaxVisitor {
132
132
  declarationKind: kind,
133
133
  accessLevel: accessLevel(of: modifiers),
134
134
  jsName: stringArgument(of: attribute),
135
+ genericArgument: genericArgument(of: attribute),
135
136
  arguments: arguments(of: attribute),
136
137
  file: file,
137
138
  line: location.line,
@@ -155,6 +156,44 @@ private func accessLevel(of modifiers: DeclModifierListSyntax) -> String {
155
156
  return "internal"
156
157
  }
157
158
 
159
+ /// The attribute's name without a generic argument clause, so `@ExpoView<CardProps>` is `ExpoView`. A
160
+ /// name qualified with the module that declares the macros is read without the module, so
161
+ /// `@ExpoModulesCore.ExpoView<CardProps>` is `ExpoView` too.
162
+ func attributeName(of attribute: AttributeSyntax) -> String {
163
+ if let identifier = attribute.attributeName.as(IdentifierTypeSyntax.self) {
164
+ return identifier.name.text
165
+ }
166
+ if let member = attribute.attributeName.as(MemberTypeSyntax.self),
167
+ member.baseType.as(IdentifierTypeSyntax.self)?.name.text == macrosModuleName
168
+ {
169
+ return member.name.text
170
+ }
171
+ return attribute.attributeName.trimmedDescription
172
+ }
173
+
174
+ /// The module that declares the macros, which an attribute may be qualified with:
175
+ /// `@ExpoModulesCore.ExpoView<CardProps>`.
176
+ let macrosModuleName = "ExpoModulesCore"
177
+
178
+ /// The first generic argument of the attribute as written, e.g. `@ExpoView<CardProps>` -> "CardProps".
179
+ /// Returns `nil` when the attribute has no generic argument clause.
180
+ private func genericArgument(of attribute: AttributeSyntax) -> String? {
181
+ return genericArgumentType(of: attribute)?.trimmedDescription
182
+ }
183
+
184
+ /// The first generic argument of the attribute, `CardProps` in `@ExpoView<CardProps>` and in
185
+ /// `@ExpoModulesCore.ExpoView<CardProps>`, or `nil` when the attribute has no generic argument clause.
186
+ /// Like `genericArgument(of:)` in `ExpoViewMacro`.
187
+ func genericArgumentType(of attribute: AttributeSyntax) -> TypeSyntax? {
188
+ let clause =
189
+ attribute.attributeName.as(IdentifierTypeSyntax.self)?.genericArgumentClause
190
+ ?? attribute.attributeName.as(MemberTypeSyntax.self)?.genericArgumentClause
191
+ guard let first = clause?.arguments.first, case .type(let type) = first.argument else {
192
+ return nil
193
+ }
194
+ return type
195
+ }
196
+
158
197
  /// Every argument passed to the attribute, in source order, each as a label (or `nil` when
159
198
  /// positional) plus the value expression's source text. Returns an empty array when the attribute
160
199
  /// is written bare (`@ExpoModule`) or with empty parens.
@@ -106,9 +106,10 @@ struct MacroPrefilter {
106
106
  }
107
107
 
108
108
  /// A pre-filter for attributes that `DetectedMacro` doesn't list, such as every macro that `expand`
109
- /// runs.
109
+ /// runs. Each name also matches qualified with the module that declares the macros
110
+ /// (`@ExpoModulesCore.ExpoView`), since the scanners read that spelling as the bare one.
110
111
  init(attributeNames: Set<String>, conformances: Set<String> = []) {
111
- attributes = attributeNames.map { Array($0.utf8) }
112
+ attributes = attributeNames.flatMap { [Array($0.utf8), Array("\(macrosModuleName).\($0)".utf8)] }
112
113
  bareNames = conformances.map { Array($0.utf8) }
113
114
  }
114
115
 
@@ -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"
@@ -861,17 +861,6 @@ private func isVoidType(_ type: TypeSyntax) -> Bool {
861
861
  return ["Void", "Swift.Void"].contains(type.trimmedDescription)
862
862
  }
863
863
 
864
- /// The type inside any number of unlabeled one-element parentheses: `(T)` and `((T))` are `T`. A
865
- /// label would make it a tuple, which Swift rejects with a single element anyway.
866
- private func withoutParentheses(_ type: TypeSyntax) -> TypeSyntax {
867
- guard let tuple = type.as(TupleTypeSyntax.self), tuple.elements.count == 1, let only = tuple.elements.first,
868
- only.firstName == nil
869
- else {
870
- return type
871
- }
872
- return withoutParentheses(only.type)
873
- }
874
-
875
864
  /// True if the binding is computed with a `mutating get`.
876
865
  private func hasMutatingGetter(_ binding: PatternBindingSyntax) -> Bool {
877
866
  guard case .accessors(let accessors) = binding.accessorBlock?.accessors else {
@@ -25,7 +25,7 @@ public struct ProvidedMacro {
25
25
  conformances: ["JavaScriptDecodable", "JavaScriptEncodable"]
26
26
  ),
27
27
  ProvidedMacro(name: "ViewProps", type: ViewPropsMacro.self, conformances: ["AnyViewProps"]),
28
- ProvidedMacro(name: "ExpoView", type: ExpoViewMacro.self),
28
+ ProvidedMacro(name: "ExpoView", type: ExpoViewMacro.self, conformances: ["AnyNativeView"]),
29
29
  ]
30
30
  }
31
31
  }
@@ -111,9 +111,11 @@ internal func hasAsyncVariant(_ decl: DeclSyntaxProtocol) -> Bool {
111
111
  return asyncVariantOption(of: attribute) != nil
112
112
  }
113
113
 
114
- /// True if the type is written as an optional: `T?`, `T!`, or the explicit `Optional<T>`. Used to
115
- /// decide argument requiredness (an optional parameter may be omitted) and record-field nullability.
114
+ /// True if the type is written as an optional: `T?`, `T!`, or the explicit `Optional<T>`, also in
115
+ /// parentheses (`(T?)`), which Swift treats as the same type. Used to decide argument requiredness (an
116
+ /// optional parameter may be omitted) and record-field nullability.
116
117
  internal func isOptionalType(_ type: TypeSyntax) -> Bool {
118
+ let type = withoutParentheses(type)
117
119
  if let attributed = type.as(AttributedTypeSyntax.self) {
118
120
  return isOptionalType(attributed.baseType)
119
121
  }
@@ -159,6 +161,17 @@ internal struct SpecifiedType {
159
161
  /// a fresh decoded value satisfies either way.
160
162
  internal let ownershipSpecifiers: Set<String> = ["borrowing", "consuming", "sending", "__shared", "__owned"]
161
163
 
164
+ /// The type inside any number of unlabeled one-element parentheses: `(T)` and `((T))` are `T`. A
165
+ /// label would make it a tuple, which Swift rejects with a single element anyway.
166
+ internal func withoutParentheses(_ type: TypeSyntax) -> TypeSyntax {
167
+ guard let tuple = type.as(TupleTypeSyntax.self), tuple.elements.count == 1, let only = tuple.elements.first,
168
+ only.firstName == nil
169
+ else {
170
+ return type
171
+ }
172
+ return withoutParentheses(only.type)
173
+ }
174
+
162
175
  /// The type wrapped by one optional layer (`T?`, `T!` or `Optional<T>`), or `nil` when the type isn't
163
176
  /// optional.
164
177
  internal func optionalWrappedType(_ type: TypeSyntax) -> TypeSyntax? {
@@ -0,0 +1 @@
1
+ // Empty on purpose: this target only carries the dependency on the macro target (see Package.swift).
package/build/types.d.ts CHANGED
@@ -368,6 +368,58 @@ export interface ExportedUnion extends ExportedAccess, ExportedAvailability {
368
368
  members: ExportedUnionMember[];
369
369
  file: string;
370
370
  }
371
+ /**
372
+ * One value prop of a `@ViewProps` struct: a stored property that isn't function-typed. It has no
373
+ * `required`: native code resets a prop that React leaves out to its default, so JS can always omit
374
+ * one.
375
+ */
376
+ export interface ExportedViewProp extends ExportedAccess, ExportedAvailability {
377
+ name: string;
378
+ type: TypeNode;
379
+ /** Optional-typed: true exactly when `type` is an `optional` node. */
380
+ optional: boolean;
381
+ /** The initial value as Swift source text, absent when none is written. */
382
+ defaultValue?: string;
383
+ }
384
+ /**
385
+ * One event of a `@ViewProps` struct: a function-typed stored property. Its name is the React prop
386
+ * name as written, with no `on` prefix stripped, so it has no `jsName`.
387
+ */
388
+ export interface ExportedViewEvent extends ExportedAccess, ExportedAvailability {
389
+ /** The Swift property name, e.g. `onTap`. */
390
+ name: string;
391
+ /**
392
+ * The type of the function's single parameter, absent when it takes none (`() -> Void`) or more
393
+ * than one.
394
+ */
395
+ payload?: TypeNode;
396
+ }
397
+ /**
398
+ * An `@ExpoView` class with the props and events of the `@ViewProps` struct in its generic argument.
399
+ * It is reported on its own rather than under a module.
400
+ */
401
+ export interface ExportedView extends ExportedAccess, ExportedAvailability {
402
+ /** The Swift class name. Only a view declared at file scope is reported. */
403
+ name: string;
404
+ /** The name the view registers under. `@ExpoView` takes no name, so it is always the class name. */
405
+ jsName: string;
406
+ /**
407
+ * The props type: the generic argument of `@ExpoView<CardProps>`, as written without backticks
408
+ * (`ExternalProps<Int>`), or the qualified name of the scanned `@ViewProps` struct it names
409
+ * (`CardView.Props`).
410
+ */
411
+ propsType: string;
412
+ /**
413
+ * The value props of the `@ViewProps` struct, in declaration order. Absent when the scan declares
414
+ * no struct named `propsType` on a platform of the view, so unknown props differ from no props. A
415
+ * struct declared in several `#if` branches contributes the props of each declaration whose
416
+ * platforms overlap the view's, and each prop keeps its own `platforms`.
417
+ */
418
+ props?: ExportedViewProp[];
419
+ /** The events of the `@ViewProps` struct, in declaration order. Absent when `props` is absent. */
420
+ events?: ExportedViewEvent[];
421
+ file: string;
422
+ }
371
423
  /** The exported types grouped by kind. */
372
424
  export interface ExportedSurface {
373
425
  modules: ExportedModule[];
@@ -375,6 +427,7 @@ export interface ExportedSurface {
375
427
  records: ExportedRecord[];
376
428
  enums: ExportedEnum[];
377
429
  unions: ExportedUnion[];
430
+ views: ExportedView[];
378
431
  }
379
432
  /** A `#if` condition the scan couldn't answer statically. */
380
433
  export interface ScanWarning {
@@ -392,14 +445,14 @@ export interface ScanStats {
392
445
  durationMs: number;
393
446
  }
394
447
  /**
395
- * One class in the `scan-modules` output: an `@ExpoModule` class in `modules`, or a `@SharedObject`
396
- * class in `sharedObjects`.
448
+ * One class in the `scan-modules` output: an `@ExpoModule` class in `modules`, a `@SharedObject`
449
+ * class in `sharedObjects`, or an `@ExpoView` class in `views`.
397
450
  */
398
451
  export interface ScannedClass {
399
452
  name: string;
400
453
  /**
401
454
  * The resolved JS name: the `@ExpoModule("Foo")` or `@SharedObject("Foo")` override, else the class
402
- * name.
455
+ * name. A view's JS name is its class name.
403
456
  */
404
457
  jsName: string;
405
458
  /**
@@ -422,6 +475,8 @@ export interface ScanModulesResult {
422
475
  modules: ScannedClass[];
423
476
  /** The top-level `@SharedObject` classes, with the same platform rules as `modules`. */
424
477
  sharedObjects: ScannedClass[];
478
+ /** The top-level `@ExpoView` classes, with the same platform rules as `modules`. */
479
+ views: ScannedClass[];
425
480
  warnings: ScanWarning[];
426
481
  stats: ScanStats;
427
482
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-modules-macros",
3
- "version": "0.32.0",
3
+ "version": "0.33.0",
4
4
  "description": "Swift macro plugin for Expo modules",
5
5
  "main": "build/index.js",
6
6
  "types": "build/index.d.ts",
@@ -26,9 +26,9 @@
26
26
  "typescript": "^5.4.0"
27
27
  },
28
28
  "optionalDependencies": {
29
- "@expo/modules-macros-linux-arm64": "0.32.0",
30
- "@expo/modules-macros-linux-x64": "0.32.0",
31
- "@expo/modules-macros-win32-arm64": "0.32.0",
32
- "@expo/modules-macros-win32-x64": "0.32.0"
29
+ "@expo/modules-macros-linux-arm64": "0.33.0",
30
+ "@expo/modules-macros-linux-x64": "0.33.0",
31
+ "@expo/modules-macros-win32-arm64": "0.33.0",
32
+ "@expo/modules-macros-win32-x64": "0.33.0"
33
33
  }
34
34
  }