expo-modules-macros 0.25.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
 
@@ -499,11 +499,12 @@ private let resultSlotClosureParameters =
499
499
  + "result: borrowing JavaScriptValue.Slot, runtime: borrowing JavaScriptRuntime)"
500
500
 
501
501
  /// The statement that encodes `valueExpression` of a boundary type into the result slot `result`. A type
502
- /// that mentions `Any` has no `encode(_:into:in:)`, so its free-form encode result is moved into the slot.
502
+ /// that mentions `Any` and a tuple have no `encode(_:into:in:)`, so their encode result is moved into
503
+ /// the slot.
503
504
  private func encodeIntoSlotStatement(_ type: String, freeForm: FreeFormConversion?, of valueExpression: String)
504
505
  -> String
505
506
  {
506
- if freeForm != nil {
507
+ if freeForm != nil || tupleConversion(of: type) != nil {
507
508
  return "result.emplace(try \(encodeCall(type, freeForm: freeForm, of: valueExpression)))"
508
509
  }
509
510
  return "try \(expressionType(type)).encode(\(valueExpression), into: result, in: runtime)"
@@ -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
 
@@ -423,10 +423,17 @@ private func dictionaryReadLines(properties: [RecordProperty]) -> [String] {
423
423
  let entry = "entry\(index)"
424
424
  let exprType = expressionType(property.type)
425
425
  let isBareAny = property.freeForm?.isBareAny == true
426
- let cast =
427
- isBareAny
428
- ? entry
429
- : "try \(exprType).getDynamicType().cast(\(entry), appContext: appContext) as! \(exprType)"
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
+ }
430
437
  lines.append(" let \(entry) = dictionary[\"\(property.key)\"]")
431
438
  if property.isRequired {
432
439
  lines.append(" guard let \(entry) else {")
@@ -544,7 +551,11 @@ private func objectWriteLines(properties: [RecordProperty], of record: String, c
544
551
  encode = "try JavaScriptValue.encodeAny(\(value) as Any, in: runtime)"
545
552
  case nil:
546
553
  let propName = propNameArgument(for: property, at: index, cached: cachesPropNames)
547
- return " try object.setProperty(\(propName), encoding: \(value), in: runtime)"
554
+ // A type that holds a tuple has no `encode(_:into:in:)`, so its encoded value is written instead.
555
+ if let tuple = tupleConversion(of: property.type) {
556
+ return " object.setProperty(\(propName), value: try \(tuple.encodeExpression(of: value)))"
557
+ }
558
+ return " try object.setProperty(\(propName), encoding: \(value))"
548
559
  }
549
560
  let propName = propNameArgument(for: property, at: index, cached: cachesPropNames)
550
561
  return " object.setProperty(\(propName), value: \(encode))"
@@ -50,9 +50,15 @@ public struct SharedObjectMacro: MemberMacro {
50
50
  var staticFunctions: [JSFunction] = []
51
51
  var staticProperties: [JSProperty] = []
52
52
  var constructor: JSConstructor?
53
+ var assertions: [MemberConformanceAssertion] = []
54
+ let aliases = TupleTypeAliases(in: classDecl.memberBlock)
53
55
 
54
56
  for member in classDecl.memberBlock.members {
55
- let decl = member.decl
57
+ // The types of a `@JS` member are read with the class's tuple aliases resolved.
58
+ let decl = aliases.resolving(member.decl)
59
+ if memberHasJSAttribute(decl), let assertion = jsConformanceAssertionBody(for: decl) {
60
+ assertions.append(assertion)
61
+ }
56
62
 
57
63
  if let initDecl = decl.as(InitializerDeclSyntax.self),
58
64
  initDecl.attributes.firstAttribute(named: "JS") != nil
@@ -129,6 +135,12 @@ public struct SharedObjectMacro: MemberMacro {
129
135
  emitted.append(constructor.buildConstructor(typeName: typeName))
130
136
  }
131
137
 
138
+ // The conformance assertions of the `@JS` members, which `@JS` leaves to this macro (see
139
+ // `jsConformanceAssertionBody`).
140
+ if let assertion = classConformanceAssertions(assertions) {
141
+ emitted.append(assertion)
142
+ }
143
+
132
144
  return emitted
133
145
  }
134
146
  }