expo-modules-macros 0.21.0 → 0.23.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.
Files changed (41) hide show
  1. package/.github/workflows/linux.yml +8 -5
  2. package/.github/workflows/publish.yml +26 -11
  3. package/.github/workflows/swift-format.yml +79 -0
  4. package/.github/workflows/swift.yml +7 -4
  5. package/.github/workflows/windows.yml +8 -5
  6. package/AGENTS.md +16 -9
  7. package/README.md +20 -5
  8. package/apple/ExpoModulesMacros +0 -0
  9. package/apple/Package.resolved +3 -3
  10. package/apple/Package.swift +1 -2
  11. package/apple/Sources/ExpoModulesMacros/ClosureArguments.swift +39 -14
  12. package/apple/Sources/ExpoModulesMacros/DecorateModuleBuilder.swift +144 -30
  13. package/apple/Sources/ExpoModulesMacros/EventMacro.swift +29 -15
  14. package/apple/Sources/ExpoModulesMacros/ExpoModuleMacro.swift +63 -68
  15. package/apple/Sources/ExpoModulesMacros/ExpoModulesOptimizedMacro.swift +2 -1
  16. package/apple/Sources/ExpoModulesMacros/FreeFormTypes.swift +2 -61
  17. package/apple/Sources/ExpoModulesMacros/JSConstructor.swift +6 -3
  18. package/apple/Sources/ExpoModulesMacros/JSMacro.swift +184 -48
  19. package/apple/Sources/ExpoModulesMacros/JSNameCollisions.swift +26 -6
  20. package/apple/Sources/ExpoModulesMacros/MacroHelpers.swift +172 -43
  21. package/apple/Sources/ExpoModulesMacros/RecordMacro.swift +286 -222
  22. package/apple/Sources/ExpoModulesMacros/SharedObjectMacro.swift +49 -56
  23. package/apple/Sources/ExpoModulesMacros/TypeConformanceAssertion.swift +57 -27
  24. package/apple/Sources/ExpoModulesMacros/UnionMacro.swift +23 -9
  25. package/apple/Sources/ExpoModulesMacros/ViewPropsMacro.swift +10 -5
  26. package/apple/Sources/ExpoModulesScanner/CLI.swift +3 -1
  27. package/apple/Sources/ExpoModulesScanner/Core/DetectionVisitor.swift +11 -7
  28. package/apple/Sources/ExpoModulesScanner/Core/ScanBuildConfiguration.swift +2 -1
  29. package/apple/Sources/ExpoModulesScanner/Core/SourceScan.swift +10 -7
  30. package/apple/Sources/ExpoModulesScanner/Exports/Availability.swift +6 -3
  31. package/apple/Sources/ExpoModulesScanner/Exports/ExportedSurface.swift +20 -4
  32. package/apple/Sources/ExpoModulesScanner/Exports/ResolveRefs.swift +18 -5
  33. package/apple/Sources/ExpoModulesScanner/Exports/ScanExports.swift +8 -3
  34. package/apple/Sources/ExpoModulesScanner/Exports/SurfaceVisitor.swift +179 -46
  35. package/apple/Sources/ExpoModulesScanner/Exports/TypeNode.swift +28 -8
  36. package/apple/Sources/ExpoModulesScanner/Modules/ScanModules.swift +8 -2
  37. package/build/index.d.ts +1 -1
  38. package/build/index.js +3 -3
  39. package/build/types.d.ts +32 -2
  40. package/build/types.js +1 -1
  41. package/package.json +8 -5
@@ -3,53 +3,59 @@ import SwiftSyntax
3
3
  import SwiftSyntaxBuilder
4
4
  import SwiftSyntaxMacros
5
5
 
6
- /**
7
- Member macro applied to a record type. Treats **every stored property** that is not
8
- `static`, `private`, `fileprivate`, `lazy` or computed as a property — no `@Field` wrapper
9
- needed — and synthesizes the conversion surface from the property's static type:
10
-
11
- - an explicit memberwise `init` (so the static factories have a single construction point),
12
- - `decode(_:in:)` and `encode(_:in:)` — the fast paths, reading each property straight off a JS
13
- object and writing it straight into one,
14
- - `from(object:appContext:)` and `toObject(appContext:)`, which forward to `decode` and `encode`,
15
- - `from(dictionary:appContext:)` and `toDictionary(appContext:)` — converting each property from and
16
- to a `[String: Any]` dictionary.
17
-
18
- The type is auto-conformed to `Record` (a core protocol whose requirements are this surface); the
19
- synthesized methods override `Record`'s reflection-based defaults, and the synthesized `decode` and
20
- `encode` replace its default `JavaScriptCodable` witnesses. Author-facing shape — no conformance to
21
- spell out:
22
-
23
- @Record
24
- struct Options {
25
- var name: String // required (non-optional, no default)
26
- var count: Int = 0 // optional (has default)
27
- var note: String? // nullable + optional
28
- }
29
-
30
- The JS key is always the property name. Requiredness is inferred from the declaration:
31
- a default value makes a property optional (the default applies when the source omits it),
32
- an optional type makes it nullable and optional, and a non-optional property without a
33
- default is required (the factories throw when the source omits it).
34
-
35
- The JS-value paths convert through `JavaScriptDecodable.decode` / `JavaScriptEncodable.encode`
36
- (`decode(_:in:)` / `encode(_:in:)`); the native-`Any` dictionary paths still go through the
37
- public dynamic-type API — `T.getDynamicType()` plus `cast(_:appContext:)` / `convertToJS(_:appContext:)`
38
- (`from(dictionary:)` / `toDictionary(appContext:)`), since `JavaScriptCodable` only converts JS
39
- values, not native `Any`. Both spell types as `public` symbols so the synthesized code compiles inside
40
- user modules without any internal core symbols. Every property type must therefore conform to both
41
- `AnyArgument` and `JavaScriptDecodable & JavaScriptEncodable`.
42
-
43
- A property whose type mentions `Any` (`Any?`, `[String: Any]?`, `[String: [Any]]`, …) can't conform to
44
- the JS codable protocols. On the JS-value paths it converts through the `JavaScriptValue` free-form
45
- methods instead: `decodeAny`/`decodeAnyArray`/`decodeAnyDictionary` and the matching `encodeAny…`
46
- for `Any`, `[Any]` and `[String: Any]` (optional or not), and the generic `decodeAny(_:as:in:)` /
47
- `encodeAny(_:in:)` for any other type. Such a property only needs `AnyArgument` for the dictionary
48
- paths, and a bare `Any` needs nothing, since the dictionary paths pass it through unchanged.
49
-
50
- For classes that inherit from another `@Record`-annotated class, the synthesized
51
- methods chain to `super` so inherited properties are handled first.
52
- */
6
+ /// Member macro applied to a record type. Treats **every stored property** that is not
7
+ /// `static`, `private`, `fileprivate`, `lazy` or computed as a property — no `@Field` wrapper
8
+ /// needed — and synthesizes the conversion surface from the property's static type:
9
+ ///
10
+ /// - an explicit memberwise `init` (so the static factories have a single construction point),
11
+ /// - `decode(_:in:)` and `encode(_:in:)` — the fast paths, reading each property straight off a JS
12
+ /// object and writing it straight into one,
13
+ /// - `from(object:appContext:)` and `toObject(appContext:)`, which forward to `decode` and `encode`,
14
+ /// - `from(dictionary:appContext:)` and `toDictionary(appContext:)` — converting each property from and
15
+ /// to a `[String: Any]` dictionary.
16
+ ///
17
+ /// The type is auto-conformed to `Record` (a core protocol whose requirements are this surface); the
18
+ /// synthesized methods override `Record`'s reflection-based defaults, and the synthesized `decode` and
19
+ /// `encode` replace its default `JavaScriptCodable` witnesses. Author-facing shape — no conformance to
20
+ /// spell out:
21
+ ///
22
+ /// @Record
23
+ /// struct Options {
24
+ /// var name: String // required (non-optional, no default)
25
+ /// var count: Int = 0 // optional (has default)
26
+ /// var note: String? // nullable + optional
27
+ /// }
28
+ ///
29
+ /// The JS key is always the property name. Requiredness is inferred from the declaration:
30
+ /// a default value makes a property optional (the default applies when the source omits it),
31
+ /// an optional type makes it nullable and optional, and a non-optional property without a
32
+ /// default is required (the factories throw when the source omits it).
33
+ ///
34
+ /// `decode(_:in:)` and `encode(_:in:)` name each property with a `JavaScriptPropNameID` that each runtime
35
+ /// creates once. The names are held by the nested `_PropNames` class, which each runtime caches under one
36
+ /// key. A record that is generic or nested in a generic type uses string names, since a generic type can't
37
+ /// have static stored properties. The macro can't tell that an extension extends a generic type, so a
38
+ /// record declared in one doesn't compile.
39
+ ///
40
+ /// The JS-value paths convert through `JavaScriptDecodable.decode` / `JavaScriptEncodable.encode`
41
+ /// (`decode(_:in:)` / `encode(_:in:)`); the native-`Any` dictionary paths still go through the
42
+ /// public dynamic-type API — `T.getDynamicType()` plus `cast(_:appContext:)` / `convertToJS(_:appContext:)`
43
+ /// (`from(dictionary:)` / `toDictionary(appContext:)`), since `JavaScriptCodable` only converts JS
44
+ /// values, not native `Any`. Both spell types as `public` symbols so the synthesized code compiles inside
45
+ /// user modules without any internal core symbols. Every property type must therefore conform to both
46
+ /// `AnyArgument` and `JavaScriptDecodable & JavaScriptEncodable`.
47
+ ///
48
+ /// A property whose type mentions `Any` (`Any?`, `[String: Any]?`, `[String: [Any]]`, …) can't conform to
49
+ /// the JS codable protocols. On the JS-value paths it converts through the `JavaScriptValue` free-form
50
+ /// methods instead: `decodeAny`/`decodeAnyArray`/`decodeAnyDictionary` and the matching `encodeAny…`
51
+ /// for `Any`, `[Any]` and `[String: Any]` (optional or not), and the generic `decodeAny(_:as:in:)` /
52
+ /// `encodeAny(_:in:)` for any other type. Such a property only needs `AnyArgument` for the dictionary
53
+ /// paths, and a bare `Any` needs nothing, since the dictionary paths pass it through unchanged.
54
+ ///
55
+ /// A record class must be `final` and have no inheritance clause. The synthesized factories construct
56
+ /// `Self` through the memberwise init, which only a final class can do without a `required` init, and
57
+ /// the macro can't tell a superclass from a protocol in the clause, so protocol conformances go in an
58
+ /// extension.
53
59
  public struct RecordMacro: MemberMacro, ExtensionMacro {
54
60
  public static func expansion(
55
61
  of node: AttributeSyntax,
@@ -65,10 +71,13 @@ public struct RecordMacro: MemberMacro, ExtensionMacro {
65
71
  } else {
66
72
  throw MacroExpansionErrorMessage("@Record can only be applied to a struct or class")
67
73
  }
68
- let isClass = declaration.is(ClassDeclSyntax.self)
74
+ let classDiagnostics = unsupportedClassDiagnostics(of: declaration)
75
+ guard classDiagnostics.isEmpty else {
76
+ throw DiagnosticsError(diagnostics: classDiagnostics)
77
+ }
69
78
 
70
79
  let properties = try recordProperties(of: declaration)
71
- let inheritsRecord = isClass && classHasInheritance(declaration)
80
+ let cachesPropNames = !properties.isEmpty && !isInGenericContext(declaration, context: context)
72
81
 
73
82
  // The user may hand-write any of the initializers the macro would otherwise synthesize. Emitting
74
83
  // a duplicate is a hard "invalid redeclaration" error, so skip anything already declared and let
@@ -99,32 +108,37 @@ public struct RecordMacro: MemberMacro, ExtensionMacro {
99
108
  }
100
109
 
101
110
  if !existingInitLabels.contains([]) {
102
- if let defaultInit = defaultInit(properties: properties, isClass: isClass) {
111
+ if let defaultInit = defaultInit(properties: properties) {
103
112
  members.append(defaultInit)
104
113
  }
105
114
  }
106
115
  if !existingInitLabels.contains(properties.map { $0.key }) {
107
116
  members.append(memberwiseInit(properties: properties))
108
117
  }
109
- members.append(fromJSObjectFactory(properties: properties, inheritsRecord: inheritsRecord))
118
+ members.append(fromJSObjectFactory())
110
119
  members.append(fromDictionaryFactory(properties: properties))
111
- members.append(toDictionaryMethod(properties: properties, inheritsRecord: inheritsRecord))
112
- members.append(toObjectMethod(properties: properties, inheritsRecord: inheritsRecord))
113
- if !inheritsRecord {
114
- members.append(contentsOf: javaScriptCodableWitnesses(properties: properties, typeName: typeName))
120
+ members.append(toDictionaryMethod(properties: properties))
121
+ members.append(toObjectMethod())
122
+ if cachesPropNames {
123
+ members.append(propNamesClass(properties: properties))
115
124
  }
125
+ members.append(
126
+ contentsOf: javaScriptCodableWitnesses(
127
+ properties: properties,
128
+ typeName: typeName,
129
+ cachesPropNames: cachesPropNames
130
+ )
131
+ )
116
132
  return members
117
133
  }
118
134
 
119
- /**
120
- Auto-conforms the type to `Record` — the protocol whose requirements are exactly the members
121
- synthesized above (`init()`, the two `from(_:)` factories, and the `toDictionary`/`toObject`
122
- write side), which the synthesized methods satisfy by overriding `Record`'s reflection-based
123
- defaults.
124
-
125
- The conformance is skipped when the type already declares it, and for class subclasses
126
- (which inherit it from a `@Record`-annotated parent) no extension is emitted at all.
127
- */
135
+ /// Auto-conforms the type to `Record` — the protocol whose requirements are exactly the members
136
+ /// synthesized above (`init()`, the two `from(_:)` factories, and the `toDictionary`/`toObject`
137
+ /// write side), which the synthesized methods satisfy by overriding `Record`'s reflection-based
138
+ /// defaults.
139
+ ///
140
+ /// The conformance is skipped when the type already declares it, and for a class the member role
141
+ /// rejects, which would otherwise get conformance errors for the members it didn't synthesize.
128
142
  public static func expansion(
129
143
  of node: AttributeSyntax,
130
144
  attachedTo declaration: some DeclGroupSyntax,
@@ -132,10 +146,10 @@ public struct RecordMacro: MemberMacro, ExtensionMacro {
132
146
  conformingTo protocols: [TypeSyntax],
133
147
  in context: some MacroExpansionContext
134
148
  ) throws -> [ExtensionDeclSyntax] {
135
- if declaration.is(ClassDeclSyntax.self) && classHasInheritance(declaration) {
149
+ guard declaration.is(StructDeclSyntax.self) || declaration.is(ClassDeclSyntax.self) else {
136
150
  return []
137
151
  }
138
- guard declaration.is(StructDeclSyntax.self) || declaration.is(ClassDeclSyntax.self) else {
152
+ guard unsupportedClassDiagnostics(of: declaration).isEmpty else {
139
153
  return []
140
154
  }
141
155
  if inheritsProtocol(named: "Record", in: declaration) {
@@ -154,10 +168,8 @@ public struct RecordMacro: MemberMacro, ExtensionMacro {
154
168
 
155
169
  // MARK: - Property model
156
170
 
157
- /**
158
- A single record property discovered on the type, paired with everything the synthesized
159
- conversions need: its property name, its written type, and how the source may omit it.
160
- */
171
+ /// A single record property discovered on the type, paired with everything the synthesized
172
+ /// conversions need: its property name, its written type, and how the source may omit it.
161
173
  private struct RecordProperty {
162
174
  /// The property name as written, backticks included for an escaped name (`` `default` ``), since
163
175
  /// every identifier position in the generated code needs them.
@@ -192,11 +204,9 @@ private struct RecordProperty {
192
204
  }
193
205
  }
194
206
 
195
- /**
196
- Discovers the record's properties: every stored `var`/`let` binding that is not `static`,
197
- `private`, `fileprivate`, `lazy`, or computed. Each property must declare an explicit type
198
- annotation, since the synthesized conversions name the type (e.g. `Type.decode(…)`).
199
- */
207
+ /// Discovers the record's properties: every stored `var`/`let` binding that is not `static`,
208
+ /// `private`, `fileprivate`, `lazy`, or computed. Each property must declare an explicit type
209
+ /// annotation, since the synthesized conversions name the type (e.g. `Type.decode(…)`).
200
210
  private func recordProperties(
201
211
  of declaration: some DeclGroupSyntax
202
212
  ) throws -> [RecordProperty] {
@@ -230,7 +240,8 @@ private func recordProperties(
230
240
  // (Swift's own default-literal type) — covers the common `var name = "foo"` case. Anything
231
241
  // whose type a syntactic macro can't determine (calls, collections, member access) still needs
232
242
  // an annotation.
233
- let inferredType = binding.typeAnnotation?.type.trimmedDescription
243
+ let inferredType =
244
+ binding.typeAnnotation?.type.trimmedDescription
234
245
  ?? binding.initializer.flatMap { inferredLiteralType(of: $0.value) }
235
246
  guard let type = inferredType else {
236
247
  throw MacroExpansionErrorMessage(
@@ -249,7 +260,8 @@ private func recordProperties(
249
260
  message: RecordDiagnosticMessage(
250
261
  "@Record property '\(ident.identifier.text)' can't hold a closure: a record property is also encoded to JavaScript, and a native closure can't be encoded",
251
262
  id: "record-closure-property"
252
- ))
263
+ )
264
+ )
253
265
  ])
254
266
  }
255
267
  let freeForm = try annotatedType.flatMap { annotatedType -> FreeFormConversion? in
@@ -279,23 +291,19 @@ private func recordProperties(
279
291
 
280
292
  // MARK: - Synthesized members
281
293
 
282
- /**
283
- The parameterless `init()` required by the `Record` protocol. Nothing in the synthesized code uses
284
- it — the factories construct through the memberwise init with defaults inlined — so it's purely a
285
- conformance witness. Swift only synthesizes an implicit `init()` for a struct that declares no other
286
- initializer, but the macro always emits a memberwise `init(…)`, which suppresses it, so for structs
287
- the macro must supply `init()` itself.
288
-
289
- When no property is required, the body is empty: every stored property initializes from its own
290
- declaration. When a property *is* required there's no value to give it, so the body traps — this `init()`
291
- is never reached (the `from(…)` factories don't call it), it exists only to satisfy the requirement.
292
-
293
- Not emitted when there are no properties at all — there the memberwise init already *is* `init()`. Classes
294
- are left alone: they inherit `init()` from their `@Record` superclass, and a base `@Record` class is
295
- unsupported today.
296
- */
297
- private func defaultInit(properties: [RecordProperty], isClass: Bool) -> DeclSyntax? {
298
- if isClass || properties.isEmpty {
294
+ /// The parameterless `init()` required by the `Record` protocol. Nothing in the synthesized code uses
295
+ /// it — the factories construct through the memberwise init with defaults inlined — so it's purely a
296
+ /// conformance witness. Swift only synthesizes an implicit `init()` for a type that declares no other
297
+ /// initializer, but the macro always emits a memberwise `init(…)`, which suppresses it, so the macro
298
+ /// must supply `init()` itself.
299
+ ///
300
+ /// When no property is required, the body is empty: every stored property initializes from its own
301
+ /// declaration. When a property *is* required there's no value to give it, so the body traps — this `init()`
302
+ /// is never reached (the `from(…)` factories don't call it), it exists only to satisfy the requirement.
303
+ ///
304
+ /// Not emitted when there are no properties at all — there the memberwise init already *is* `init()`.
305
+ private func defaultInit(properties: [RecordProperty]) -> DeclSyntax? {
306
+ if properties.isEmpty {
299
307
  return nil
300
308
  }
301
309
  if properties.contains(where: { $0.isRequired }) {
@@ -335,44 +343,24 @@ private func memberwiseInit(properties: [RecordProperty]) -> DeclSyntax {
335
343
  """
336
344
  }
337
345
 
338
- /**
339
- `from(object:appContext:)`. A record forwards to its own unowned `decode(_:in:)` witness (see
340
- `javaScriptCodableWitnesses`), which holds the property reads. A subclass, which gets no witnesses,
341
- reads the properties here, in the app context's runtime.
342
- */
343
- private func fromJSObjectFactory(properties: [RecordProperty], inheritsRecord: Bool) -> DeclSyntax {
344
- guard inheritsRecord else {
345
- // The record declares only the unowned `decode`, so the call lends the value unowned. With an owning
346
- // value it would reach an owning `decode` default, and `Record`'s converts through this factory.
347
- return """
348
- @JavaScriptActor
349
- public static func from(object: borrowing JavaScriptObject, appContext: AppContext) throws -> Self {
350
- let runtime = try appContext.runtime
351
- return try object.asValue().withUnownedValue(in: runtime) { unownedValue in
352
- return try decode(unownedValue, in: runtime)
353
- }
354
- }
355
- """
356
- }
357
- var lines: [String] = []
358
- // Only bind the runtime when there's a property to decode — an empty record's factory would
359
- // otherwise leave it unused.
360
- if !properties.isEmpty {
361
- lines.append(" let runtime = try appContext.runtime")
362
- }
363
- lines.append(factoryBody(properties: properties, readLines: jsObjectReadLines(properties: properties)))
346
+ /// `from(object:appContext:)`, which forwards to the record's own unowned `decode(_:in:)` witness (see
347
+ /// `javaScriptCodableWitnesses`), where the property reads are.
348
+ private func fromJSObjectFactory() -> DeclSyntax {
349
+ // The record declares only the unowned `decode`, so the call lends the value unowned. With an owning
350
+ // value it would reach an owning `decode` default, and `Record`'s converts through this factory.
364
351
  return """
365
352
  @JavaScriptActor
366
353
  public static func from(object: borrowing JavaScriptObject, appContext: AppContext) throws -> Self {
367
- \(raw: lines.joined(separator: "\n"))
354
+ let runtime = try appContext.runtime
355
+ return try object.asValue().withUnownedValue(in: runtime) { unownedValue in
356
+ return try decode(unownedValue, in: runtime)
357
+ }
368
358
  }
369
359
  """
370
360
  }
371
361
 
372
- /**
373
- `from(dictionary:appContext:)` — same as the JS path but reads from `[String: Any]` and
374
- uses the `Any?` cast overload; a missing key is `dictionary[key] == nil`.
375
- */
362
+ /// `from(dictionary:appContext:)` — same as the JS path but reads from `[String: Any]` and
363
+ /// uses the `Any?` cast overload; a missing key is `dictionary[key] == nil`.
376
364
  private func fromDictionaryFactory(properties: [RecordProperty]) -> DeclSyntax {
377
365
  let body = factoryBody(properties: properties, readLines: dictionaryReadLines(properties: properties))
378
366
  return """
@@ -382,12 +370,10 @@ private func fromDictionaryFactory(properties: [RecordProperty]) -> DeclSyntax {
382
370
  """
383
371
  }
384
372
 
385
- /**
386
- Builds a factory body: per-property reads into locals, then a single call to the synthesized
387
- memberwise init. Each property's declared default expression is captured at parse time and inlined
388
- directly into the omitted-property branch (see the read-line builders), so the factory needs neither
389
- a throwaway `Self()` nor the record's `init()`.
390
- */
373
+ /// Builds a factory body: per-property reads into locals, then a single call to the synthesized
374
+ /// memberwise init. Each property's declared default expression is captured at parse time and inlined
375
+ /// directly into the omitted-property branch (see the read-line builders), so the factory needs neither
376
+ /// a throwaway `Self()` nor the record's `init()`.
391
377
  private func factoryBody(properties: [RecordProperty], readLines: [String]) -> String {
392
378
  var lines: [String] = []
393
379
  lines.append(contentsOf: readLines)
@@ -407,12 +393,13 @@ private func fieldLocal(_ index: Int) -> String {
407
393
  /// named `object`. The property is lent to a closure as a `JavaScriptUnownedValue` and decoded there the
408
394
  /// same way as a `@JS` argument (see `decodeCall`), so the read doesn't allocate a `JavaScriptValue`.
409
395
  /// Only the generic free-form decode copies the value into one.
410
- private func jsObjectReadLines(properties: [RecordProperty]) -> [String] {
396
+ private func jsObjectReadLines(properties: [RecordProperty], cachesPropNames: Bool) -> [String] {
411
397
  var lines: [String] = []
412
398
  for (index, property) in properties.enumerated() {
413
399
  let valueVar = derivedIdentifier(from: property.name, suffix: "JSValue")
414
400
  let decode = "try \(decodeCall(property.type, freeForm: property.freeForm, from: valueVar))"
415
- lines.append(" let \(fieldLocal(index)) = try object.withUnownedProperty(\"\(property.key)\") { \(valueVar) in")
401
+ let propName = propNameArgument(for: property, at: index, cached: cachesPropNames)
402
+ lines.append(" let \(fieldLocal(index)) = try object.withUnownedProperty(\(propName)) { \(valueVar) in")
416
403
  if property.isRequired {
417
404
  lines.append(" guard !\(valueVar).isUndefined() else {")
418
405
  lines.append(" throw RecordPropertyRequiredException(\"\(property.key)\")")
@@ -440,7 +427,8 @@ private func dictionaryReadLines(properties: [RecordProperty]) -> [String] {
440
427
  let entry = "entry\(index)"
441
428
  let exprType = expressionType(property.type)
442
429
  let isBareAny = property.freeForm?.isBareAny == true
443
- let cast = isBareAny
430
+ let cast =
431
+ isBareAny
444
432
  ? entry
445
433
  : "try \(exprType).getDynamicType().cast(\(entry), appContext: appContext) as! \(exprType)"
446
434
  lines.append(" let \(entry) = dictionary[\"\(property.key)\"]")
@@ -458,91 +446,60 @@ private func dictionaryReadLines(properties: [RecordProperty]) -> [String] {
458
446
  return lines
459
447
  }
460
448
 
461
- /**
462
- `toDictionary(appContext:)` — converts each property back to a JS-compatible value via the
463
- dynamic type and assembles a `[String: Any]`. Inherited properties are merged in first.
464
- */
465
- private func toDictionaryMethod(properties: [RecordProperty], inheritsRecord: Bool) -> DeclSyntax {
466
- let overrideKeyword = inheritsRecord ? "override " : ""
467
- var lines: [String] = []
468
- if inheritsRecord {
469
- lines.append(" var dictionary = super.toDictionary(appContext: appContext)")
470
- } else {
471
- lines.append(" var dictionary: [String: Any] = [:]")
472
- }
449
+ /// `toDictionary(appContext:)` — converts each property back to a JS-compatible value via the
450
+ /// dynamic type and assembles a `[String: Any]`.
451
+ private func toDictionaryMethod(properties: [RecordProperty]) -> DeclSyntax {
452
+ var lines = [" var dictionary: [String: Any] = [:]"]
473
453
  for property in properties {
474
454
  lines.append(" dictionary[\"\(property.key)\"] = self.\(property.name)")
475
455
  }
476
456
  lines.append(" return dictionary")
477
- let body = lines.joined(separator: "\n")
478
-
479
- if inheritsRecord {
480
- return """
481
- public \(raw: overrideKeyword)func toDictionary(appContext: AppContext? = nil) -> [String: Any] {
482
- \(raw: body)
483
- }
484
- """
485
- }
486
457
  return """
487
458
  public func toDictionary(appContext: AppContext? = nil) -> [String: Any] {
488
- \(raw: body)
459
+ \(raw: lines.joined(separator: "\n"))
489
460
  }
490
461
  """
491
462
  }
492
463
 
493
- /**
494
- `toObject(appContext:)`. A record forwards to its own `encode(_:in:)` witness (see
495
- `javaScriptCodableWitnesses`), which holds the property writes. A subclass, which gets no witnesses,
496
- overrides it to chain to `super`, so inherited properties are written first, and writes its own
497
- properties in the app context's runtime.
498
- */
499
- private func toObjectMethod(properties: [RecordProperty], inheritsRecord: Bool) -> DeclSyntax {
500
- guard inheritsRecord else {
501
- return """
502
- @JavaScriptActor
503
- public func toObject(appContext: AppContext) throws -> JavaScriptObject {
504
- return try Self.encode(self, in: appContext.runtime).asObject()
505
- }
506
- """
507
- }
508
- var lines: [String] = []
509
- // The runtime is only needed when there's a property to encode (the object itself comes from
510
- // `super`). Binding it when unused would warn.
511
- if !properties.isEmpty {
512
- lines.append(" let runtime = try appContext.runtime")
513
- }
514
- lines.append(" let object = try super.toObject(appContext: appContext)")
515
- lines.append(contentsOf: objectWriteLines(properties: properties, of: "self"))
516
- lines.append(" return object")
464
+ /// `toObject(appContext:)`, which forwards to the record's own `encode(_:in:)` witness (see
465
+ /// `javaScriptCodableWitnesses`), where the property writes are.
466
+ private func toObjectMethod() -> DeclSyntax {
517
467
  return """
518
468
  @JavaScriptActor
519
- public override func toObject(appContext: AppContext) throws -> JavaScriptObject {
520
- \(raw: lines.joined(separator: "\n"))
469
+ public func toObject(appContext: AppContext) throws -> JavaScriptObject {
470
+ return try Self.encode(self, in: appContext.runtime).asObject()
521
471
  }
522
472
  """
523
473
  }
524
474
 
525
- /**
526
- The record's own `JavaScriptDecodable` and `JavaScriptEncodable` witnesses, which convert in the
527
- runtime they're given. Without them the record would convert through `Record`'s default witnesses,
528
- which recover the app context from the runtime and convert in the app context's runtime.
529
-
530
- `decode` reads each property off the JS object into a local, then constructs the record through the
531
- memberwise init. Required properties throw when undefined; defaulted properties fall back to the
532
- property's declared default (inlined) when undefined; optional properties become `nil` when
533
- undefined/null. It's the unowned overload: an owning value decodes through `JavaScriptDecodable`'s
534
- default, which forwards to it. `encode` builds the JS object, writing each property. Its parameter
535
- names the type, because a class can't take `Self` as a parameter type.
536
- */
537
- private func javaScriptCodableWitnesses(properties: [RecordProperty], typeName: String) -> [DeclSyntax] {
475
+ /// The record's own `JavaScriptDecodable` and `JavaScriptEncodable` witnesses, which convert in the
476
+ /// runtime they're given. Without them the record would convert through `Record`'s default witnesses,
477
+ /// which recover the app context from the runtime and convert in the app context's runtime.
478
+ ///
479
+ /// `decode` reads each property off the JS object into a local, then constructs the record through the
480
+ /// memberwise init. Required properties throw when undefined; defaulted properties fall back to the
481
+ /// property's declared default (inlined) when undefined; optional properties become `nil` when
482
+ /// undefined/null. It's the unowned overload: an owning value decodes through `JavaScriptDecodable`'s
483
+ /// default, which forwards to it. `encode` builds the JS object, writing each property. Its parameter
484
+ /// names the type, because a class can't take `Self` as a parameter type.
485
+ private func javaScriptCodableWitnesses(
486
+ properties: [RecordProperty],
487
+ typeName: String,
488
+ cachesPropNames: Bool
489
+ ) -> [DeclSyntax] {
538
490
  // An empty record still decodes only from an object, but has no property to read off it.
539
- let objectLine = properties.isEmpty
491
+ let objectLine =
492
+ properties.isEmpty
540
493
  ? " _ = try value.asObject(in: runtime)"
541
494
  : " let object = try value.asObject(in: runtime)"
542
- let decodeBody = objectLine + "\n" + factoryBody(properties: properties, readLines: jsObjectReadLines(properties: properties))
543
- let encodeLines = [" let object = runtime.createObject()"]
544
- + objectWriteLines(properties: properties, of: "value")
545
- + [" return object.asValue()"]
495
+ let lookupLines = cachesPropNames ? propNamesLookupLines() : []
496
+ let readLines = lookupLines + jsObjectReadLines(properties: properties, cachesPropNames: cachesPropNames)
497
+ let decodeBody = objectLine + "\n" + factoryBody(properties: properties, readLines: readLines)
498
+ let encodeLines =
499
+ [" let object = runtime.createObject()"]
500
+ + lookupLines
501
+ + objectWriteLines(properties: properties, of: "value", cachesPropNames: cachesPropNames)
502
+ + [" return object.takeValue()"]
546
503
  return [
547
504
  """
548
505
  @JavaScriptActor
@@ -562,13 +519,14 @@ private func javaScriptCodableWitnesses(properties: [RecordProperty], typeName:
562
519
  /// The statements that write each property of `record` (`self` or the `encode` parameter) into the
563
520
  /// `object` local, encoding it with `JavaScriptEncodable.encode` or, for a free-form property, the
564
521
  /// matching `JavaScriptValue` free-form method.
565
- private func objectWriteLines(properties: [RecordProperty], of record: String) -> [String] {
566
- return properties.map { property in
522
+ private func objectWriteLines(properties: [RecordProperty], of record: String, cachesPropNames: Bool) -> [String] {
523
+ return properties.enumerated().map { index, property in
567
524
  let value = "\(record).\(property.name)"
568
525
  let encode: String
569
526
  switch property.freeForm {
570
527
  case .shape(_, let encodeMethod, let isOptional, _):
571
- encode = isOptional
528
+ encode =
529
+ isOptional
572
530
  ? "\(value) == nil ? .null() : try JavaScriptValue.\(encodeMethod)(\(value)!, in: runtime)"
573
531
  : "try JavaScriptValue.\(encodeMethod)(\(value), in: runtime)"
574
532
  case .generic:
@@ -577,19 +535,74 @@ private func objectWriteLines(properties: [RecordProperty], of record: String) -
577
535
  case nil:
578
536
  encode = "try \(expressionType(property.type)).encode(\(value), in: runtime)"
579
537
  }
580
- return " object.setProperty(\"\(property.key)\", value: \(encode))"
538
+ let propName = propNameArgument(for: property, at: index, cached: cachesPropNames)
539
+ return " object.setProperty(\(propName), value: \(encode))"
540
+ }
541
+ }
542
+
543
+ /// The name of the nested class that holds the property names (see `propNamesClass`).
544
+ private let propNamesTypeName = "_PropNames"
545
+
546
+ /// A nested class that holds the `JavaScriptPropNameID` of each property, `_name<index>` for the property
547
+ /// at `index`, and the runtime cache key for its instances. Each runtime creates one instance, so the
548
+ /// whole record takes one cache key.
549
+ private func propNamesClass(properties: [RecordProperty]) -> DeclSyntax {
550
+ let storedProperties = properties.indices.map { index in
551
+ " let \(propNameMember(index)): JavaScriptPropNameID"
552
+ }
553
+ let assignments = properties.enumerated().map { index, property in
554
+ " self.\(propNameMember(index)) = JavaScriptPropNameID(runtime, string: \"\(property.key)\")"
555
+ }
556
+ return """
557
+ private final class \(raw: propNamesTypeName) {
558
+ static let _key = JavaScriptRuntime.Cache.Key<\(raw: propNamesTypeName)>()
559
+
560
+ \(raw: storedProperties.joined(separator: "\n"))
561
+
562
+ init(_ runtime: borrowing JavaScriptRuntime) {
563
+ \(raw: assignments.joined(separator: "\n"))
564
+ }
565
+ }
566
+ """
567
+ }
568
+
569
+ private func propNameMember(_ index: Int) -> String {
570
+ return "_name\(index)"
571
+ }
572
+
573
+ /// The statements that get the record's property names from the runtime cache into `_propNames`, at the
574
+ /// start of `decode` and `encode`.
575
+ private func propNamesLookupLines() -> [String] {
576
+ return [
577
+ " let _propNames = runtime.cached(\(propNamesTypeName)._key) {",
578
+ " return \(propNamesTypeName)(runtime)",
579
+ " }",
580
+ ]
581
+ }
582
+
583
+ /// The first argument of the `withUnownedProperty` and `setProperty` calls for the property at `index`:
584
+ /// its name from `_propNames`, or the key as a string literal when the record can't hold the cache key.
585
+ private func propNameArgument(for property: RecordProperty, at index: Int, cached: Bool) -> String {
586
+ return cached ? "_propNames.\(propNameMember(index))" : "\"\(property.key)\""
587
+ }
588
+
589
+ /// True when the record or a type that encloses it is generic. Swift doesn't support static stored
590
+ /// properties in generic types, so the record can't hold the cache key.
591
+ private func isInGenericContext(_ declaration: some DeclGroupSyntax, context: some MacroExpansionContext) -> Bool {
592
+ // The lexical context of a member macro usually starts with the type it's attached to, but the
593
+ // declaration is checked on its own too.
594
+ return ([Syntax(declaration)] + context.lexicalContext).contains { node in
595
+ return node.asProtocol(WithGenericParametersSyntax.self)?.genericParameterClause != nil
581
596
  }
582
597
  }
583
598
 
584
599
  // MARK: - Helpers
585
600
 
586
- /**
587
- The parameter-label list of every initializer the type already declares, used to avoid emitting a
588
- duplicate of one the author hand-wrote. Each entry is the ordered external argument labels of one
589
- `init` — e.g. `init(name: String, count: Int)` → `["name", "count"]`, and `init()` → `[]`. A
590
- parameter written with `_` or with no external label contributes its internal name's absence as an
591
- empty-string label, which simply won't match the macro's always-labeled signatures (a safe miss).
592
- */
601
+ /// The parameter-label list of every initializer the type already declares, used to avoid emitting a
602
+ /// duplicate of one the author hand-wrote. Each entry is the ordered external argument labels of one
603
+ /// `init` — e.g. `init(name: String, count: Int)` → `["name", "count"]`, and `init()` → `[]`. A
604
+ /// parameter written with `_` or with no external label contributes its internal name's absence as an
605
+ /// empty-string label, which simply won't match the macro's always-labeled signatures (a safe miss).
593
606
  private func initializerParameterLabels(of declaration: some DeclGroupSyntax) -> [[String]] {
594
607
  var signatures: [[String]] = []
595
608
  for member in declaration.memberBlock.members {
@@ -606,18 +619,59 @@ private func initializerParameterLabels(of declaration: some DeclGroupSyntax) ->
606
619
  return signatures
607
620
  }
608
621
 
609
- /**
610
- True if the class declaration has any inheritance clause. Used as a heuristic for
611
- whether the superclass also conforms to `Record` and provides the synthesized methods;
612
- the macro emits `override` in this case.
613
- */
614
- private func classHasInheritance(_ declaration: some DeclGroupSyntax) -> Bool {
615
- guard let classDecl = declaration.as(ClassDeclSyntax.self),
616
- let inherited = classDecl.inheritanceClause?.inheritedTypes,
617
- !inherited.isEmpty else {
618
- return false
622
+ /// The errors for a class the macro can't synthesize a record for: one that isn't `final`, or one
623
+ /// with an inheritance clause. Empty for a struct and for a supported class.
624
+ private func unsupportedClassDiagnostics(of declaration: some DeclGroupSyntax) -> [Diagnostic] {
625
+ guard let classDecl = declaration.as(ClassDeclSyntax.self) else {
626
+ return []
627
+ }
628
+ let name = classDecl.name.text
629
+ var diagnostics: [Diagnostic] = []
630
+ if !classDecl.modifiers.contains(where: { $0.name.tokenKind == .keyword(.final) }) {
631
+ // An `open` class can't also be `final`, so changing its access level is left to the author.
632
+ let isOpen = classDecl.modifiers.contains(where: { $0.name.tokenKind == .keyword(.open) })
633
+ diagnostics.append(
634
+ Diagnostic(
635
+ node: classDecl.classKeyword,
636
+ message: RecordDiagnosticMessage(
637
+ "@Record class '\(name)' must be final, because the macro doesn't support subclasses of a record",
638
+ id: "record-class-not-final"
639
+ ),
640
+ fixIts: isOpen ? [] : [markFinalFixIt(for: classDecl)]
641
+ )
642
+ )
643
+ }
644
+ if let inheritanceClause = classDecl.inheritanceClause {
645
+ diagnostics.append(
646
+ Diagnostic(
647
+ node: inheritanceClause,
648
+ message: RecordDiagnosticMessage(
649
+ "@Record class '\(name)' can't have an inheritance clause: a record class can't have a superclass, and the macro can't tell a superclass from a protocol. Declare protocol conformances in an extension",
650
+ id: "record-class-inheritance"
651
+ )
652
+ )
653
+ )
619
654
  }
620
- return true
655
+ return diagnostics
656
+ }
657
+
658
+ /// The fix-it for a record class that isn't `final`: add the modifier right before `class`.
659
+ private func markFinalFixIt(for classDecl: ClassDeclSyntax) -> FixIt {
660
+ // The new modifier takes over the leading trivia of `class` (the line break after the attribute
661
+ // when no other modifier comes first), so it lands where `class` started.
662
+ let classKeyword = classDecl.classKeyword
663
+ var modifiers = classDecl.modifiers
664
+ modifiers.append(
665
+ DeclModifierSyntax(name: .keyword(.final, leadingTrivia: classKeyword.leadingTrivia, trailingTrivia: .space))
666
+ )
667
+ let newDecl =
668
+ classDecl
669
+ .with(\.modifiers, modifiers)
670
+ .with(\.classKeyword, classKeyword.with(\.leadingTrivia, []))
671
+ return FixIt(
672
+ message: RecordFixItMessage("Mark the class 'final'", id: "record-class-insert-final"),
673
+ changes: [.replace(oldNode: Syntax(classDecl), newNode: Syntax(newDecl))]
674
+ )
621
675
  }
622
676
 
623
677
  private struct RecordDiagnosticMessage: DiagnosticMessage {
@@ -630,3 +684,13 @@ private struct RecordDiagnosticMessage: DiagnosticMessage {
630
684
  self.diagnosticID = MessageID(domain: "ExpoModulesMacros", id: id)
631
685
  }
632
686
  }
687
+
688
+ private struct RecordFixItMessage: FixItMessage {
689
+ let message: String
690
+ let fixItID: MessageID
691
+
692
+ init(_ message: String, id: String) {
693
+ self.message = message
694
+ self.fixItID = MessageID(domain: "ExpoModulesMacros", id: id)
695
+ }
696
+ }