expo-modules-macros 0.33.0 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -40,6 +40,19 @@ jobs:
40
40
  - name: Resolve dependencies
41
41
  run: swift package resolve
42
42
 
43
+ - name: Test
44
+ # Also builds the debug tool, which the next step uses.
45
+ run: swift test -v
46
+
47
+ - name: Compile the macro expansions
48
+ # The expansion tests compare text. This compiles and runs the expansions of the fixtures in
49
+ # `scripts/check-compile`, also with library evolution and with `-default-isolation MainActor`.
50
+ # It runs in the background during the release build, with the debug tool, which the release
51
+ # build doesn't change. `Wait for the compile check` fails the job when it fails.
52
+ id: check-compile
53
+ background: true
54
+ run: node scripts/check-compile.js .build/debug/ExpoModulesMacrosTool
55
+
43
56
  - name: Build release tool
44
57
  # Runs `pnpm run build` (scripts/build.js): builds the universal
45
58
  # (arm64 + x86_64) release plugin binary, copies it to
@@ -58,9 +71,6 @@ jobs:
58
71
  arch -arm64 apple/ExpoModulesMacros --help > /dev/null
59
72
  arch -x86_64 apple/ExpoModulesMacros --help > /dev/null
60
73
 
61
- - name: Test
62
- run: swift test -v
63
-
64
74
  - name: Typecheck the TypeScript wrapper
65
75
  run: |
66
76
  pnpm install
@@ -72,3 +82,6 @@ jobs:
72
82
  run: |
73
83
  pnpm run build:ts
74
84
  node scripts/check-scan.js .
85
+
86
+ - name: Wait for the compile check
87
+ wait: check-compile
package/AGENTS.md CHANGED
@@ -17,6 +17,7 @@
17
17
  - `src/`: the TypeScript wrapper around the scanner CLI. `types.ts` mirrors the Swift `Codable` output types by hand.
18
18
  - `scripts/build.js` (`pnpm run build`): builds the release binaries: the universal macOS `apple/ExpoModulesMacros` (committed and published), and on Windows and Linux `platforms/win32-<arch>/ExpoModulesMacros.exe` or `platforms/linux-<arch>/ExpoModulesMacros` for the host architecture (not committed; published in that package).
19
19
  - `scripts/swift-format.js` (`pnpm run swift:format`, `pnpm run swift:lint`): runs swift-format on the `.swift` files that Git tracks or doesn't ignore, with the configuration in `.swift-format`.
20
+ - `scripts/check-compile.js` (macOS only): compiles the `@ViewProps` expansions of the fixtures in `scripts/check-compile` with `swiftc` and a given plugin binary, and runs them, without a default isolation, with library evolution (also building a client from the `.swiftinterface`), and with `-default-isolation MainActor`. Warnings fail it. `Core.swift` holds stand-ins for the core and JSI symbols that the expansions use, `Props.swift` the props structs, and `main.swift` checks the decoded and reset values at run time.
20
21
 
21
22
  ## Commands
22
23
 
@@ -30,9 +31,10 @@ pnpm run typecheck # TypeScript wrapper
30
31
  pnpm run build # release binary (slow; on macOS uses Rosetta for x86_64)
31
32
  pnpm run swift:format # format the Swift sources in place
32
33
  pnpm run swift:lint # check the formatting, as CI does
34
+ node scripts/check-compile.js .build/debug/ExpoModulesMacrosTool # compile and run expansions (macOS, after swift build)
33
35
  ```
34
36
 
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.
37
+ 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 `swift test`, starts `scripts/check-compile.js` with the debug tool as a background step, which compiles and runs the expansions of the fixtures, then runs the release build, checks both binary slices, runs `pnpm run typecheck`, scans through the TypeScript wrapper with `scripts/check-scan.js`, and waits for the compile check. 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.
36
38
 
37
39
  ## Windows
38
40
 
@@ -58,13 +60,14 @@ CI runs on macOS (`.github/workflows/macos.yml`), on Windows x64 and arm64 (`.gi
58
60
  ## Changing macros
59
61
 
60
62
  - 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.
63
+ - The expansion tests compare text, so they don't show whether the generated code compiles. After a change to the code that `@ViewProps` generates, run `swift build` and `node scripts/check-compile.js .build/debug/ExpoModulesMacrosTool`. A new case that the generated code must handle gets a props struct in `scripts/check-compile/Props.swift`, and a check in `main.swift` when its values matter.
61
64
  - 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.
62
65
  - 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.
63
66
  - 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`.
64
67
 
65
68
  ## Contracts with other packages
66
69
 
67
- - **Macro declarations live in `expo-modules-core`** (`ios/Core/ExpoModulesMacros.swift` in `expo/expo`), as `#externalMacro(module: "ExpoModulesMacros", type: ...)`. Adding a macro, renaming a macro type or changing a macro's signature needs a matching edit there, and the type must be listed in `ProvidedMacro.all` in `ProvidedMacros.swift`, with the same attribute name and extension conformances as the declaration.
70
+ - **Macro declarations live in `expo-modules-core`** (`ios/Core/ExpoModulesMacros.swift` in `expo/expo`), as `#externalMacro(module: "ExpoModulesMacros", type: ...)`. Adding a macro, renaming a macro type or changing a macro's signature needs a matching edit there, and the type must be listed in `ProvidedMacro.all` in `ProvidedMacros.swift`, with the same attribute name and extension conformances as the declaration. `scripts/check-compile/Core.swift` copies the `@ViewProps` declaration and `AnyViewProps` (`ios/Core/Views/v2/AnyViewProps.swift`), so a change to them in core needs the same change there.
68
71
  - **Generated code calls `expo-modules-core` API.** An expansion that uses a new core symbol only works with a core version that has it.
69
72
  - **Scanner output is versioned.** `scanModulesSchemaVersion` (`Commands/ScanModules/ScanModules.swift`), `scanExportsSchemaVersion` (`Commands/ScanExports/ExportedSurface.swift`) and `expandSchemaVersion` (`Commands/Expand/ExpandReport.swift`) change independently. When the output shape of a command changes in a way that breaks a consumer, bump its version and update the matching `SUPPORTED_*_SCHEMA_VERSION` constant. A new field that older consumers can ignore doesn't need a bump, but still update the mirror types in `src/types.ts`. The TypeScript wrapper throws when a report's version differs from its constant. `expo-modules-autolinking` gets the `scan-modules` report through the wrapper, and when the call throws, it links only the modules declared in `expo-module.config.json`.
70
73
  - **The binary path is part of the contract.** `expo-modules-autolinking` resolves this package from `expo-modules-core` and passes `-load-plugin-executable <package>/apple/<binary>#ExpoModulesMacros` to the compiler. The scanner wrapper in autolinking uses the same path, and `expo-modules-cli` uses `getScannerBinaryPath()`. On Windows the binary is `ExpoModulesMacros.exe` in `@expo/modules-macros-win32-<arch>`, and on Linux `ExpoModulesMacros` in `@expo/modules-macros-linux-<arch>`. Renaming or moving a binary needs a matching change in `expo/expo`.
package/README.md CHANGED
@@ -25,13 +25,13 @@ 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. 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.
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. A synchronous function or an initializer can take the JavaScript runtime as its last parameter, `in runtime: borrowing JavaScriptRuntime`, for example to create a JavaScript value. The binding passes the runtime of the call, and JavaScript doesn't see the parameter. The runtime can be used only on the JavaScript thread, and an `async` function can resume on another thread after an `await`, so the macro reports the parameter on an `async` function or a function with `.withAsync`, and when it isn't the last parameter, isn't labeled `in`, isn't `borrowing` or is optional. The macro finds the parameter by its type as written, so write it as `JavaScriptRuntime`: a typealias or a subclass is decoded as a JavaScript argument instead.
29
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
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
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
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
- 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`.
34
+ The members and conformances that `@Record`, `@Union` and `@ViewProps` 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`. A `@ViewProps` struct needs it when it has an event prop (a function type isn't `Sendable`), a value prop whose type isn't `Sendable`, or a computed prop, for example `@ViewProps nonisolated struct CardProps`, and its own `init()` must be `nonisolated` too. A conformance that the type declares itself also needs `nonisolated`, for example `struct Options: nonisolated Record`.
35
35
 
36
36
  A tuple can cross the boundary as a `@JS` argument, result or property, a `@Record` property, a `@Union` payload, an `@Event` payload, or a parameter or result of a closure argument that isn't `async`. An unlabeled tuple such as `(Int, String)` maps to an array with one value per element, and a labeled tuple such as `(x: Double, y: Double)` to an object with one property per label. A tuple must have two or more elements, all labeled or none, and each element must be convertible. Tuples nest in other tuples, optionals, arrays and `String`-keyed dictionaries. `@ExpoModule` and `@SharedObject` also resolve a type alias of a tuple, such as `typealias Span = (start: Int, end: Int)`, when the class body declares it. A macro sees only the declaration it is attached to, so an alias declared anywhere else doesn't resolve.
37
37
 
Binary file
@@ -639,8 +639,10 @@ final class SurfaceVisitor: SyntaxVisitor {
639
639
 
640
640
  /// Projects a parameter clause into `ExportedParameter`s: label = first name, name = second (else
641
641
  /// first), `optional` when it has a default value or an optional type, and the default as written.
642
+ /// A parameter that takes the JavaScript runtime (its type is written as `JavaScriptRuntime`) is left
643
+ /// out: the binding passes the runtime itself, and JavaScript doesn't see the parameter.
642
644
  private func parameters(of clause: FunctionParameterClauseSyntax) -> [ExportedParameter] {
643
- clause.parameters.map { parameter in
645
+ clause.parameters.filter { !takesRuntime($0) }.map { parameter in
644
646
  let label = parameter.firstName.identifierName
645
647
  let name = parameter.secondName?.identifierName ?? label
646
648
  return ExportedParameter(
@@ -800,6 +802,35 @@ private func underlyingFunctionType(of type: TypeSyntax?) -> FunctionTypeSyntax?
800
802
  return type.as(FunctionTypeSyntax.self)
801
803
  }
802
804
 
805
+ /// True when a parameter takes the JavaScript runtime: its type, without specifiers such as `borrowing`,
806
+ /// is written as `JavaScriptRuntime` or `….JavaScriptRuntime`, or as an optional of it. This matches
807
+ /// `isRuntimeParameter` in the macros: a type that also carries attributes (`@Sendable`) isn't one.
808
+ private func takesRuntime(_ parameter: FunctionParameterSyntax) -> Bool {
809
+ var type = parameter.type
810
+ if let attributed = type.as(AttributedTypeSyntax.self) {
811
+ guard attributed.attributes.isEmpty else {
812
+ return false
813
+ }
814
+ type = attributed.baseType
815
+ }
816
+ if let optional = type.as(OptionalTypeSyntax.self) {
817
+ type = optional.wrappedType
818
+ } else if let implicitlyUnwrapped = type.as(ImplicitlyUnwrappedOptionalTypeSyntax.self) {
819
+ type = implicitlyUnwrapped.wrappedType
820
+ } else if let identifier = type.as(IdentifierTypeSyntax.self), identifier.name.text == "Optional",
821
+ let argument = identifier.genericArgumentClause?.arguments.first, case .type(let wrapped) = argument.argument
822
+ {
823
+ type = wrapped
824
+ }
825
+ if let identifier = type.as(IdentifierTypeSyntax.self) {
826
+ return identifier.name.text == "JavaScriptRuntime"
827
+ }
828
+ if let member = type.as(MemberTypeSyntax.self) {
829
+ return member.name.text == "JavaScriptRuntime"
830
+ }
831
+ return false
832
+ }
833
+
803
834
  /// True when an event's function type returns `Void`. The shared `isVoidType` is not reused: it
804
835
  /// accepts neither `Swift.Void` nor a parenthesized `(Void)`, so it would drop valid events.
805
836
  private func isVoidEventReturn(_ type: TypeSyntax) -> Bool {
@@ -51,6 +51,7 @@ public struct JSMacro: PeerMacro {
51
51
  diagnoseConcurrentOption(of: node, on: declaration, in: context)
52
52
  diagnoseAsyncVariantOption(of: node, on: declaration, in: context)
53
53
  diagnoseJavaScriptValuesOffThread(of: node, on: declaration, in: context)
54
+ diagnoseRuntimeParameter(of: node, on: declaration, in: context)
54
55
 
55
56
  // In an `@ExpoModule` or `@SharedObject` class, the class macro emits the assertion instead: it sees
56
57
  // the type aliases declared in the class body, which this macro can't (see `TupleTypeAliases`).
@@ -425,6 +426,175 @@ private func diagnoseJavaScriptValuesOffThread(
425
426
  }
426
427
  }
427
428
 
429
+ /// Emits the diagnostics for a parameter that takes the JavaScript runtime (see `isRuntimeParameter`).
430
+ /// The binding passes the runtime of the call, which can be used only on the JavaScript thread. So
431
+ /// the parameter must be:
432
+ /// - in a synchronous function or an initializer. An `async` function can resume on another thread
433
+ /// after an `await`: `@JavaScriptActor` runs a job on the thread that enqueues it, and doesn't move
434
+ /// it to the JavaScript thread. A `.concurrent` function and the variant that `.withAsync` adds run
435
+ /// on the concurrent thread pool;
436
+ /// - `borrowing`, so the body can't keep the runtime for later without an explicit `copy`;
437
+ /// - not optional, since the binding always has a runtime to pass;
438
+ /// - the last parameter, labeled `in` (or `runtime`), so every declaration reads the same and the JavaScript
439
+ /// arguments keep their order.
440
+ private func diagnoseRuntimeParameter(
441
+ of node: AttributeSyntax,
442
+ on declaration: some DeclSyntaxProtocol,
443
+ in context: some MacroExpansionContext
444
+ ) {
445
+ let parameters: FunctionParameterListSyntax
446
+ if let funcDecl = declaration.as(FunctionDeclSyntax.self) {
447
+ parameters = funcDecl.signature.parameterClause.parameters
448
+ // `.concurrent` is checked first: its body never runs on the JavaScript thread, so making the
449
+ // function synchronous doesn't help (a synchronous `.concurrent` function is an error too).
450
+ let offThread: (reason: String, remedy: String)?
451
+ if hasOption(node, named: "concurrent") {
452
+ offThread = ("'.concurrent' runs the body on the concurrent thread pool", "Remove the parameter.")
453
+ } else if funcDecl.signature.effectSpecifiers?.asyncSpecifier != nil {
454
+ offThread = (
455
+ "an 'async' function can resume on another thread after an 'await'",
456
+ "Remove the parameter, or make the function synchronous."
457
+ )
458
+ } else if asyncVariantOption(of: node) != nil {
459
+ offThread = (
460
+ "the variant that '.withAsync' adds runs the function on the concurrent thread pool",
461
+ "Remove the parameter, or remove '.withAsync'."
462
+ )
463
+ } else {
464
+ offThread = nil
465
+ }
466
+ if let offThread {
467
+ for parameter in parameters where isRuntimeParameter(parameter) {
468
+ context.diagnose(
469
+ Diagnostic(
470
+ node: parameter.type,
471
+ message: JSDiagnosticMessage(
472
+ "A @JS function can take the JavaScript runtime only when it's synchronous: \(offThread.reason), and the runtime can be used only on the JavaScript thread. \(offThread.remedy)",
473
+ id: "js-runtime-parameter-async",
474
+ severity: .error
475
+ )
476
+ )
477
+ )
478
+ }
479
+ return
480
+ }
481
+ } else if let initDecl = declaration.as(InitializerDeclSyntax.self) {
482
+ parameters = initDecl.signature.parameterClause.parameters
483
+ } else {
484
+ return
485
+ }
486
+ for (index, parameter) in parameters.enumerated() where isRuntimeParameter(parameter) {
487
+ guard index == parameters.count - 1 else {
488
+ context.diagnose(
489
+ Diagnostic(
490
+ node: parameter,
491
+ message: JSDiagnosticMessage(
492
+ "The JavaScript runtime must be the last parameter of a @JS function or initializer, and only one parameter can take it. Move it to the end as 'in runtime: borrowing JavaScriptRuntime'.",
493
+ id: "js-runtime-parameter-position",
494
+ severity: .error
495
+ )
496
+ )
497
+ )
498
+ continue
499
+ }
500
+ // The errors and the docs name only `in`, which matches the JSI API, such as `encode(_:into:in:)`.
501
+ // An escaped label (`` `in` ``) is the same label.
502
+ if !runtimeParameterLabels.contains(withoutBackticks(parameter.firstName.text)) {
503
+ context.diagnose(
504
+ Diagnostic(
505
+ node: parameter.firstName,
506
+ message: JSDiagnosticMessage(
507
+ "The parameter that takes the JavaScript runtime must be labeled 'in', as in 'in runtime: borrowing JavaScriptRuntime'.",
508
+ id: "js-runtime-parameter-label",
509
+ severity: .error
510
+ ),
511
+ fixIts: [runtimeLabelFixIt(for: parameter)]
512
+ )
513
+ )
514
+ }
515
+ let specified = SpecifiedType(parameter.type)
516
+ let wrapped = optionalWrappedType(specified.type)
517
+ if let wrapped {
518
+ context.diagnose(
519
+ Diagnostic(
520
+ node: specified.type,
521
+ message: JSDiagnosticMessage(
522
+ "The parameter that takes the JavaScript runtime can't be optional: the binding always passes the runtime of the call.",
523
+ id: "js-runtime-parameter-optional",
524
+ severity: .error
525
+ ),
526
+ fixIts: [
527
+ FixIt(
528
+ message: JSFixItMessage("Remove the optional", id: "js-runtime-parameter-remove-optional"),
529
+ changes: [
530
+ .replace(
531
+ oldNode: Syntax(specified.type),
532
+ newNode: Syntax(
533
+ wrapped.trimmed
534
+ .with(\.leadingTrivia, specified.type.leadingTrivia)
535
+ .with(\.trailingTrivia, specified.type.trailingTrivia)
536
+ )
537
+ )
538
+ ]
539
+ )
540
+ ]
541
+ )
542
+ )
543
+ }
544
+ if specified.specifiers != ["borrowing"] {
545
+ context.diagnose(
546
+ Diagnostic(
547
+ node: parameter.type,
548
+ message: JSDiagnosticMessage(
549
+ "The parameter that takes the JavaScript runtime must be 'borrowing': the runtime can be used only on the JavaScript thread, and a borrowed parameter can't be kept for later without an explicit 'copy'.",
550
+ id: "js-runtime-parameter-borrowing",
551
+ severity: .error
552
+ ),
553
+ fixIts: [
554
+ FixIt(
555
+ message: JSFixItMessage("Mark the parameter 'borrowing'", id: "js-runtime-parameter-insert-borrowing"),
556
+ changes: [
557
+ .replace(
558
+ oldNode: Syntax(parameter.type),
559
+ newNode: Syntax(borrowingType(wrapped ?? specified.type, like: parameter.type))
560
+ )
561
+ ]
562
+ )
563
+ ]
564
+ )
565
+ )
566
+ }
567
+ }
568
+ }
569
+
570
+ /// The labels that the runtime parameter can have.
571
+ private let runtimeParameterLabels: Set<String> = ["in", "runtime"]
572
+
573
+ /// The fix-it that labels the runtime parameter `in`. It keeps the parameter name that the body uses, or
574
+ /// names the parameter `runtime` when it has no name.
575
+ private func runtimeLabelFixIt(for parameter: FunctionParameterSyntax) -> FixIt {
576
+ var newParameter = parameter
577
+ let name = parameter.secondName ?? (parameter.firstName.tokenKind == .wildcard ? nil : parameter.firstName)
578
+ newParameter.firstName = .keyword(.in, leadingTrivia: parameter.firstName.leadingTrivia, trailingTrivia: .space)
579
+ newParameter.secondName = name?.trimmed ?? .identifier("runtime")
580
+ return FixIt(
581
+ message: JSFixItMessage("Label the parameter 'in'", id: "js-runtime-parameter-label"),
582
+ changes: [.replace(oldNode: Syntax(parameter), newNode: Syntax(newParameter))]
583
+ )
584
+ }
585
+
586
+ /// The type `type` with the single specifier `borrowing`, with the trivia of `original`.
587
+ private func borrowingType(_ type: TypeSyntax, like original: TypeSyntax) -> TypeSyntax {
588
+ let specifier = SimpleTypeSpecifierSyntax(specifier: .keyword(.borrowing, trailingTrivia: .space))
589
+ let attributed = AttributedTypeSyntax(
590
+ specifiers: [.simpleTypeSpecifier(specifier)],
591
+ baseType: type.trimmed
592
+ )
593
+ return TypeSyntax(attributed)
594
+ .with(\.leadingTrivia, original.leadingTrivia)
595
+ .with(\.trailingTrivia, original.trailingTrivia)
596
+ }
597
+
428
598
  private func diagnoseParameterSpecifiers(
429
599
  in declaration: some DeclSyntaxProtocol,
430
600
  in context: some MacroExpansionContext
@@ -437,7 +607,8 @@ private func diagnoseParameterSpecifiers(
437
607
  } else {
438
608
  return
439
609
  }
440
- for parameter in parameters {
610
+ // The runtime parameter isn't decoded, and `diagnoseRuntimeParameter` requires it to be `borrowing`.
611
+ for parameter in parameters where !isRuntimeParameter(parameter) {
441
612
  guard let specifier = SpecifiedType(parameter.type).unsupportedSpecifier else {
442
613
  continue
443
614
  }
@@ -608,7 +779,8 @@ private func argumentBoundaryTypes(of parameters: FunctionParameterListSyntax) -
608
779
  // A closure in any other position is diagnosed by `diagnoseClosureTypes` and left out here, since
609
780
  // its metatype isn't a valid expression in the assertion.
610
781
  var types: [AssertedType] = []
611
- for parameter in parameters {
782
+ // The runtime parameter takes no JavaScript argument, so nothing decodes into it.
783
+ for parameter in parameters where !isRuntimeParameter(parameter) {
612
784
  let label = parameter.firstName.tokenKind == .wildcard ? nil : parameter.firstName.text
613
785
  // The specifier isn't part of the asserted type, and a parameter with one the binding can't honor
614
786
  // is reported by `diagnoseParameterSpecifiers` and not asserted.
@@ -39,6 +39,10 @@ import SwiftSyntaxMacros
39
39
  /// event without an initial value an empty closure (`{}`, `{ _ in }`, ...) and leaves the value
40
40
  /// props at their initial values. It is never generated when the type declares its own `init()`.
41
41
  /// Like any initializer in the struct body, the generated one removes the memberwise initializer.
42
+ /// - `_resetViewProp(_:in:)`: sets one value prop back to its default, by assigning the property's
43
+ /// initial value or `nil`, so a reset evaluates only that prop's default. It is a static member of
44
+ /// the struct body, because an initial value can name a static member without `Self.` or a member of
45
+ /// an enclosing type, which an instance method or the extension can't.
42
46
  ///
43
47
  /// An event's function type must return `Void`, because JavaScript can't return a value to it.
44
48
  ///
@@ -47,7 +51,16 @@ import SwiftSyntaxMacros
47
51
  /// - `allProps`: every bit set, the changed set on the first application.
48
52
  /// - `propSet(for:)`: one name's bit, for folding raw changed keys into the mask.
49
53
  /// - `_decode(_:from:in:)`: assigns one value prop from a JavaScript value. `null` and `undefined`
50
- /// reset it to its value in `Self()`, and any other value decodes through `JavaScriptDecodable`.
54
+ /// reset it with `_resetViewProp(_:in:)`, and any other value decodes through
55
+ /// `JavaScriptDecodable`.
56
+ ///
57
+ /// `_decode` and `_resetViewProp` run on the JavaScript actor. The other generated types and members
58
+ /// and the conformance are `nonisolated`, so core can use them on any thread also when the module
59
+ /// builds with a default isolation, such as `-default-isolation MainActor`. The props' own
60
+ /// declarations still take that isolation, so a struct with an event prop, a value prop whose type
61
+ /// isn't `Sendable`, or a computed prop needs `nonisolated` on its declaration, and the author's
62
+ /// `init()` must be `nonisolated` too. So must a conformance that the author declares
63
+ /// (`: nonisolated AnyViewProps`).
51
64
  ///
52
65
  /// Author-facing shape — no conformance to spell out:
53
66
  ///
@@ -83,6 +96,9 @@ public struct ViewPropsMacro: MemberMacro, ExtensionMacro {
83
96
  if let initializer = defaultInitializer(props: props) {
84
97
  members.append(initializer)
85
98
  }
99
+ if let reset = resetFunction(valueProps: props.valueProps) {
100
+ members.append(reset)
101
+ }
86
102
  return members
87
103
  }
88
104
 
@@ -120,29 +136,33 @@ public struct ViewPropsMacro: MemberMacro, ExtensionMacro {
120
136
  ? "0"
121
137
  : "0b" + String(repeating: "1", count: props.valueProps.count)
122
138
 
123
- // With no value prop there's nothing to switch over, so return the empty set.
139
+ // With no value prop, `PropName` has no case, so the function can't be called and its body is
140
+ // empty, like the one of `_decode`. Swift reports any statement in it, also an empty `switch`, as
141
+ // "will never be executed". The body starts with a line break, so the empty one closes on the
142
+ // next line.
124
143
  let propSetBody: String
125
144
  if props.valueProps.isEmpty {
126
- propSetBody = " return []"
145
+ propSetBody = ""
127
146
  } else {
128
147
  let cases = props.valueProps
129
148
  .map { " case .\($0.name):\n return .\($0.name)" }
130
149
  .joined(separator: "\n")
131
- propSetBody = " switch name {\n\(cases)\n }"
150
+ propSetBody = "\n switch name {\n\(cases)\n }"
132
151
  }
133
152
 
134
153
  let decodeBody = decodeFunctionBody(valueProps: props.valueProps)
135
154
 
136
- let conformanceClause = alreadyConforms ? "" : ": \(viewPropsProtocolName)"
155
+ // `nonisolated` keeps the conformance and its members usable on the JavaScript thread when the
156
+ // module builds with a default isolation, which would otherwise isolate them to the main actor.
157
+ let conformanceClause = alreadyConforms ? "" : ": nonisolated \(viewPropsProtocolName)"
137
158
  let ext: DeclSyntax = """
138
159
  extension \(type.trimmed)\(raw: conformanceClause) {
139
- public static let allProps = PropSet(rawValue: \(raw: allPropsMask))
160
+ nonisolated public static let allProps = PropSet(rawValue: \(raw: allPropsMask))
140
161
 
141
162
  /// `@inlinable` so core's raw-key fold can inline the lookup across the module boundary:
142
163
  /// it runs once per changed key per props batch.
143
164
  @inlinable
144
- public static func propSet(for name: PropName) -> PropSet {
145
- \(raw: propSetBody)
165
+ nonisolated public static func propSet(for name: PropName) -> PropSet {\(raw: propSetBody)
146
166
  }
147
167
 
148
168
  @JavaScriptActor
@@ -488,9 +508,11 @@ private struct ViewProp {
488
508
  /// The property's declared type, verbatim, or the type of its literal initial value. `_decode`
489
509
  /// doesn't spell it: the decoded type is inferred from the assignment.
490
510
  let type: String
491
- /// Whether the prop is computed with a `mutating get`, which `_decode` can only read from a
511
+ /// Whether the prop is computed with a `mutating get`, which `_resetViewProp` can only read from a
492
512
  /// mutable value.
493
513
  let hasMutatingGetter: Bool
514
+ /// What `_resetViewProp` assigns when React removes the prop. Events aren't reset.
515
+ let defaultValue: PropDefault
494
516
 
495
517
  /// The name with any escaping backticks removed: the JS-visible key, so the enum's raw value and
496
518
  /// `_eventNames` both read `"default"`, never `` "`default`" ``.
@@ -499,6 +521,49 @@ private struct ViewProp {
499
521
  }
500
522
  }
501
523
 
524
+ /// The value that a value prop goes back to when React removes it. Each one equals the prop's value
525
+ /// in `Self()`.
526
+ private enum PropDefault {
527
+ /// The initial value of a stored property, as written. Evaluating it again gives the value that
528
+ /// `Self()` gives the property.
529
+ case initialValue(ExprSyntax)
530
+ /// The implicit `nil` of a `var` whose type is written as `T?` or `T!`.
531
+ case implicitNil
532
+ /// The prop's value in a new `Self()`, when the macro can't tell it from the declaration: the
533
+ /// struct declares its own `init()`, which can set any value, the prop is computed, the property
534
+ /// has an attribute, such as a property wrapper, whose value after `init(wrappedValue:)` can differ
535
+ /// from its initial value, or the initial value has a `#` expression, such as `#function` or
536
+ /// `#line`, whose value depends on where it's evaluated.
537
+ case newInstance
538
+ }
539
+
540
+ /// The default of a property with the given binding, for a struct that does or doesn't declare its own
541
+ /// `init()`.
542
+ private func propDefault(
543
+ of binding: PatternBindingSyntax,
544
+ in varDecl: VariableDeclSyntax,
545
+ declaredType: TypeSyntax?,
546
+ declaresUsableInitializer: Bool
547
+ ) -> PropDefault {
548
+ if declaresUsableInitializer || !isStoredBinding(binding) || !varDecl.attributes.isEmpty {
549
+ return .newInstance
550
+ }
551
+ if let initializer = binding.initializer {
552
+ // A macro expansion, also a literal such as `#function`, can depend on where it's evaluated, so
553
+ // a copy in `_resetViewProp` could give another value.
554
+ let hasPoundExpression = initializer.value.tokens(viewMode: .sourceAccurate).contains {
555
+ $0.tokenKind == .pound
556
+ }
557
+ return hasPoundExpression ? .newInstance : .initialValue(initializer.value)
558
+ }
559
+ if varDecl.bindingSpecifier.tokenKind == .keyword(.var), let declaredType, hasImplicitNilDefault(declaredType) {
560
+ return .implicitNil
561
+ }
562
+ // A value prop without a default is reported, so this is only reached for an event, which isn't
563
+ // reset.
564
+ return .newInstance
565
+ }
566
+
502
567
  /// An event prop declared without an initial value, which the generated `init()` assigns.
503
568
  private struct EventDefault {
504
569
  /// The property name as written, backticks included.
@@ -664,7 +729,17 @@ private func validatedViewProps(
664
729
  // Only stored storage takes an initial value; a computed prop with a setter needs none.
665
730
  let needsInitialValue =
666
731
  binding.initializer == nil && isStoredBinding(binding) && !declaresUsableInitializer
667
- let prop = ViewProp(name: name, type: resolvedType, hasMutatingGetter: hasMutatingGetter(binding))
732
+ let prop = ViewProp(
733
+ name: name,
734
+ type: resolvedType,
735
+ hasMutatingGetter: hasMutatingGetter(binding),
736
+ defaultValue: propDefault(
737
+ of: binding,
738
+ in: varDecl,
739
+ declaredType: declaredType,
740
+ declaresUsableInitializer: declaresUsableInitializer
741
+ )
742
+ )
668
743
  if let eventFunctionType {
669
744
  // An event is dispatched to JavaScript, which can't hand a value back to the caller.
670
745
  if !returnsVoid(eventFunctionType) {
@@ -926,6 +1001,10 @@ private func underlyingFunctionType(of type: TypeSyntax) -> FunctionTypeSyntax?
926
1001
  /// Emitted even when empty, so a props type with only events still conforms. An enum with no cases
927
1002
  /// can't declare a raw type, so the empty one conforms to `RawRepresentable` by hand, with `String`
928
1003
  /// as its `RawValue` like the others.
1004
+ ///
1005
+ /// The enum is `@frozen`, so the `switch` in the `@inlinable` `propSet(for:)` compiles in a module
1006
+ /// built with library evolution, where it would otherwise need a case for unknown values. The cases
1007
+ /// and their order are already part of the ABI, as the bits of `PropSet`.
929
1008
  private func propNameEnum(valueProps: [ViewProp]) -> DeclSyntax {
930
1009
  // An escaped case takes its raw value from the bare identifier already, but spelling it out keeps
931
1010
  // the wire key visible in the generated source and independent of that implicit rule.
@@ -939,7 +1018,7 @@ private func propNameEnum(valueProps: [ViewProp]) -> DeclSyntax {
939
1018
  .joined(separator: "\n")
940
1019
  if valueProps.isEmpty {
941
1020
  return """
942
- public enum PropName: RawRepresentable, CaseIterable {
1021
+ @frozen nonisolated public enum PropName: RawRepresentable, CaseIterable {
943
1022
  public init?(rawValue: String) {
944
1023
  return nil
945
1024
  }
@@ -952,7 +1031,7 @@ private func propNameEnum(valueProps: [ViewProp]) -> DeclSyntax {
952
1031
  """
953
1032
  }
954
1033
  return """
955
- public enum PropName: String, CaseIterable {
1034
+ @frozen nonisolated public enum PropName: String, CaseIterable {
956
1035
  \(raw: cases)
957
1036
  }
958
1037
  """
@@ -970,7 +1049,7 @@ private func propSetStruct(valueProps: [ViewProp]) -> DeclSyntax {
970
1049
 
971
1050
  if valueProps.isEmpty {
972
1051
  return """
973
- public struct PropSet: OptionSet, Sendable {
1052
+ nonisolated public struct PropSet: OptionSet, Sendable {
974
1053
  public let rawValue: UInt64
975
1054
 
976
1055
  public init(rawValue: UInt64) {
@@ -980,7 +1059,7 @@ private func propSetStruct(valueProps: [ViewProp]) -> DeclSyntax {
980
1059
  """
981
1060
  }
982
1061
  return """
983
- public struct PropSet: OptionSet, Sendable {
1062
+ nonisolated public struct PropSet: OptionSet, Sendable {
984
1063
  public let rawValue: UInt64
985
1064
 
986
1065
  public init(rawValue: UInt64) {
@@ -998,31 +1077,23 @@ private func propSetStruct(valueProps: [ViewProp]) -> DeclSyntax {
998
1077
  private func eventNamesConstant(eventProps: [ViewProp]) -> DeclSyntax {
999
1078
  let names = eventProps.map { "\"\($0.wireName)\"" }.joined(separator: ", ")
1000
1079
  return """
1001
- public static let _eventNames: [String] = [\(raw: names)]
1080
+ nonisolated public static let _eventNames: [String] = [\(raw: names)]
1002
1081
  """
1003
1082
  }
1004
1083
 
1005
1084
  /// The body of `_decode`, which core calls once per changed prop. React sends a removed prop as
1006
- /// `null`, so `null` and `undefined` reset the prop to its value in a fresh `Self()`; any other value
1007
- /// decodes through `JavaScriptDecodable`, with the type inferred from the assignment. The helper is a
1008
- /// static member of `AnyViewProps`, reached through `Self.`, so a prop named `_decodeViewProp`
1009
- /// doesn't shadow it. The checks run
1010
- /// once, before the switch, so each case is a single assignment. Members are reached through `self.`
1011
- /// so that a prop named like a parameter (`value`, `runtime`) isn't shadowed by it.
1085
+ /// `null`, so `null` and `undefined` reset the prop with `_resetViewProp`; any other value decodes
1086
+ /// through `JavaScriptDecodable`, with the type inferred from the assignment. `_decodeViewProp` is a
1087
+ /// static member of `AnyViewProps`, reached through `Self.`, so a prop with the same name doesn't
1088
+ /// shadow it. The checks run once, before the switch, so each case is a single assignment. Members
1089
+ /// are reached through `self.` so that a prop named like a parameter (`value`, `runtime`) isn't
1090
+ /// shadowed by it.
1012
1091
  ///
1013
1092
  /// With no value prop the body is empty: `PropName` is uninhabited, so the function is never called.
1014
- ///
1015
- /// `defaults` is a `var` only when a prop has a `mutating get`, which can't be read from a constant.
1016
- /// Otherwise it's a `let`, so the common case gets no "never mutated" warning.
1017
1093
  private func decodeFunctionBody(valueProps: [ViewProp]) -> String {
1018
1094
  if valueProps.isEmpty {
1019
1095
  return ""
1020
1096
  }
1021
- let defaultsSpecifier = valueProps.contains(where: \.hasMutatingGetter) ? "var" : "let"
1022
- let resetCases =
1023
- valueProps
1024
- .map { " case .\($0.name):\n self.\($0.name) = defaults.\($0.name)" }
1025
- .joined(separator: "\n")
1026
1097
  let decodeCases =
1027
1098
  valueProps
1028
1099
  .map { " case .\($0.name):\n self.\($0.name) = try Self._decodeViewProp(value, in: runtime)" }
@@ -1032,10 +1103,7 @@ private func decodeFunctionBody(valueProps: [ViewProp]) -> String {
1032
1103
  return """
1033
1104
 
1034
1105
  if value.isNull() || value.isUndefined() {
1035
- \(defaultsSpecifier) defaults = Self()
1036
- switch name {
1037
- \(resetCases)
1038
- }
1106
+ Self._resetViewProp(name, in: &self)
1039
1107
  return
1040
1108
  }
1041
1109
  switch name {
@@ -1044,6 +1112,79 @@ private func decodeFunctionBody(valueProps: [ViewProp]) -> String {
1044
1112
  """
1045
1113
  }
1046
1114
 
1115
+ /// `_resetViewProp(_:in:)`, which sets one value prop back to its default when React removes it. Each
1116
+ /// case assigns the prop's initial value or `nil`, so a reset evaluates only that prop's default. A
1117
+ /// prop whose default the macro can't tell from the declaration reads it from a new `Self()`.
1118
+ ///
1119
+ /// A static function of the struct body resolves names as a property initializer does: a static
1120
+ /// member without `Self.` and a member of an enclosing type, which `_decode` in the extension can't
1121
+ /// name. The parameters are `_name` and `_props`, with a number after the name when an initial value
1122
+ /// uses it, so they don't hide a declaration that an initial value names.
1123
+ ///
1124
+ /// A default argument such as `#function` or `#line` of a function that an initial value calls gets
1125
+ /// the location of `_resetViewProp`, which the macro can't see.
1126
+ ///
1127
+ /// `nil` with no value prop: `PropName` is uninhabited, so `_decode` never resets.
1128
+ private func resetFunction(valueProps: [ViewProp]) -> DeclSyntax? {
1129
+ if valueProps.isEmpty {
1130
+ return nil
1131
+ }
1132
+ let usedNames = Set(
1133
+ valueProps.flatMap { prop -> [String] in
1134
+ guard case .initialValue(let expression) = prop.defaultValue else {
1135
+ return []
1136
+ }
1137
+ return expression.tokens(viewMode: .sourceAccurate).compactMap { token in
1138
+ guard case .identifier(let text) = token.tokenKind else {
1139
+ return nil
1140
+ }
1141
+ return withoutBackticks(text)
1142
+ }
1143
+ }
1144
+ )
1145
+ let nameParameter = unusedName("_name", in: usedNames)
1146
+ let propsParameter = unusedName("_props", in: usedNames)
1147
+ let cases =
1148
+ valueProps
1149
+ .map { prop -> String in
1150
+ let assignment: String
1151
+ switch prop.defaultValue {
1152
+ case .initialValue(let expression):
1153
+ assignment = "\(propsParameter).\(prop.name) = \(expression.trimmedDescription)"
1154
+ case .implicitNil:
1155
+ assignment = "\(propsParameter).\(prop.name) = nil"
1156
+ case .newInstance where prop.hasMutatingGetter:
1157
+ // A mutating getter can't be read from a constant. The local is scoped to its case, where
1158
+ // no initial value is copied.
1159
+ assignment = "var _defaults = Self()\n \(propsParameter).\(prop.name) = _defaults.\(prop.name)"
1160
+ case .newInstance:
1161
+ assignment = "\(propsParameter).\(prop.name) = Self().\(prop.name)"
1162
+ }
1163
+ return " case .\(prop.name):\n \(assignment)"
1164
+ }
1165
+ .joined(separator: "\n")
1166
+ return """
1167
+ @JavaScriptActor
1168
+ static func _resetViewProp(_ \(raw: nameParameter): PropName, in \(raw: propsParameter): inout Self) {
1169
+ switch \(raw: nameParameter) {
1170
+ \(raw: cases)
1171
+ }
1172
+ }
1173
+ """
1174
+ }
1175
+
1176
+ /// `base`, or `base` with the lowest number after it that gives a name not in `names`.
1177
+ private func unusedName(_ base: String, in names: Set<String>) -> String {
1178
+ if !names.contains(base) {
1179
+ return base
1180
+ }
1181
+ var number = 1
1182
+ while names.contains("\(base)\(number)") {
1183
+ number += 1
1184
+ }
1185
+ return "\(base)\(number)"
1186
+ }
1187
+
1047
1188
  /// The public `init()` that `AnyViewProps` requires, when Swift's own can't satisfy it: it gives each
1048
1189
  /// event prop without an initial value an empty closure, and leaves the value props at their initial
1049
1190
  /// values. `nil` when the type declares a usable `init()`, or when Swift's `init()` is enough (see
@@ -1054,7 +1195,7 @@ private func defaultInitializer(props: ViewPropsModel) -> DeclSyntax? {
1054
1195
  }
1055
1196
  if props.eventDefaults.isEmpty {
1056
1197
  return """
1057
- public init() {
1198
+ nonisolated public init() {
1058
1199
  }
1059
1200
  """
1060
1201
  }
@@ -1062,7 +1203,7 @@ private func defaultInitializer(props: ViewPropsModel) -> DeclSyntax? {
1062
1203
  .map { " self.\($0.name) = \($0.emptyClosure)" }
1063
1204
  .joined(separator: "\n")
1064
1205
  return """
1065
- public init() {
1206
+ nonisolated public init() {
1066
1207
  \(raw: assignments)
1067
1208
  }
1068
1209
  """
@@ -17,10 +17,17 @@ import SwiftSyntax
17
17
  /// A synchronous function marked `@JS(.withAsync("name"))` also binds a promise-returning variant
18
18
  /// under that name (see `asyncVariant`). It decodes and encodes like an `async` function, and calls
19
19
  /// the function on the concurrent pool.
20
+ ///
21
+ /// A synchronous function can take the JavaScript runtime as its last parameter,
22
+ /// `in runtime: borrowing JavaScriptRuntime`. JavaScript doesn't see it: the binding passes the runtime
23
+ /// of the call.
20
24
  internal struct JSFunction {
21
25
  let swiftName: String
22
26
  private(set) var jsName: String
27
+ /// The parameters that take JavaScript arguments, without the runtime parameter.
23
28
  let parameters: [FunctionParameterSyntax]
29
+ /// The label of the parameter that takes the JavaScript runtime, or `nil` when the function has none.
30
+ private let runtimeLabel: String?
24
31
  /// The declared return type as written, or `nil` when the function returns `Void`/nothing.
25
32
  let returnType: String?
26
33
  /// How the return value encodes when its type mentions `Any`, or `nil` for any other type.
@@ -39,7 +46,9 @@ internal struct JSFunction {
39
46
  self.swiftName = funcDecl.name.text
40
47
  self.jsName = jsNameArgument(of: attribute) ?? withoutBackticks(funcDecl.name.text)
41
48
  self.nameToken = funcDecl.name
42
- self.parameters = Array(funcDecl.signature.parameterClause.parameters)
49
+ let declaredParameters = funcDecl.signature.parameterClause.parameters
50
+ self.parameters = declaredParameters.filter { !isRuntimeParameter($0) }
51
+ self.runtimeLabel = declaredParameters.first(where: isRuntimeParameter)?.firstName.text
43
52
 
44
53
  let declaredReturnType = funcDecl.signature.returnClause?.type
45
54
  self.returnType = isVoidType(declaredReturnType) ? nil : declaredReturnType?.trimmedDescription
@@ -264,6 +273,9 @@ internal struct JSFunction {
264
273
  }
265
274
  callArguments.append(label == "_" ? value : "\(label): \(value)")
266
275
  }
276
+ if let runtimeLabel {
277
+ callArguments.append(runtimeLabel == "_" ? "runtime" : "\(runtimeLabel): runtime")
278
+ }
267
279
  return callArguments.joined(separator: ", ")
268
280
  }
269
281
 
@@ -338,12 +350,14 @@ internal struct JSFunction {
338
350
  // returns nothing binds through the `Void` overload (`JavaScriptRuntime.VoidFunctionClosure`)
339
351
  // instead, which leaves JS's result as `undefined`. A call through it is faster than through a
340
352
  // result-slot closure that writes nothing. The closure declares `-> Void` because a body of several
341
- // statements without a `return` is otherwise ambiguous between the overloads.
353
+ // statements without a `return` is otherwise ambiguous between the overloads. A function that takes
354
+ // the runtime binds through the result-slot overload even when it returns nothing, so it gets the
355
+ // runtime as the closure's parameter instead of capturing the decorator's.
342
356
  let captures = receiver.captureClause
343
357
  let parameters =
344
358
  isAsync
345
359
  ? "(this: borrowing JavaScriptUnownedValue, arguments: consuming JavaScriptValuesBuffer)"
346
- : returnType == nil
360
+ : returnType == nil && runtimeLabel == nil
347
361
  ? "(this: borrowing JavaScriptUnownedValue, arguments: consuming JavaScriptValuesBuffer) -> Void"
348
362
  : resultSlotClosureParameters
349
363
 
@@ -4,12 +4,18 @@ import SwiftSyntax
4
4
  /// have a single constructor). Instead of a `Constructor { … }` DSL entry, the macro synthesizes an
5
5
  /// override of `SharedObject._constructSharedObject(...)` that decodes the JS arguments and returns a
6
6
  /// fresh instance; unlike the method/property bindings it produces the native instance rather than
7
- /// recovering one.
7
+ /// recovering one. Like a function, it can take the JavaScript runtime as its last parameter.
8
8
  internal struct JSConstructor {
9
+ /// The parameters that take JavaScript arguments, without the runtime parameter.
9
10
  let parameters: [FunctionParameterSyntax]
11
+ /// The label of the parameter that takes the JavaScript runtime, or `nil` when the initializer has
12
+ /// none.
13
+ private let runtimeLabel: String?
10
14
 
11
15
  init(initDecl: InitializerDeclSyntax) {
12
- self.parameters = Array(initDecl.signature.parameterClause.parameters)
16
+ let declaredParameters = initDecl.signature.parameterClause.parameters
17
+ self.parameters = declaredParameters.filter { !isRuntimeParameter($0) }
18
+ self.runtimeLabel = declaredParameters.first(where: isRuntimeParameter)?.firstName.text
13
19
  }
14
20
 
15
21
  /// The body statements, indented with `indent`: arity guard, per-argument decode through
@@ -39,6 +45,9 @@ internal struct JSConstructor {
39
45
  let label = parameter.firstName.text
40
46
  callArguments.append(label == "_" ? "arg\(index)" : "\(label): arg\(index)")
41
47
  }
48
+ if let runtimeLabel {
49
+ callArguments.append(runtimeLabel == "_" ? "runtime" : "\(runtimeLabel): runtime")
50
+ }
42
51
 
43
52
  lines.append("return \(typeName)(\(callArguments.joined(separator: ", ")))")
44
53
 
@@ -128,6 +128,17 @@ internal func isOptionalType(_ type: TypeSyntax) -> Bool {
128
128
  return false
129
129
  }
130
130
 
131
+ /// True when the parameter takes the JavaScript runtime instead of a JavaScript argument: its type,
132
+ /// without specifiers, is written as `JavaScriptRuntime` (or `….JavaScriptRuntime`), or as an optional
133
+ /// of it. The binding passes the runtime of the call, and JavaScript doesn't see the parameter. `@JS`
134
+ /// requires it to be the last parameter, labeled `in` or `runtime`, `borrowing` and not optional (see
135
+ /// `diagnoseRuntimeParameter`). A macro can't resolve types, so a typealias or a subclass of
136
+ /// `JavaScriptRuntime` isn't detected.
137
+ internal func isRuntimeParameter(_ parameter: FunctionParameterSyntax) -> Bool {
138
+ let type = SpecifiedType(parameter.type).type
139
+ return baseIdentifier(of: optionalWrappedType(type) ?? type) == "JavaScriptRuntime"
140
+ }
141
+
131
142
  /// A parameter's type split from the specifiers written in front of it (`borrowing`, `consuming`,
132
143
  /// `sending`, `inout`, `isolated`, ...). The specifiers aren't part of the type: the decode call and
133
144
  /// the conformance assertion need the bare type (`borrowing Point.decode(...)` isn't valid Swift), and
@@ -620,8 +631,9 @@ internal func isExcludedByModifier(_ modifiers: DeclModifierListSyntax) -> Bool
620
631
 
621
632
  /// True if the type's inheritance clause already lists a protocol with the given name. Matches
622
633
  /// either the bare identifier (`Record`) or a qualified member access ending in the name
623
- /// (`ExpoModulesCore.Record`). Used by the extension macros to skip a conformance the author already
624
- /// spelled out. Works for `struct`, `class`, and `enum` declarations; any other declaration kind has no
634
+ /// (`ExpoModulesCore.Record`), also with `nonisolated` or an attribute such as `@preconcurrency`
635
+ /// before it. Used by the extension macros to skip a conformance the author already spelled out.
636
+ /// Works for `struct`, `class`, and `enum` declarations; any other declaration kind has no
625
637
  /// inheritance clause to read and reports `false`.
626
638
  internal func inheritsProtocol(named name: String, in declaration: some DeclGroupSyntax) -> Bool {
627
639
  let inheritanceClause: InheritanceClauseSyntax?
@@ -637,5 +649,11 @@ internal func inheritsProtocol(named name: String, in declaration: some DeclGrou
637
649
  guard let inherited = inheritanceClause?.inheritedTypes else {
638
650
  return false
639
651
  }
640
- return inherited.contains { baseIdentifier(of: $0.type) == name }
652
+ return inherited.contains { entry in
653
+ var type = entry.type
654
+ while let attributed = type.as(AttributedTypeSyntax.self) {
655
+ type = attributed.baseType
656
+ }
657
+ return baseIdentifier(of: type) == name
658
+ }
641
659
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-modules-macros",
3
- "version": "0.33.0",
3
+ "version": "0.34.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.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"
29
+ "@expo/modules-macros-linux-arm64": "0.34.0",
30
+ "@expo/modules-macros-linux-x64": "0.34.0",
31
+ "@expo/modules-macros-win32-arm64": "0.34.0",
32
+ "@expo/modules-macros-win32-x64": "0.34.0"
33
33
  }
34
34
  }