expo-modules-macros 0.27.1 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -24,13 +24,15 @@ This package lists them as optional dependencies, and their `os`, `cpu` and (on
24
24
 
25
25
  # Macros
26
26
 
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.
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. 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 or the module's default isolation, 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.
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. 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
29
  - **`@Event(_ name: String? = nil, sync: Bool = false)`** 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. `sync: true` dispatches inline instead of asynchronously.
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.
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. Requiredness is inferred: a default value makes a field optional, an optional type makes it nullable and optional.
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
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.
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`.
35
+
34
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.
35
37
 
36
38
  How that looks in a module:
@@ -64,6 +66,10 @@ final class Cache: SharedObject {
64
66
 
65
67
  The author-facing documentation for each macro lives next to its declaration in `expo-modules-core`, in `ios/Core/ExpoModulesMacros.swift`. This repository holds the implementations.
66
68
 
69
+ ## Sendable conformance
70
+
71
+ Native code calls a module or shared object from more than one thread: a `.withAsync` variant runs on the concurrent thread pool, and events and closure arguments can be called from any thread. So the generated code needs the class to be `Sendable`. A subclass of `Module` or `SharedObject` can't be checked `Sendable`, because those base classes aren't, so `@ExpoModule` and `@SharedObject` add `extension MyModule: @unchecked Sendable {}`. The compiler doesn't check the class's mutable state then: keep state that more than one thread uses behind a lock or an actor. The macros add nothing when the class already declares `Sendable` or `@unchecked Sendable`, or inherits it. Swift warns on a subclass of a module or shared object until the subclass restates `@unchecked Sendable`.
72
+
67
73
  # Scanner
68
74
 
69
75
  The Swift compiler launches a plugin executable with no arguments and speaks the plugin protocol over stdin, so the binary treats any argument as a scanner invocation instead:
Binary file
@@ -293,9 +293,12 @@ struct ExportedSharedObject: Encodable, Equatable {
293
293
  /// if a malformed source has more. Not named `constructor`, which every JS object inherits.
294
294
  var initializer: ExportedInitializer?
295
295
 
296
- /// What the class inherits from `SharedRef<T>`, or `nil` when its superclass isn't `SharedRef`.
297
- let sharedRef: ExportedSharedRef?
296
+ /// What the class inherits from `SharedRef<T>`, directly or through another scanned `@SharedObject`
297
+ /// class, or `nil` when it doesn't inherit `SharedRef`.
298
+ var sharedRef: ExportedSharedRef?
298
299
 
300
+ /// The class's `@JS` members, followed by the ones it inherits from scanned `@SharedObject`
301
+ /// superclasses (see `ExportedSurface.mergingInheritedMembers()`).
299
302
  var functions: [ExportedFunction]
300
303
  var properties: [ExportedProperty]
301
304
  var events: [ExportedEvent]
@@ -306,10 +309,24 @@ struct ExportedSharedObject: Encodable, Equatable {
306
309
  let platforms: [ExportedPlatform]
307
310
 
308
311
  let file: String
312
+
313
+ /// The superclass as written, without generic arguments, or `nil` when there is none or it is
314
+ /// `SharedObject` or `SharedRef`. Not encoded: it is only read to find the inherited members.
315
+ var superclass: String? = nil
316
+
317
+ /// The value of the class's own `nativeRefType` override (see `ExportedSharedRef.nativeRefType`), or
318
+ /// `.none` when it declares none. Not encoded: a subclass of a `SharedRef` subclass reports its own
319
+ /// override in the inherited `sharedRef`.
320
+ var ownNativeRefType: String?? = .none
321
+
322
+ private enum CodingKeys: String, CodingKey {
323
+ case name, jsName, initializer, sharedRef, functions, properties, events, accessLevel, platforms, file
324
+ }
309
325
  }
310
326
 
311
- /// The `SharedRef<T>` superclass of a shared object. Only the superclass written on the class is read,
312
- /// so a class that inherits `SharedRef` through another subclass has none.
327
+ /// The `SharedRef<T>` superclass of a shared object. A class that inherits `SharedRef` through another
328
+ /// `@SharedObject` class reports the `T` of that class. A class that inherits it through a class the
329
+ /// scan doesn't report has none.
313
330
  struct ExportedSharedRef: Encodable, Equatable {
314
331
  /// The `T` in `SharedRef<T>`: the native type the object holds, which JS never sees.
315
332
  let native: TypeNode
@@ -0,0 +1,96 @@
1
+ /// Adds to each `@SharedObject` class the `@JS` members it inherits from scanned `@SharedObject`
2
+ /// superclasses.
3
+ ///
4
+ /// The `@SharedObject` macro binds the members of the superclass on the JavaScript objects of the
5
+ /// subclass too, since core builds each JavaScript class on the base `SharedObject` class and not on the
6
+ /// JavaScript class of the superclass. So the reported surface of a subclass lists the inherited members
7
+ /// as its own, and a consumer generates the same type as for any other class.
8
+
9
+ extension ExportedSurface {
10
+ /// This surface with the inherited members added to each shared object. Runs after
11
+ /// `resolvingRefs()`, so the refs in an inherited member are resolved from the superclass that
12
+ /// declares it, as Swift resolves them.
13
+ ///
14
+ /// The superclass is looked up from the scope that encloses the class, the way `RefIndex` looks up a
15
+ /// ref. A superclass the scan doesn't report as a shared object (a protocol, a class without
16
+ /// `@SharedObject`, or a class from another module) ends the chain, so nothing is inherited through it.
17
+ ///
18
+ /// A superclass declared more than once, in `#if` branches, contributes the members of each
19
+ /// declaration whose platforms overlap the platforms of the class. Each member keeps its own
20
+ /// `platforms`.
21
+ ///
22
+ /// The members merge the way the macro binds them: the inherited ones first, then the class's own. An
23
+ /// own member replaces an inherited function or property with the same JS name, separately for static
24
+ /// and instance members. Events are kept from both: an event is a stored property that emits under its
25
+ /// JS name, so a subclass can't replace an inherited one, and both are sent. The `@JS init` isn't
26
+ /// inherited: core doesn't construct a subclass through the initializer of its superclass.
27
+ ///
28
+ /// A class that inherits `SharedRef` through its superclass gets the `sharedRef` of the superclass,
29
+ /// with the class's own `nativeRefType` override when it has one.
30
+ func mergingInheritedMembers() -> ExportedSurface {
31
+ let index = RefIndex(surface: self)
32
+ let declarations = Dictionary(grouping: sharedObjects, by: \.name)
33
+
34
+ // `chain` holds the classes being merged, so a malformed cycle in the inheritance ends instead of
35
+ // recursing forever.
36
+ func merged(_ sharedObject: ExportedSharedObject, chain: Set<String>) -> ExportedSharedObject {
37
+ let scope = sharedObject.name.split(separator: ".").dropLast().joined(separator: ".")
38
+ guard let superclass = sharedObject.superclass,
39
+ let entry = index.lookup(superclass, in: scope),
40
+ entry.kind == .sharedObject,
41
+ !chain.contains(entry.name)
42
+ else {
43
+ return sharedObject
44
+ }
45
+ let platforms = Set(sharedObject.platforms.map(\.name))
46
+ let parents = (declarations[entry.name] ?? [])
47
+ .filter { declaration in declaration.platforms.contains { platforms.contains($0.name) } }
48
+ .map { merged($0, chain: chain.union([sharedObject.name])) }
49
+ guard !parents.isEmpty else {
50
+ return sharedObject
51
+ }
52
+ var result = sharedObject
53
+
54
+ let ownMembers = Set(
55
+ sharedObject.functions.map { MemberKey(jsName: $0.jsName, isStatic: $0.isStatic) }
56
+ + sharedObject.properties.map { MemberKey(jsName: $0.jsName, isStatic: $0.isStatic) }
57
+ )
58
+ result.functions =
59
+ parents.flatMap(\.functions).filter {
60
+ !ownMembers.contains(MemberKey(jsName: $0.jsName, isStatic: $0.isStatic))
61
+ }
62
+ + sharedObject.functions
63
+ result.properties =
64
+ parents.flatMap(\.properties).filter {
65
+ !ownMembers.contains(MemberKey(jsName: $0.jsName, isStatic: $0.isStatic))
66
+ }
67
+ + sharedObject.properties
68
+ result.events = parents.flatMap(\.events) + sharedObject.events
69
+
70
+ // Every declaration of a superclass names the same superclass in practice, so the first one that
71
+ // inherits `SharedRef` stands for all of them.
72
+ if result.sharedRef == nil, let inherited = parents.lazy.compactMap(\.sharedRef).first {
73
+ result.sharedRef = ExportedSharedRef(
74
+ native: inherited.native,
75
+ nativeRefType: sharedObject.ownNativeRefType ?? inherited.nativeRefType
76
+ )
77
+ }
78
+ return result
79
+ }
80
+
81
+ return ExportedSurface(
82
+ modules: modules,
83
+ sharedObjects: sharedObjects.map { merged($0, chain: []) },
84
+ records: records,
85
+ enums: enums,
86
+ unions: unions
87
+ )
88
+ }
89
+ }
90
+
91
+ /// A member's slot on the JavaScript side: static members live on the constructor and instance members
92
+ /// on the prototype, so the two don't replace each other.
93
+ private struct MemberKey: Hashable {
94
+ let jsName: String
95
+ let isStatic: Bool
96
+ }
@@ -57,14 +57,15 @@ func scanExports(paths: [String]) -> ScanExportsResult {
57
57
  }
58
58
 
59
59
  // Refs resolve only once every file has been walked: a type parsed in one file may name a type
60
- // declared in another, so the full set of declarations has to exist before any lookup is valid.
60
+ // 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.
61
62
  let surface = ExportedSurface(
62
63
  modules: modules,
63
64
  sharedObjects: sharedObjects,
64
65
  records: records,
65
66
  enums: enums,
66
67
  unions: unions
67
- ).resolvingRefs()
68
+ ).resolvingRefs().mergingInheritedMembers()
68
69
 
69
70
  return ScanExportsResult(
70
71
  schemaVersion: scanExportsSchemaVersion,
@@ -183,7 +183,9 @@ final class SurfaceVisitor: SyntaxVisitor {
183
183
  events: events,
184
184
  accessLevel: accessLevel,
185
185
  platforms: platforms,
186
- file: file
186
+ file: file,
187
+ superclass: sharedObjectSuperclass(of: node),
188
+ ownNativeRefType: declaredNativeRefType(in: members)
187
189
  )
188
190
  )
189
191
  return
@@ -759,21 +761,50 @@ private func sharedRefSuperclass(of node: some DeclGroupSyntax) -> ExportedShare
759
761
  else {
760
762
  return nil
761
763
  }
762
- return ExportedSharedRef(native: native, nativeRefType: nativeRefType(in: node.memberBlock.members))
764
+ return ExportedSharedRef(
765
+ native: native,
766
+ nativeRefType: declaredNativeRefType(in: node.memberBlock.members) ?? "unknown"
767
+ )
768
+ }
769
+
770
+ /// The superclass a `@SharedObject` class names, without generic arguments (`Outer.ImageRef`), or `nil`
771
+ /// when it names none, `SharedObject`, or `SharedRef`. The first entry of the inheritance clause is read,
772
+ /// since Swift requires the superclass to come first. It may also be a protocol, which then names no
773
+ /// scanned shared object.
774
+ private func sharedObjectSuperclass(of node: some DeclGroupSyntax) -> String? {
775
+ guard let type = node.inheritanceClause?.inheritedTypes.first?.type,
776
+ let spelling = spellingWithoutGenericArguments(of: type)
777
+ else {
778
+ return nil
779
+ }
780
+ let name = spelling.split(separator: ".").last.map(String.init)
781
+ return name == "SharedObject" || name == "SharedRef" ? nil : spelling
782
+ }
783
+
784
+ /// A simple or qualified type name as written, without backticks or generic arguments, or `nil` for any
785
+ /// other kind of type.
786
+ private func spellingWithoutGenericArguments(of type: TypeSyntax) -> String? {
787
+ if let identifier = type.as(IdentifierTypeSyntax.self) {
788
+ return identifier.name.text.filter { $0 != "`" }
789
+ }
790
+ if let member = type.as(MemberTypeSyntax.self), let base = spellingWithoutGenericArguments(of: member.baseType) {
791
+ return base + "." + member.name.text.filter { $0 != "`" }
792
+ }
793
+ return nil
763
794
  }
764
795
 
765
- /// The value of a `nativeRefType` override that returns a plain string literal: `"image"` in
766
- /// `override var nativeRefType: String { "image" }`, also with `return` or inside a `get` block.
767
- /// `unknown`, core's default, when the class declares no `nativeRefType`, and `nil` when its getter is
768
- /// anything else.
769
- private func nativeRefType(in members: MemberBlockItemListSyntax) -> String? {
796
+ /// The value of the class's `nativeRefType` override when it returns a plain string literal: `"image"`
797
+ /// in `override var nativeRefType: String { "image" }`, also with `return` or inside a `get` block.
798
+ /// `.none` when the class declares no `nativeRefType` (core's default is `unknown`), and `.some(nil)`
799
+ /// when its getter is anything else.
800
+ private func declaredNativeRefType(in members: MemberBlockItemListSyntax) -> String?? {
770
801
  let binding = members.lazy
771
802
  .compactMap { $0.decl.as(VariableDeclSyntax.self) }
772
803
  .filter { !isTypeLevel($0.modifiers) }
773
804
  .flatMap { $0.bindings }
774
805
  .first { $0.pattern.as(IdentifierPatternSyntax.self)?.identifier.text == "nativeRefType" }
775
806
  guard let binding else {
776
- return "unknown"
807
+ return .none
777
808
  }
778
809
  let statements: CodeBlockItemListSyntax?
779
810
  switch binding.accessorBlock?.accessors {
@@ -785,18 +816,18 @@ private func nativeRefType(in members: MemberBlockItemListSyntax) -> String? {
785
816
  statements = nil
786
817
  }
787
818
  guard let statements, statements.count == 1, let item = statements.first?.item else {
788
- return nil
819
+ return .some(nil)
789
820
  }
790
821
  let expression = item.as(ReturnStmtSyntax.self)?.expression ?? item.as(ExprSyntax.self)
791
822
  guard let literal = expression?.as(StringLiteralExprSyntax.self),
792
823
  literal.segments.count == 1,
793
824
  let segment = literal.segments.first?.as(StringSegmentSyntax.self)
794
825
  else {
795
- return nil
826
+ return .some(nil)
796
827
  }
797
828
  // An escape would need real unescaping, so it's treated as unreadable, as in `writtenRawValue(of:)`.
798
829
  let content = segment.content.text
799
- return content.contains("\\") ? nil : content
830
+ return .some(content.contains("\\") ? nil : content)
800
831
  }
801
832
 
802
833
  private func isTypeLevel(_ modifiers: DeclModifierListSyntax) -> Bool {
@@ -13,7 +13,8 @@ import SwiftSyntaxMacros
13
13
  /// synthesizes the `appContext` storage and `init(appContext:)` those base classes provide.
14
14
  /// - `MemberAttributeMacro`: stamps `@JavaScriptActor` on `@JS` sync members and
15
15
  /// `@ModuleDefinitionBuilder` on a `definition()` method.
16
- /// - `ExtensionMacro`: adds the `AnyModule` conformance when the class doesn't inherit it.
16
+ /// - `ExtensionMacro`: adds the `AnyModule` conformance when the class doesn't inherit it, and an
17
+ /// `@unchecked Sendable` conformance when the class isn't `Sendable` yet.
17
18
  ///
18
19
  /// Inheriting from `Module` is therefore optional — a class can carry any superclass (or
19
20
  /// none) and still be a module, because everything inheritance used to provide is synthesized.
@@ -219,15 +220,20 @@ extension ExpoModuleMacro: ExtensionMacro {
219
220
  guard !protocols.isEmpty else {
220
221
  return []
221
222
  }
223
+ var extensions: [ExtensionDeclSyntax] = []
222
224
  // These base types already supply `AnyModule`; emitting a second conformance here
223
225
  // would be redundant and error.
224
- if let classDecl = declaration.as(ClassDeclSyntax.self),
225
- inheritsFromAny(classDecl, names: ["Module", "BaseModule", "AnyModule"])
226
+ if protocols.contains(where: { $0.trimmedDescription == "AnyModule" }),
227
+ let classDecl = declaration.as(ClassDeclSyntax.self),
228
+ !inheritsFromAny(classDecl, names: ["Module", "BaseModule", "AnyModule"])
226
229
  {
227
- return []
230
+ let conformance: DeclSyntax = "extension \(type.trimmed): AnyModule {}"
231
+ extensions.append(conformance.cast(ExtensionDeclSyntax.self))
232
+ }
233
+ if let sendable = uncheckedSendableExtension(for: type, of: declaration, conformingTo: protocols) {
234
+ extensions.append(sendable)
228
235
  }
229
- let conformance: DeclSyntax = "extension \(type.trimmed): AnyModule {}"
230
- return [conformance.cast(ExtensionDeclSyntax.self)]
236
+ return extensions
231
237
  }
232
238
  }
233
239
 
@@ -11,8 +11,9 @@ import SwiftSyntaxMacros
11
11
  /// - `decode(_:in:)` and `encode(_:in:)` — the fast paths, reading each property straight off a JS
12
12
  /// object and writing it straight into one,
13
13
  /// - `from(object:appContext:)` and `toObject(appContext:)`, which forward to `decode` and `encode`,
14
- /// - `from(dictionary:appContext:)` and `toDictionary(appContext:)` — converting each property from and
15
- /// to a `[String: Any]` dictionary.
14
+ /// - `toDictionary(appContext:)`, which puts each property into a `[String: Any]` dictionary,
15
+ /// - `from(dictionary:appContext:)`, which throws `RecordDictionaryConversionException`: a record
16
+ /// converts only from JavaScript values, so it can't be created from a native dictionary.
16
17
  ///
17
18
  /// The type is auto-conformed to `Record` (a core protocol whose requirements are this surface); the
18
19
  /// synthesized methods override `Record`'s reflection-based defaults, and the synthesized `decode` and
@@ -37,23 +38,16 @@ import SwiftSyntaxMacros
37
38
  /// have static stored properties. The macro can't tell that an extension extends a generic type, so a
38
39
  /// record declared in one doesn't compile.
39
40
  ///
40
- /// The JS-value paths convert through `JavaScriptDecodable.decode` / `JavaScriptEncodable.encode`
41
- /// (`decode(_:in:)` / `encode(_:in:)`); the native-`Any` dictionary paths still go through the
42
- /// public dynamic-type API — `T.getDynamicType()` plus `cast(_:appContext:)` / `convertToJS(_:appContext:)`
43
- /// (`from(dictionary:)` / `toDictionary(appContext:)`), since `JavaScriptCodable` only converts JS
44
- /// values, not native `Any`. Both spell types as `public` symbols so the synthesized code compiles inside
45
- /// user modules without any internal core symbols. Every property type must therefore conform to both
46
- /// `AnyArgument` and `JavaScriptDecodable & JavaScriptEncodable`. The conformance assertion checks only
47
- /// the JS codable protocols; the compiler reports a missing `AnyArgument` at the `getDynamicType()`
48
- /// call.
41
+ /// Properties convert through `JavaScriptDecodable.decode` / `JavaScriptEncodable.encode`
42
+ /// (`decode(_:in:)` / `encode(_:in:)`), spelled with `public` symbols so the synthesized code compiles
43
+ /// inside user modules without any internal core symbols. Every property type must conform to
44
+ /// `JavaScriptDecodable & JavaScriptEncodable`, which the conformance assertion checks.
49
45
  ///
50
46
  /// A property whose type mentions `Any` (`Any?`, `[String: Any]?`, `[String: [Any]]`, …) can't conform to
51
- /// the JS codable protocols, so it isn't asserted. On the JS-value paths it converts through the
52
- /// `JavaScriptValue` free-form methods instead: `decodeAny`/`decodeAnyArray`/`decodeAnyDictionary` and
53
- /// the matching `encodeAny…` for `Any`, `[Any]` and `[String: Any]` (optional or not), and the generic
54
- /// `decodeAny(_:as:in:)` / `encodeAny(_:in:)` for any other type. Such a property only needs
55
- /// `AnyArgument` for the dictionary paths, and a bare `Any` needs nothing, since the dictionary paths
56
- /// pass it through unchanged.
47
+ /// the JS codable protocols, so it isn't asserted. It converts through the `JavaScriptValue` free-form
48
+ /// methods instead: `decodeAny`/`decodeAnyArray`/`decodeAnyDictionary` and the matching `encodeAny…` for
49
+ /// `Any`, `[Any]` and `[String: Any]` (optional or not), and the generic `decodeAny(_:as:in:)` /
50
+ /// `encodeAny(_:in:)` for any other type.
57
51
  ///
58
52
  /// A record class must be `final` and have no inheritance clause. The synthesized factories construct
59
53
  /// `Self` through the memberwise init, which only a final class can do without a `required` init, and
@@ -112,7 +106,7 @@ public struct RecordMacro: MemberMacro, ExtensionMacro {
112
106
  members.append(memberwiseInit(properties: properties))
113
107
  }
114
108
  members.append(fromJSObjectFactory())
115
- members.append(fromDictionaryFactory(properties: properties))
109
+ members.append(fromDictionaryFactory())
116
110
  members.append(toDictionaryMethod(properties: properties))
117
111
  members.append(toObjectMethod())
118
112
  if cachesPropNames {
@@ -152,8 +146,10 @@ public struct RecordMacro: MemberMacro, ExtensionMacro {
152
146
  return []
153
147
  }
154
148
 
149
+ // `nonisolated` keeps the conformance usable on the JavaScript thread when the module builds with a
150
+ // default isolation, which would otherwise isolate it to the main actor.
155
151
  let ext: DeclSyntax = """
156
- extension \(type.trimmed): Record {}
152
+ extension \(type.trimmed): nonisolated Record {}
157
153
  """
158
154
  guard let extDecl = ext.as(ExtensionDeclSyntax.self) else {
159
155
  return []
@@ -171,8 +167,7 @@ private struct RecordProperty {
171
167
  /// every identifier position in the generated code needs them.
172
168
  let name: String
173
169
  /// The property's declared type, verbatim (e.g. `String`, `Int`, `String?`). Used to build the
174
- /// memberwise-init parameter and as the receiver of the per-property `decode`/`encode` (and, on the
175
- /// dictionary paths, `getDynamicType()`) conversions.
170
+ /// memberwise-init parameter and as the receiver of the per-property `decode`/`encode` conversions.
176
171
  let type: String
177
172
  /// The default-value expression verbatim (`0`, `""`, `[]`), or `nil` when the property has none.
178
173
  /// Inlined into the memberwise init and the factories' omitted-property branch so the synthesized
@@ -304,13 +299,13 @@ private func defaultInit(properties: [RecordProperty]) -> DeclSyntax? {
304
299
  }
305
300
  if properties.contains(where: { $0.isRequired }) {
306
301
  return """
307
- public init() {
308
- fatalError("\\(Self.self) has required properties and cannot be created with init(); construct it through the @Record-synthesized from(dictionary:) or from(object:) factories")
302
+ nonisolated public init() {
303
+ fatalError("\\(Self.self) has required properties and cannot be created with init(); create it from JavaScript or with its memberwise initializer")
309
304
  }
310
305
  """
311
306
  }
312
307
  return """
313
- public init() {
308
+ nonisolated public init() {
314
309
  }
315
310
  """
316
311
  }
@@ -333,7 +328,7 @@ private func memberwiseInit(properties: [RecordProperty]) -> DeclSyntax {
333
328
  let signature = params.joined(separator: ", ")
334
329
 
335
330
  return """
336
- public init(\(raw: signature)) {
331
+ nonisolated public init(\(raw: signature)) {
337
332
  \(raw: assignments)
338
333
  }
339
334
  """
@@ -355,13 +350,13 @@ private func fromJSObjectFactory() -> DeclSyntax {
355
350
  """
356
351
  }
357
352
 
358
- /// `from(dictionary:appContext:)` — same as the JS path but reads from `[String: Any]` and
359
- /// uses the `Any?` cast overload; a missing key is `dictionary[key] == nil`.
360
- private func fromDictionaryFactory(properties: [RecordProperty]) -> DeclSyntax {
361
- let body = factoryBody(properties: properties, readLines: dictionaryReadLines(properties: properties))
353
+ /// `from(dictionary:appContext:)`, a `Record` requirement. A record converts only from JavaScript values,
354
+ /// through `decode(_:in:)`, so it throws. Converting each property from a native `Any` would need the
355
+ /// dynamic types of the 1.0 conversion API.
356
+ private func fromDictionaryFactory() -> DeclSyntax {
362
357
  return """
363
- public static func from(dictionary: [String: Any], appContext: AppContext) throws -> Self {
364
- \(raw: body)
358
+ nonisolated public static func from(dictionary: [String: Any], appContext: AppContext) throws -> Self {
359
+ throw RecordDictionaryConversionException(String(describing: Self.self))
365
360
  }
366
361
  """
367
362
  }
@@ -413,44 +408,7 @@ private func jsObjectReadLines(properties: [RecordProperty], cachesPropNames: Bo
413
408
  return lines
414
409
  }
415
410
 
416
- /// Per-property read statements for the dictionary factory, each producing a `let field<index>` from the
417
- /// raw dictionary value in `entry<index>`. A bare `Any` property has no dynamic type to cast through, and
418
- /// the dictionary value already is an `Any`, so it's read unchanged.
419
- private func dictionaryReadLines(properties: [RecordProperty]) -> [String] {
420
- var lines: [String] = []
421
- for (index, property) in properties.enumerated() {
422
- let field = fieldLocal(index)
423
- let entry = "entry\(index)"
424
- let exprType = expressionType(property.type)
425
- let isBareAny = property.freeForm?.isBareAny == true
426
- let cast: String
427
- if isBareAny {
428
- cast = entry
429
- } else if tupleConversion(of: property.type) != nil {
430
- // A type that holds a tuple has no dynamic type, so the dictionary value must already be of the
431
- // property's type.
432
- cast =
433
- "try { () throws -> \(exprType) in guard let value = \(entry) as? \(exprType) else { throw Conversions.ConvertingException<\(exprType)>(\(entry)) }; return value }()"
434
- } else {
435
- cast = "try \(exprType).getDynamicType().cast(\(entry), appContext: appContext) as! \(exprType)"
436
- }
437
- lines.append(" let \(entry) = dictionary[\"\(property.key)\"]")
438
- if property.isRequired {
439
- lines.append(" guard let \(entry) else {")
440
- lines.append(" throw RecordPropertyRequiredException(\"\(property.key)\")")
441
- lines.append(" }")
442
- lines.append(" let \(field) = \(cast)")
443
- } else if property.isOptional {
444
- lines.append(" let \(field): \(property.type) = (\(entry) == nil || \(entry)! is NSNull) ? nil : \(cast)")
445
- } else {
446
- lines.append(" let \(field) = \(entry) == nil ? \(property.defaultValue!) : \(isBareAny ? entry + "!" : cast)")
447
- }
448
- }
449
- return lines
450
- }
451
-
452
- /// `toDictionary(appContext:)` — converts each property back to a JS-compatible value via the
453
- /// dynamic type and assembles a `[String: Any]`.
411
+ /// `toDictionary(appContext:)`, which puts each property into a `[String: Any]` as it is.
454
412
  private func toDictionaryMethod(properties: [RecordProperty]) -> DeclSyntax {
455
413
  var lines = [" var dictionary: [String: Any] = [:]"]
456
414
  for property in properties {
@@ -458,7 +416,7 @@ private func toDictionaryMethod(properties: [RecordProperty]) -> DeclSyntax {
458
416
  }
459
417
  lines.append(" return dictionary")
460
418
  return """
461
- public func toDictionary(appContext: AppContext? = nil) -> [String: Any] {
419
+ nonisolated public func toDictionary(appContext: AppContext? = nil) -> [String: Any] {
462
420
  \(raw: lines.joined(separator: "\n"))
463
421
  }
464
422
  """
@@ -578,7 +536,7 @@ private func propNamesClass(properties: [RecordProperty]) -> DeclSyntax {
578
536
  " self.\(propNameMember(index)) = JavaScriptPropNameID(runtime, string: \"\(property.key)\")"
579
537
  }
580
538
  return """
581
- private final class \(raw: propNamesTypeName) {
539
+ nonisolated private final class \(raw: propNamesTypeName) {
582
540
  static let _key = JavaScriptRuntime.Cache.Key<\(raw: propNamesTypeName)>()
583
541
 
584
542
  \(raw: storedProperties.joined(separator: "\n"))
@@ -21,6 +21,9 @@ import SwiftSyntaxMacros
21
21
  ///
22
22
  /// The companion `@ExpoModule(classes: [Cache.self])` registers the class with the module, which
23
23
  /// exposes it in its JavaScript object.
24
+ ///
25
+ /// As an extension macro, it adds an `@unchecked Sendable` conformance when the class isn't
26
+ /// `Sendable` yet.
24
27
  public struct SharedObjectMacro: MemberMacro {
25
28
  public static func expansion(
26
29
  of node: AttributeSyntax,
@@ -38,6 +41,11 @@ public struct SharedObjectMacro: MemberMacro {
38
41
  let typeName = classDecl.name.text
39
42
  let jsName = jsNameArgument(of: node) ?? withoutBackticks(typeName)
40
43
 
44
+ // A superclass other than `SharedObject` or `SharedRef` may be another `@SharedObject` class, so the
45
+ // decorator phases call `super` to bind its `@JS` members too. The base classes' phases bind nothing.
46
+ let callsSuper = !inheritsFromAny(classDecl, names: ["SharedObject", "SharedRef"])
47
+ let isFinal = classDecl.modifiers.contains { $0.name.tokenKind == .keyword(.final) }
48
+
41
49
  // `@JS func`s/`var`s and the `@JS init` are bound directly into the shared object's JS object by
42
50
  // the synthesized `_decorateSharedObject(prototype:)` / `_decorateSharedObject(constructor:)` / `_constructSharedObject`.
43
51
  //
@@ -117,7 +125,9 @@ public struct SharedObjectMacro: MemberMacro {
117
125
  phase: .prototype,
118
126
  functions: instanceFunctions,
119
127
  properties: instanceProperties,
120
- typeName: typeName
128
+ typeName: typeName,
129
+ callsSuper: callsSuper,
130
+ isFinal: isFinal
121
131
  )
122
132
  )
123
133
  }
@@ -127,12 +137,14 @@ public struct SharedObjectMacro: MemberMacro {
127
137
  phase: .constructor,
128
138
  functions: staticFunctions,
129
139
  properties: staticProperties,
130
- typeName: typeName
140
+ typeName: typeName,
141
+ callsSuper: callsSuper,
142
+ isFinal: isFinal
131
143
  )
132
144
  )
133
145
  }
134
146
  if let constructor {
135
- emitted.append(constructor.buildConstructor(typeName: typeName))
147
+ emitted.append(constructor.buildConstructor(typeName: typeName, isFinal: isFinal))
136
148
  }
137
149
 
138
150
  // The conformance assertions of the `@JS` members, which `@JS` leaves to this macro (see
@@ -168,3 +180,18 @@ extension SharedObjectMacro: MemberAttributeMacro {
168
180
  return ["@JavaScriptActor"]
169
181
  }
170
182
  }
183
+
184
+ extension SharedObjectMacro: ExtensionMacro {
185
+ public static func expansion(
186
+ of node: AttributeSyntax,
187
+ attachedTo declaration: some DeclGroupSyntax,
188
+ providingExtensionsOf type: some TypeSyntaxProtocol,
189
+ conformingTo protocols: [TypeSyntax],
190
+ in context: some MacroExpansionContext
191
+ ) throws -> [ExtensionDeclSyntax] {
192
+ guard let sendable = uncheckedSendableExtension(for: type, of: declaration, conformingTo: protocols) else {
193
+ return []
194
+ }
195
+ return [sendable]
196
+ }
197
+ }
@@ -98,7 +98,9 @@ public struct UnionMacro: MemberMacro, ExtensionMacro {
98
98
  return []
99
99
  }
100
100
 
101
- let conformances = missing.map { $0.trimmedDescription }.joined(separator: ", ")
101
+ // `nonisolated` keeps the conformances usable on the JavaScript thread when the module builds with
102
+ // a default isolation, which would otherwise isolate them to the main actor.
103
+ let conformances = missing.map { "nonisolated \($0.trimmedDescription)" }.joined(separator: ", ")
102
104
  let ext: DeclSyntax = """
103
105
  extension \(type.trimmed): \(raw: conformances) {}
104
106
  """
@@ -298,7 +300,7 @@ private func decodableKindsProperty(union: UnionType) -> DeclSyntax {
298
300
  // optimizer folds it to a constant after specialization, the same as a chain of `union(_:)` calls.
299
301
  let expression = kinds.count == 1 ? kinds[0] : "[\(kinds.joined(separator: ", "))]"
300
302
  return """
301
- public static var decodableKinds: JavaScriptValueKinds {
303
+ nonisolated public static var decodableKinds: JavaScriptValueKinds {
302
304
  return \(raw: expression)
303
305
  }
304
306
  """
@@ -367,7 +369,7 @@ private func accessorMethods(union: UnionType) -> [DeclSyntax] {
367
369
  let mismatch =
368
370
  "(unionName: \"\(union.name)\", received: _payloadTypeName, expected: [\"\(unionCase.payloadType)\"])"
369
371
  return """
370
- public func `as`(_ type: \(raw: payloadType).Type) throws -> \(raw: payloadType) {
372
+ nonisolated public func `as`(_ type: \(raw: payloadType).Type) throws -> \(raw: payloadType) {
371
373
  if case \(raw: unionCase.pattern) = self {
372
374
  return payload
373
375
  }
@@ -389,7 +391,7 @@ private func payloadTypeNameProperty(union: UnionType) -> DeclSyntax {
389
391
  lines.append(" }")
390
392
  let body = lines.joined(separator: "\n")
391
393
  return """
392
- private var _payloadTypeName: String {
394
+ nonisolated private var _payloadTypeName: String {
393
395
  \(raw: body)
394
396
  }
395
397
  """
@@ -16,8 +16,8 @@ struct ProvidedMacro {
16
16
  ProvidedMacro(name: "OptimizedFunction", type: OptimizedFunctionAttachedMacro.self),
17
17
  ProvidedMacro(name: "JS", type: JSMacro.self),
18
18
  ProvidedMacro(name: "Event", type: EventMacro.self),
19
- ProvidedMacro(name: "ExpoModule", type: ExpoModuleMacro.self, conformances: ["AnyModule"]),
20
- ProvidedMacro(name: "SharedObject", type: SharedObjectMacro.self),
19
+ ProvidedMacro(name: "ExpoModule", type: ExpoModuleMacro.self, conformances: ["AnyModule", "Sendable"]),
20
+ ProvidedMacro(name: "SharedObject", type: SharedObjectMacro.self, conformances: ["Sendable"]),
21
21
  ProvidedMacro(name: "Record", type: RecordMacro.self, conformances: ["Record"]),
22
22
  ProvidedMacro(
23
23
  name: "Union",
@@ -398,7 +398,10 @@ internal struct JSProperty {
398
398
  /// closure — and installs it with `object.defineProperty(name, descriptor:)`. Capture matches the
399
399
  /// function bindings: a module captures `self` strong, a shared object captures nothing of the
400
400
  /// instance. Getter and setter are gated independently.
401
- func decorateStatements(object: String, receiver: Receiver) -> String {
401
+ ///
402
+ /// With `configurable`, the accessor can be redefined, so a subclass of a shared object class can
403
+ /// replace it with its own member of the same JS name. A `lazy var` is always configurable.
404
+ func decorateStatements(object: String, receiver: Receiver, configurable: Bool = false) -> String {
402
405
  let descriptorName = derivedIdentifier(from: swiftName, suffix: "Descriptor")
403
406
  let callee = receiver.callee
404
407
  // A shared object's accessors unwrap the JS `this` into `_self` before reading/writing; a module
@@ -409,8 +412,8 @@ internal struct JSProperty {
409
412
 
410
413
  lines.append("let \(descriptorName) = runtime.createObject()")
411
414
  lines.append("\(descriptorName).setProperty(\"enumerable\", value: true)")
412
- if isLazy {
413
- // The getter redefines the property on `this`, which needs a configurable accessor when `this`
415
+ if isLazy || configurable {
416
+ // A lazy getter redefines the property on `this`, which needs a configurable accessor when `this`
414
417
  // is the object that holds it (a module).
415
418
  lines.append("\(descriptorName).setProperty(\"configurable\", value: true)")
416
419
  }
@@ -523,14 +526,30 @@ private func encodeIntoSlotStatement(_ type: String, freeForm: FreeFormConversio
523
526
  /// is the local the bindings decorate (matching the entry point's argument label); `receiver` selects
524
527
  /// how each binding reaches its Swift value (module `self`, shared-object instance `_self`, or the
525
528
  /// metatype for a static member).
529
+ ///
530
+ /// `configurableProperties` makes the property accessors configurable. `replacesInherited` first
531
+ /// redefines the name of each function as a writable data property: the object may already hold an
532
+ /// inherited accessor with that name, which an assignment would call instead of replacing.
526
533
  private func decorateBody(
527
534
  functions: [JSFunction],
528
535
  properties: [JSProperty],
529
536
  object: String,
530
- receiver: Receiver
537
+ receiver: Receiver,
538
+ configurableProperties: Bool = false,
539
+ replacesInherited: Bool = false
531
540
  ) -> String {
532
- let functionBody = functions.map { $0.decorateStatements(object: object, receiver: receiver) }
533
- let propertyBody = properties.map { $0.decorateStatements(object: object, receiver: receiver) }
541
+ let functionBody = functions.map { function in
542
+ let binding = function.decorateStatements(object: object, receiver: receiver)
543
+ guard replacesInherited else {
544
+ return binding
545
+ }
546
+ let redefine =
547
+ " \(object).defineProperty(\"\(function.jsName)\", descriptor: .init(configurable: true, enumerable: true, writable: true))"
548
+ return redefine + "\n" + binding
549
+ }
550
+ let propertyBody = properties.map {
551
+ $0.decorateStatements(object: object, receiver: receiver, configurable: configurableProperties)
552
+ }
534
553
  return (functionBody + propertyBody).joined(separator: "\n")
535
554
  }
536
555
 
@@ -568,11 +587,21 @@ internal func buildDecorateJavaScriptObject(functions: [JSFunction], properties:
568
587
  /// constructor). The constructor *object* the static members decorate is distinct from the `@JS init`,
569
588
  /// which builds an instance and is emitted separately (see `JSConstructor.buildConstructor`). Each phase
570
589
  /// is emitted only when the type has a member for it.
590
+ ///
591
+ /// With `callsSuper`, the phase first calls the superclass's phase, so a subclass of another
592
+ /// `@SharedObject` class also gets the inherited `@JS` members. Core builds each JavaScript class on
593
+ /// the base `SharedObject` class, not on the JavaScript class of the superclass, so the inherited
594
+ /// members have to be bound on the subclass's own objects. They are bound first, so a subclass member
595
+ /// with the same JavaScript name replaces the inherited one. For that, the property accessors of a
596
+ /// class that isn't `final` are configurable, and a subclass redefines the name of each of its functions
597
+ /// before it binds the function.
571
598
  internal func buildDecorateSharedObjectPhase(
572
599
  phase: Phase,
573
600
  functions: [JSFunction],
574
601
  properties: [JSProperty],
575
- typeName: String
602
+ typeName: String,
603
+ callsSuper: Bool,
604
+ isFinal: Bool
576
605
  ) -> DeclSyntax {
577
606
  let receiver: Receiver =
578
607
  phase == .constructor
@@ -582,12 +611,18 @@ internal func buildDecorateSharedObjectPhase(
582
611
  functions: functions,
583
612
  properties: properties,
584
613
  object: phase.rawValue,
585
- receiver: receiver
614
+ receiver: receiver,
615
+ configurableProperties: !isFinal,
616
+ replacesInherited: callsSuper
586
617
  )
618
+ let superCall =
619
+ callsSuper
620
+ ? " try super._decorateSharedObject(\(phase.rawValue): \(phase.rawValue), in: runtime)\n"
621
+ : ""
587
622
  return """
588
623
  @JavaScriptActor
589
624
  public override class func _decorateSharedObject(\(raw: phase.rawValue): borrowing JavaScriptObject, in runtime: JavaScriptRuntime) throws {
590
- \(raw: body)
625
+ \(raw: superCall)\(raw: body)
591
626
  }
592
627
  """
593
628
  }
@@ -51,11 +51,24 @@ internal struct JSConstructor {
51
51
 
52
52
  /// Builds an instance from the JS arguments. Overrides the base `SharedObject` class method; returns
53
53
  /// the concrete instance, which promotes to the base `SharedObject?` return type.
54
- func buildConstructor(typeName: String) -> DeclSyntax {
54
+ ///
55
+ /// A subclass without a `@JS init` of its own inherits this override, and would construct an instance
56
+ /// of this class instead of the subclass. Unless the class is `final`, the override returns `nil` when
57
+ /// called on a subclass, so core reports that the subclass has no JavaScript constructor.
58
+ func buildConstructor(typeName: String, isFinal: Bool) -> DeclSyntax {
59
+ let subclassGuard =
60
+ isFinal
61
+ ? ""
62
+ : """
63
+ guard self == \(typeName).self else {
64
+ return nil
65
+ }
66
+
67
+ """
55
68
  return """
56
69
  @JavaScriptActor
57
70
  public override class func _constructSharedObject(this: borrowing JavaScriptValue, arguments: borrowing JavaScriptValuesBuffer, in runtime: JavaScriptRuntime) throws -> SharedObject? {
58
- \(raw: bodyStatements(typeName: typeName, indent: " "))
71
+ \(raw: subclassGuard)\(raw: bodyStatements(typeName: typeName, indent: " "))
59
72
  }
60
73
  """
61
74
  }
@@ -479,8 +479,7 @@ extension AttributeListSyntax {
479
479
  }
480
480
  }
481
481
 
482
- /// A type spelled so it's valid in expression position (before `.decode`/`.encode`, before
483
- /// `.getDynamicType()`, or after `as!`). Implicitly-unwrapped optionals (`T!`) are only allowed in
482
+ /// A type spelled so it's valid in expression position (before `.decode`/`.encode`). Implicitly-unwrapped optionals (`T!`) are only allowed in
484
483
  /// type-annotation position, so a trailing `!` is rewritten to `?` (`T!` and `T?` are both
485
484
  /// `Optional<T>`, which the conversion layer treats identically). Other type spellings pass through
486
485
  /// unchanged.
@@ -0,0 +1,36 @@
1
+ import SwiftSyntax
2
+ import SwiftSyntaxBuilder
3
+
4
+ /// The `@unchecked Sendable` conformance that `@ExpoModule` and `@SharedObject` add to their class, or
5
+ /// `nil` when the class already conforms. The compiler leaves `Sendable` out of `protocols` when the
6
+ /// class already conforms, also through a superclass. The inheritance clause is checked too, so that
7
+ /// `expand`, which passes every declared conformance, gives the same result for a class that spells
8
+ /// out its own conformance.
9
+ ///
10
+ /// A module or shared object is called from more than one thread: `.withAsync` runs a function on
11
+ /// the concurrent thread pool, and native code calls events and closures from any thread. The class
12
+ /// can't be checked `Sendable`, because `Module` and `SharedObject` aren't `Sendable`, so
13
+ /// `@unchecked Sendable` is the only conformance it can have.
14
+ internal func uncheckedSendableExtension(
15
+ for type: some TypeSyntaxProtocol,
16
+ of declaration: some DeclGroupSyntax,
17
+ conformingTo protocols: [TypeSyntax]
18
+ ) -> ExtensionDeclSyntax? {
19
+ guard protocols.contains(where: { $0.trimmedDescription == "Sendable" }) else {
20
+ return nil
21
+ }
22
+ let inherited = declaration.inheritanceClause?.inheritedTypes ?? []
23
+ if inherited.contains(where: { isSendable($0.type) }) {
24
+ return nil
25
+ }
26
+ let conformance: DeclSyntax = "extension \(type.trimmed): @unchecked Sendable {}"
27
+ return conformance.cast(ExtensionDeclSyntax.self)
28
+ }
29
+
30
+ /// Whether an inherited type is `Sendable`, with or without attributes such as `@unchecked`.
31
+ private func isSendable(_ type: TypeSyntax) -> Bool {
32
+ if let attributed = type.as(AttributedTypeSyntax.self) {
33
+ return isSendable(attributed.baseType)
34
+ }
35
+ return baseIdentifier(of: type) == "Sendable"
36
+ }
package/build/types.d.ts CHANGED
@@ -281,16 +281,24 @@ export interface ExportedSharedObject extends ExportedAccess, ExportedAvailabili
281
281
  * which every JS object inherits.
282
282
  */
283
283
  initializer?: ExportedInitializer;
284
- /** What the class inherits from `SharedRef<T>`, or absent when its superclass isn't `SharedRef`. */
284
+ /**
285
+ * What the class inherits from `SharedRef<T>`, directly or through another scanned `@SharedObject`
286
+ * class, or absent when it doesn't inherit `SharedRef`.
287
+ */
285
288
  sharedRef?: ExportedSharedRef;
289
+ /**
290
+ * The class's `@JS` members include the ones it inherits from scanned `@SharedObject` superclasses,
291
+ * the inherited ones first. An own member replaces an inherited one with the same JS name.
292
+ */
286
293
  functions: ExportedFunction[];
287
294
  properties: ExportedProperty[];
288
295
  events: ExportedEvent[];
289
296
  file: string;
290
297
  }
291
298
  /**
292
- * The `SharedRef<T>` superclass of a shared object. Only the superclass written on the class is read,
293
- * so a class that inherits `SharedRef` through another subclass has none.
299
+ * The `SharedRef<T>` superclass of a shared object. A class that inherits `SharedRef` through another
300
+ * `@SharedObject` class reports the `T` of that class. A class that inherits it through a class the
301
+ * scan doesn't report has none.
294
302
  */
295
303
  export interface ExportedSharedRef {
296
304
  /** The `T` in `SharedRef<T>`: the native type the object holds, which JS never sees. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-modules-macros",
3
- "version": "0.27.1",
3
+ "version": "0.28.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.27.1",
30
- "@expo/modules-macros-linux-x64": "0.27.1",
31
- "@expo/modules-macros-win32-arm64": "0.27.1",
32
- "@expo/modules-macros-win32-x64": "0.27.1"
29
+ "@expo/modules-macros-linux-arm64": "0.28.0",
30
+ "@expo/modules-macros-linux-x64": "0.28.0",
31
+ "@expo/modules-macros-win32-arm64": "0.28.0",
32
+ "@expo/modules-macros-win32-x64": "0.28.0"
33
33
  }
34
34
  }