expo-modules-macros 0.31.0 → 0.33.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 (28) hide show
  1. package/.github/workflows/{swift.yml → macos.yml} +12 -3
  2. package/AGENTS.md +4 -2
  3. package/Package.swift +10 -1
  4. package/README.md +6 -6
  5. package/apple/ExpoModulesMacros +0 -0
  6. package/apple/Sources/ExpoModulesCLI/Commands/ScanExports/ExportedSurface.swift +124 -5
  7. package/apple/Sources/ExpoModulesCLI/Commands/ScanExports/InheritedMembers.swift +2 -1
  8. package/apple/Sources/ExpoModulesCLI/Commands/ScanExports/ResolveRefs.swift +76 -17
  9. package/apple/Sources/ExpoModulesCLI/Commands/ScanExports/ScanExports.swift +15 -8
  10. package/apple/Sources/ExpoModulesCLI/Commands/ScanExports/SurfaceVisitor.swift +174 -20
  11. package/apple/Sources/ExpoModulesCLI/Commands/ScanModules/ScanModules.swift +14 -6
  12. package/apple/Sources/ExpoModulesCLI/Core/Detection.swift +6 -0
  13. package/apple/Sources/ExpoModulesCLI/Core/DetectionVisitor.swift +41 -2
  14. package/apple/Sources/ExpoModulesCLI/Core/SourceScan.swift +3 -2
  15. package/apple/Sources/ExpoModulesMacrosCore/Macros/EventMacro.swift +12 -2
  16. package/apple/Sources/ExpoModulesMacrosCore/Macros/ExpoModuleMacro.swift +12 -0
  17. package/apple/Sources/ExpoModulesMacrosCore/Macros/ExpoViewMacro.swift +64 -30
  18. package/apple/Sources/ExpoModulesMacrosCore/Macros/JSMacro.swift +124 -0
  19. package/apple/Sources/ExpoModulesMacrosCore/Macros/RecordMacro.swift +68 -0
  20. package/apple/Sources/ExpoModulesMacrosCore/Macros/SharedObjectMacro.swift +79 -1
  21. package/apple/Sources/ExpoModulesMacrosCore/Macros/UnionMacro.swift +101 -5
  22. package/apple/Sources/ExpoModulesMacrosCore/Macros/ViewPropsMacro.swift +574 -23
  23. package/apple/Sources/ExpoModulesMacrosCore/ProvidedMacros.swift +1 -1
  24. package/apple/Sources/ExpoModulesMacrosCore/Support/JSNameCollisions.swift +111 -2
  25. package/apple/Sources/ExpoModulesMacrosCore/Support/MacroHelpers.swift +28 -2
  26. package/apple/Sources/ExpoModulesMacrosPlugin/ExpoModulesMacrosPlugin.swift +1 -0
  27. package/build/types.d.ts +58 -3
  28. package/package.json +5 -5
@@ -11,6 +11,17 @@ import SwiftSyntaxMacros
11
11
  /// name through the view's event emitter. No JS function object is ever decoded into it.
12
12
  /// - every other property is a **value prop**, decoded from the raw props Fabric delivers.
13
13
  ///
14
+ /// An event must be spelled as a function type. The macro reads only the syntax, so a closure type
15
+ /// behind a typealias (`var onTap: TapHandler`) is a value prop.
16
+ ///
17
+ /// Core creates the props with `Self()` and resets a prop that React removes (sends as `null`) to its
18
+ /// value there, so every value prop is a `var` with a default. When the type declares its own
19
+ /// `init()`, that initializer sets the defaults, and the compiler checks that it sets every stored
20
+ /// property. Otherwise each stored value prop needs an initial value, or a type written as `T?` or
21
+ /// `T!`, which defaults to `nil` (`Optional<T>` doesn't), and so does each `private` or `fileprivate`
22
+ /// stored property, which isn't a prop. The author's `init()` must be non-failable, non-throwing and
23
+ /// synchronous, so that core can call it.
24
+ ///
14
25
  /// The macro synthesizes the identity surface the batched reaction model reads:
15
26
  ///
16
27
  /// - `PropName`: a `String`-backed `CaseIterable` enum, one case per value prop, whose raw value is
@@ -21,6 +32,22 @@ import SwiftSyntaxMacros
21
32
  /// allocation and no hashing.
22
33
  /// - `_eventNames`: the event props' names, verbatim, for core to register at view creation.
23
34
  /// - `typealias Diff = PropsDiff<Self>`, the nested spelling of core's one generic diff.
35
+ /// - a public `init()`, which `AnyViewProps` requires, when Swift's own can't satisfy it: when an
36
+ /// event prop has no initial value, when the type is `public` or `package` (Swift's `init()` is
37
+ /// `internal`), also through a `public` or `package` extension that contains it, or when the type
38
+ /// declares other initializers (Swift then synthesizes no `init()`). It gives each
39
+ /// event without an initial value an empty closure (`{}`, `{ _ in }`, ...) and leaves the value
40
+ /// props at their initial values. It is never generated when the type declares its own `init()`.
41
+ /// Like any initializer in the struct body, the generated one removes the memberwise initializer.
42
+ ///
43
+ /// An event's function type must return `Void`, because JavaScript can't return a value to it.
44
+ ///
45
+ /// The extension that conforms the type to `AnyViewProps` adds:
46
+ ///
47
+ /// - `allProps`: every bit set, the changed set on the first application.
48
+ /// - `propSet(for:)`: one name's bit, for folding raw changed keys into the mask.
49
+ /// - `_decode(_:from:in:)`: assigns one value prop from a JavaScript value. `null` and `undefined`
50
+ /// reset it to its value in `Self()`, and any other value decodes through `JavaScriptDecodable`.
24
51
  ///
25
52
  /// Author-facing shape — no conformance to spell out:
26
53
  ///
@@ -46,19 +73,23 @@ public struct ViewPropsMacro: MemberMacro, ExtensionMacro {
46
73
  conformingTo protocols: [TypeSyntax],
47
74
  in context: some MacroExpansionContext
48
75
  ) throws -> [DeclSyntax] {
49
- let props = try validatedViewProps(of: declaration)
76
+ let props = try validatedViewProps(of: declaration, in: context.lexicalContext)
50
77
 
51
78
  var members: [DeclSyntax] = []
52
79
  members.append(propNameEnum(valueProps: props.valueProps))
53
80
  members.append(propSetStruct(valueProps: props.valueProps))
54
81
  members.append(eventNamesConstant(eventProps: props.eventProps))
55
82
  members.append(diffTypealias())
83
+ if let initializer = defaultInitializer(props: props) {
84
+ members.append(initializer)
85
+ }
56
86
  return members
57
87
  }
58
88
 
59
- /// Auto-conforms the type to `AnyViewProps`, supplying the two requirements that can only be written
60
- /// per-props: `allProps` (every bit set, the changed set on the first application) and
61
- /// `propSet(for:)` (one name's bit, for folding raw changed keys into the mask).
89
+ /// Auto-conforms the type to `AnyViewProps`, supplying the requirements that can only be written
90
+ /// per-props: `allProps` (every bit set, the changed set on the first application),
91
+ /// `propSet(for:)` (one name's bit, for folding raw changed keys into the mask) and `_decode`
92
+ /// (one value prop assigned from a JavaScript value).
62
93
  ///
63
94
  /// A conformance the author already spelled out in the inheritance clause is not repeated.
64
95
  public static func expansion(
@@ -75,7 +106,7 @@ public struct ViewPropsMacro: MemberMacro, ExtensionMacro {
75
106
  // expansion runs each role independently, so throwing here too would surface every diagnostic
76
107
  // twice. When the model doesn't validate, the member macro has already emitted the error and
77
108
  // there is nothing to extend.
78
- guard let props = try? validatedViewProps(of: declaration) else {
109
+ guard let props = try? validatedViewProps(of: declaration, in: context.lexicalContext) else {
79
110
  return []
80
111
  }
81
112
  let alreadyConforms = inheritsProtocol(named: viewPropsProtocolName, in: declaration)
@@ -89,8 +120,7 @@ public struct ViewPropsMacro: MemberMacro, ExtensionMacro {
89
120
  ? "0"
90
121
  : "0b" + String(repeating: "1", count: props.valueProps.count)
91
122
 
92
- // With no value prop there's nothing to switch over, and an empty `switch` over an uninhabited
93
- // enum doesn't compile — return the empty set instead.
123
+ // With no value prop there's nothing to switch over, so return the empty set.
94
124
  let propSetBody: String
95
125
  if props.valueProps.isEmpty {
96
126
  propSetBody = " return []"
@@ -101,6 +131,8 @@ public struct ViewPropsMacro: MemberMacro, ExtensionMacro {
101
131
  propSetBody = " switch name {\n\(cases)\n }"
102
132
  }
103
133
 
134
+ let decodeBody = decodeFunctionBody(valueProps: props.valueProps)
135
+
104
136
  let conformanceClause = alreadyConforms ? "" : ": \(viewPropsProtocolName)"
105
137
  let ext: DeclSyntax = """
106
138
  extension \(type.trimmed)\(raw: conformanceClause) {
@@ -112,6 +144,10 @@ public struct ViewPropsMacro: MemberMacro, ExtensionMacro {
112
144
  public static func propSet(for name: PropName) -> PropSet {
113
145
  \(raw: propSetBody)
114
146
  }
147
+
148
+ @JavaScriptActor
149
+ public mutating func _decode(_ name: PropName, from value: borrowing JavaScriptUnownedValue, in runtime: borrowing JavaScriptRuntime) throws {\(raw: decodeBody)
150
+ }
115
151
  }
116
152
  """
117
153
  guard let extDecl = ext.as(ExtensionDeclSyntax.self) else {
@@ -249,6 +285,178 @@ private func innerFunctionType(of type: TypeSyntax) -> TypeSyntax? {
249
285
  return only.type
250
286
  }
251
287
 
288
+ /// The error for a `let` declaring value props, attached to the `let` keyword and carrying a fix-it
289
+ /// that changes it to `var`. `_decode` assigns each value prop when React sets or removes it, which a
290
+ /// constant doesn't allow. The fix-it changes every binding of the declaration, so the message names
291
+ /// each value prop it declares.
292
+ private func letValuePropDiagnostic(for varDecl: VariableDeclSyntax, names: [String]) -> Diagnostic {
293
+ let specifier = varDecl.bindingSpecifier
294
+ let fixIt = FixIt(
295
+ message: ViewPropsFixItMessage("Replace 'let' with 'var'", id: "viewprops-let-to-var"),
296
+ changes: [
297
+ .replace(
298
+ oldNode: Syntax(specifier),
299
+ newNode: Syntax(specifier.with(\.tokenKind, .keyword(.var)))
300
+ )
301
+ ]
302
+ )
303
+ let quoted = names.map { "'\(withoutBackticks($0))'" }
304
+ let text =
305
+ quoted.count == 1
306
+ ? "View prop \(quoted[0]) can't be a 'let' constant, because it's assigned whenever React sets or removes the prop. Declare it with 'var'"
307
+ : "View props \(quoted.dropLast().joined(separator: ", ")) and \(quoted[quoted.count - 1]) can't be 'let' constants, because they're assigned whenever React sets or removes the props. Declare them with 'var'"
308
+ return Diagnostic(
309
+ node: specifier,
310
+ message: ViewPropsDiagnosticMessage(text, id: "viewprops-let-prop"),
311
+ fixIts: [fixIt]
312
+ )
313
+ }
314
+
315
+ /// The error for an event prop whose function type returns something other than `Void`, attached to
316
+ /// the return type and carrying a fix-it that changes it to `Void`. Core dispatches the event to
317
+ /// JavaScript, which can't return a value to the caller, and the generated `init()` couldn't give the
318
+ /// event an empty closure.
319
+ private func eventReturnTypeDiagnostic(for returnType: TypeSyntax, name: String) -> Diagnostic {
320
+ let message = ViewPropsDiagnosticMessage(
321
+ "Event prop '\(withoutBackticks(name))' must return Void, because an event is sent to JavaScript, which can't return a value. Change the return type '\(returnType.trimmedDescription)' to 'Void'",
322
+ id: "viewprops-event-return-type"
323
+ )
324
+ let void = TypeSyntax(IdentifierTypeSyntax(name: .identifier("Void")))
325
+ .with(\.leadingTrivia, returnType.leadingTrivia)
326
+ .with(\.trailingTrivia, returnType.trailingTrivia)
327
+ let fixIt = FixIt(
328
+ message: ViewPropsFixItMessage("Change the return type to 'Void'", id: "viewprops-event-return-void"),
329
+ changes: [.replace(oldNode: Syntax(returnType), newNode: Syntax(void))]
330
+ )
331
+ return Diagnostic(node: returnType, message: message, fixIts: [fixIt])
332
+ }
333
+
334
+ /// The error for a value prop with no default, attached to the property name. A value prop starts
335
+ /// from its value in `Self()` and returns to it when React removes the prop, so it needs an initial
336
+ /// value, or a type written as `T?`, whose default is `nil`. There is no fix-it, since the default is
337
+ /// the author's choice, except for a type written as `Optional<T>`: rewriting it as `T?` keeps the
338
+ /// type and only adds the implicit `nil`.
339
+ private func missingDefaultDiagnostic(
340
+ for pattern: IdentifierPatternSyntax,
341
+ name: String,
342
+ type: TypeSyntax?
343
+ ) -> Diagnostic {
344
+ if let type, let sugared = optionalSugar(for: type) {
345
+ let fixIt = FixIt(
346
+ message: ViewPropsFixItMessage(
347
+ "Write the type as '\(sugared.trimmedDescription)'",
348
+ id: "viewprops-optional-sugar"
349
+ ),
350
+ changes: [.replace(oldNode: Syntax(type), newNode: Syntax(sugared))]
351
+ )
352
+ let message = ViewPropsDiagnosticMessage(
353
+ "View prop '\(withoutBackticks(name))' needs an initial value, which is its default when React removes the prop. A type written as '\(type.trimmedDescription)' gets no implicit nil, so write it as '\(sugared.trimmedDescription)'",
354
+ id: "viewprops-missing-default"
355
+ )
356
+ return Diagnostic(node: pattern, message: message, fixIts: [fixIt])
357
+ }
358
+ let message = ViewPropsDiagnosticMessage(
359
+ "View prop '\(withoutBackticks(name))' needs an initial value, which is its default when React removes the prop. Give it one, or make its type optional with '?', which defaults to nil",
360
+ id: "viewprops-missing-default"
361
+ )
362
+ return Diagnostic(node: pattern, message: message)
363
+ }
364
+
365
+ /// `T?` for a type written as `Optional<T>`, with the trivia of the original, or `nil` for any other
366
+ /// type. A wrapped `any P`, `some P` or `P & Q` keeps parentheses, since `any P?` would make only `P`
367
+ /// optional.
368
+ private func optionalSugar(for type: TypeSyntax) -> TypeSyntax? {
369
+ guard let identifier = type.as(IdentifierTypeSyntax.self), identifier.name.text == "Optional",
370
+ let wrapped = optionalWrappedType(type)
371
+ else {
372
+ return nil
373
+ }
374
+ var wrappedType = wrapped.trimmed
375
+ if wrappedType.is(SomeOrAnyTypeSyntax.self) || wrappedType.is(CompositionTypeSyntax.self) {
376
+ wrappedType = TypeSyntax(TupleTypeSyntax(elements: [TupleTypeElementSyntax(type: wrappedType)]))
377
+ }
378
+ return TypeSyntax(OptionalTypeSyntax(wrappedType: wrappedType))
379
+ .with(\.leadingTrivia, type.leadingTrivia)
380
+ .with(\.trailingTrivia, type.trailingTrivia)
381
+ }
382
+
383
+ /// The error for a `private` or `fileprivate` stored property without an initial value, attached to
384
+ /// its name. `@ViewProps` leaves it out of the props, so the `init()` that core calls doesn't set it.
385
+ /// There is no fix-it: the value is the author's choice.
386
+ private func unsetPrivatePropertyDiagnostic(
387
+ for pattern: IdentifierPatternSyntax,
388
+ access: String
389
+ ) -> Diagnostic {
390
+ let message = ViewPropsDiagnosticMessage(
391
+ "@ViewProps leaves out the \(access) property '\(withoutBackticks(pattern.identifier.text))', so 'init()' doesn't set it. Give it an initial value, or remove '\(access)' to make it a prop",
392
+ id: "viewprops-unset-private-property"
393
+ )
394
+ return Diagnostic(node: pattern, message: message)
395
+ }
396
+
397
+ /// The errors for an `init()` that core can't call as `Self()`, one for each token that makes it
398
+ /// unusable: the `?` or `!` of a failable initializer, `async`, and the `throws` clause. Each is
399
+ /// attached to its token and carries a fix-it that removes it with its trailing space, so
400
+ /// `init() async throws {` becomes `init() throws {`, and then `init() {`.
401
+ private func unusableInitializerDiagnostics(for initializer: InitializerDeclSyntax) -> [Diagnostic] {
402
+ var diagnostics: [Diagnostic] = []
403
+ if let optionalMark = initializer.optionalMark {
404
+ diagnostics.append(
405
+ unusableInitializerDiagnostic(
406
+ on: Syntax(optionalMark),
407
+ removing: Syntax(optionalMark.with(\.presence, .missing)),
408
+ requirement: "non-failable",
409
+ fixItID: "viewprops-remove-failable"
410
+ )
411
+ )
412
+ }
413
+ let effectSpecifiers = initializer.signature.effectSpecifiers
414
+ if let asyncSpecifier = effectSpecifiers?.asyncSpecifier {
415
+ diagnostics.append(
416
+ unusableInitializerDiagnostic(
417
+ on: Syntax(asyncSpecifier),
418
+ removing: Syntax(asyncSpecifier.with(\.presence, .missing)),
419
+ requirement: "synchronous",
420
+ fixItID: "viewprops-remove-async"
421
+ )
422
+ )
423
+ }
424
+ if let throwsClause = effectSpecifiers?.throwsClause {
425
+ let missingClause = ThrowsClauseSyntax(
426
+ throwsSpecifier: throwsClause.throwsSpecifier.with(\.presence, .missing)
427
+ )
428
+ diagnostics.append(
429
+ unusableInitializerDiagnostic(
430
+ on: Syntax(throwsClause),
431
+ removing: Syntax(missingClause),
432
+ requirement: "non-throwing",
433
+ fixItID: "viewprops-remove-throws"
434
+ )
435
+ )
436
+ }
437
+ return diagnostics
438
+ }
439
+
440
+ /// One error of `unusableInitializerDiagnostics`. The fix-it replaces the node, together with its
441
+ /// trivia, by a missing node, which prints as nothing.
442
+ private func unusableInitializerDiagnostic(
443
+ on node: Syntax,
444
+ removing missingNode: Syntax,
445
+ requirement: String,
446
+ fixItID: String
447
+ ) -> Diagnostic {
448
+ let text = node.trimmedDescription
449
+ let fixIt = FixIt(
450
+ message: ViewPropsFixItMessage("Remove '\(text)'", id: fixItID),
451
+ changes: [.replace(oldNode: node, newNode: missingNode)]
452
+ )
453
+ let message = ViewPropsDiagnosticMessage(
454
+ "@ViewProps needs a \(requirement) 'init()', because core creates the props with 'Self()'. Remove '\(text)'",
455
+ id: "viewprops-unusable-init"
456
+ )
457
+ return Diagnostic(node: node, message: message, fixIts: [fixIt])
458
+ }
459
+
252
460
  private struct ViewPropsDiagnosticMessage: DiagnosticMessage {
253
461
  let message: String
254
462
  let diagnosticID: MessageID
@@ -277,9 +485,12 @@ private struct ViewProp {
277
485
  /// The property name as written, backticks included for an escaped name. Every identifier position
278
486
  /// in the generated code uses this, since `case default` and `static let default` don't parse.
279
487
  let name: String
280
- /// The property's declared type, verbatim. Retained for the decode surface, which lands with the
281
- /// core props contract.
488
+ /// The property's declared type, verbatim, or the type of its literal initial value. `_decode`
489
+ /// doesn't spell it: the decoded type is inferred from the assignment.
282
490
  let type: String
491
+ /// Whether the prop is computed with a `mutating get`, which `_decode` can only read from a
492
+ /// mutable value.
493
+ let hasMutatingGetter: Bool
283
494
 
284
495
  /// The name with any escaping backticks removed: the JS-visible key, so the enum's raw value and
285
496
  /// `_eventNames` both read `"default"`, never `` "`default`" ``.
@@ -288,20 +499,41 @@ private struct ViewProp {
288
499
  }
289
500
  }
290
501
 
502
+ /// An event prop declared without an initial value, which the generated `init()` assigns.
503
+ private struct EventDefault {
504
+ /// The property name as written, backticks included.
505
+ let name: String
506
+ /// A closure literal that ignores its arguments and does nothing: `{}`, `{ _ in }`, `{ _, _ in }`
507
+ /// and so on, by the arity of the event's function type.
508
+ let emptyClosure: String
509
+ }
510
+
291
511
  private struct ViewPropsModel {
292
512
  /// Value props, in declaration order. The order is the bit order, so it is part of the ABI
293
513
  /// between a compiled view and the runtime that fills the mask.
294
514
  let valueProps: [ViewProp]
295
515
  /// Event props, in declaration order.
296
516
  let eventProps: [ViewProp]
517
+ /// The event props without an initial value, in declaration order.
518
+ let eventDefaults: [EventDefault]
519
+ /// Whether the macro generates `init()`. See `needsGeneratedInitializer`.
520
+ let generatesInitializer: Bool
297
521
  }
298
522
 
299
523
  /// Reads and validates the props type's stored properties, splitting them into value props and events.
300
524
  ///
301
525
  /// Rejects what can't be expressed: a non-`struct` declaration (the class form is the SwiftUI path and
302
526
  /// needs the observable protocol, which is deferred), a leftover `@Field` attribute, an optional
303
- /// function type, a property with no determinable type, and more than 64 value props.
304
- private func validatedViewProps(of declaration: some DeclGroupSyntax) throws -> ViewPropsModel {
527
+ /// function type, an event whose function type returns a value, a property with no determinable type,
528
+ /// a value prop declared with `let` or without a default, a `private` stored property without an
529
+ /// initial value, an `init()` that is failable, throwing or `async`, and more than 64 value props.
530
+ ///
531
+ /// When the type declares a usable `init()`, that initializer is the default: it must set every
532
+ /// stored property, which the compiler checks, so the props need no initial values.
533
+ private func validatedViewProps(
534
+ of declaration: some DeclGroupSyntax,
535
+ in lexicalContext: [Syntax]
536
+ ) throws -> ViewPropsModel {
305
537
  guard let structDecl = declaration.as(StructDeclSyntax.self) else {
306
538
  throw MacroExpansionErrorMessage(
307
539
  "@ViewProps can only be applied to a struct — the class form (for SwiftUI views) is not supported yet"
@@ -332,16 +564,50 @@ private func validatedViewProps(of declaration: some DeclGroupSyntax) throws ->
332
564
  throw DiagnosticsError(diagnostics: fieldDiagnostics)
333
565
  }
334
566
 
567
+ // A usable `init()` belongs to the author and sets the defaults. An `init()` that core can't call
568
+ // as `Self()` doesn't count, and is reported only when there is no usable one: Swift allows an
569
+ // `init() async` next to `init()`, and `Self()` in the synchronous `_decode` calls the latter.
570
+ var propDiagnostics: [Diagnostic] = []
571
+ var unusableInitializerDiagnosticsFound: [Diagnostic] = []
572
+ var declaresUsableInitializer = false
573
+ var declaresOtherInitializer = false
574
+ for member in flattenedMembers(declaration.memberBlock.members) {
575
+ guard let initializer = member.decl.as(InitializerDeclSyntax.self) else {
576
+ continue
577
+ }
578
+ guard initializer.signature.parameterClause.parameters.isEmpty else {
579
+ declaresOtherInitializer = true
580
+ continue
581
+ }
582
+ let unusableDiagnostics = unusableInitializerDiagnostics(for: initializer)
583
+ if unusableDiagnostics.isEmpty {
584
+ declaresUsableInitializer = true
585
+ } else {
586
+ unusableInitializerDiagnosticsFound += unusableDiagnostics
587
+ }
588
+ }
589
+ if !declaresUsableInitializer {
590
+ propDiagnostics += unusableInitializerDiagnosticsFound
591
+ }
592
+
335
593
  var valueProps: [ViewProp] = []
336
594
  var eventProps: [ViewProp] = []
595
+ var eventDefaults: [EventDefault] = []
337
596
 
338
597
  for member in declaration.memberBlock.members {
339
598
  guard let varDecl = member.decl.as(VariableDeclSyntax.self) else {
340
599
  continue
341
600
  }
342
601
  if isExcludedByModifier(varDecl.modifiers) {
602
+ if !declaresUsableInitializer {
603
+ propDiagnostics += unsetPrivatePropertyDiagnostics(of: varDecl)
604
+ }
343
605
  continue
344
606
  }
607
+ // One `let` can declare several props, but its diagnostic and fix-it belong to the keyword, so
608
+ // it comes before the diagnostics of the bindings, in source order.
609
+ var letValuePropNames: [String] = []
610
+ let letDiagnosticIndex = propDiagnostics.count
345
611
  for binding in varDecl.bindings {
346
612
  // A computed property is never a prop, but an accessor block alone doesn't mean computed:
347
613
  // `willSet`/`didSet` observers imply stored storage. `bindingIsSettable` draws exactly that
@@ -381,12 +647,12 @@ private func validatedViewProps(of declaration: some DeclGroupSyntax) throws ->
381
647
  // dynamic, but events are registered once at view creation from a static name list, so there
382
648
  // is no shape that could express "sometimes emitted".
383
649
  if let declaredType, isOptionalType(declaredType), underlyingFunctionType(of: declaredType) != nil {
384
- throw DiagnosticsError(diagnostics: [
385
- optionalEventDiagnostic(for: declaredType, name: name)
386
- ])
650
+ propDiagnostics.append(optionalEventDiagnostic(for: declaredType, name: name))
651
+ continue
387
652
  }
388
653
 
389
- let isEvent = declaredType.map { underlyingFunctionType(of: $0) != nil } ?? false
654
+ let eventFunctionType = declaredType.flatMap(underlyingFunctionType)
655
+ let isEvent = eventFunctionType != nil
390
656
  // `PropSet` stores its mask in `rawValue`, so a value prop of that name would emit a static
391
657
  // member shadowing it and the option set would not compile ("circular reference"). The error
392
658
  // would point at generated code, so catch it here and name the property.
@@ -395,21 +661,231 @@ private func validatedViewProps(of declaration: some DeclGroupSyntax) throws ->
395
661
  "'rawValue' can't be used as a prop name — it collides with the synthesized PropSet's storage. Rename the property"
396
662
  )
397
663
  }
398
- let prop = ViewProp(name: name, type: resolvedType)
399
- if isEvent {
664
+ // Only stored storage takes an initial value; a computed prop with a setter needs none.
665
+ let needsInitialValue =
666
+ binding.initializer == nil && isStoredBinding(binding) && !declaresUsableInitializer
667
+ let prop = ViewProp(name: name, type: resolvedType, hasMutatingGetter: hasMutatingGetter(binding))
668
+ if let eventFunctionType {
669
+ // An event is dispatched to JavaScript, which can't hand a value back to the caller.
670
+ if !returnsVoid(eventFunctionType) {
671
+ propDiagnostics.append(eventReturnTypeDiagnostic(for: eventFunctionType.returnClause.type, name: name))
672
+ }
673
+ if needsInitialValue {
674
+ eventDefaults.append(
675
+ EventDefault(name: name, emptyClosure: emptyClosure(arity: eventFunctionType.parameters.count))
676
+ )
677
+ }
400
678
  eventProps.append(prop)
401
- } else {
402
- valueProps.append(prop)
679
+ continue
403
680
  }
681
+ // `_decode` assigns every value prop, so none can be a constant.
682
+ if varDecl.bindingSpecifier.tokenKind == .keyword(.let) {
683
+ letValuePropNames.append(name)
684
+ }
685
+ // A removed prop is reset to its value in a fresh `Self()`, so every value prop needs a
686
+ // default: its initial value, or the `nil` that Swift gives an optional `var` without one.
687
+ if needsInitialValue && !(declaredType.map(hasImplicitNilDefault) ?? false) {
688
+ propDiagnostics.append(missingDefaultDiagnostic(for: ident, name: name, type: declaredType))
689
+ }
690
+ valueProps.append(prop)
691
+ }
692
+ if !letValuePropNames.isEmpty {
693
+ propDiagnostics.insert(letValuePropDiagnostic(for: varDecl, names: letValuePropNames), at: letDiagnosticIndex)
404
694
  }
405
695
  }
696
+ // Reported together, so one run names every prop that the generated code can't reset, assign or
697
+ // default.
698
+ if !propDiagnostics.isEmpty {
699
+ throw DiagnosticsError(diagnostics: propDiagnostics)
700
+ }
406
701
 
407
702
  guard valueProps.count <= maxValueProps else {
408
703
  throw MacroExpansionErrorMessage(
409
704
  "@ViewProps supports at most \(maxValueProps) value props (the changed-props mask is a UInt64), but this type declares \(valueProps.count); '\(valueProps[maxValueProps].name)' is the first over the limit. Event props don't count toward it"
410
705
  )
411
706
  }
412
- return ViewPropsModel(valueProps: valueProps, eventProps: eventProps)
707
+ return ViewPropsModel(
708
+ valueProps: valueProps,
709
+ eventProps: eventProps,
710
+ eventDefaults: eventDefaults,
711
+ generatesInitializer: needsGeneratedInitializer(
712
+ structDecl,
713
+ in: lexicalContext,
714
+ eventDefaults: eventDefaults,
715
+ declaresUsableInitializer: declaresUsableInitializer,
716
+ declaresOtherInitializer: declaresOtherInitializer
717
+ )
718
+ )
719
+ }
720
+
721
+ /// The members of a type body in source order, with the members of every `#if` clause in place of
722
+ /// the conditional declaration. The original conditions remain in the source; this is only for
723
+ /// discovering declarations that exist in one or more compilation configurations.
724
+ private func flattenedMembers(_ members: MemberBlockItemListSyntax) -> [MemberBlockItemSyntax] {
725
+ return members.flatMap { member -> [MemberBlockItemSyntax] in
726
+ guard let ifConfig = member.decl.as(IfConfigDeclSyntax.self) else {
727
+ return [member]
728
+ }
729
+ return ifConfig.clauses.flatMap { clause -> [MemberBlockItemSyntax] in
730
+ guard case .decls(let declarations) = clause.elements else {
731
+ return []
732
+ }
733
+ return flattenedMembers(declarations)
734
+ }
735
+ }
736
+ }
737
+
738
+ /// Whether Swift's own `init()` can't satisfy `AnyViewProps`, so the macro generates a public one.
739
+ /// Swift synthesizes `init()` only when every stored property has a default and the type declares
740
+ /// no initializer, and that `init()` is `internal`, which the conformance of a `public` or `package`
741
+ /// type can't use. The author's usable `init()` always wins, so there is never a second one.
742
+ private func needsGeneratedInitializer(
743
+ _ structDecl: StructDeclSyntax,
744
+ in lexicalContext: [Syntax],
745
+ eventDefaults: [EventDefault],
746
+ declaresUsableInitializer: Bool,
747
+ declaresOtherInitializer: Bool
748
+ ) -> Bool {
749
+ if declaresUsableInitializer {
750
+ return false
751
+ }
752
+ return !eventDefaults.isEmpty || isAboveInternal(structDecl, in: lexicalContext) || declaresOtherInitializer
753
+ }
754
+
755
+ /// True if the type's access is `public`, `open` or `package`: written on the type, or, when the type
756
+ /// has no access modifier, taken from the extension that directly contains it
757
+ /// (`public extension Outer { struct Props {} }`). Only that extension sets the default; an enclosing
758
+ /// type doesn't, so a type nested in a `public` type without a modifier is `internal`.
759
+ private func isAboveInternal(_ structDecl: StructDeclSyntax, in lexicalContext: [Syntax]) -> Bool {
760
+ if let access = accessLevelModifier(in: structDecl.modifiers) {
761
+ return isAboveInternal(access)
762
+ }
763
+ guard let enclosingExtension = lexicalContext.first?.as(ExtensionDeclSyntax.self),
764
+ let access = accessLevelModifier(in: enclosingExtension.modifiers)
765
+ else {
766
+ return false
767
+ }
768
+ return isAboveInternal(access)
769
+ }
770
+
771
+ /// The access level modifier of a declaration, without a `(set)` detail, which only limits a setter.
772
+ private func accessLevelModifier(in modifiers: DeclModifierListSyntax) -> TokenKind? {
773
+ return modifiers.first { modifier in
774
+ guard modifier.detail == nil else {
775
+ return false
776
+ }
777
+ switch modifier.name.tokenKind {
778
+ case .keyword(.public), .keyword(.open), .keyword(.package), .keyword(.internal), .keyword(.fileprivate),
779
+ .keyword(.private):
780
+ return true
781
+ default:
782
+ return false
783
+ }
784
+ }?.name.tokenKind
785
+ }
786
+
787
+ private func isAboveInternal(_ access: TokenKind) -> Bool {
788
+ return access == .keyword(.public) || access == .keyword(.open) || access == .keyword(.package)
789
+ }
790
+
791
+ /// True if the binding has storage: no accessor block, or only `willSet`/`didSet` observers.
792
+ private func isStoredBinding(_ binding: PatternBindingSyntax) -> Bool {
793
+ guard let accessorBlock = binding.accessorBlock else {
794
+ return true
795
+ }
796
+ guard case .accessors(let accessors) = accessorBlock.accessors else {
797
+ return false
798
+ }
799
+ return accessors.allSatisfy { accessor in
800
+ accessor.accessorSpecifier.tokenKind == .keyword(.willSet)
801
+ || accessor.accessorSpecifier.tokenKind == .keyword(.didSet)
802
+ }
803
+ }
804
+
805
+ /// The errors for the `private` and `fileprivate` stored properties of a declaration that have no
806
+ /// initial value. `static`, `class` and `lazy` properties are exempt, and so is an optional `var`
807
+ /// written as `T?` or `T!`, which defaults to `nil`.
808
+ private func unsetPrivatePropertyDiagnostics(of varDecl: VariableDeclSyntax) -> [Diagnostic] {
809
+ guard
810
+ let access = varDecl.modifiers.first(where: {
811
+ $0.name.tokenKind == .keyword(.private) || $0.name.tokenKind == .keyword(.fileprivate)
812
+ }),
813
+ !varDecl.modifiers.contains(where: {
814
+ $0.name.tokenKind == .keyword(.static) || $0.name.tokenKind == .keyword(.class)
815
+ || $0.name.tokenKind == .keyword(.lazy)
816
+ })
817
+ else {
818
+ return []
819
+ }
820
+ let isVar = varDecl.bindingSpecifier.tokenKind == .keyword(.var)
821
+ return varDecl.bindings.compactMap { binding in
822
+ guard binding.initializer == nil, isStoredBinding(binding),
823
+ let ident = binding.pattern.as(IdentifierPatternSyntax.self),
824
+ !(isVar && resolvedTypeAnnotation(of: binding, in: varDecl).map { hasImplicitNilDefault($0.type) } == true)
825
+ else {
826
+ return nil
827
+ }
828
+ return unsetPrivatePropertyDiagnostic(for: ident, access: access.name.text)
829
+ }
830
+ }
831
+
832
+ /// The binding's own type annotation, or the trailing annotation shared with preceding bindings.
833
+ private func resolvedTypeAnnotation(
834
+ of binding: PatternBindingSyntax,
835
+ in varDecl: VariableDeclSyntax
836
+ ) -> TypeAnnotationSyntax? {
837
+ if let typeAnnotation = binding.typeAnnotation {
838
+ return typeAnnotation
839
+ }
840
+ var foundBinding = false
841
+ for candidate in varDecl.bindings {
842
+ if foundBinding, let typeAnnotation = candidate.typeAnnotation {
843
+ return typeAnnotation
844
+ }
845
+ foundBinding = candidate.id == binding.id
846
+ }
847
+ return nil
848
+ }
849
+
850
+ /// True if the function type returns `Void`, written as `Void`, `Swift.Void` or `()`, in any number
851
+ /// of enclosing parentheses, such as `(Void)` or `((()))`.
852
+ private func returnsVoid(_ functionType: FunctionTypeSyntax) -> Bool {
853
+ return isVoidType(functionType.returnClause.type)
854
+ }
855
+
856
+ private func isVoidType(_ type: TypeSyntax) -> Bool {
857
+ let type = withoutParentheses(type)
858
+ if let tuple = type.as(TupleTypeSyntax.self) {
859
+ return tuple.elements.isEmpty
860
+ }
861
+ return ["Void", "Swift.Void"].contains(type.trimmedDescription)
862
+ }
863
+
864
+ /// True if the binding is computed with a `mutating get`.
865
+ private func hasMutatingGetter(_ binding: PatternBindingSyntax) -> Bool {
866
+ guard case .accessors(let accessors) = binding.accessorBlock?.accessors else {
867
+ return false
868
+ }
869
+ return accessors.contains { accessor in
870
+ accessor.accessorSpecifier.tokenKind == .keyword(.get)
871
+ && accessor.modifiers.contains { $0.name.tokenKind == .keyword(.mutating) }
872
+ }
873
+ }
874
+
875
+ /// A closure literal that takes `arity` arguments, ignores them and does nothing.
876
+ private func emptyClosure(arity: Int) -> String {
877
+ if arity == 0 {
878
+ return "{}"
879
+ }
880
+ return "{ \(Array(repeating: "_", count: arity).joined(separator: ", ")) in }"
881
+ }
882
+
883
+ /// True if Swift gives a `var` of this type an implicit `nil` initial value, so `Self()` can leave it
884
+ /// out. Only the `T?` and `T!` sugar does, also in parentheses (`(T?)`): a property written as
885
+ /// `Optional<T>` has no default.
886
+ private func hasImplicitNilDefault(_ type: TypeSyntax) -> Bool {
887
+ let type = withoutParentheses(type)
888
+ return type.is(OptionalTypeSyntax.self) || type.is(ImplicitlyUnwrappedOptionalTypeSyntax.self)
413
889
  }
414
890
 
415
891
  /// The function type a props property declares, unwrapping any number of enclosing parentheses and an
@@ -447,8 +923,9 @@ private func underlyingFunctionType(of type: TypeSyntax) -> FunctionTypeSyntax?
447
923
  // MARK: - Synthesized members
448
924
 
449
925
  /// The `PropName` enum: one case per value prop, `String`-backed so the raw value is the wire key.
450
- /// Emitted even when empty (an uninhabited enum is legal and keeps the conformance's associated type
451
- /// satisfied), so a props type with only events still conforms.
926
+ /// Emitted even when empty, so a props type with only events still conforms. An enum with no cases
927
+ /// can't declare a raw type, so the empty one conforms to `RawRepresentable` by hand, with `String`
928
+ /// as its `RawValue` like the others.
452
929
  private func propNameEnum(valueProps: [ViewProp]) -> DeclSyntax {
453
930
  // An escaped case takes its raw value from the bare identifier already, but spelling it out keeps
454
931
  // the wire key visible in the generated source and independent of that implicit rule.
@@ -462,7 +939,15 @@ private func propNameEnum(valueProps: [ViewProp]) -> DeclSyntax {
462
939
  .joined(separator: "\n")
463
940
  if valueProps.isEmpty {
464
941
  return """
465
- public enum PropName: String, CaseIterable {
942
+ public enum PropName: RawRepresentable, CaseIterable {
943
+ public init?(rawValue: String) {
944
+ return nil
945
+ }
946
+
947
+ public var rawValue: String {
948
+ switch self {
949
+ }
950
+ }
466
951
  }
467
952
  """
468
953
  }
@@ -517,6 +1002,72 @@ private func eventNamesConstant(eventProps: [ViewProp]) -> DeclSyntax {
517
1002
  """
518
1003
  }
519
1004
 
1005
+ /// The body of `_decode`, which core calls once per changed prop. React sends a removed prop as
1006
+ /// `null`, so `null` and `undefined` reset the prop to its value in a fresh `Self()`; any other value
1007
+ /// decodes through `JavaScriptDecodable`, with the type inferred from the assignment. The helper is a
1008
+ /// static member of `AnyViewProps`, reached through `Self.`, so a prop named `_decodeViewProp`
1009
+ /// doesn't shadow it. The checks run
1010
+ /// once, before the switch, so each case is a single assignment. Members are reached through `self.`
1011
+ /// so that a prop named like a parameter (`value`, `runtime`) isn't shadowed by it.
1012
+ ///
1013
+ /// With no value prop the body is empty: `PropName` is uninhabited, so the function is never called.
1014
+ ///
1015
+ /// `defaults` is a `var` only when a prop has a `mutating get`, which can't be read from a constant.
1016
+ /// Otherwise it's a `let`, so the common case gets no "never mutated" warning.
1017
+ private func decodeFunctionBody(valueProps: [ViewProp]) -> String {
1018
+ if valueProps.isEmpty {
1019
+ return ""
1020
+ }
1021
+ let defaultsSpecifier = valueProps.contains(where: \.hasMutatingGetter) ? "var" : "let"
1022
+ let resetCases =
1023
+ valueProps
1024
+ .map { " case .\($0.name):\n self.\($0.name) = defaults.\($0.name)" }
1025
+ .joined(separator: "\n")
1026
+ let decodeCases =
1027
+ valueProps
1028
+ .map { " case .\($0.name):\n self.\($0.name) = try Self._decodeViewProp(value, in: runtime)" }
1029
+ .joined(separator: "\n")
1030
+ // Starts with a line break, so the empty body closes on the next line instead of leaving a blank
1031
+ // one.
1032
+ return """
1033
+
1034
+ if value.isNull() || value.isUndefined() {
1035
+ \(defaultsSpecifier) defaults = Self()
1036
+ switch name {
1037
+ \(resetCases)
1038
+ }
1039
+ return
1040
+ }
1041
+ switch name {
1042
+ \(decodeCases)
1043
+ }
1044
+ """
1045
+ }
1046
+
1047
+ /// The public `init()` that `AnyViewProps` requires, when Swift's own can't satisfy it: it gives each
1048
+ /// event prop without an initial value an empty closure, and leaves the value props at their initial
1049
+ /// values. `nil` when the type declares a usable `init()`, or when Swift's `init()` is enough (see
1050
+ /// `needsGeneratedInitializer`).
1051
+ private func defaultInitializer(props: ViewPropsModel) -> DeclSyntax? {
1052
+ if !props.generatesInitializer {
1053
+ return nil
1054
+ }
1055
+ if props.eventDefaults.isEmpty {
1056
+ return """
1057
+ public init() {
1058
+ }
1059
+ """
1060
+ }
1061
+ let assignments = props.eventDefaults
1062
+ .map { " self.\($0.name) = \($0.emptyClosure)" }
1063
+ .joined(separator: "\n")
1064
+ return """
1065
+ public init() {
1066
+ \(raw: assignments)
1067
+ }
1068
+ """
1069
+ }
1070
+
520
1071
  /// The nested spelling of core's one generic diff, so a view writes `MyProps.Diff`.
521
1072
  private func diffTypealias() -> DeclSyntax {
522
1073
  return """