expo-modules-macros 0.31.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.
Files changed (28) hide show
  1. package/.github/workflows/{swift.yml → macos.yml} +12 -3
  2. package/AGENTS.md +4 -2
  3. package/Package.swift +10 -1
  4. package/README.md +6 -6
  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 +174 -20
  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/EventMacro.swift +12 -2
  16. package/apple/Sources/ExpoModulesMacrosCore/Macros/ExpoModuleMacro.swift +12 -0
  17. package/apple/Sources/ExpoModulesMacrosCore/Macros/ExpoViewMacro.swift +64 -30
  18. package/apple/Sources/ExpoModulesMacrosCore/Macros/JSMacro.swift +124 -0
  19. package/apple/Sources/ExpoModulesMacrosCore/Macros/RecordMacro.swift +68 -0
  20. package/apple/Sources/ExpoModulesMacrosCore/Macros/SharedObjectMacro.swift +79 -1
  21. package/apple/Sources/ExpoModulesMacrosCore/Macros/UnionMacro.swift +101 -5
  22. package/apple/Sources/ExpoModulesMacrosCore/Macros/ViewPropsMacro.swift +574 -23
  23. package/apple/Sources/ExpoModulesMacrosCore/ProvidedMacros.swift +1 -1
  24. package/apple/Sources/ExpoModulesMacrosCore/Support/JSNameCollisions.swift +111 -2
  25. package/apple/Sources/ExpoModulesMacrosCore/Support/MacroHelpers.swift +28 -2
  26. package/apple/Sources/ExpoModulesMacrosPlugin/ExpoModulesMacrosPlugin.swift +1 -0
  27. package/build/types.d.ts +58 -3
  28. package/package.json +5 -5
@@ -1,4 +1,6 @@
1
- name: Swift
1
+ name: macOS
2
+
3
+ # Builds and tests the package on macOS, and checks the universal release tool there.
2
4
 
3
5
  on:
4
6
  push:
@@ -12,8 +14,8 @@ concurrency:
12
14
  cancel-in-progress: true
13
15
 
14
16
  jobs:
15
- build-and-test:
16
- name: Build & test
17
+ macos:
18
+ name: Build & test (macOS)
17
19
  # The package declares swift-tools-version 6.2, which requires Xcode 26+.
18
20
  runs-on: macos-26
19
21
  steps:
@@ -63,3 +65,10 @@ jobs:
63
65
  run: |
64
66
  pnpm install
65
67
  pnpm run typecheck
68
+
69
+ - name: Scan through the TypeScript wrapper
70
+ # The wrapper must find the release tool in `apple/`, and `scanExports` must report the
71
+ # surface in `scripts/check-scan-exports.json`, which the Windows and Linux workflows also check.
72
+ run: |
73
+ pnpm run build:ts
74
+ node scripts/check-scan.js .
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.
@@ -31,7 +32,7 @@ pnpm run swift:format # format the Swift sources in place
31
32
  pnpm run swift:lint # check the formatting, as CI does
32
33
  ```
33
34
 
34
- CI runs on macOS (`.github/workflows/swift.yml`), on Windows x64 and arm64 (`.github/workflows/windows.yml`), and on Linux x64 and arm64 (`.github/workflows/linux.yml`). On macOS it runs the release build, checks both binary slices, then runs `swift test` and `pnpm run typecheck`. On Windows it builds and tests the package, builds the release binary, checks that it starts without the Swift runtime, packs it into its `platforms/` package (checking the files and the `os`/`cpu` fields), installs that package, and scans through the TypeScript wrapper with `scripts/check-scan.js`, which must find the executable there. On Linux it does the same in the `swift:6.4.0-rhel-ubi9` image, and also checks that no Swift library is linked dynamically, the newest glibc symbol version that the binary needs, and that the package keeps the executable bit. The Swift Format workflow (`.github/workflows/swift-format.yml`) builds the pinned swift-format version and runs `pnpm run swift:lint`. Code in `Sources` and `Tests` must not assume Apple platforms or POSIX paths.
35
+ CI runs on macOS (`.github/workflows/macos.yml`), on Windows x64 and arm64 (`.github/workflows/windows.yml`), and on Linux x64 and arm64 (`.github/workflows/linux.yml`). On macOS it runs the release build, checks both binary slices, runs `swift test` and `pnpm run typecheck`, then scans through the TypeScript wrapper with `scripts/check-scan.js`. On Windows it builds and tests the package, builds the release binary, checks that it starts without the Swift runtime, packs it into its `platforms/` package (checking the files and the `os`/`cpu` fields), installs that package, and scans through the TypeScript wrapper with `scripts/check-scan.js`, which must find the executable there. On Linux it does the same in the `swift:6.4.0-rhel-ubi9` image, and also checks that no Swift library is linked dynamically, the newest glibc symbol version that the binary needs, and that the package keeps the executable bit. On every platform, `check-scan.js` also requires `scanExports` to report the surface in `scripts/check-scan-exports.json` for its fixture, so the generated types are the same on each OS. The Swift Format workflow (`.github/workflows/swift-format.yml`) builds the pinned swift-format version and runs `pnpm run swift:lint`. Code in `Sources` and `Tests` must not assume Apple platforms or POSIX paths.
35
36
 
36
37
  ## Windows
37
38
 
@@ -57,6 +58,7 @@ CI runs on macOS (`.github/workflows/swift.yml`), on Windows x64 and arm64 (`.gi
57
58
  ## Changing macros
58
59
 
59
60
  - The scanner re-implements the macros' naming and member rules on the syntax tree; it does not call the macro code. When a macro changes which members it binds or how it names them (the `@JS` name override, the `on` prefix `@Event` strips, which properties `@Record` treats as fields), change `scan-exports` to match, or generated TypeScript types drift from the runtime.
61
+ - When the `scan-exports` output changes on purpose, update `scripts/check-scan-exports.json`: run `swift build`, then `node scripts/check-scan.js . --update .build/debug/ExpoModulesMacrosTool`, and review the diff. CI compares it with the output on macOS, Windows and Linux.
60
62
  - The scanner only parses files that pass the pre-filter, `MacroPrefilter` in `ExpoModulesCLI/Core/SourceScan.swift`. Each command builds it from the `macros` and `conformances` arguments of its `scanFiles` call. A new attribute or conformance the scanner must detect has to be added to those arguments (an attribute also needs a `DetectedMacro` case), or files that use it are skipped without an error.
61
63
  - A new macro needs: the implementation in `apple/Sources/ExpoModulesMacrosCore/Macros`, an entry in `ProvidedMacro.all` in `ProvidedMacros.swift` (with the attribute name and the extension conformances of its declaration), an expansion test file, the `#externalMacro` declaration in `expo-modules-core`, and an entry in the macro list in `README.md`.
62
64
 
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
@@ -25,11 +25,11 @@ This package lists them as optional dependencies, and their `os`, `cpu` and (on
25
25
  # Macros
26
26
 
27
27
  - **`@ExpoModule(_ name: String? = nil, classes: [Any.Type] = [])`** on a class. Turns the class into a module: binds its `@JS` members into the module's JavaScript object and resolves the module name from the argument, falling back to the class name. It also synthesizes everything inheriting from `Module` used to provide, so a module class can carry any superclass, or none. It adds an `@unchecked Sendable` conformance when the class isn't `Sendable` yet (see [Sendable conformance](#sendable-conformance)).
28
- - **`@JS(_ jsName: String? = nil, _ options: JSOptions...)`** on a member of a module or shared object. Marks a function, property or initializer for export. `@ExpoModule` and `@SharedObject` bind each marked member straight into the JavaScript object, with the argument decoding, the call and the result encoding inlined, so there is no dynamic per-call path. The JS name defaults to the Swift name; pass a string to override it. Two `@JS` members with the same JS name on one JavaScript object are a compile-time error, since JavaScript could reach only one of them. A shared object's static and instance members bind onto different objects, so they can share a name. The macro also checks that every type crossing the boundary is convertible in the direction it travels. Outside a module or shared object, a bad type is reported on the author's own declaration. In an `@ExpoModule` or `@SharedObject` class, the class macro makes the check, since only it sees the type aliases in the class body, so the error is reported on the class and names the member and its argument labels, such as `local function 'move(_:by:returning:)' requires that 'Point' conform to 'JavaScriptEncodable'`. A `lazy var` is read-only from JavaScript. On the first read, its getter stores the value as a data property on the JavaScript object, so later reads don't call into native code. On a frozen object the value isn't stored, and each read calls the getter. The `.concurrent` option moves an `async` body off the JavaScript thread while still decoding and encoding on it. The `.withAsync("name")` option binds a synchronous function a second time under that name, as a function that returns a promise and runs the body on the concurrent thread pool. That function isn't isolated to the JavaScript thread, so its body must be safe to run on any thread. Because of that, a `.concurrent` function and a function with `.withAsync` can't take a JavaScript value such as `JavaScriptValue` or `JavaScriptObject`, which is valid only on the JavaScript thread; the macro reports such a parameter. The macro reports a function isolated to a global actor through its own attribute or its type's. When the isolation comes from elsewhere, such as a protocol, the error points into the generated code, so mark the function `nonisolated`. A function or initializer can take a closure argument, such as `(Point) throws -> Size` or `(String) async throws -> Data`. JavaScript passes a function for it, and native code can store the closure and call it from any thread. A closure that throws or returns a value blocks the caller until JavaScript returns, and an `async` closure suspends instead and awaits a promise that the function returns. A closure that returns a value must be `throws`, so that an error from JavaScript can reach the caller. A non-throwing `Void` closure returns before JavaScript reads its arguments, so don't change a class instance after passing it to one.
29
- - **`@Event(_ name: String? = nil, _ options: EventOptions...)`** on a function-typed `var`. Expands the property into a closure that emits the event, so calling the property sends it to JavaScript with the closure's parameter as the payload. The JS name defaults to the property name with a leading `on` stripped. By default the event is scheduled on the JavaScript thread. The `.inline` option runs the listeners before the call returns when it is made on the JavaScript thread, and schedules the event otherwise; it never blocks. The `.blocking` option runs the listeners before the call returns on any thread, so a call from another thread blocks until the JavaScript thread runs them. An event property is callable from any thread with each option.
30
- - **`@SharedObject(_ name: String? = nil)`** on a subclass of `SharedObject`, direct or not, such as a `SharedRef<T>` subclass. Binds the class's `@JS` members through the `SharedObject` hooks that core calls when it builds the JavaScript class, and emits the JS class name, which defaults to the class name. A module exposes the class through `@ExpoModule(classes:)`, which only accepts `SharedObject` subclasses. A subclass of another `@SharedObject` class also gets the `@JS` members of that class, and its own members replace inherited members with the same JS name. A subclass that isn't `final` can't be constructed from JavaScript through the `@JS init` of its superclass, so give each subclass that JavaScript constructs its own `@JS init`. Like `@ExpoModule`, it adds an `@unchecked Sendable` conformance when the class isn't `Sendable` yet.
31
- - **`@Record()`** on a struct, or on a final class with no inheritance clause (declare a class's protocol conformances in an extension). Treats every non-static, non-private, non-computed stored property as a field, with no per-field wrapper, and synthesizes the memberwise initializer, the conversions in both directions, and the `Record` conformance. A record converts only from and to JavaScript values: its `from(dictionary:appContext:)` throws `RecordDictionaryConversionException`, so a record can't be created from a native `[String: Any]`. A `@Union` can be a record property. Requiredness is inferred: a default value makes a field optional, an optional type makes it nullable and optional.
32
- - **`@Union()`** on an enum whose cases each carry one associated value. Models a TypeScript union. Synthesizes the conversions in both directions, a typed accessor per payload type, and the `JavaScriptDecodable` and `JavaScriptEncodable` conformances. Decoding is ordered: the first case whose payload decodes wins, so the more specific case goes first.
28
+ - **`@JS(_ jsName: String? = nil, _ options: JSOptions...)`** on a member of a module or shared object. Marks a function, property or initializer for export. The class macro reads only the class body, so `@JS` on a member of an extension, or of a type that is neither a module nor a shared object, is an error. So is `@JS` on an initializer or a `static` member of a module, since JavaScript doesn't construct a module and gets only its instance. `@JS` on any other declaration, such as a subscript, is an error too. A `@JS` function or initializer can't be generic, since the binding converts each value by its static type. `@ExpoModule` and `@SharedObject` bind each marked member straight into the JavaScript object, with the argument decoding, the call and the result encoding inlined, so there is no dynamic per-call path. The JS name defaults to the Swift name; pass a string to override it. Two `@JS` members with the same JS name on one JavaScript object are a compile-time error, since JavaScript could reach only one of them. A shared object's static and instance members bind onto different objects, so they can share a name. A name that JavaScript already gives a meaning on the object is an error too: `__proto__` on any object, `constructor` on a shared object's prototype, and `prototype` on its constructor. The macro also checks that every type crossing the boundary is convertible in the direction it travels. For a top-level declaration, a bad type is reported on the author's own declaration. In an `@ExpoModule` or `@SharedObject` class, the class macro makes the check, since only it sees the type aliases in the class body, so the error is reported on the class and names the member and its argument labels, such as `local function 'move(_:by:returning:)' requires that 'Point' conform to 'JavaScriptEncodable'`. A `lazy var` is read-only from JavaScript. On the first read, its getter stores the value as a data property on the JavaScript object, so later reads don't call into native code. On a frozen object the value isn't stored, and each read calls the getter. The `.concurrent` option moves an `async` body off the JavaScript thread while still decoding and encoding on it. The `.withAsync("name")` option binds a synchronous function a second time under that name, as a function that returns a promise and runs the body on the concurrent thread pool. That function isn't isolated to the JavaScript thread, so its body must be safe to run on any thread. Because of that, a `.concurrent` function and a function with `.withAsync` can't take a JavaScript value such as `JavaScriptValue` or `JavaScriptObject`, which is valid only on the JavaScript thread; the macro reports such a parameter. The macro reports a function isolated to a global actor through its own attribute or its type's. When the isolation comes from elsewhere, such as a protocol, the error points into the generated code, so mark the function `nonisolated`. A function or initializer can take a closure argument, such as `(Point) throws -> Size` or `(String) async throws -> Data`. JavaScript passes a function for it, and native code can store the closure and call it from any thread. A closure that throws or returns a value blocks the caller until JavaScript returns, and an `async` closure suspends instead and awaits a promise that the function returns. A closure that returns a value must be `throws`, so that an error from JavaScript can reach the caller. A non-throwing `Void` closure returns before JavaScript reads its arguments, so don't change a class instance after passing it to one.
29
+ - **`@Event(_ name: String? = nil, _ options: EventOptions...)`** on a function-typed `var`. Expands the property into a closure that emits the event, so calling the property sends it to JavaScript with the closure's parameter as the payload. The JS name defaults to the property name with a leading `on` stripped. `@ExpoModule` and `@SharedObject` warn when two events in the class body have the same JS name. By default the event is scheduled on the JavaScript thread. The `.inline` option runs the listeners before the call returns when it is made on the JavaScript thread, and schedules the event otherwise; it never blocks. The `.blocking` option runs the listeners before the call returns on any thread, so a call from another thread blocks until the JavaScript thread runs them. An event property is callable from any thread with each option.
30
+ - **`@SharedObject(_ name: String? = nil)`** on a subclass of `SharedObject`, direct or not, such as a `SharedRef<T>` subclass. Binds the class's `@JS` members through the `SharedObject` hooks that core calls when it builds the JavaScript class, and emits the JS class name, which defaults to the class name. A class without an inheritance clause is an error, with a Fix-It that adds `: SharedObject`. A module exposes the class through `@ExpoModule(classes:)`, which only accepts `SharedObject` subclasses. A subclass of another `@SharedObject` class also gets the `@JS` members of that class, and its own members replace inherited members with the same JS name. A subclass that isn't `final` can't be constructed from JavaScript through the `@JS init` of its superclass, so give each subclass that JavaScript constructs its own `@JS init`. Like `@ExpoModule`, it adds an `@unchecked Sendable` conformance when the class isn't `Sendable` yet.
31
+ - **`@Record()`** on a struct, or on a final class with no inheritance clause (declare a class's protocol conformances in an extension). Treats every non-static, non-private, non-computed stored property as a field, with no per-field wrapper, and synthesizes the memberwise initializer, the conversions in both directions, and the `Record` conformance. A record converts only from and to JavaScript values: its `from(dictionary:appContext:)` throws `RecordDictionaryConversionException`, so a record can't be created from a native `[String: Any]`. A `@Union` can be a record property. Requiredness is inferred: a default value makes a field optional, an optional type makes it nullable and optional. The memberwise initializer sets every field, so a field that is a `let` with a default value is an error. It doesn't set a `private` or `fileprivate` stored property, so such a property needs a default value, unless it's an optional `var` or the type declares its own memberwise initializer.
32
+ - **`@Union()`** on an enum whose cases each carry one associated value. Models a TypeScript union. Synthesizes the conversions in both directions, a typed accessor per payload type, and the `JavaScriptDecodable` and `JavaScriptEncodable` conformances. Decoding is ordered: the first case whose payload decodes wins, so the more specific case goes first. The macro warns about a case that an earlier case always decodes first: any case after a `JavaScriptValue` case, and a case with a payload that decodes only numbers, such as `Float` or `Int32`, after a `Double`, `Float` or `CGFloat` case.
33
33
 
34
34
  The members and conformances that `@Record` and `@Union` generate are `nonisolated`, so they also work in a module that builds with a default isolation, such as `-default-isolation MainActor`. The stored properties of a record still take that isolation. A class record, or a struct record with a property whose type isn't `Sendable`, then needs `nonisolated` on its declaration, for example `@Record nonisolated final class Options`.
35
35
 
@@ -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,