expo-modules-macros 0.29.0 → 0.31.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.
@@ -32,12 +32,12 @@ import SwiftSyntaxMacros
32
32
  /// also self-contained: `@ExpoModule`/`@SharedObject` neither collect `@Event` members nor register
33
33
  /// their names anywhere.
34
34
  ///
35
- /// `@Event(sync: true)` opts into **synchronous dispatch**: the closure calls `emitSync` (inline
36
- /// conversion + dispatch, no scheduling) instead of `emit`, and `@ExpoModule`/`@SharedObject` stamp
37
- /// the member `@JavaScriptActor` so the compiler forces the call site onto the JS thread, the
38
- /// inverse of the async default. The isolation is on the property access, so it guards the inline
39
- /// `self.onTick(…)` usage; a closure stored or handed off escapes it, after which `emitSync` runs
40
- /// wherever the caller invokes it.
35
+ /// Two options change how the event is dispatched, and each one calls its own `EventEmitter` method,
36
+ /// which is callable from any thread as well:
37
+ /// - `@Event(.inline)` calls `emitInline`: on the JS thread the listeners run before the call
38
+ /// returns; on any other thread the event is scheduled, the same as `emit`. It never blocks.
39
+ /// - `@Event(.blocking)` calls `emitBlocking`: the listeners run before the call returns on any
40
+ /// thread, so a call from another thread blocks until the JS thread runs them.
41
41
  ///
42
42
  /// As a **peer**, the macro emits a never-called conformance assertion (see
43
43
  /// `TypeConformanceAssertion.swift`) checking that the payload type is JS-convertible and that the
@@ -51,11 +51,10 @@ public struct EventMacro: AccessorMacro {
51
51
  ) throws -> [AccessorDeclSyntax] {
52
52
  let event = try validatedEvent(of: node, on: declaration)
53
53
  // The closure's parameter and return types are inferred from the property's declared type
54
- // through the getter, so the body never has to spell the payload type. A sync event calls
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.
58
- let emitMethod = event.isSync ? "emitSync" : "emit"
54
+ // through the getter, so the body never has to spell the payload type. The dispatch option
55
+ // picks the `EventEmitter` method. A payload that holds a tuple can't conform to the payload
56
+ // protocols, so it's passed with a closure that encodes it.
57
+ let emitMethod = event.dispatch.emitMethod
59
58
  let closure: String
60
59
  if let tuple = event.payloadType.flatMap({ tupleConversion(of: $0) }) {
61
60
  let encode = encoderClosure(encoding: tuple.encodeCall(of: "value", into: "slot"))
@@ -96,9 +95,13 @@ extension EventMacro: PeerMacro {
96
95
  //
97
96
  // A payload that holds a tuple is encoded element by element (see `TupleConversion`), so the types
98
97
  // in it are asserted `JavaScriptEncodable`, which the encode closure needs, in its place.
98
+ // `emitInline` and `emitBlocking` take only `JavaScriptEncodable` payloads, so with those options
99
+ // the payload is asserted `JavaScriptEncodable` too.
99
100
  let name = event.swiftName
100
101
  let tupleElementTypes = event.payloadType.flatMap { tupleConversion(of: $0)?.conformingTypes }
101
- let payloadConstraint = tupleElementTypes == nil ? jsConvertibleProtocolName : javaScriptEncodableProtocolName
102
+ let payloadConstraint =
103
+ tupleElementTypes == nil && event.dispatch == .scheduled
104
+ ? jsConvertibleProtocolName : javaScriptEncodableProtocolName
102
105
  let payloads =
103
106
  tupleElementTypes.map { distinctAssertableTypes($0, constraint: payloadConstraint) }
104
107
  ?? event.payloadType.flatMap { assertableBoundaryType($0, constraint: payloadConstraint) }.map { [$0] } ?? []
@@ -127,8 +130,8 @@ private struct EventMember {
127
130
  let jsName: String
128
131
  /// The payload type as written, or `nil` for a no-payload `() -> Void` event.
129
132
  let payloadType: String?
130
- /// Whether the event dispatches synchronously (`@Event(sync: true)`) via `emitSync`.
131
- let isSync: Bool
133
+ /// How the event is dispatched, from the `.inline` or `.blocking` option.
134
+ let dispatch: EventDispatch
132
135
 
133
136
  var hasPayload: Bool {
134
137
  return payloadType != nil
@@ -208,14 +211,88 @@ private func validatedEvent(
208
211
  }
209
212
 
210
213
  let swiftName = identifier.identifier.text
214
+ let dispatch = try eventDispatch(of: node)
211
215
  return EventMember(
212
216
  swiftName: swiftName,
213
217
  jsName: jsNameArgument(of: node) ?? defaultEventName(for: withoutBackticks(swiftName)),
214
218
  payloadType: functionType.parameters.first?.type.trimmedDescription,
215
- isSync: boolArgument(of: node, label: "sync") == true
219
+ dispatch: dispatch
216
220
  )
217
221
  }
218
222
 
223
+ /// How an `@Event` is dispatched to JavaScript, picked by its option.
224
+ private enum EventDispatch {
225
+ case scheduled
226
+ case inline
227
+ case blocking
228
+
229
+ var emitMethod: String {
230
+ switch self {
231
+ case .scheduled:
232
+ return "emit"
233
+ case .inline:
234
+ return "emitInline"
235
+ case .blocking:
236
+ return "emitBlocking"
237
+ }
238
+ }
239
+ }
240
+
241
+ /// Reads the dispatch option of an `@Event` attribute. `.inline` and `.blocking` exclude each other.
242
+ private func eventDispatch(of node: AttributeSyntax) throws -> EventDispatch {
243
+ let isInline = hasOption(node, named: "inline")
244
+ let isBlocking = hasOption(node, named: "blocking")
245
+ if isInline && isBlocking {
246
+ throw conflictingOptionsDiagnostic(in: node)
247
+ }
248
+ if isInline {
249
+ return .inline
250
+ }
251
+ if isBlocking {
252
+ return .blocking
253
+ }
254
+ return .scheduled
255
+ }
256
+
257
+ /// The error for `@Event(.inline, .blocking)`, with a single Fix-It that removes the option written
258
+ /// last. One Fix-It (rather than one for each option) lets the `fix` command apply it, since it skips
259
+ /// a diagnostic with alternatives.
260
+ private func conflictingOptionsDiagnostic(in node: AttributeSyntax) -> DiagnosticsError {
261
+ let message = EventDiagnosticMessage(
262
+ "@Event can't be both '.inline' and '.blocking'; '.blocking' also runs the listeners inline on the JS thread, so remove one of the options",
263
+ id: "event-conflicting-options"
264
+ )
265
+ guard let arguments = node.arguments?.as(LabeledExprListSyntax.self),
266
+ let index = arguments.lastIndex(where: { isDispatchOption($0.expression) })
267
+ else {
268
+ return DiagnosticsError(diagnostics: [Diagnostic(node: node, message: message)])
269
+ }
270
+ let removed = arguments[index]
271
+ var remaining = Array(arguments)
272
+ remaining.removeAll { $0.id == removed.id }
273
+ // The argument before the removed one keeps its comma only when another argument follows it.
274
+ if let last = remaining.indices.last {
275
+ remaining[last] = remaining[last].with(\.trailingComma, nil)
276
+ }
277
+ let optionName = removed.expression.as(MemberAccessExprSyntax.self)?.declName.baseName.text ?? "option"
278
+ let fixIt = FixIt(
279
+ message: EventFixItMessage("Remove '.\(optionName)'", id: "event-remove-option"),
280
+ changes: [
281
+ .replace(oldNode: Syntax(arguments), newNode: Syntax(LabeledExprListSyntax(remaining)))
282
+ ]
283
+ )
284
+ return DiagnosticsError(diagnostics: [Diagnostic(node: node, message: message, fixIts: [fixIt])])
285
+ }
286
+
287
+ /// True when the expression is the `.inline` or `.blocking` option (or `EventOptions.<option>`).
288
+ private func isDispatchOption(_ expression: ExprSyntax) -> Bool {
289
+ guard let member = expression.as(MemberAccessExprSyntax.self) else {
290
+ return false
291
+ }
292
+ let name = member.declName.baseName.text
293
+ return name == "inline" || name == "blocking"
294
+ }
295
+
219
296
  // MARK: - Default event name
220
297
 
221
298
  /// The default JS event name for a property: the Swift name with the conventional `on` prefix
@@ -38,6 +38,13 @@ public struct ExpoModuleMacro: MemberMacro {
38
38
  guard let classDecl = declaration.as(ClassDeclSyntax.self) else {
39
39
  throw MacroExpansionErrorMessage("@ExpoModule can only be applied to a class")
40
40
  }
41
+ diagnoseNonPublicClass(
42
+ classDecl,
43
+ macroName: "@ExpoModule",
44
+ reason: "register it",
45
+ idPrefix: "expomodule",
46
+ in: context
47
+ )
41
48
 
42
49
  // The module name is fully resolved here (the explicit `@ExpoModule("…")` argument, else the
43
50
  // class name) and emitted as a non-optional `_jsName` static below — so the
@@ -160,10 +167,7 @@ extension ExpoModuleMacro: MemberAttributeMacro {
160
167
  // checked at compile time. Skipped when the member already chose an isolation
161
168
  // (`nonisolated` or another global actor), or when `.concurrent` or `.withAsync("name")` runs
162
169
  // it on the concurrent pool. See `shouldStampJavaScriptActor`.
163
- // `@Event(sync: true)` members get the stamp too: a sync event dispatches inline, so the
164
- // isolation forces its call site onto the JS thread. Async events (the default) are
165
- // deliberately left unstamped — their `emit` schedules onto the JS thread itself.
166
- if memberHasJSAttribute(member) || isSyncEventMember(member),
170
+ if memberHasJSAttribute(member),
167
171
  shouldStampJavaScriptActor(on: member, enclosedBy: declaration)
168
172
  {
169
173
  attributes.append("@JavaScriptActor")
@@ -36,6 +36,7 @@ public struct JSMacro: PeerMacro {
36
36
  diagnoseClosureTypes(in: declaration, in: context)
37
37
  diagnoseConcurrentOption(of: node, on: declaration, in: context)
38
38
  diagnoseAsyncVariantOption(of: node, on: declaration, in: context)
39
+ diagnoseJavaScriptValuesOffThread(of: node, on: declaration, in: context)
39
40
 
40
41
  // In an `@ExpoModule` or `@SharedObject` class, the class macro emits the assertion instead: it sees
41
42
  // the type aliases declared in the class body, which this macro can't (see `TupleTypeAliases`).
@@ -83,7 +84,7 @@ private func diagnoseConcurrentOption(
83
84
  on declaration: some DeclSyntaxProtocol,
84
85
  in context: some MacroExpansionContext
85
86
  ) {
86
- guard hasJSOption(node, named: "concurrent") else {
87
+ guard hasOption(node, named: "concurrent") else {
87
88
  return
88
89
  }
89
90
  if let funcDecl = declaration.as(FunctionDeclSyntax.self) {
@@ -248,6 +249,58 @@ private func insertAsyncFixIt(for funcDecl: FunctionDeclSyntax) -> FixIt {
248
249
  /// `consuming`, `sending`) are fine: the binding passes the decoded value under whichever convention
249
250
  /// the parameter asks for. The specifier is left off the decode and the assertion (see
250
251
  /// `SpecifiedType`); a parameter reported here is also left out of the assertion.
252
+ /// The JSI types that own an engine value and release it wherever they are dropped. Such a value is
253
+ /// valid only on the JavaScript thread. `JavaScriptPromise` releases its value on the JavaScript
254
+ /// thread, and `JavaScriptRef` and `JavaScriptCallback` exist to carry a value across threads, so they
255
+ /// aren't listed.
256
+ private let javaScriptThreadBoundTypes: Set<String> = [
257
+ "JavaScriptValue", "JavaScriptObject", "JavaScriptFunction", "JavaScriptArray", "JavaScriptArrayBuffer",
258
+ "JavaScriptTypedArray", "JavaScriptBigInt", "JavaScriptWeakObject",
259
+ ]
260
+
261
+ /// Emits the diagnostic for a JSI value parameter of a `@JS(.concurrent)` function or of the variant
262
+ /// that `@JS(.withAsync("name"))` adds. Both run the body on the concurrent thread pool, never on the
263
+ /// JavaScript thread, so the body can't use the value, and the value is released off the JavaScript
264
+ /// thread too.
265
+ private func diagnoseJavaScriptValuesOffThread(
266
+ of node: AttributeSyntax,
267
+ on declaration: some DeclSyntaxProtocol,
268
+ in context: some MacroExpansionContext
269
+ ) {
270
+ guard let funcDecl = declaration.as(FunctionDeclSyntax.self) else {
271
+ return
272
+ }
273
+ let option: String
274
+ let reason: String
275
+ if hasOption(node, named: "concurrent") {
276
+ option = ".concurrent"
277
+ reason = "'.concurrent' runs the body on the concurrent thread pool"
278
+ } else if asyncVariantOption(of: node) != nil {
279
+ option = ".withAsync"
280
+ reason = "the variant that '.withAsync' adds runs the function on the concurrent thread pool"
281
+ } else {
282
+ return
283
+ }
284
+ for parameter in funcDecl.signature.parameterClause.parameters {
285
+ let type = SpecifiedType(parameter.type).type
286
+ guard let name = baseIdentifier(of: optionalWrappedType(type) ?? type), javaScriptThreadBoundTypes.contains(name)
287
+ else {
288
+ continue
289
+ }
290
+ let parameterName = (parameter.secondName ?? parameter.firstName).text
291
+ context.diagnose(
292
+ Diagnostic(
293
+ node: parameter.type,
294
+ message: JSDiagnosticMessage(
295
+ "'\(parameterName)' can't be a '\(name)' in a '\(option)' function: \(reason), and a JavaScript value is valid only on the JavaScript thread. Take a native type that the value decodes to, such as a @Record or [String: Any], or remove '\(option)'.",
296
+ id: "js-javascript-value-off-thread",
297
+ severity: .error
298
+ )
299
+ )
300
+ )
301
+ }
302
+ }
303
+
251
304
  private func diagnoseParameterSpecifiers(
252
305
  in declaration: some DeclSyntaxProtocol,
253
306
  in context: some MacroExpansionContext
@@ -8,7 +8,7 @@ import SwiftSyntaxMacros
8
8
  /// is emitted as a `_jsName` override.
9
9
  ///
10
10
  /// @SharedObject
11
- /// final class Cache: SharedObject {
11
+ /// public final class Cache: SharedObject {
12
12
  /// @JS
13
13
  /// init(name: String) { self.name = name }
14
14
  ///
@@ -34,6 +34,13 @@ public struct SharedObjectMacro: MemberMacro {
34
34
  guard let classDecl = declaration.as(ClassDeclSyntax.self) else {
35
35
  throw MacroExpansionErrorMessage("@SharedObject can only be applied to a class")
36
36
  }
37
+ diagnoseNonPublicClass(
38
+ classDecl,
39
+ macroName: "@SharedObject",
40
+ reason: "export it",
41
+ idPrefix: "sharedobject",
42
+ in: context
43
+ )
37
44
 
38
45
  // The superclass isn't checked here: the macro sees only this declaration, so it can't tell an
39
46
  // indirect subclass (`class Thumbnail: ImageRef`) from an unrelated class. The compiler reports a
@@ -164,10 +171,7 @@ extension SharedObjectMacro: MemberAttributeMacro {
164
171
  providingAttributesFor member: some DeclSyntaxProtocol,
165
172
  in context: some MacroExpansionContext
166
173
  ) throws -> [AttributeSyntax] {
167
- // `@Event(sync: true)` members are stamped alongside `@JS` ones: a sync event dispatches
168
- // inline, so the isolation forces its call site onto the JS thread. Async events (the
169
- // default) stay unstamped — their `emit` schedules onto the JS thread itself.
170
- guard memberHasJSAttribute(member) || isSyncEventMember(member) else {
174
+ guard memberHasJSAttribute(member) else {
171
175
  return []
172
176
  }
173
177
  // `@JS(.concurrent)` opts the member out of the JS-thread stamp and onto the concurrent pool.
@@ -134,36 +134,71 @@ private let maxValueProps = 64
134
134
  /// The error for a leftover `@Field`, attached to the attribute itself and carrying a fix-it that
135
135
  /// deletes it. `@ViewProps` treats every stored property as a prop, so the attribute has no meaning
136
136
  /// here; left in place it would wrap the value in `Field<T>` and the props type would decode against
137
- /// the wrong type.
137
+ /// the wrong type. A `@Field` with arguments, such as `@Field(.keyed("borderColor"))`, gets no
138
+ /// fix-it: they can set the prop name, which removing the attribute would change silently, so the
139
+ /// message tells the author to rename the property.
138
140
  private func fieldAttributeDiagnostic(
139
141
  for attribute: AttributeSyntax,
140
142
  on varDecl: VariableDeclSyntax
141
143
  ) -> Diagnostic {
142
- // Rebuild the attribute list without this entry, so the fix-it removes the attribute and the
143
- // trivia it carried rather than leaving a blank line behind.
144
- var attributes = varDecl.attributes
145
- if let index = attributes.firstIndex(where: { element in
144
+ // The token after the attribute takes over the trivia of the attribute: the line break,
145
+ // indentation and comments before it, and the comments after it. Its own leading whitespace is
146
+ // left out unless a comment comes before it, so `@Field var size` and `@Field` on its own line both
147
+ // become `var size` on the line of the attribute, and `@Field // note` keeps the line break after
148
+ // the comment.
149
+ var newDecl = varDecl
150
+ if let next = attribute.lastToken(viewMode: .sourceAccurate)?.nextToken(viewMode: .sourceAccurate) {
151
+ let comments = Trivia(pieces: attribute.trailingTrivia.pieces.drop(while: \.isWhitespace))
152
+ let nextTrivia =
153
+ comments.isEmpty ? Trivia(pieces: next.leadingTrivia.pieces.drop(while: \.isWhitespace)) : next.leadingTrivia
154
+ newDecl =
155
+ LeadingTriviaRewriter(token: next.id, trivia: attribute.leadingTrivia + comments + nextTrivia)
156
+ .rewrite(varDecl).cast(VariableDeclSyntax.self)
157
+ }
158
+ // Then remove the attribute with its trivia, so it doesn't leave a blank line behind. The rewritten
159
+ // declaration is a new tree, so the attribute is found by its place in the list.
160
+ if let index = varDecl.attributes.firstIndex(where: { element in
146
161
  guard case .attribute(let candidate) = element else {
147
162
  return false
148
163
  }
149
164
  return candidate == attribute
150
165
  }) {
151
- attributes.remove(at: index)
166
+ let offset = varDecl.attributes.distance(from: varDecl.attributes.startIndex, to: index)
167
+ newDecl.attributes.remove(at: newDecl.attributes.index(newDecl.attributes.startIndex, offsetBy: offset))
152
168
  }
153
169
  let fixIt = FixIt(
154
170
  message: ViewPropsFixItMessage("Remove the '@Field' attribute", id: "viewprops-remove-field"),
155
171
  changes: [
156
172
  .replace(
157
173
  oldNode: Syntax(varDecl),
158
- newNode: Syntax(varDecl.with(\.attributes, attributes))
174
+ newNode: Syntax(newDecl)
159
175
  )
160
176
  ]
161
177
  )
178
+ let hasArguments = attribute.arguments?.as(LabeledExprListSyntax.self)?.isEmpty == false
162
179
  let message = ViewPropsDiagnosticMessage(
163
- "@Field is no longer used — @ViewProps treats every stored property as a prop. Remove the @Field attribute",
180
+ hasArguments
181
+ ? "@Field is no longer used: @ViewProps treats every stored property as a prop, named after the property. Remove the @Field attribute, and rename the property if its arguments set another prop name"
182
+ : "@Field is no longer used: @ViewProps treats every stored property as a prop. Remove the @Field attribute",
164
183
  id: "viewprops-field-attribute"
165
184
  )
166
- return Diagnostic(node: attribute, message: message, fixIts: [fixIt])
185
+ return Diagnostic(node: attribute, message: message, fixIts: hasArguments ? [] : [fixIt])
186
+ }
187
+
188
+ /// Replaces the leading trivia of one token.
189
+ private final class LeadingTriviaRewriter: SyntaxRewriter {
190
+ private let token: SyntaxIdentifier
191
+ private let trivia: Trivia
192
+
193
+ init(token: SyntaxIdentifier, trivia: Trivia) {
194
+ self.token = token
195
+ self.trivia = trivia
196
+ super.init(viewMode: .sourceAccurate)
197
+ }
198
+
199
+ override func visit(_ token: TokenSyntax) -> TokenSyntax {
200
+ token.id == self.token ? token.with(\.leadingTrivia, trivia) : token
201
+ }
167
202
  }
168
203
 
169
204
  /// The error for an optional event prop, attached to the declared type and carrying a fix-it that
@@ -281,6 +316,22 @@ private func validatedViewProps(of declaration: some DeclGroupSyntax) throws ->
281
316
  )
282
317
  }
283
318
 
319
+ // `@Field` is the v1 property wrapper and has no meaning here — every stored property is already
320
+ // part of the surface. Left in place it would wrap the value in `Field<T>`, so the props type
321
+ // would decode against the wrong type. Flag every one rather than emit code built on them, all at
322
+ // once, so a `@Field` without a fix-it doesn't hide the fix-its of the others.
323
+ let fieldDiagnostics = declaration.memberBlock.members.compactMap { member -> Diagnostic? in
324
+ guard let varDecl = member.decl.as(VariableDeclSyntax.self), !isExcludedByModifier(varDecl.modifiers),
325
+ let fieldAttribute = varDecl.attributes.firstAttribute(named: "Field")
326
+ else {
327
+ return nil
328
+ }
329
+ return fieldAttributeDiagnostic(for: fieldAttribute, on: varDecl)
330
+ }
331
+ if !fieldDiagnostics.isEmpty {
332
+ throw DiagnosticsError(diagnostics: fieldDiagnostics)
333
+ }
334
+
284
335
  var valueProps: [ViewProp] = []
285
336
  var eventProps: [ViewProp] = []
286
337
 
@@ -291,13 +342,6 @@ private func validatedViewProps(of declaration: some DeclGroupSyntax) throws ->
291
342
  if isExcludedByModifier(varDecl.modifiers) {
292
343
  continue
293
344
  }
294
- // `@Field` is the v1 property wrapper and has no meaning here — every stored property is already
295
- // part of the surface. Left in place it would wrap the value in `Field<T>`, so the props type
296
- // would decode against the wrong type. Flag it rather than emit code built on it.
297
- if let fieldAttribute = varDecl.attributes.firstAttribute(named: "Field") {
298
- throw DiagnosticsError(diagnostics: [fieldAttributeDiagnostic(for: fieldAttribute, on: varDecl)])
299
- }
300
-
301
345
  for binding in varDecl.bindings {
302
346
  // A computed property is never a prop, but an accessor block alone doesn't mean computed:
303
347
  // `willSet`/`didSet` observers imply stored storage. `bindingIsSettable` draws exactly that
@@ -13,26 +13,12 @@ internal func jsNameArgument(of attribute: AttributeSyntax) -> String? {
13
13
  return segment.content.text
14
14
  }
15
15
 
16
- /// Reads a labeled boolean-literal argument of an attribute, e.g. `@Event(sync: true)` -> true.
17
- /// Returns nil if the attribute has no argument with that label or its value isn't a boolean literal.
18
- internal func boolArgument(of attribute: AttributeSyntax, label: String) -> Bool? {
19
- guard let args = attribute.arguments?.as(LabeledExprListSyntax.self) else {
20
- return nil
21
- }
22
- for arg in args where arg.label?.text == label {
23
- guard let literal = arg.expression.as(BooleanLiteralExprSyntax.self) else {
24
- return nil
25
- }
26
- return literal.literal.tokenKind == .keyword(.true)
27
- }
28
- return nil
29
- }
30
-
31
- /// True if the attribute lists the given `JSOptions` member, e.g. `@JS(.concurrent)` or
32
- /// `@JS("name", [.concurrent])` -> true for "concurrent". Options are written as member-access
33
- /// expressions (`.concurrent`), optionally inside an array literal when more than one is combined, so
34
- /// both spellings are scanned. The leading string literal, when present, is the JS name and is skipped.
35
- internal func hasJSOption(_ attribute: AttributeSyntax, named option: String) -> Bool {
16
+ /// True if the attribute lists the given option member, e.g. `@JS(.concurrent)`, `@Event(.inline)` or
17
+ /// `@Event("name", EventOptions.inline)` -> true for the matching name. Options are written as
18
+ /// member-access expressions, one per variadic argument. Array literals are scanned too, though the
19
+ /// variadic declarations in `expo-modules-core` don't accept them. The leading string literal, when
20
+ /// present, is the JS name and is skipped.
21
+ internal func hasOption(_ attribute: AttributeSyntax, named option: String) -> Bool {
36
22
  guard let args = attribute.arguments?.as(LabeledExprListSyntax.self) else {
37
23
  return false
38
24
  }
@@ -49,7 +35,7 @@ internal func hasJSOption(_ attribute: AttributeSyntax, named option: String) ->
49
35
  return false
50
36
  }
51
37
 
52
- /// True when the expression is the member access `.<option>` (or `JSOptions.<option>`).
38
+ /// True when the expression is the member access `.<option>` (or `JSOptions.<option>`, `EventOptions.<option>`).
53
39
  private func namesOption(_ expression: ExprSyntax, _ option: String) -> Bool {
54
40
  guard let member = expression.as(MemberAccessExprSyntax.self) else {
55
41
  return false
@@ -64,7 +50,7 @@ internal func isConcurrentJSMember(_ decl: DeclSyntaxProtocol) -> Bool {
64
50
  guard let attribute = memberAttributes(of: decl).firstAttribute(named: "JS") else {
65
51
  return false
66
52
  }
67
- return hasJSOption(attribute, named: "concurrent")
53
+ return hasOption(attribute, named: "concurrent")
68
54
  }
69
55
 
70
56
  /// The `.withAsync("name")` option of a `@JS` attribute, e.g. `@JS(.withAsync("loadAsync"))` or
@@ -74,7 +60,7 @@ internal func asyncVariantOption(of attribute: AttributeSyntax) -> FunctionCallE
74
60
  return asyncVariantOptions(of: attribute).first
75
61
  }
76
62
 
77
- /// Every `.withAsync(...)` option of a `@JS` attribute, in source order. Like `hasJSOption`, it scans
63
+ /// Every `.withAsync(...)` option of a `@JS` attribute, in source order. Like `hasOption`, it scans
78
64
  /// the arguments and the elements of an array literal.
79
65
  internal func asyncVariantOptions(of attribute: AttributeSyntax) -> [FunctionCallExprSyntax] {
80
66
  guard let args = attribute.arguments?.as(LabeledExprListSyntax.self) else {
@@ -290,20 +276,6 @@ internal func hasDefaultValue(_ parameter: FunctionParameterSyntax) -> Bool {
290
276
  return parameter.defaultValue != nil
291
277
  }
292
278
 
293
- /// True if the declaration is a `@Event(sync: true)` property. A sync event dispatches inline on the
294
- /// JS thread instead of scheduling, so `@ExpoModule`/`@SharedObject` stamp it with `@JavaScriptActor`,
295
- /// making "must be called on the JS thread" a compile-time guarantee at the call site. Async events
296
- /// (the default) are deliberately not stamped: their `emit` schedules onto the JS thread itself, so
297
- /// they stay callable from any thread.
298
- internal func isSyncEventMember(_ decl: DeclSyntaxProtocol) -> Bool {
299
- guard let varDecl = decl.as(VariableDeclSyntax.self),
300
- let attribute = varDecl.attributes.firstAttribute(named: "Event")
301
- else {
302
- return false
303
- }
304
- return boolArgument(of: attribute, label: "sync") == true
305
- }
306
-
307
279
  /// Reads a labeled array-literal argument of an attribute, e.g. `@ExpoModule(classes: [Foo.self, Bar.self])`,
308
280
  /// and returns the type names referenced (e.g. `["Foo", "Bar"]`). Each element must be a
309
281
  /// `<TypeName>.self` member-access expression; non-conforming elements are skipped silently.
@@ -0,0 +1,105 @@
1
+ import SwiftDiagnostics
2
+ import SwiftSyntax
3
+ import SwiftSyntaxMacros
4
+
5
+ /// Warns when a class that autolinking exports isn't `public` or `open`. Autolinking generates the modules
6
+ /// provider in the app target, so it can reference only public classes of other modules. The warning has a
7
+ /// fix-it that makes the class `public`. A local class is skipped, because it can't have an access modifier.
8
+ internal func diagnoseNonPublicClass(
9
+ _ classDecl: ClassDeclSyntax,
10
+ macroName: String,
11
+ reason: String,
12
+ idPrefix: String,
13
+ in context: some MacroExpansionContext
14
+ ) {
15
+ guard !isLocal(context: context) else {
16
+ return
17
+ }
18
+ let accessModifier = classDecl.modifiers.first { accessLevelKeywords.contains($0.name.tokenKind) }
19
+ if let accessModifier,
20
+ [.keyword(.public), .keyword(.open)].contains(accessModifier.name.tokenKind)
21
+ {
22
+ return
23
+ }
24
+ let name = classDecl.name.text
25
+ context.diagnose(
26
+ Diagnostic(
27
+ node: accessModifier.map(Syntax.init) ?? Syntax(classDecl.classKeyword),
28
+ message: PublicAccessDiagnosticMessage(
29
+ "\(macroName) class '\(name)' should be public or open, so that the modules provider that autolinking generates in the app target can \(reason)",
30
+ id: "\(idPrefix)-class-not-public"
31
+ ),
32
+ fixIts: [makePublicFixIt(for: classDecl, replacing: accessModifier, idPrefix: idPrefix)]
33
+ )
34
+ )
35
+ }
36
+
37
+ private let accessLevelKeywords: [TokenKind] = [
38
+ .keyword(.open), .keyword(.public), .keyword(.package), .keyword(.internal), .keyword(.fileprivate),
39
+ .keyword(.private),
40
+ ]
41
+
42
+ /// True when the declaration is inside a function, closure or another scope that isn't a type or an extension.
43
+ private func isLocal(context: some MacroExpansionContext) -> Bool {
44
+ return context.lexicalContext.contains { node in
45
+ return !node.is(ClassDeclSyntax.self) && !node.is(StructDeclSyntax.self) && !node.is(EnumDeclSyntax.self)
46
+ && !node.is(ActorDeclSyntax.self) && !node.is(ExtensionDeclSyntax.self)
47
+ }
48
+ }
49
+
50
+ /// The fix-it for a class that isn't public: replace its access modifier with `public`, or add `public` before
51
+ /// its other modifiers.
52
+ private func makePublicFixIt(
53
+ for classDecl: ClassDeclSyntax,
54
+ replacing accessModifier: DeclModifierSyntax?,
55
+ idPrefix: String
56
+ ) -> FixIt {
57
+ let message = PublicAccessFixItMessage("Make the class 'public'", id: "\(idPrefix)-class-insert-public")
58
+ if let accessModifier {
59
+ let newModifier = accessModifier.with(\.name, accessModifier.name.with(\.tokenKind, .keyword(.public)))
60
+ return FixIt(message: message, changes: [.replace(oldNode: Syntax(accessModifier), newNode: Syntax(newModifier))])
61
+ }
62
+ // The new modifier takes over the leading trivia of the first modifier or of `class` (the line break after the
63
+ // attribute), so it lands where the declaration started.
64
+ let newDecl: ClassDeclSyntax
65
+ if let firstModifier = classDecl.modifiers.first {
66
+ let publicModifier = DeclModifierSyntax(
67
+ name: .keyword(.public, leadingTrivia: firstModifier.leadingTrivia, trailingTrivia: .space)
68
+ )
69
+ var modifiers = classDecl.modifiers
70
+ modifiers[modifiers.startIndex] = firstModifier.with(\.leadingTrivia, [])
71
+ modifiers.insert(publicModifier, at: modifiers.startIndex)
72
+ newDecl = classDecl.with(\.modifiers, modifiers)
73
+ } else {
74
+ let classKeyword = classDecl.classKeyword
75
+ let publicModifier = DeclModifierSyntax(
76
+ name: .keyword(.public, leadingTrivia: classKeyword.leadingTrivia, trailingTrivia: .space)
77
+ )
78
+ newDecl =
79
+ classDecl
80
+ .with(\.modifiers, [publicModifier])
81
+ .with(\.classKeyword, classKeyword.with(\.leadingTrivia, []))
82
+ }
83
+ return FixIt(message: message, changes: [.replace(oldNode: Syntax(classDecl), newNode: Syntax(newDecl))])
84
+ }
85
+
86
+ private struct PublicAccessDiagnosticMessage: DiagnosticMessage {
87
+ let message: String
88
+ let diagnosticID: MessageID
89
+ let severity: DiagnosticSeverity = .warning
90
+
91
+ init(_ message: String, id: String) {
92
+ self.message = message
93
+ self.diagnosticID = MessageID(domain: "ExpoModulesMacros", id: id)
94
+ }
95
+ }
96
+
97
+ private struct PublicAccessFixItMessage: FixItMessage {
98
+ let message: String
99
+ let fixItID: MessageID
100
+
101
+ init(_ message: String, id: String) {
102
+ self.message = message
103
+ self.fixItID = MessageID(domain: "ExpoModulesMacros", id: id)
104
+ }
105
+ }
package/build/types.d.ts CHANGED
@@ -260,8 +260,8 @@ export interface ExportedEvent extends ExportedAccess, ExportedAvailability {
260
260
  jsName: string;
261
261
  /** The payload type, absent for a no-payload `() -> Void` event. */
262
262
  payload?: TypeNode;
263
- /** `@Event(sync: true)`, dispatching inline on the JS thread instead of scheduling. */
264
- sync: boolean;
263
+ /** How the event is dispatched: the default `scheduled`, or the `.inline` or `.blocking` option. */
264
+ dispatch: 'scheduled' | 'inline' | 'blocking';
265
265
  }
266
266
  /** The `@JS init` of a shared object: the constructor JS calls with `new`. */
267
267
  export interface ExportedInitializer extends ExportedAccess, ExportedAvailability {
@@ -391,10 +391,16 @@ export interface ScanStats {
391
391
  /** Wall-clock duration of the scan, in milliseconds. */
392
392
  durationMs: number;
393
393
  }
394
- /** One module in the `scan-modules` output. */
395
- export interface ScannedModule {
394
+ /**
395
+ * One class in the `scan-modules` output: an `@ExpoModule` class in `modules`, or a `@SharedObject`
396
+ * class in `sharedObjects`.
397
+ */
398
+ export interface ScannedClass {
396
399
  name: string;
397
- /** The resolved JS name: the `@ExpoModule("Foo")` override, else the class name. */
400
+ /**
401
+ * The resolved JS name: the `@ExpoModule("Foo")` or `@SharedObject("Foo")` override, else the class
402
+ * name.
403
+ */
398
404
  jsName: string;
399
405
  /**
400
406
  * The spelled access level, or `internal` when none is written. The generated provider references
@@ -403,7 +409,7 @@ export interface ScannedModule {
403
409
  accessLevel: string;
404
410
  /**
405
411
  * The OSes whose builds include this class, resolved from the enclosing `#if` conditions. An
406
- * unconditional module lists every OS. Empty means no build is known to include it, so don't
412
+ * unconditional class lists every OS. Empty means no build is known to include it, so don't
407
413
  * register it.
408
414
  */
409
415
  platforms: ScannedPlatform[];
@@ -412,7 +418,10 @@ export interface ScannedModule {
412
418
  /** The `scan-modules` result. */
413
419
  export interface ScanModulesResult {
414
420
  schemaVersion: number;
415
- modules: ScannedModule[];
421
+ /** The top-level `@ExpoModule` classes. */
422
+ modules: ScannedClass[];
423
+ /** The top-level `@SharedObject` classes, with the same platform rules as `modules`. */
424
+ sharedObjects: ScannedClass[];
416
425
  warnings: ScanWarning[];
417
426
  stats: ScanStats;
418
427
  }
@@ -471,5 +480,5 @@ export interface ExpandResult {
471
480
  * as a clear error instead of silently misread fields.
472
481
  */
473
482
  export declare const SUPPORTED_SCAN_MODULES_SCHEMA_VERSION = 2;
474
- export declare const SUPPORTED_SCAN_EXPORTS_SCHEMA_VERSION = 10;
483
+ export declare const SUPPORTED_SCAN_EXPORTS_SCHEMA_VERSION = 11;
475
484
  export declare const SUPPORTED_EXPAND_SCHEMA_VERSION = 1;