expo-modules-macros 0.24.0 → 0.26.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
@@ -25,12 +25,14 @@ 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.
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, so a bad type is reported on the author's own declaration rather than on the enclosing type. 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.
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.
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
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
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.
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
+ 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
+
34
36
  How that looks in a module:
35
37
 
36
38
  ```swift
Binary file
@@ -146,13 +146,20 @@ private func closureLiteralLines(_ closure: JSClosureType, callback: String) ->
146
146
  // parameters instead would send non-`Sendable` values to the `@JavaScriptActor` closure, which
147
147
  // Swift 6 rejects. The closure captures nothing, so it needs no allocation. Without parameters
148
148
  // there is nothing to emplace, and `invokeAsync` takes the parameters variadically.
149
- let emplaces = !closure.isAsync && !parameters.isEmpty
149
+ //
150
+ // A result type that holds a tuple can't be passed as `returning:`, so a sync closure returning one
151
+ // decodes it through a `decoding:` closure, which only the `emplacing` variant takes. With no
152
+ // parameters it emplaces nothing.
153
+ let returnTuple = closure.returnType.flatMap { tupleConversion(of: $0) }
154
+ let emplaces = !closure.isAsync && (!parameters.isEmpty || returnTuple != nil)
150
155
  var arguments = parameters
151
156
  if emplaces {
152
157
  let values = parameters.count == 1 ? "p0" : "(" + parameters.joined(separator: ", ") + ")"
153
158
  arguments = [values, "argumentCount: \(parameters.count)"]
154
159
  }
155
- if let returnType = closure.returnType {
160
+ if let returnTuple {
161
+ arguments.append("decoding: \(elementClosure(returning: returnTuple.decodeExpression(of: "value")))")
162
+ } else if let returnType = closure.returnType {
156
163
  arguments.append("returning: \(expressionType(returnType)).self")
157
164
  }
158
165
  let argumentList = arguments.joined(separator: ", ")
@@ -168,9 +175,14 @@ private func closureLiteralLines(_ closure: JSClosureType, callback: String) ->
168
175
 
169
176
  if emplaces {
170
177
  var lines = [header, " \(call) { _values, _arguments, _runtime in"]
171
- for index in parameters.indices {
178
+ for (index, parameter) in closure.parameters.enumerated() {
172
179
  let value = parameters.count == 1 ? "_values" : "_values.\(index)"
173
- lines.append(" try _arguments.emplace(\(value), at: \(index), in: _runtime)")
180
+ // A parameter that holds a tuple has no `encode(_:into:in:)`, so its encoded value is moved in.
181
+ if let tuple = tupleConversion(of: parameter.type) {
182
+ lines.append(" _arguments.emplace(try \(tuple.encodeExpression(of: value, in: "_runtime")), at: \(index))")
183
+ } else {
184
+ lines.append(" try _arguments.emplace(\(value), at: \(index), in: _runtime)")
185
+ }
174
186
  }
175
187
  return lines + [" }", "}"]
176
188
  }
@@ -336,6 +348,18 @@ private func diagnoseClosure(_ closure: JSClosureType, in context: some MacroExp
336
348
  )
337
349
  )
338
350
  )
351
+ } else if closure.isAsync && tupleConversion(of: type) != nil {
352
+ // `invokeAsync` takes its arguments and result as `JavaScriptEncodable` / `JavaScriptDecodable`
353
+ // types, and a type that holds a tuple can't conform.
354
+ context.diagnose(
355
+ Diagnostic(
356
+ node: type,
357
+ message: ClosureDiagnosticMessage(
358
+ "An async @JS closure argument can't take or return a tuple. Use a @Record type, or a closure that isn't async.",
359
+ id: "js-closure-async-tuple"
360
+ )
361
+ )
362
+ )
339
363
  }
340
364
  }
341
365
 
@@ -152,9 +152,9 @@ internal struct JSFunction {
152
152
  } else {
153
153
  // One call shape per accepted arity, branching on `arguments.count`. A branch decodes a
154
154
  // trailing slot before the call, so this is a `switch` statement (not an expression): a
155
- // value-returning function declares `result` up front and each branch assigns it.
155
+ // value-returning function declares `value` up front and each branch assigns it.
156
156
  if let returnType {
157
- lines.append("let result: \(expressionType(returnType))")
157
+ lines.append("let value: \(expressionType(returnType))")
158
158
  }
159
159
  lines.append("switch arguments.count {")
160
160
  for arity in required...maximum {
@@ -163,7 +163,7 @@ internal struct JSFunction {
163
163
  for index in required..<arity {
164
164
  lines.append(contentsOf: decodeLines(at: index).map { " " + $0 })
165
165
  }
166
- let assignment = returnType != nil ? "result = " : ""
166
+ let assignment = returnType != nil ? "value = " : ""
167
167
  lines.append(" \(assignment)\(callExpression(receiver: receiver, arity: arity))")
168
168
  }
169
169
  lines.append("}")
@@ -268,7 +268,7 @@ internal struct JSFunction {
268
268
  }
269
269
 
270
270
  /// The flat (single-arity) call-and-encode lines used when no trailing parameter is omittable:
271
- /// `let result = <callee>.f(...)` then the return encode (or just `<callee>.f(...)` for a function
271
+ /// `let value = <callee>.f(...)` then the encode (or just `<callee>.f(...)` for a function
272
272
  /// that returns nothing).
273
273
  private func callAndEncodeLines(receiver: Receiver, arity: Int, decodingFrom: Int) -> [String] {
274
274
  var lines: [String] = []
@@ -276,7 +276,7 @@ internal struct JSFunction {
276
276
  lines.append(contentsOf: decodeLines(at: index))
277
277
  }
278
278
  if returnType != nil {
279
- lines.append("let result = \(callExpression(receiver: receiver, arity: arity))")
279
+ lines.append("let value = \(callExpression(receiver: receiver, arity: arity))")
280
280
  lines.append(contentsOf: encodeResultLines())
281
281
  } else {
282
282
  lines.append(callExpression(receiver: receiver, arity: arity))
@@ -290,8 +290,9 @@ internal struct JSFunction {
290
290
  /// `toJavaScriptValue(in:)` except for `Int`/`UInt`, where it range-checks
291
291
  /// and throws instead of silently encoding an out-of-safe-range value as a lossy number — the
292
292
  /// catchable error is the right behavior, and matches how non-primitive integers already encode. A
293
- /// no-return function has nothing to encode: its binding closure returns `Void` (see
294
- /// `decorateStatements(object:receiver:)`).
293
+ /// no-return function has nothing to encode, so its binding closure leaves the result slot empty (see
294
+ /// `decorateStatements(object:receiver:)`). A synchronous function encodes straight into the
295
+ /// engine's result slot, so a number, a boolean or a string never becomes a `JavaScriptValue`.
295
296
  ///
296
297
  /// For an `async` function the encode must run on the JS thread. An `async` body may suspend and
297
298
  /// resume on an arbitrary cooperative-pool thread, and encoding a heap-allocated JS value (a string,
@@ -303,15 +304,15 @@ internal struct JSFunction {
303
304
  guard let returnType else {
304
305
  return []
305
306
  }
306
- let encode = "try \(encodeCall(returnType, freeForm: returnFreeForm, of: "result"))"
307
307
  if isAsync {
308
+ let encode = "try \(encodeCall(returnType, freeForm: returnFreeForm, of: "result"))"
308
309
  return [
309
310
  "return try await runtime.execute {",
310
311
  " return \(encode)",
311
312
  "}",
312
313
  ]
313
314
  }
314
- return ["return \(encode)"]
315
+ return [encodeIntoSlotStatement(returnType, freeForm: returnFreeForm, of: "value")]
315
316
  }
316
317
 
317
318
  /// The `setProperty` statement that installs this function on the JS object. The decode-call-encode
@@ -331,19 +332,18 @@ internal struct JSFunction {
331
332
  // differ only in what they return. A module ignores `this`; a shared object unwraps it (still
332
333
  // borrowed) — an async binding does so in its synchronous decode phase, before the borrow ends.
333
334
  // The parameter list is parenthesized and fully typed, since Swift rejects a type annotation on
334
- // a shorthand `{ [capture] name, name in }` parameter; for a sync binding the explicit
335
- // `JavaScriptUnownedValue` also selects the (otherwise `@_disfavoredOverload`) unowned-`this`
336
- // overload.
337
- // A sync function that returns nothing binds through the `Void` overload, which leaves JS's
338
- // result as `undefined` without building and writing a value. The closure declares `-> Void`
339
- // because a body of several statements without a `return` is otherwise ambiguous between the
340
- // two unowned-`this` overloads.
335
+ // a shorthand `{ [capture] name, name in }` parameter.
336
+ // A sync function binds through the result-slot overload (`JavaScriptRuntime.HostFunctionClosure`):
337
+ // it encodes its result into the slot and gets the runtime as a parameter. A function that returns
338
+ // nothing writes nothing, so JS gets `undefined` without a value being built.
341
339
  let captures = receiver.captureClause
342
- let parameters = "(this: borrowing JavaScriptUnownedValue, arguments: consuming JavaScriptValuesBuffer)"
343
- let returnClause = returnType == nil && !isAsync ? " -> Void" : ""
340
+ let parameters =
341
+ isAsync
342
+ ? "(this: borrowing JavaScriptUnownedValue, arguments: consuming JavaScriptValuesBuffer)"
343
+ : resultSlotClosureParameters
344
344
 
345
345
  return """
346
- \(object).setProperty("\(jsName)") { \(captures)\(parameters)\(returnClause) in
346
+ \(object).setProperty("\(jsName)") { \(captures)\(parameters) in
347
347
  \(bodyStatements(receiver: receiver, indent: " "))
348
348
  }
349
349
  """
@@ -419,8 +419,16 @@ internal struct JSProperty {
419
419
  } else {
420
420
  encode = "\(callee).\(swiftName).toJavaScriptValue(in: runtime)"
421
421
  }
422
+ // A getter encodes straight into the engine's result slot. A lazy getter needs the encoded value as
423
+ // a `JavaScriptValue` to define it on `this` as well, and then moves it into the slot.
422
424
  let getBody: String
423
- if isLazy {
425
+ if !isLazy {
426
+ if let valueType {
427
+ getBody = encodeIntoSlotStatement(valueType, freeForm: freeForm, of: "\(callee).\(swiftName)")
428
+ } else {
429
+ getBody = "result.emplace(\(encode))"
430
+ }
431
+ } else {
424
432
  let define = "this.getObject(in: runtime).defineProperty(\"\(jsName)\", value: value, options: [.enumerable])"
425
433
  // A module getter ignores `this` otherwise, so it can be anything, and only an object can hold the
426
434
  // value. A shared object's unwrap has already checked that `this` is an object.
@@ -428,9 +436,7 @@ internal struct JSProperty {
428
436
  receiver.unwrapStatement == nil
429
437
  ? "if this.isObject() {\n \(define)\n}"
430
438
  : define
431
- getBody = "let value = \(encode)\n\(defineStatement)\nreturn value"
432
- } else {
433
- getBody = "return \(encode)"
439
+ getBody = "let value = \(encode)\n\(defineStatement)\nresult.emplace(value)"
434
440
  }
435
441
  lines.append(
436
442
  accessorClosure(descriptorName, "get", receiver: receiver, body: "\(unwrap)\(getBody)")
@@ -444,13 +450,7 @@ internal struct JSProperty {
444
450
  let decode = decodeCall(valueType, freeForm: freeForm, from: "arguments.unownedValue(at: 0)")
445
451
  let setDecode = "\(callee).\(swiftName) = try \(decode)"
446
452
  lines.append(
447
- accessorClosure(
448
- descriptorName,
449
- "set",
450
- receiver: receiver,
451
- returnsVoid: true,
452
- body: "\(unwrap)\(setDecode)"
453
- )
453
+ accessorClosure(descriptorName, "set", receiver: receiver, body: "\(unwrap)\(setDecode)")
454
454
  )
455
455
  }
456
456
 
@@ -465,13 +465,10 @@ internal struct JSProperty {
465
465
 
466
466
  /// One `descriptor.setProperty("get"/"set") { … }` accessor entry. The capture list follows the
467
467
  /// receiver: a module captures `self` strong; a shared object captures nothing of the instance.
468
- /// A setter passes `returnsVoid`, so its closure declares `-> Void` and binds through the `Void`
469
- /// overload, like a function that returns nothing.
470
468
  private func accessorClosure(
471
469
  _ descriptorName: String,
472
470
  _ key: String,
473
471
  receiver: Receiver,
474
- returnsVoid: Bool = false,
475
472
  body: String
476
473
  ) -> String {
477
474
  let captures = receiver.captureClause
@@ -482,21 +479,37 @@ internal struct JSProperty {
482
479
  .split(separator: "\n", omittingEmptySubsequences: false)
483
480
  .map { " \($0)" }
484
481
  .joined(separator: "\n")
485
- // Property `get`/`set` accessors are always synchronous, so they bind through the unowned-`this`
482
+ // Property `get`/`set` accessors are always synchronous, so they bind through the result-slot
486
483
  // `setProperty` overload like sync functions. The parameter list is parenthesized and fully typed
487
- // because Swift rejects a type annotation on a shorthand closure parameter; the explicit
488
- // `borrowing JavaScriptUnownedValue` selects the unowned-`this` overload. A module ignores `this`;
489
- // a shared object unwraps it in the body.
490
- let parameters = "(this: borrowing JavaScriptUnownedValue, arguments: consuming JavaScriptValuesBuffer)"
491
- let returnClause = returnsVoid ? " -> Void" : ""
484
+ // because Swift rejects a type annotation on a shorthand closure parameter. A module ignores `this`;
485
+ // a shared object unwraps it in the body. A setter writes nothing, so JS gets `undefined`.
492
486
  return """
493
- \(descriptorName).setProperty("\(key)") { \(captures)\(parameters)\(returnClause) in
487
+ \(descriptorName).setProperty("\(key)") { \(captures)\(resultSlotClosureParameters) in
494
488
  \(indentedBody)
495
489
  }
496
490
  """
497
491
  }
498
492
  }
499
493
 
494
+ /// The parameter list of a binding closure that writes its result into the engine's result slot
495
+ /// (`JavaScriptRuntime.HostFunctionClosure`). The closure gets the runtime as a parameter, which shadows
496
+ /// the decorator's `runtime`, so it doesn't capture it.
497
+ private let resultSlotClosureParameters =
498
+ "(this: borrowing JavaScriptUnownedValue, arguments: consuming JavaScriptValuesBuffer, "
499
+ + "result: borrowing JavaScriptValue.Slot, runtime: borrowing JavaScriptRuntime)"
500
+
501
+ /// The statement that encodes `valueExpression` of a boundary type into the result slot `result`. A type
502
+ /// that mentions `Any` and a tuple have no `encode(_:into:in:)`, so their encode result is moved into
503
+ /// the slot.
504
+ private func encodeIntoSlotStatement(_ type: String, freeForm: FreeFormConversion?, of valueExpression: String)
505
+ -> String
506
+ {
507
+ if freeForm != nil || tupleConversion(of: type) != nil {
508
+ return "result.emplace(try \(encodeCall(type, freeForm: freeForm, of: valueExpression)))"
509
+ }
510
+ return "try \(expressionType(type)).encode(\(valueExpression), into: result, in: runtime)"
511
+ }
512
+
500
513
  /// The body shared by every decorator phase: every `@JS func` bound via an inlined `setProperty`
501
514
  /// closure and every `@JS var` via a `defineProperty` accessor, joined for the function body. `object`
502
515
  /// is the local the bindings decorate (matching the entry point's argument label); `receiver` selects
@@ -53,11 +53,19 @@ public struct EventMacro: AccessorMacro {
53
53
  // The closure's parameter and return types are inferred from the property's declared type
54
54
  // through the getter, so the body never has to spell the payload type. A sync event calls
55
55
  // `emitSync` (inline dispatch, JS thread only); the default calls the scheduling `emit`.
56
+ // A payload that holds a tuple can't conform to the payload protocols, so it's passed with a
57
+ // closure that encodes it.
56
58
  let emitMethod = event.isSync ? "emitSync" : "emit"
57
- let closure =
58
- event.hasPayload
59
- ? "{ [weak self] payload in self?.\(emitMethod)(event: \"\(event.jsName)\", payload: payload) }"
60
- : "{ [weak self] in self?.\(emitMethod)(event: \"\(event.jsName)\") }"
59
+ let closure: String
60
+ if let tuple = event.payloadType.flatMap({ tupleConversion(of: $0) }) {
61
+ let encode = elementClosure(returning: tuple.encodeExpression(of: "value"))
62
+ closure =
63
+ "{ [weak self] payload in self?.\(emitMethod)(event: \"\(event.jsName)\", payload: payload, encode: \(encode)) }"
64
+ } else if event.hasPayload {
65
+ closure = "{ [weak self] payload in self?.\(emitMethod)(event: \"\(event.jsName)\", payload: payload) }"
66
+ } else {
67
+ closure = "{ [weak self] in self?.\(emitMethod)(event: \"\(event.jsName)\") }"
68
+ }
61
69
  return [
62
70
  """
63
71
  get {
@@ -85,15 +93,20 @@ extension EventMacro: PeerMacro {
85
93
  // through `self.emit`). Named after the member so the member shows up in either diagnostic,
86
94
  // and asserting the enclosing type by its spelled name so the conformance error names the
87
95
  // user's type rather than 'Self'.
96
+ //
97
+ // A payload that holds a tuple is encoded element by element (see `TupleConversion`), so the types
98
+ // in it are asserted `JavaScriptEncodable`, which the encode closure needs, in its place.
88
99
  let name = event.swiftName
89
- let payload = event.payloadType.flatMap { assertableBoundaryType($0, constraint: jsConvertibleProtocolName) }
100
+ let tupleElementTypes = event.payloadType.flatMap { tupleConversion(of: $0)?.conformingTypes }
101
+ let payloadConstraint = tupleElementTypes == nil ? jsConvertibleProtocolName : javaScriptEncodableProtocolName
102
+ let payloads =
103
+ tupleElementTypes.map { distinctAssertableTypes($0, constraint: payloadConstraint) }
104
+ ?? event.payloadType.flatMap { assertableBoundaryType($0, constraint: payloadConstraint) }.map { [$0] } ?? []
90
105
  let owner = enclosingTypeName(in: context) ?? "Self"
91
106
  let emitterParameter = opaqueMetatypeParameter(constraint: eventEmitterProtocolName)
92
- let helper =
93
- payload != nil
94
- ? "func \(name)(\(opaqueMetatypeParameter(constraint: jsConvertibleProtocolName)), \(emitterParameter)) {}"
95
- : "func \(name)(\(emitterParameter)) {}"
96
- let call = payload.map { "\(name)(\($0).self, \(owner).self)" } ?? "\(name)(\(owner).self)"
107
+ let payloadParameters = payloads.map { _ in opaqueMetatypeParameter(constraint: payloadConstraint) }
108
+ let helper = "func \(name)(\((payloadParameters + [emitterParameter]).joined(separator: ", "))) {}"
109
+ let call = "\(name)(\((payloads + [owner]).map { "\($0).self" }.joined(separator: ", ")))"
97
110
  let peerName = derivedIdentifier(from: name, prefix: "_assertTypesConformance_")
98
111
  return [
99
112
  """
@@ -49,9 +49,15 @@ public struct ExpoModuleMacro: MemberMacro {
49
49
  // DSL entry.
50
50
  var functions: [JSFunction] = []
51
51
  var properties: [JSProperty] = []
52
+ var assertions: [MemberConformanceAssertion] = []
53
+ let aliases = TupleTypeAliases(in: classDecl.memberBlock)
52
54
 
53
55
  for member in classDecl.memberBlock.members {
54
- let decl = member.decl
56
+ // The types of a `@JS` member are read with the class's tuple aliases resolved.
57
+ let decl = aliases.resolving(member.decl)
58
+ if memberHasJSAttribute(decl), let assertion = jsConformanceAssertionBody(for: decl) {
59
+ assertions.append(assertion)
60
+ }
55
61
 
56
62
  if let funcDecl = decl.as(FunctionDeclSyntax.self),
57
63
  let attribute = funcDecl.attributes.firstAttribute(named: "JS")
@@ -126,6 +132,12 @@ public struct ExpoModuleMacro: MemberMacro {
126
132
  emitted.append(buildDecorateJavaScriptObject(functions: functions, properties: properties))
127
133
  }
128
134
 
135
+ // The conformance assertions of the `@JS` members, which `@JS` leaves to this macro (see
136
+ // `jsConformanceAssertionBody`).
137
+ if let assertion = classConformanceAssertions(assertions) {
138
+ emitted.append(assertion)
139
+ }
140
+
129
141
  return emitted
130
142
  }
131
143
  }
@@ -37,21 +37,43 @@ public struct JSMacro: PeerMacro {
37
37
  diagnoseConcurrentOption(of: node, on: declaration, in: context)
38
38
  diagnoseAsyncVariantOption(of: node, on: declaration, in: context)
39
39
 
40
- guard let member = boundaryMember(of: declaration),
41
- let assertion = directionalConformanceAssertion(
42
- name: member.name,
43
- decodableTypes: member.decodableTypes,
44
- encodableType: member.encodableType,
45
- callbackArgumentTypes: member.callbackArgumentTypes,
46
- isStatic: member.isStatic
47
- )
48
- else {
40
+ // In an `@ExpoModule` or `@SharedObject` class, the class macro emits the assertion instead: it sees
41
+ // the type aliases declared in the class body, which this macro can't (see `TupleTypeAliases`).
42
+ guard !isInBindingClass(context), let assertion = jsConformanceAssertion(for: declaration) else {
49
43
  return []
50
44
  }
51
45
  return [assertion]
52
46
  }
53
47
  }
54
48
 
49
+ /// The conformance-assertion peer for a `@JS` member, or `nil` when the member has no type to assert.
50
+ private func jsConformanceAssertion(for declaration: some DeclSyntaxProtocol) -> DeclSyntax? {
51
+ guard let member = boundaryMember(of: declaration) else {
52
+ return nil
53
+ }
54
+ return directionalConformanceAssertion(name: member.name, types: member.types, isStatic: member.isStatic)
55
+ }
56
+
57
+ /// The conformance assertion of a `@JS` member, for an `@ExpoModule` or `@SharedObject` class to fold
58
+ /// into its `_assertTypesConformance()` (see `classConformanceAssertions`), or `nil` when the member has
59
+ /// no type to assert.
60
+ internal func jsConformanceAssertionBody(for declaration: some DeclSyntaxProtocol) -> MemberConformanceAssertion? {
61
+ guard let member = boundaryMember(of: declaration) else {
62
+ return nil
63
+ }
64
+ return memberConformanceAssertion(name: member.name, types: member.types)
65
+ }
66
+
67
+ /// True when the member is declared directly in an `@ExpoModule` or `@SharedObject` class, whose macro
68
+ /// binds it.
69
+ private func isInBindingClass(_ context: some MacroExpansionContext) -> Bool {
70
+ guard let classDecl = context.lexicalContext.first?.as(ClassDeclSyntax.self) else {
71
+ return false
72
+ }
73
+ return classDecl.attributes.firstAttribute(named: "ExpoModule") != nil
74
+ || classDecl.attributes.firstAttribute(named: "SharedObject") != nil
75
+ }
76
+
55
77
  /// Emits the diagnostic for `@JS(.concurrent)` on a member that can't take it. The option maps to
56
78
  /// Swift's `@concurrent`, which requires an `async` function: a synchronous member has nowhere to
57
79
  /// suspend, and a property or initializer can't be async at all. Diagnosing here points at the
@@ -323,28 +345,24 @@ private struct JSFixItMessage: FixItMessage {
323
345
  }
324
346
  }
325
347
 
326
- /// What an assertion peer needs about the `@JS` member it sits beside: a name (to keep the peer unique
327
- /// among siblings), the boundary types split by conversion direction, and whether the member is
328
- /// type-level. Arguments (and a settable property's incoming value) are decoded; return values (and a
329
- /// property's outgoing value) are encoded, so each is asserted against the protocol for its direction.
348
+ /// What an assertion needs about a `@JS` member: a name (which names the helper), the boundary types in
349
+ /// signature order, each with the protocol for the direction it travels and its argument label, and
350
+ /// whether the member is type-level. Arguments (and a settable property's incoming value) are decoded;
351
+ /// return values (and a property's outgoing value) are encoded, so each is asserted against the
352
+ /// protocol for its direction.
330
353
  private struct BoundaryMember {
331
354
  let name: String
332
- /// Types decoded from JS: function/constructor arguments, a settable property's value type, and the
333
- /// return types of closure arguments.
334
- let decodableTypes: [String]
335
- /// The single type encoded to JS: a function's return type, or a property's value type on read;
336
- /// `nil` when the member produces nothing JS-visible (a `Void` function).
337
- let encodableType: String?
338
- /// The parameter types of closure arguments, encoded to JS when native code calls the closure.
339
- var callbackArgumentTypes: [String] = []
355
+ /// The types crossing the boundary, in signature order.
356
+ let types: [AssertedType]
340
357
  /// True for `static`/`class` members, so the peer is emitted in the same metatype context.
341
358
  let isStatic: Bool
342
359
  }
343
360
 
344
- /// Reads the boundary member off a `@JS` declaration, splitting its types by conversion direction. A
345
- /// function contributes its parameter types (decodable) and its return type when non-Void (encodable);
346
- /// an initializer contributes its parameter types; a property contributes its value type as encodable
347
- /// (the getter) and also as decodable when settable (the setter). Composed types (`[Int]`, `String?`,
361
+ /// Reads the boundary member off a `@JS` declaration, with each type's conversion direction. A function
362
+ /// contributes its parameter types (decodable) under their argument labels and its return type when
363
+ /// non-Void (encodable) under `returning`; an initializer contributes its parameter types; a property
364
+ /// contributes its value type, unlabeled, as decodable when settable (the setter) and as encodable (the
365
+ /// getter). Composed types (`[Int]`, `String?`,
348
366
  /// …) are kept verbatim, their conditional conformances transitively constraining the elements. A type
349
367
  /// that mentions `Any` is left out: it converts through the free-form methods, or is diagnosed by
350
368
  /// `diagnoseFreeFormTypes`. Returns `nil` for declaration kinds `@JS` doesn't read types from, a
@@ -352,30 +370,28 @@ private struct BoundaryMember {
352
370
  /// mentions `Any`.
353
371
  private func boundaryMember(of declaration: some DeclSyntaxProtocol) -> BoundaryMember? {
354
372
  if let funcDecl = declaration.as(FunctionDeclSyntax.self) {
355
- let argumentTypes = argumentBoundaryTypes(of: funcDecl.signature.parameterClause.parameters)
356
- let returnType = funcDecl.signature.returnClause?.type
357
- let encodableType = returnType.flatMap {
358
- isVoidType($0) || containsFunctionType($0) || mentionsFreeFormAny($0) ? nil : $0.trimmedDescription
373
+ var types = argumentBoundaryTypes(of: funcDecl.signature.parameterClause.parameters)
374
+ if let returnType = funcDecl.signature.returnClause?.type,
375
+ !isVoidType(returnType) && !containsFunctionType(returnType) && !mentionsFreeFormAny(returnType)
376
+ {
377
+ types.append(
378
+ AssertedType(
379
+ label: "returning",
380
+ type: returnType.trimmedDescription,
381
+ constraint: javaScriptEncodableProtocolName
382
+ )
383
+ )
359
384
  }
360
- return BoundaryMember(
361
- name: funcDecl.name.text,
362
- decodableTypes: argumentTypes.decodable,
363
- encodableType: encodableType,
364
- callbackArgumentTypes: argumentTypes.callbackArguments,
365
- isStatic: isTypeLevel(funcDecl.modifiers)
366
- )
385
+ return BoundaryMember(name: funcDecl.name.text, types: types, isStatic: isTypeLevel(funcDecl.modifiers))
367
386
  }
368
387
 
369
388
  if let initDecl = declaration.as(InitializerDeclSyntax.self) {
370
389
  // A constructor decodes its arguments like a function does, and the instance it returns isn't
371
390
  // encoded. The name is escaped because it also names the assertion's nested helper, and `init` is
372
391
  // a keyword.
373
- let argumentTypes = argumentBoundaryTypes(of: initDecl.signature.parameterClause.parameters)
374
392
  return BoundaryMember(
375
393
  name: "`init`",
376
- decodableTypes: argumentTypes.decodable,
377
- encodableType: nil,
378
- callbackArgumentTypes: argumentTypes.callbackArguments,
394
+ types: argumentBoundaryTypes(of: initDecl.signature.parameterClause.parameters),
379
395
  isStatic: false
380
396
  )
381
397
  }
@@ -395,10 +411,11 @@ private func boundaryMember(of declaration: some DeclSyntaxProtocol) -> Boundary
395
411
  let typeText = type.trimmedDescription
396
412
  let isVar = varDecl.bindingSpecifier.tokenKind == .keyword(.var)
397
413
  let isSettable = isVar && !isLazy(varDecl.modifiers) && bindingIsSettable(binding)
414
+ let decodable = AssertedType(label: nil, type: typeText, constraint: javaScriptDecodableProtocolName)
415
+ let encodable = AssertedType(label: nil, type: typeText, constraint: javaScriptEncodableProtocolName)
398
416
  return BoundaryMember(
399
417
  name: identifier.identifier.text,
400
- decodableTypes: isSettable ? [typeText] : [],
401
- encodableType: typeText,
418
+ types: (isSettable ? [decodable] : []) + [encodable],
402
419
  isStatic: isTypeLevel(varDecl.modifiers)
403
420
  )
404
421
  }
@@ -406,18 +423,16 @@ private func boundaryMember(of declaration: some DeclSyntaxProtocol) -> Boundary
406
423
  return nil
407
424
  }
408
425
 
409
- /// The boundary types of a function's or constructor's arguments: an argument is decoded from JS, and
410
- /// a closure argument instead contributes its parameter types (encoded when native code calls it) and
411
- /// its return type (decoded).
412
- private func argumentBoundaryTypes(
413
- of parameters: FunctionParameterListSyntax
414
- ) -> (decodable: [String], callbackArguments: [String]) {
426
+ /// The boundary types of a function's or constructor's arguments, under their argument labels: an
427
+ /// argument is decoded from JS, and a closure argument instead contributes its parameter types (encoded
428
+ /// when native code calls it) and its return type (decoded).
429
+ private func argumentBoundaryTypes(of parameters: FunctionParameterListSyntax) -> [AssertedType] {
415
430
  // A closure argument moves data the other way: JS receives its parameters and returns its result.
416
431
  // A closure in any other position is diagnosed by `diagnoseClosureTypes` and left out here, since
417
432
  // its metatype isn't a valid expression in the assertion.
418
- var decodableTypes: [String] = []
419
- var callbackArgumentTypes: [String] = []
433
+ var types: [AssertedType] = []
420
434
  for parameter in parameters {
435
+ let label = parameter.firstName.tokenKind == .wildcard ? nil : parameter.firstName.text
421
436
  // The specifier isn't part of the asserted type, and a parameter with one the binding can't honor
422
437
  // is reported by `diagnoseParameterSpecifiers` and not asserted.
423
438
  let specified = SpecifiedType(parameter.type)
@@ -426,23 +441,25 @@ private func argumentBoundaryTypes(
426
441
  }
427
442
  guard let closure = JSClosureType(specified.type) else {
428
443
  if !containsFunctionType(specified.type) && !mentionsFreeFormAny(specified.type) {
429
- decodableTypes.append(specified.type.trimmedDescription)
444
+ let type = specified.type.trimmedDescription
445
+ types.append(AssertedType(label: label, type: type, constraint: javaScriptDecodableProtocolName))
430
446
  }
431
447
  continue
432
448
  }
433
449
  // A closure type that mentions `Any` is reported by `diagnoseClosureTypes` instead.
434
450
  for parameter in closure.parameters
435
451
  where !containsFunctionType(parameter.type) && !mentionsFreeFormAny(parameter.type) {
436
- callbackArgumentTypes.append(parameter.type.trimmedDescription)
452
+ let type = parameter.type.trimmedDescription
453
+ types.append(AssertedType(label: label, type: type, constraint: javaScriptEncodableProtocolName))
437
454
  }
438
455
  let closureReturnType = closure.functionType.returnClause.type
439
456
  if let returnType = closure.returnType,
440
457
  !containsFunctionType(closureReturnType) && !mentionsFreeFormAny(closureReturnType)
441
458
  {
442
- decodableTypes.append(returnType)
459
+ types.append(AssertedType(label: label, type: returnType, constraint: javaScriptDecodableProtocolName))
443
460
  }
444
461
  }
445
- return (decodableTypes, callbackArgumentTypes)
462
+ return types
446
463
  }
447
464
 
448
465
  /// True when a return clause is written as `Void` / `()` — nothing crosses the boundary, so it needs
@@ -493,23 +493,30 @@ internal func expressionType(_ type: String) -> String {
493
493
 
494
494
  /// The decode expression for a boundary type read from `valueExpression` (a `JavaScriptUnownedValue`),
495
495
  /// as it appears after `try`. A type that mentions `Any` can't conform to `JavaScriptDecodable`, so it
496
- /// decodes through the `JavaScriptValue` free-form methods its `freeForm` conversion names; every other
497
- /// type decodes through its own static `decode`, spelled in expression position (`T!` rewritten to
498
- /// `T?`).
496
+ /// decodes through the `JavaScriptValue` free-form methods its `freeForm` conversion names, and a tuple
497
+ /// through the `JavaScriptValue` tuple methods (see `TupleConversion`); every other type decodes through
498
+ /// its own static `decode`, spelled in expression position (`T!` rewritten to `T?`).
499
499
  internal func decodeCall(_ type: String, freeForm: FreeFormConversion?, from valueExpression: String) -> String {
500
500
  if let freeForm {
501
501
  return freeForm.decodeExpression(of: valueExpression, as: expressionType(type))
502
502
  }
503
+ if let tuple = tupleConversion(of: type) {
504
+ return tuple.decodeExpression(of: valueExpression)
505
+ }
503
506
  return "\(expressionType(type)).decode(\(valueExpression), in: runtime)"
504
507
  }
505
508
 
506
509
  /// The encode expression for the native value `valueExpression` of a boundary type, as it appears
507
510
  /// after `try`. The counterpart of `decodeCall`: a type that mentions `Any` encodes through the
508
- /// `JavaScriptValue` free-form methods, and every other type through its own static `encode`.
511
+ /// `JavaScriptValue` free-form methods, a tuple through the `JavaScriptValue` tuple methods, and every
512
+ /// other type through its own static `encode`.
509
513
  internal func encodeCall(_ type: String, freeForm: FreeFormConversion?, of valueExpression: String) -> String {
510
514
  if let freeForm {
511
515
  return freeForm.encodeExpression(of: valueExpression)
512
516
  }
517
+ if let tuple = tupleConversion(of: type) {
518
+ return tuple.encodeExpression(of: valueExpression)
519
+ }
513
520
  return "\(expressionType(type)).encode(\(valueExpression), in: runtime)"
514
521
  }
515
522