expo-modules-macros 0.30.0 → 0.32.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.
@@ -285,7 +285,9 @@ final class SurfaceVisitor: SyntaxVisitor {
285
285
  return []
286
286
  }
287
287
  let override = stringArgument(of: attribute)
288
- let isSync = hasEventOption(attribute, named: "sync")
288
+ let dispatch: ExportedEventDispatch =
289
+ hasEventOption(attribute, named: "inline")
290
+ ? .inline : hasEventOption(attribute, named: "blocking") ? .blocking : .scheduled
289
291
  var result: [ExportedEvent] = []
290
292
 
291
293
  for binding in varDecl.bindings {
@@ -306,7 +308,7 @@ final class SurfaceVisitor: SyntaxVisitor {
306
308
  name: name,
307
309
  jsName: override ?? defaultEventName(for: name),
308
310
  payload: functionType.parameters.first.map { typeNode(from: $0.type) },
309
- isSync: isSync,
311
+ dispatch: dispatch,
310
312
  accessLevel: spelledAccessLevel(of: varDecl.modifiers),
311
313
  platforms: platforms
312
314
  )
@@ -704,8 +706,8 @@ private func isVoidEventReturn(_ type: TypeSyntax) -> Bool {
704
706
  return text == "Void" || text == "()" || text == "Swift.Void"
705
707
  }
706
708
 
707
- /// True if an `@Event` attribute lists the given option member (`@Event(.sync)` or
708
- /// `@Event("name", EventOptions.sync)`). Like `hasOption` in the macros, but without the array-literal
709
+ /// True if an `@Event` attribute lists the given option member (`@Event(.inline)` or
710
+ /// `@Event("name", EventOptions.inline)`). Like `hasOption` in the macros, but without the array-literal
709
711
  /// branch, which `EventOptions...` doesn't accept.
710
712
  private func hasEventOption(_ attribute: AttributeSyntax, named option: String) -> Bool {
711
713
  guard let arguments = attribute.arguments?.as(LabeledExprListSyntax.self) else {
@@ -29,15 +29,15 @@ import SwiftSyntaxMacros
29
29
  /// The synthesized property is deliberately **not** isolated to `@JavaScriptActor`, unlike `@JS`
30
30
  /// members: `emit` is itself non-isolated and schedules the dispatch onto the JS thread internally,
31
31
  /// so the event is callable from any thread or isolation with no actor hop at the call site. It is
32
- /// also self-contained: `@ExpoModule`/`@SharedObject` neither collect `@Event` members nor register
33
- /// their names anywhere.
32
+ /// also self-contained: `@ExpoModule`/`@SharedObject` don't register `@Event` members anywhere. They
33
+ /// read the event names only to warn when two events share one (see `diagnoseEventNameCollisions`).
34
34
  ///
35
- /// `@Event(.sync)` 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)`) 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,98 @@ 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: hasOption(node, named: "sync")
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
+
296
+ /// The JS name of an `@Event` property: the `@Event("…")` override, or the default name derived from
297
+ /// the property name. `nil` when the declaration isn't a single named property, which the macro reports.
298
+ internal func eventJSName(of varDecl: VariableDeclSyntax, attribute: AttributeSyntax) -> String? {
299
+ guard varDecl.bindings.count == 1, let identifier = varDecl.bindings.first?.pattern.as(IdentifierPatternSyntax.self)
300
+ else {
301
+ return nil
302
+ }
303
+ return jsNameArgument(of: attribute) ?? defaultEventName(for: withoutBackticks(identifier.identifier.text))
304
+ }
305
+
219
306
  // MARK: - Default event name
220
307
 
221
308
  /// The default JS event name for a property: the Swift name with the conventional `on` prefix
@@ -63,6 +63,16 @@ public struct ExpoModuleMacro: MemberMacro {
63
63
  for member in classDecl.memberBlock.members {
64
64
  // The types of a `@JS` member are read with the class's tuple aliases resolved.
65
65
  let decl = aliases.resolving(member.decl)
66
+ // A generic member is reported by `@JS` and left out, so the build has no errors in its binding.
67
+ if memberHasJSAttribute(decl) && jsGenericParameterClause(of: decl) != nil {
68
+ continue
69
+ }
70
+ // So is a `static` or `class` member: the module object binds only instance members.
71
+ if memberHasJSAttribute(decl),
72
+ let modifiers = decl.asProtocol(WithModifiersSyntax.self)?.modifiers, isTypeLevel(modifiers)
73
+ {
74
+ continue
75
+ }
66
76
  if memberHasJSAttribute(decl), let assertion = jsConformanceAssertionBody(for: decl) {
67
77
  assertions.append(assertion)
68
78
  }
@@ -86,6 +96,8 @@ public struct ExpoModuleMacro: MemberMacro {
86
96
  }
87
97
 
88
98
  diagnoseJSNameCollisions(functions: functions, properties: properties, in: context)
99
+ diagnoseReservedJSNames(functions: functions, properties: properties, on: .module, in: context)
100
+ diagnoseEventNameCollisions(in: classDecl.memberBlock, in: context)
89
101
 
90
102
  var emitted: [DeclSyntax] = []
91
103
 
@@ -167,10 +179,7 @@ extension ExpoModuleMacro: MemberAttributeMacro {
167
179
  // checked at compile time. Skipped when the member already chose an isolation
168
180
  // (`nonisolated` or another global actor), or when `.concurrent` or `.withAsync("name")` runs
169
181
  // it on the concurrent pool. See `shouldStampJavaScriptActor`.
170
- // `@Event(.sync)` members get the stamp too: a sync event dispatches inline, so the
171
- // isolation forces its call site onto the JS thread. Async events (the default) are
172
- // deliberately left unstamped — their `emit` schedules onto the JS thread itself.
173
- if memberHasJSAttribute(member) || isSyncEventMember(member),
182
+ if memberHasJSAttribute(member),
174
183
  shouldStampJavaScriptActor(on: member, enclosedBy: declaration)
175
184
  {
176
185
  attributes.append("@JavaScriptActor")
@@ -31,6 +31,20 @@ public struct JSMacro: PeerMacro {
31
31
  providingPeersOf declaration: some DeclSyntaxProtocol,
32
32
  in context: some MacroExpansionContext
33
33
  ) throws -> [DeclSyntax] {
34
+ diagnosePlacement(of: node, on: declaration, in: context)
35
+ if let genericParameterClause = jsGenericParameterClause(of: declaration) {
36
+ context.diagnose(
37
+ Diagnostic(
38
+ node: genericParameterClause,
39
+ message: JSDiagnosticMessage(
40
+ "A @JS function or initializer can't be generic: the binding decodes each argument and encodes the result by its static type, which a generic parameter doesn't have. Use a concrete type, a @Union of the types, or 'JavaScriptValue'.",
41
+ id: "js-generic-member",
42
+ severity: .error
43
+ )
44
+ )
45
+ )
46
+ return []
47
+ }
34
48
  diagnoseParameterSpecifiers(in: declaration, in: context)
35
49
  diagnoseFreeFormTypes(in: declaration, in: context)
36
50
  diagnoseClosureTypes(in: declaration, in: context)
@@ -75,6 +89,116 @@ private func isInBindingClass(_ context: some MacroExpansionContext) -> Bool {
75
89
  || classDecl.attributes.firstAttribute(named: "SharedObject") != nil
76
90
  }
77
91
 
92
+ /// Reports a `@JS` attribute that no macro binds, so the member would be missing in JavaScript without
93
+ /// an error:
94
+ /// - on a declaration other than a function, property or initializer, such as a subscript;
95
+ /// - on a member of an extension, since `@ExpoModule` and `@SharedObject` read only the class body;
96
+ /// - on a member of a type that isn't an `@ExpoModule` or `@SharedObject` class;
97
+ /// - on a `static` or `class` member of an `@ExpoModule` class, since JavaScript gets only the instance;
98
+ /// - on an initializer of an `@ExpoModule` class, since JavaScript doesn't construct a module.
99
+ private func diagnosePlacement(
100
+ of node: AttributeSyntax,
101
+ on declaration: some DeclSyntaxProtocol,
102
+ in context: some MacroExpansionContext
103
+ ) {
104
+ guard
105
+ declaration.is(FunctionDeclSyntax.self) || declaration.is(VariableDeclSyntax.self)
106
+ || declaration.is(InitializerDeclSyntax.self)
107
+ else {
108
+ let kind = declaration.is(SubscriptDeclSyntax.self) ? "a subscript" : "this declaration"
109
+ context.diagnose(
110
+ Diagnostic(
111
+ node: node,
112
+ message: JSDiagnosticMessage(
113
+ "@JS applies to a function, property or initializer, so \(kind) isn't bound to JavaScript. Expose a @JS function instead.",
114
+ id: "js-unsupported-declaration",
115
+ severity: .error
116
+ )
117
+ )
118
+ )
119
+ return
120
+ }
121
+ if let extensionDecl = context.lexicalContext.first?.as(ExtensionDeclSyntax.self) {
122
+ let typeName = extensionDecl.extendedType.trimmedDescription
123
+ context.diagnose(
124
+ Diagnostic(
125
+ node: node,
126
+ message: JSDiagnosticMessage(
127
+ "A @JS member of an extension isn't bound to JavaScript: @ExpoModule and @SharedObject bind only the @JS members declared in the class body. Move the member into the declaration of '\(typeName)'.",
128
+ id: "js-in-extension",
129
+ severity: .error
130
+ )
131
+ )
132
+ )
133
+ return
134
+ }
135
+ if let enclosingType = context.lexicalContext.first, enclosingType.isProtocol(DeclGroupSyntax.self),
136
+ !isInBindingClass(context)
137
+ {
138
+ let typeName = enclosingType.asProtocol(NamedDeclSyntax.self).map { "'\($0.name.text)'" } ?? "this type"
139
+ context.diagnose(
140
+ Diagnostic(
141
+ node: node,
142
+ message: JSDiagnosticMessage(
143
+ "A @JS member of \(typeName) isn't bound to JavaScript: only an @ExpoModule or @SharedObject class binds its @JS members. Add one of these macros to \(typeName), or move the member into such a class.",
144
+ id: "js-outside-binding-class",
145
+ severity: .error
146
+ )
147
+ )
148
+ )
149
+ return
150
+ }
151
+ if let modifiers = declaration.asProtocol(WithModifiersSyntax.self)?.modifiers, isTypeLevel(modifiers),
152
+ let classDecl = context.lexicalContext.first?.as(ClassDeclSyntax.self),
153
+ classDecl.attributes.firstAttribute(named: "ExpoModule") != nil
154
+ {
155
+ context.diagnose(
156
+ Diagnostic(
157
+ node: node,
158
+ message: JSDiagnosticMessage(
159
+ "A @JS member of an @ExpoModule class can't be 'static' or 'class': JavaScript gets one module object, and @ExpoModule binds the members of the instance to it. Make the member an instance member.",
160
+ id: "js-module-static-member",
161
+ severity: .error
162
+ )
163
+ )
164
+ )
165
+ return
166
+ }
167
+ if let initDecl = declaration.as(InitializerDeclSyntax.self),
168
+ let classDecl = context.lexicalContext.first?.as(ClassDeclSyntax.self),
169
+ classDecl.attributes.firstAttribute(named: "ExpoModule") != nil
170
+ {
171
+ context.diagnose(
172
+ Diagnostic(
173
+ node: node,
174
+ message: JSDiagnosticMessage(
175
+ "@JS on an initializer of an @ExpoModule class isn't bound to JavaScript: JavaScript doesn't construct a module, it gets the instance that the app context creates. Remove @JS, or construct a @SharedObject class from JavaScript instead.",
176
+ id: "js-module-initializer",
177
+ severity: .error
178
+ ),
179
+ fixIts: removeAttributeFixIts(node, before: initDecl.initKeyword)
180
+ )
181
+ )
182
+ }
183
+ }
184
+
185
+ /// The fix-it that removes a `@JS` attribute written on its own line, with the line break and
186
+ /// indentation before it. The macro gets the attribute without the trivia after it, so when the
187
+ /// declaration starts on the line of the attribute (`@JS init()`), the fix-it can't tell how much
188
+ /// space to remove, and there is none. It edits only the text of the attribute, since the declaration
189
+ /// that a peer macro gets can carry attributes that other macros added.
190
+ private func removeAttributeFixIts(_ node: AttributeSyntax, before keyword: TokenSyntax) -> [FixIt] {
191
+ guard keyword.leadingTrivia.contains(where: \.isNewline) else {
192
+ return []
193
+ }
194
+ return [
195
+ FixIt(
196
+ message: JSFixItMessage("Remove '@JS'", id: "js-remove-attribute"),
197
+ changes: [.replaceText(range: node.position..<node.endPosition, with: "", in: Syntax(node))]
198
+ )
199
+ ]
200
+ }
201
+
78
202
  /// Emits the diagnostic for `@JS(.concurrent)` on a member that can't take it. The option maps to
79
203
  /// Swift's `@concurrent`, which requires an `async` function: a synchronous member has nowhere to
80
204
  /// suspend, and a property or initializer can't be async at all. Diagnosing here points at the
@@ -81,6 +81,12 @@ public struct RecordMacro: MemberMacro, ExtensionMacro {
81
81
  // the author's version stand — the factories only need *an* `init(<properties>)` and `Record` only
82
82
  // needs *an* `init()`, regardless of who wrote them.
83
83
  let existingInitLabels = initializerParameterLabels(of: declaration)
84
+ if !existingInitLabels.contains(properties.map { $0.key }) {
85
+ let diagnostics = unsetPrivatePropertyDiagnostics(of: declaration)
86
+ guard diagnostics.isEmpty else {
87
+ throw DiagnosticsError(diagnostics: diagnostics)
88
+ }
89
+ }
84
90
 
85
91
  var members: [DeclSyntax] = []
86
92
 
@@ -227,6 +233,10 @@ private func recordProperties(
227
233
  guard let ident = binding.pattern.as(IdentifierPatternSyntax.self) else {
228
234
  continue
229
235
  }
236
+ // The memberwise init sets every property, and a `let` with a default value can't be set again.
237
+ if varDecl.bindingSpecifier.tokenKind == .keyword(.let), binding.initializer != nil {
238
+ throw DiagnosticsError(diagnostics: [letWithDefaultDiagnostic(for: varDecl, name: ident.identifier.text)])
239
+ }
230
240
  // Prefer the explicit annotation. When it's omitted, recover the type from a literal default
231
241
  // (Swift's own default-literal type) — covers the common `var name = "foo"` case. Anything
232
242
  // whose type a syntactic macro can't determine (calls, collections, member access) still needs
@@ -637,6 +647,64 @@ private func unsupportedClassDiagnostics(of declaration: some DeclGroupSyntax) -
637
647
  return diagnostics
638
648
  }
639
649
 
650
+ /// The errors for the `private` and `fileprivate` stored properties that the synthesized memberwise
651
+ /// initializer would leave without a value. Such a property isn't a record property, so the initializer
652
+ /// doesn't set it, and it needs a default value. An optional `var` starts as `nil`, so it needs none.
653
+ private func unsetPrivatePropertyDiagnostics(of declaration: some DeclGroupSyntax) -> [Diagnostic] {
654
+ var diagnostics: [Diagnostic] = []
655
+ for member in declaration.memberBlock.members {
656
+ guard let varDecl = member.decl.as(VariableDeclSyntax.self),
657
+ let access = varDecl.modifiers.first(where: {
658
+ $0.name.tokenKind == .keyword(.private) || $0.name.tokenKind == .keyword(.fileprivate)
659
+ }),
660
+ !varDecl.modifiers.contains(where: {
661
+ $0.name.tokenKind == .keyword(.static) || $0.name.tokenKind == .keyword(.class)
662
+ || $0.name.tokenKind == .keyword(.lazy)
663
+ })
664
+ else {
665
+ continue
666
+ }
667
+ let isVar = varDecl.bindingSpecifier.tokenKind == .keyword(.var)
668
+ for binding in varDecl.bindings where binding.accessorBlock == nil && binding.initializer == nil {
669
+ guard let ident = binding.pattern.as(IdentifierPatternSyntax.self),
670
+ !(isVar && binding.typeAnnotation.map { isOptionalType($0.type) } == true)
671
+ else {
672
+ continue
673
+ }
674
+ diagnostics.append(
675
+ Diagnostic(
676
+ node: ident,
677
+ message: RecordDiagnosticMessage(
678
+ "@Record leaves out the \(access.name.text) property '\(ident.identifier.text)', so the synthesized initializer doesn't set it. Give it a default value, or remove '\(access.name.text)' to make it a record property",
679
+ id: "record-unset-private-property"
680
+ )
681
+ )
682
+ )
683
+ }
684
+ }
685
+ return diagnostics
686
+ }
687
+
688
+ /// The error for a `let` property with a default value, attached to the `let` keyword and carrying a
689
+ /// fix-it that replaces it with `var`. The default still applies when JavaScript omits the property.
690
+ private func letWithDefaultDiagnostic(for varDecl: VariableDeclSyntax, name: String) -> Diagnostic {
691
+ let specifier = varDecl.bindingSpecifier
692
+ let fixIt = FixIt(
693
+ message: RecordFixItMessage("Replace 'let' with 'var'", id: "record-let-to-var"),
694
+ changes: [
695
+ .replace(oldNode: Syntax(specifier), newNode: Syntax(specifier.with(\.tokenKind, .keyword(.var))))
696
+ ]
697
+ )
698
+ return Diagnostic(
699
+ node: specifier,
700
+ message: RecordDiagnosticMessage(
701
+ "@Record property '\(name)' is a 'let' with a default value, so the synthesized initializer can't set it from JavaScript. Make it a 'var', and the default applies when JavaScript omits the property, or make it 'static' or 'private' to leave it out of the record",
702
+ id: "record-let-with-default"
703
+ ),
704
+ fixIts: [fixIt]
705
+ )
706
+ }
707
+
640
708
  /// The fix-it for a record class that isn't `final`: add the modifier right before `class`.
641
709
  private func markFinalFixIt(for classDecl: ClassDeclSyntax) -> FixIt {
642
710
  // The new modifier takes over the leading trivia of `class` (the line break after the attribute
@@ -1,3 +1,4 @@
1
+ import SwiftDiagnostics
1
2
  import SwiftSyntax
2
3
  import SwiftSyntaxBuilder
3
4
  import SwiftSyntaxMacros
@@ -42,9 +43,13 @@ public struct SharedObjectMacro: MemberMacro {
42
43
  in: context
43
44
  )
44
45
 
45
- // The superclass isn't checked here: the macro sees only this declaration, so it can't tell an
46
+ // A class without an inheritance clause has no superclass, so it can't be a `SharedObject`. Any
47
+ // other superclass isn't checked: the macro sees only this declaration, so it can't tell an
46
48
  // indirect subclass (`class Thumbnail: ImageRef`) from an unrelated class. The compiler reports a
47
49
  // class that isn't a `SharedObject` subclass on the `_jsName` override, which is always emitted.
50
+ if classDecl.inheritanceClause == nil {
51
+ throw DiagnosticsError(diagnostics: [missingBaseClassDiagnostic(for: classDecl)])
52
+ }
48
53
  let typeName = classDecl.name.text
49
54
  let jsName = jsNameArgument(of: node) ?? withoutBackticks(typeName)
50
55
 
@@ -71,6 +76,10 @@ public struct SharedObjectMacro: MemberMacro {
71
76
  for member in classDecl.memberBlock.members {
72
77
  // The types of a `@JS` member are read with the class's tuple aliases resolved.
73
78
  let decl = aliases.resolving(member.decl)
79
+ // A generic member is reported by `@JS` and left out, so the build has no errors in its binding.
80
+ if memberHasJSAttribute(decl) && jsGenericParameterClause(of: decl) != nil {
81
+ continue
82
+ }
74
83
  if memberHasJSAttribute(decl), let assertion = jsConformanceAssertionBody(for: decl) {
75
84
  assertions.append(assertion)
76
85
  }
@@ -114,6 +123,19 @@ public struct SharedObjectMacro: MemberMacro {
114
123
 
115
124
  diagnoseJSNameCollisions(functions: instanceFunctions, properties: instanceProperties, in: context)
116
125
  diagnoseJSNameCollisions(functions: staticFunctions, properties: staticProperties, in: context)
126
+ diagnoseReservedJSNames(
127
+ functions: instanceFunctions,
128
+ properties: instanceProperties,
129
+ on: .prototype,
130
+ in: context
131
+ )
132
+ diagnoseReservedJSNames(
133
+ functions: staticFunctions,
134
+ properties: staticProperties,
135
+ on: .constructor,
136
+ in: context
137
+ )
138
+ diagnoseEventNameCollisions(in: classDecl.memberBlock, in: context)
117
139
 
118
140
  // The resolved JS class name, read by core when it builds the class. An `override` of the
119
141
  // `SharedObject` class property, so core reads it through the concrete type's metatype.
@@ -171,10 +193,7 @@ extension SharedObjectMacro: MemberAttributeMacro {
171
193
  providingAttributesFor member: some DeclSyntaxProtocol,
172
194
  in context: some MacroExpansionContext
173
195
  ) throws -> [AttributeSyntax] {
174
- // `@Event(.sync)` members are stamped alongside `@JS` ones: a sync event dispatches
175
- // inline, so the isolation forces its call site onto the JS thread. Async events (the
176
- // default) stay unstamped — their `emit` schedules onto the JS thread itself.
177
- guard memberHasJSAttribute(member) || isSyncEventMember(member) else {
196
+ guard memberHasJSAttribute(member) else {
178
197
  return []
179
198
  }
180
199
  // `@JS(.concurrent)` opts the member out of the JS-thread stamp and onto the concurrent pool.
@@ -202,3 +221,59 @@ extension SharedObjectMacro: ExtensionMacro {
202
221
  return [sendable]
203
222
  }
204
223
  }
224
+
225
+ /// The error for a `@SharedObject` class without an inheritance clause, with a fix-it that makes it
226
+ /// inherit from `SharedObject`: `class Cache {` becomes `class Cache: SharedObject {`.
227
+ private func missingBaseClassDiagnostic(for classDecl: ClassDeclSyntax) -> Diagnostic {
228
+ // The name carries the space before `{` as trailing trivia, so move it onto the clause to keep the
229
+ // spacing.
230
+ let name = classDecl.name
231
+ let clause = InheritanceClauseSyntax(
232
+ colon: .colonToken(trailingTrivia: .space),
233
+ inheritedTypes: InheritedTypeListSyntax([
234
+ InheritedTypeSyntax(type: TypeSyntax(IdentifierTypeSyntax(name: .identifier("SharedObject"))))
235
+ ])
236
+ )
237
+ let fixIt = FixIt(
238
+ message: SharedObjectFixItMessage("Inherit from 'SharedObject'", id: "sharedobject-add-base-class"),
239
+ changes: [
240
+ .replace(
241
+ oldNode: Syntax(classDecl),
242
+ newNode: Syntax(
243
+ classDecl
244
+ .with(\.name, name.with(\.trailingTrivia, []))
245
+ .with(\.inheritanceClause, clause.with(\.trailingTrivia, name.trailingTrivia))
246
+ )
247
+ )
248
+ ]
249
+ )
250
+ return Diagnostic(
251
+ node: name,
252
+ message: SharedObjectDiagnosticMessage(
253
+ "@SharedObject class '\(name.text)' must inherit from 'SharedObject', directly or through another class such as 'SharedRef'. Add ': SharedObject' to the class declaration.",
254
+ id: "sharedobject-missing-base-class"
255
+ ),
256
+ fixIts: [fixIt]
257
+ )
258
+ }
259
+
260
+ private struct SharedObjectDiagnosticMessage: DiagnosticMessage {
261
+ let message: String
262
+ let diagnosticID: MessageID
263
+ let severity: DiagnosticSeverity = .error
264
+
265
+ init(_ message: String, id: String) {
266
+ self.message = message
267
+ self.diagnosticID = MessageID(domain: "ExpoModulesMacros", id: id)
268
+ }
269
+ }
270
+
271
+ private struct SharedObjectFixItMessage: FixItMessage {
272
+ let message: String
273
+ let fixItID: MessageID
274
+
275
+ init(_ message: String, id: String) {
276
+ self.message = message
277
+ self.fixItID = MessageID(domain: "ExpoModulesMacros", id: id)
278
+ }
279
+ }