expo-modules-macros 0.29.0 → 0.30.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.
@@ -0,0 +1,228 @@
1
+ #if canImport(FoundationEssentials)
2
+ import FoundationEssentials
3
+ #else
4
+ import Foundation
5
+ #endif
6
+
7
+ /// The options of `fix`, parsed by `ScannerCLI`.
8
+ struct FixOptions: Equatable {
9
+ var paths: [String] = []
10
+ var symbols: [String] = []
11
+ var dryRun = false
12
+ var onlyErrors = false
13
+ }
14
+
15
+ /// A Fix-It that `fix` applied or skipped, at the location of its diagnostic.
16
+ struct FixItReport: Equatable {
17
+ let line: Int
18
+ let column: Int
19
+ let message: String
20
+
21
+ /// Why `fix` skipped the Fix-It, or `nil` when it applied it.
22
+ let skipReason: String?
23
+ }
24
+
25
+ /// The result of applying the Fix-Its of one file.
26
+ struct FixedSource: Equatable {
27
+ /// The source with the applied Fix-Its.
28
+ let source: String
29
+ let fixIts: [FixItReport]
30
+
31
+ /// Whether `fixSource` stopped after `maxFixPasses` passes while the last pass still applied a
32
+ /// Fix-It, so the source can have more.
33
+ var reachedPassLimit = false
34
+
35
+ var applied: [FixItReport] {
36
+ fixIts.filter { $0.skipReason == nil }
37
+ }
38
+ }
39
+
40
+ extension Scanner {
41
+ /// Runs `fix`: expands the macros in each file like `expand` and applies the Fix-Its of their
42
+ /// diagnostics to the file, in place (see `fixSource`). It doesn't format the file.
43
+ ///
44
+ /// Each applied Fix-It is printed to stdout as `<file>:<line>:<column>: fix-it: <message>` after its
45
+ /// file is written, followed by a summary, and each skipped one to stderr as a warning. With
46
+ /// `--dry-run`, the files aren't written. A file that can't be written is reported and the others are
47
+ /// still fixed. The exit code is `0` also when a Fix-It is skipped, `1` when a file can't be written,
48
+ /// and `2` for an input that `expand` doesn't take.
49
+ static func runFix(options: FixOptions, expander: MacroExpander) -> Int32 {
50
+ let sources: [(source: String, expanded: ExpandedFile)]
51
+ switch expandSources(options: ExpandOptions(paths: options.paths, symbols: options.symbols), expander: expander) {
52
+ case .success(let expanded):
53
+ sources = expanded
54
+ case .failure(let error):
55
+ writeToStandardError("error: \(error.message)\n")
56
+ return 2
57
+ }
58
+
59
+ var appliedCount = 0
60
+ var fixedFiles = 0
61
+ var failed = false
62
+ for (source, expanded) in sources {
63
+ let fixed = fixSource(
64
+ source,
65
+ expanded: expanded,
66
+ symbols: options.symbols,
67
+ onlyErrors: options.onlyErrors,
68
+ expander: expander
69
+ )
70
+ writeToStandardError(renderSkippedFixIts(fixed, file: expanded.file))
71
+ guard !fixed.applied.isEmpty else {
72
+ continue
73
+ }
74
+ if !options.dryRun {
75
+ do {
76
+ try writeSource(fixed.source, toFile: expanded.file)
77
+ } catch {
78
+ writeToStandardError("error: could not write '\(expanded.file)': \(error)\n")
79
+ failed = true
80
+ continue
81
+ }
82
+ }
83
+ writeToStandardOutput(renderAppliedFixIts(fixed, file: expanded.file))
84
+ appliedCount += fixed.applied.count
85
+ fixedFiles += 1
86
+ }
87
+
88
+ writeToStandardOutput(renderFixSummary(applied: appliedCount, files: fixedFiles, dryRun: options.dryRun))
89
+ return failed ? 1 : 0
90
+ }
91
+ }
92
+
93
+ /// Writes `source` to the file at `path` in place, so a symbolic link keeps pointing to the file and a
94
+ /// hard link keeps sharing it, and a writable file can be written in a read-only directory. Keeps the
95
+ /// UTF-8 byte order mark of the file, which the source doesn't have because reading it as a `String`
96
+ /// removes it.
97
+ func writeSource(_ source: String, toFile path: String) throws {
98
+ let url = URL(fileURLWithPath: path)
99
+ let byteOrderMark: [UInt8] = [0xEF, 0xBB, 0xBF]
100
+ let hasByteOrderMark = try Data(contentsOf: url).starts(with: byteOrderMark)
101
+ try Data((hasByteOrderMark ? byteOrderMark : []) + Array(source.utf8)).write(to: url)
102
+ }
103
+
104
+ /// The most times that `fixSource` expands a file again after applying Fix-Its.
105
+ let maxFixPasses = 10
106
+
107
+ /// Applies the Fix-Its of `expanded`, the expansion of `source`, to `source`. A macro can stop at its
108
+ /// first error, so after applying Fix-Its, this expands the result again and applies the new Fix-Its,
109
+ /// until none applies or after `maxFixPasses` passes. The location of an applied Fix-It is in the
110
+ /// source of its pass. The skipped Fix-Its are those of the last pass. With `onlyErrors`, the Fix-Its
111
+ /// of warnings and other diagnostics that aren't errors are left out.
112
+ func fixSource(
113
+ _ source: String,
114
+ expanded: ExpandedFile,
115
+ symbols: [String],
116
+ onlyErrors: Bool = false,
117
+ expander: MacroExpander
118
+ ) -> FixedSource {
119
+ let selected = { (diagnostics: [ExpansionDiagnostic]) in
120
+ onlyErrors ? diagnostics.filter { $0.severity == .error } : diagnostics
121
+ }
122
+ var fixed = applyFixIts(to: source, diagnostics: selected(expanded.diagnostics))
123
+ var applied = fixed.applied
124
+ for _ in 1..<maxFixPasses where !fixed.applied.isEmpty {
125
+ let diagnostics = expander.expand(fixed.source, expanded.file, symbols).diagnostics
126
+ fixed = applyFixIts(to: fixed.source, diagnostics: selected(diagnostics))
127
+ applied += fixed.applied
128
+ }
129
+ return FixedSource(
130
+ source: fixed.source,
131
+ fixIts: applied + fixed.fixIts.filter { $0.skipReason != nil },
132
+ reachedPassLimit: !fixed.applied.isEmpty
133
+ )
134
+ }
135
+
136
+ /// Applies the Fix-Its of `diagnostics` to `source`, which is the file that they were reported for.
137
+ ///
138
+ /// A diagnostic with more than one Fix-It is skipped, because they are alternatives. So is a Fix-It
139
+ /// whose edits aren't in the file, or whose edits overlap the edits of a Fix-It before it. A Fix-It
140
+ /// with the same edits as one before it, which a macro can report twice, is left out. A diagnostic
141
+ /// without `fixItEdits`, such as a syntax error, is left out too.
142
+ func applyFixIts(to source: String, diagnostics: [ExpansionDiagnostic]) -> FixedSource {
143
+ let text = Array(source.utf8)
144
+ var accepted: [ExpansionEdit] = []
145
+ var reports: [FixItReport] = []
146
+
147
+ for diagnostic in diagnostics
148
+ where !diagnostic.fixIts.isEmpty && diagnostic.fixItEdits.count == diagnostic.fixIts.count {
149
+ let report = { (message: String, reason: String?) in
150
+ reports.append(
151
+ FixItReport(line: diagnostic.line, column: diagnostic.column, message: message, skipReason: reason)
152
+ )
153
+ }
154
+ guard diagnostic.fixIts.count == 1, let message = diagnostic.fixIts.first, let edits = diagnostic.fixItEdits.first
155
+ else {
156
+ for message in diagnostic.fixIts {
157
+ report(message, "the diagnostic has \(diagnostic.fixIts.count) Fix-Its to choose from")
158
+ }
159
+ continue
160
+ }
161
+ if !edits.isEmpty && edits.allSatisfy(accepted.contains) {
162
+ continue
163
+ }
164
+ guard !edits.isEmpty, edits.allSatisfy({ matches($0, in: text) }) else {
165
+ report(message, "its edits aren't in the file")
166
+ continue
167
+ }
168
+ let overlaps = edits.enumerated().contains { index, edit in
169
+ (accepted + edits.prefix(index)).contains { overlap(edit, $0) }
170
+ }
171
+ guard !overlaps else {
172
+ report(message, "it overlaps another Fix-It")
173
+ continue
174
+ }
175
+ accepted += edits
176
+ report(message, nil)
177
+ }
178
+
179
+ var fixed = text
180
+ for edit in accepted.sorted(by: { $0.offset > $1.offset }) {
181
+ fixed.replaceSubrange(edit.offset..<(edit.offset + edit.original.utf8.count), with: Array(edit.replacement.utf8))
182
+ }
183
+ return FixedSource(source: String(decoding: fixed, as: UTF8.self), fixIts: reports)
184
+ }
185
+
186
+ /// Whether `text` has the original text of `edit` at its offset.
187
+ private func matches(_ edit: ExpansionEdit, in text: [UInt8]) -> Bool {
188
+ let original = Array(edit.original.utf8)
189
+ return edit.offset >= 0 && edit.offset + original.count <= text.count
190
+ && text[edit.offset..<(edit.offset + original.count)].elementsEqual(original)
191
+ }
192
+
193
+ /// Whether two edits change the same bytes, or insert at the same offset, where their order would
194
+ /// matter.
195
+ private func overlap(_ a: ExpansionEdit, _ b: ExpansionEdit) -> Bool {
196
+ let aEnd = a.offset + a.original.utf8.count
197
+ let bEnd = b.offset + b.original.utf8.count
198
+ return a.offset == b.offset || (a.offset < bEnd && b.offset < aEnd)
199
+ }
200
+
201
+ /// The applied Fix-Its of `fixed`, one line each.
202
+ func renderAppliedFixIts(_ fixed: FixedSource, file: String) -> String {
203
+ fixed.applied.map { "\(file):\($0.line):\($0.column): fix-it: \($0.message)\n" }.joined()
204
+ }
205
+
206
+ /// The warnings for the skipped Fix-Its of `fixed`, and for reaching the pass limit.
207
+ func renderSkippedFixIts(_ fixed: FixedSource, file: String) -> String {
208
+ var text = ""
209
+ for fixIt in fixed.fixIts {
210
+ if let reason = fixIt.skipReason {
211
+ text += "\(file):\(fixIt.line):\(fixIt.column): warning: skipped fix-it '\(fixIt.message)': \(reason)\n"
212
+ }
213
+ }
214
+ if fixed.reachedPassLimit {
215
+ text += "\(file): warning: stopped after \(maxFixPasses) passes; run fix again for the Fix-Its left\n"
216
+ }
217
+ return text
218
+ }
219
+
220
+ /// The summary line of `fix`, for example `Applied 3 Fix-Its in 2 files.`
221
+ func renderFixSummary(applied: Int, files: Int, dryRun: Bool) -> String {
222
+ guard applied > 0 else {
223
+ return "No Fix-Its to apply.\n"
224
+ }
225
+ let fixIts = "\(applied) Fix-It\(applied == 1 ? "" : "s")"
226
+ let fileCount = "\(files) file\(files == 1 ? "" : "s")"
227
+ return "\(dryRun ? "Would apply" : "Applied") \(fixIts) in \(fileCount).\n"
228
+ }
@@ -207,7 +207,7 @@ struct ExportedEvent: Encodable, Equatable {
207
207
  /// The single payload parameter's type, or `nil` for a no-payload `() -> Void` event.
208
208
  var payload: TypeNode?
209
209
 
210
- /// `@Event(sync: true)`, dispatching inline on the JS thread instead of scheduling.
210
+ /// `@Event(.sync)`, dispatching inline on the JS thread instead of scheduling.
211
211
  let isSync: Bool
212
212
 
213
213
  /// The spelled access modifier, or `nil` when none is written.
@@ -285,7 +285,7 @@ final class SurfaceVisitor: SyntaxVisitor {
285
285
  return []
286
286
  }
287
287
  let override = stringArgument(of: attribute)
288
- let isSync = boolArgument(of: attribute, label: "sync") == true
288
+ let isSync = hasEventOption(attribute, named: "sync")
289
289
  var result: [ExportedEvent] = []
290
290
 
291
291
  for binding in varDecl.bindings {
@@ -704,18 +704,16 @@ private func isVoidEventReturn(_ type: TypeSyntax) -> Bool {
704
704
  return text == "Void" || text == "()" || text == "Swift.Void"
705
705
  }
706
706
 
707
- /// The value of a labeled boolean macro argument (`@Event(sync: true)`), or `nil` when absent.
708
- private func boolArgument(of attribute: AttributeSyntax, label: String) -> Bool? {
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
+ /// branch, which `EventOptions...` doesn't accept.
710
+ private func hasEventOption(_ attribute: AttributeSyntax, named option: String) -> Bool {
709
711
  guard let arguments = attribute.arguments?.as(LabeledExprListSyntax.self) else {
710
- return nil
712
+ return false
711
713
  }
712
- for argument in arguments where argument.label?.text == label {
713
- guard let literal = argument.expression.as(BooleanLiteralExprSyntax.self) else {
714
- return nil
715
- }
716
- return literal.literal.tokenKind == .keyword(.true)
714
+ return arguments.contains { argument in
715
+ argument.expression.as(MemberAccessExprSyntax.self)?.declName.baseName.text == option
717
716
  }
718
- return nil
719
717
  }
720
718
 
721
719
  /// The name in a `@JS` attribute's `.withAsync("name")` option, in the arguments or inside an array
@@ -34,19 +34,19 @@ public enum Scanner {
34
34
  }
35
35
  }
36
36
 
37
- /// One module in the `scan-modules` output. Trimmed to what `expo-modules-autolinking` needs to
38
- /// register a module: the Swift class name, the JS name it registers under, the platforms that
39
- /// include it, and the file it's in. The richer fields the visitor captures (declaration kind, raw
40
- /// macro arguments, line/column) are dropped here — they're redundant for this command (the macro
41
- /// is always `@ExpoModule` on a class) and the deep `scan-exports` surface carries the richer
42
- /// per-member detail instead.
43
- struct ScannedModule: Codable, Equatable {
44
- /// The Swift class name the module is declared as.
37
+ /// One class in the `scan-modules` output: an `@ExpoModule` class in `modules`, or a `@SharedObject`
38
+ /// class in `sharedObjects`. Trimmed to what `expo-modules-autolinking` needs to register the class:
39
+ /// the Swift class name, the JS name it registers under, the platforms that include it, and the file
40
+ /// it's in. The richer fields the visitor captures (declaration kind, raw macro arguments,
41
+ /// line/column) are dropped here, because this command doesn't need them (the macro is always on a
42
+ /// class) and the deep `scan-exports` surface carries the richer per-member detail instead.
43
+ struct ScannedClass: Codable, Equatable {
44
+ /// The Swift class name.
45
45
  let name: String
46
46
 
47
- /// The fully-resolved JS module name: the `@ExpoModule("Foo")` override when present, otherwise the
48
- /// class name. Resolved here (rather than left `nil`) so it matches how the macro derives the name
49
- /// and the consumer never has to apply the fallback itself.
47
+ /// The fully-resolved JS name: the `@ExpoModule("Foo")` or `@SharedObject("Foo")` override when
48
+ /// present, otherwise the class name. Resolved here (rather than left `nil`) so it matches how the
49
+ /// macro derives the name and the consumer never has to apply the fallback itself.
50
50
  let jsName: String
51
51
 
52
52
  /// The class's spelled access modifier (`open`, `public`, `package`, `fileprivate`, `private`),
@@ -56,20 +56,21 @@ struct ScannedModule: Codable, Equatable {
56
56
  let accessLevel: String
57
57
 
58
58
  /// The Apple OSes whose builds include this class, spelled as `os(...)` spells them and compared
59
- /// case-sensitively. An unconditional module lists every OS. Empty means no build is known to
59
+ /// case-sensitively. An unconditional class lists every OS. Empty means no build is known to
60
60
  /// include it, because the enclosing conditions depend on flags not asserted with `--define` or
61
61
  /// on conditions a static scan cannot answer (those are reported in `warnings`). The consumer
62
62
  /// must not assume such a class exists.
63
63
  let platforms: [String]
64
64
 
65
- /// Source file the module was found in, relative to the path the scanner was invoked with.
65
+ /// Source file the class was found in, relative to the path the scanner was invoked with.
66
66
  let file: String
67
67
  }
68
68
 
69
69
  /// Version of the `scan-modules` output shape. Bumped on any breaking change to the envelope or to
70
- /// `ScannedModule`, so `expo-modules-autolinking` can verify it understands the output before
70
+ /// `ScannedClass`, so `expo-modules-autolinking` can verify it understands the output before
71
71
  /// trusting it (and fall back to config-declared modules when it doesn't). Version 2 added the
72
- /// per-module `platforms` list and made the module list platform-agnostic.
72
+ /// per-module `platforms` list and made the module list platform-agnostic. A new field that older
73
+ /// consumers can ignore, such as `sharedObjects`, doesn't change the version.
73
74
  let scanModulesSchemaVersion = 2
74
75
 
75
76
  /// The `scan-modules` result: the detected modules plus the stats describing the run. Encoded as the
@@ -77,7 +78,11 @@ let scanModulesSchemaVersion = 2
77
78
  /// serve different consumers and don't share an envelope.)
78
79
  struct ScanModulesResult: Codable, Equatable {
79
80
  let schemaVersion: Int
80
- let modules: [ScannedModule]
81
+ let modules: [ScannedClass]
82
+
83
+ /// The top-level `@SharedObject` classes, in the same order and with the same platform rules as
84
+ /// `modules`.
85
+ let sharedObjects: [ScannedClass]
81
86
 
82
87
  /// Warnings for `#if` conditions the scan couldn't answer statically (see `ScanWarning`). Carried
83
88
  /// in the report rather than on stderr so the consumer can attach them to its own output.
@@ -97,25 +102,28 @@ private let platformUniverse: [(condition: String, reported: String)] = [
97
102
  (condition: "visionOS", reported: "visionOS"),
98
103
  ]
99
104
 
100
- /// Scans the given paths for top-level `@ExpoModule` types and returns every module found in any
105
+ /// Scans the given paths for top-level `@ExpoModule` and `@SharedObject` types and returns every one
106
+ /// found in any
101
107
  /// `#if` branch (in file then source order), each with the platforms whose builds include it, plus
102
108
  /// the stats for the run — the `scan-modules` command. Kept separate from the public entry (and
103
109
  /// `internal`) so tests can drive it without going through argv/stdout.
104
110
  ///
105
111
  /// Each file is parsed once and walked once per platform (plus once unconditionally to enumerate
106
- /// every module): a module's `platforms` are the OSes whose evaluated walk reached it, given the
112
+ /// every type): a type's `platforms` are the OSes whose evaluated walk reached it, given the
107
113
  /// asserted `defines`. The scanner reports the facts; filtering to the platform being linked is the
108
114
  /// consumer's call.
109
115
  func scanModules(paths: [String], defines: Set<String> = []) -> ScanModulesResult {
110
- var modules: [ScannedModule] = []
116
+ var modules: [ScannedClass] = []
117
+ var sharedObjects: [ScannedClass] = []
111
118
  var warnings: [ScanWarning] = []
112
119
  var seenWarnings = Set<ScanWarning>()
113
120
 
114
- let stats = scanFiles(paths: paths, macros: [.expoModule]) { source, file in
121
+ let macros: Set<DetectedMacro> = [.expoModule, .sharedObject]
122
+ let stats = scanFiles(paths: paths, macros: macros) { source, file in
115
123
  let tree = Parser.parse(source: source)
116
124
 
117
- // The unconditional walk enumerates every module in the file, in source order.
118
- let allModules = DetectionVisitor(file: file, tree: tree, detectedMacros: [.expoModule], configuration: nil)
125
+ // The unconditional walk enumerates every type in the file, in source order.
126
+ let allModules = DetectionVisitor(file: file, tree: tree, detectedMacros: macros, configuration: nil)
119
127
  allModules.walk(tree)
120
128
 
121
129
  // One evaluated walk per OS attributes each module to the platforms that include it. The walks
@@ -126,7 +134,7 @@ func scanModules(paths: [String], defines: Set<String> = []) -> ScanModulesResul
126
134
  let visitor = DetectionVisitor(
127
135
  file: file,
128
136
  tree: tree,
129
- detectedMacros: [.expoModule],
137
+ detectedMacros: macros,
130
138
  configuration: configuration
131
139
  )
132
140
  visitor.walk(tree)
@@ -140,24 +148,30 @@ func scanModules(paths: [String], defines: Set<String> = []) -> ScanModulesResul
140
148
  }
141
149
  }
142
150
 
143
- for detection in allModules.detections {
144
- // Resolve the JS name the way the macro does: explicit `@ExpoModule("Foo")` override, else
145
- // the class name.
146
- modules.append(
147
- ScannedModule(
148
- name: detection.name,
149
- jsName: detection.jsName ?? detection.name,
150
- accessLevel: detection.accessLevel,
151
- platforms: platformsByDetection[detectionKey(detection)] ?? [],
152
- file: detection.file
153
- )
151
+ // The macros reject anything but a class, so a struct with one of them is never registered.
152
+ for detection in allModules.detections where detection.declarationKind == "class" {
153
+ // Resolve the JS name the way the macro does: explicit `@ExpoModule("Foo")` or
154
+ // `@SharedObject("Foo")` override, else the class name.
155
+ let scanned = ScannedClass(
156
+ name: detection.name,
157
+ jsName: detection.jsName ?? detection.name,
158
+ accessLevel: detection.accessLevel,
159
+ platforms: platformsByDetection[detectionKey(detection)] ?? [],
160
+ file: detection.file
154
161
  )
162
+ switch detection.macro {
163
+ case .sharedObject:
164
+ sharedObjects.append(scanned)
165
+ default:
166
+ modules.append(scanned)
167
+ }
155
168
  }
156
169
  }
157
170
 
158
171
  return ScanModulesResult(
159
172
  schemaVersion: scanModulesSchemaVersion,
160
173
  modules: modules,
174
+ sharedObjects: sharedObjects,
161
175
  warnings: warnings,
162
176
  stats: stats
163
177
  )
@@ -32,7 +32,7 @@ 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
35
+ /// `@Event(.sync)` opts into **synchronous dispatch**: the closure calls `emitSync` (inline
36
36
  /// conversion + dispatch, no scheduling) instead of `emit`, and `@ExpoModule`/`@SharedObject` stamp
37
37
  /// the member `@JavaScriptActor` so the compiler forces the call site onto the JS thread, the
38
38
  /// inverse of the async default. The isolation is on the property access, so it guards the inline
@@ -127,7 +127,7 @@ private struct EventMember {
127
127
  let jsName: String
128
128
  /// The payload type as written, or `nil` for a no-payload `() -> Void` event.
129
129
  let payloadType: String?
130
- /// Whether the event dispatches synchronously (`@Event(sync: true)`) via `emitSync`.
130
+ /// Whether the event dispatches synchronously (`@Event(.sync)`) via `emitSync`.
131
131
  let isSync: Bool
132
132
 
133
133
  var hasPayload: Bool {
@@ -212,7 +212,7 @@ private func validatedEvent(
212
212
  swiftName: swiftName,
213
213
  jsName: jsNameArgument(of: node) ?? defaultEventName(for: withoutBackticks(swiftName)),
214
214
  payloadType: functionType.parameters.first?.type.trimmedDescription,
215
- isSync: boolArgument(of: node, label: "sync") == true
215
+ isSync: hasOption(node, named: "sync")
216
216
  )
217
217
  }
218
218
 
@@ -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,7 +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
170
+ // `@Event(.sync)` members get the stamp too: a sync event dispatches inline, so the
164
171
  // isolation forces its call site onto the JS thread. Async events (the default) are
165
172
  // deliberately left unstamped — their `emit` schedules onto the JS thread itself.
166
173
  if memberHasJSAttribute(member) || isSyncEventMember(member),
@@ -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,7 +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
174
+ // `@Event(.sync)` members are stamped alongside `@JS` ones: a sync event dispatches
168
175
  // inline, so the isolation forces its call site onto the JS thread. Async events (the
169
176
  // default) stay unstamped — their `emit` schedules onto the JS thread itself.
170
177
  guard memberHasJSAttribute(member) || isSyncEventMember(member) else {
@@ -139,23 +139,37 @@ private func fieldAttributeDiagnostic(
139
139
  for attribute: AttributeSyntax,
140
140
  on varDecl: VariableDeclSyntax
141
141
  ) -> 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
142
+ // The token after the attribute takes over the trivia of the attribute: the line break,
143
+ // indentation and comments before it, and the comments after it. Its own leading whitespace is
144
+ // left out unless a comment comes before it, so `@Field var size` and `@Field` on its own line both
145
+ // become `var size` on the line of the attribute, and `@Field // note` keeps the line break after
146
+ // the comment.
147
+ var newDecl = varDecl
148
+ if let next = attribute.lastToken(viewMode: .sourceAccurate)?.nextToken(viewMode: .sourceAccurate) {
149
+ let comments = Trivia(pieces: attribute.trailingTrivia.pieces.drop(while: \.isWhitespace))
150
+ let nextTrivia =
151
+ comments.isEmpty ? Trivia(pieces: next.leadingTrivia.pieces.drop(while: \.isWhitespace)) : next.leadingTrivia
152
+ newDecl =
153
+ LeadingTriviaRewriter(token: next.id, trivia: attribute.leadingTrivia + comments + nextTrivia)
154
+ .rewrite(varDecl).cast(VariableDeclSyntax.self)
155
+ }
156
+ // Then remove the attribute with its trivia, so it doesn't leave a blank line behind. The rewritten
157
+ // declaration is a new tree, so the attribute is found by its place in the list.
158
+ if let index = varDecl.attributes.firstIndex(where: { element in
146
159
  guard case .attribute(let candidate) = element else {
147
160
  return false
148
161
  }
149
162
  return candidate == attribute
150
163
  }) {
151
- attributes.remove(at: index)
164
+ let offset = varDecl.attributes.distance(from: varDecl.attributes.startIndex, to: index)
165
+ newDecl.attributes.remove(at: newDecl.attributes.index(newDecl.attributes.startIndex, offsetBy: offset))
152
166
  }
153
167
  let fixIt = FixIt(
154
168
  message: ViewPropsFixItMessage("Remove the '@Field' attribute", id: "viewprops-remove-field"),
155
169
  changes: [
156
170
  .replace(
157
171
  oldNode: Syntax(varDecl),
158
- newNode: Syntax(varDecl.with(\.attributes, attributes))
172
+ newNode: Syntax(newDecl)
159
173
  )
160
174
  ]
161
175
  )
@@ -166,6 +180,22 @@ private func fieldAttributeDiagnostic(
166
180
  return Diagnostic(node: attribute, message: message, fixIts: [fixIt])
167
181
  }
168
182
 
183
+ /// Replaces the leading trivia of one token.
184
+ private final class LeadingTriviaRewriter: SyntaxRewriter {
185
+ private let token: SyntaxIdentifier
186
+ private let trivia: Trivia
187
+
188
+ init(token: SyntaxIdentifier, trivia: Trivia) {
189
+ self.token = token
190
+ self.trivia = trivia
191
+ super.init(viewMode: .sourceAccurate)
192
+ }
193
+
194
+ override func visit(_ token: TokenSyntax) -> TokenSyntax {
195
+ token.id == self.token ? token.with(\.leadingTrivia, trivia) : token
196
+ }
197
+ }
198
+
169
199
  /// The error for an optional event prop, attached to the declared type and carrying a fix-it that
170
200
  /// unwraps it. Events are registered once at view creation from a static name list, so there is no
171
201
  /// shape that could express "sometimes emitted".