@expo/expo-modules-macros-plugin 0.5.1 → 0.6.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.
Binary file
@@ -50,12 +50,11 @@ internal struct JSFunction {
50
50
  /// The decode-call-encode statements that form the host-function body, indented with the given
51
51
  /// prefix. Receiver unwrap (shared objects only), then an arity guard (an exact check when every
52
52
  /// parameter is required, otherwise a range check) throwing `Exceptions.ArgumentsRangeMismatch`;
53
- /// then the decode of the always-present required prefix (primitives via a direct typed accessor
54
- /// like `asDouble()` on a zero-copy `arguments.unownedValue(at:)`, others via
55
- /// `getDynamicType().cast(...)`); then the call and result encode (primitives via
56
- /// `toJavaScriptValue(in:)`, others via `castToJS(...)`). When a trailing run of parameters is
57
- /// omittable the call branches on `arguments.count`, decoding only the slots that branch actually
58
- /// has — `arguments[i]` traps past `count`, so a slot the caller didn't pass is never indexed.
53
+ /// then the decode of the always-present required prefix via `JavaScriptDecodable.decode` on a
54
+ /// zero-copy `arguments.unownedValue(at:)`; then the call and result encode via
55
+ /// `JavaScriptEncodable.encode`. When a trailing run of parameters is omittable the call branches
56
+ /// on `arguments.count`, decoding only the slots that branch actually has `unownedValue(at:)` is
57
+ /// unchecked, so a slot the caller didn't pass is never indexed.
59
58
  private func bodyStatements(receiver: Receiver, indent: String) -> String {
60
59
  let required = requiredArgumentCount
61
60
  let maximum = parameters.count
@@ -119,18 +118,14 @@ internal struct JSFunction {
119
118
  .joined(separator: "\n")
120
119
  }
121
120
 
122
- /// `let arg<index> = …` decoding the slot at `index` by its static type: a primitive through a
123
- /// direct typed accessor on a borrowed `JavaScriptUnownedValue` (no owning value, no `jsi::Value`
124
- /// copy, no `getDynamicType()` allocation, no `Any` boxing, no force-cast still validating and
125
- /// throwing `TypeError` on a mismatch), any other type through the dynamic converter (which needs
126
- /// an owning value, so it indexes the buffer directly).
121
+ /// `let arg<index> = …` decoding the slot at `index` by its static type through
122
+ /// `JavaScriptDecodable.decode` on the borrowed `JavaScriptUnownedValue` no owning value, no
123
+ /// `jsi::Value` copy, no `Any` boxing, no force-cast; it returns the concrete type directly. A
124
+ /// primitive's `decode` is `@inlinable` and lowers to the same direct accessor a hand-rolled fast
125
+ /// path would use.
127
126
  private func decodeStatement(at index: Int) -> String {
128
- let type = parameters[index].type.trimmedDescription
129
- if let accessor = fastDecodeAccessor(for: type) {
130
- return "let arg\(index) = try arguments.unownedValue(at: \(index)).\(accessor)()"
131
- }
132
- let exprType = expressionType(type)
133
- return "let arg\(index) = try \(exprType).getDynamicType().cast(jsValue: arguments[\(index)], appContext: appContext) as! \(exprType)"
127
+ let exprType = expressionType(parameters[index].type.trimmedDescription)
128
+ return "let arg\(index) = try \(exprType).decode(arguments.unownedValue(at: \(index)), in: runtime)"
134
129
  }
135
130
 
136
131
  /// The `<callee>.<name>(...)` call for the given arity. Slots `0..<arity` are passed their decoded
@@ -178,17 +173,17 @@ internal struct JSFunction {
178
173
  return lines
179
174
  }
180
175
 
181
- /// Encode the `result` local back to JS and return it: a primitive through `toJavaScriptValue(in:)`
182
- /// (the typed `JavaScriptRepresentable` conversion no `Any`, no dynamic-type allocation), any
183
- /// other type through the dynamic converter. A no-return function returns `.undefined` instead.
176
+ /// Encode the `result` local back to JS and return it through `JavaScriptEncodable.encode`, the same
177
+ /// for every type. Unlike the decode side there's no primitive fast path: `encode` produces the same
178
+ /// value as the primitive's `toJavaScriptValue(in:)` except for `Int`/`UInt`, where it range-checks
179
+ /// and throws instead of silently encoding an out-of-safe-range value as a lossy number — the
180
+ /// catchable error is the right behavior, and matches how non-primitive integers already encode. A
181
+ /// no-return function returns `.undefined` instead.
184
182
  private func encodeResultLines() -> [String] {
185
183
  guard let returnType else {
186
184
  return ["return .undefined"]
187
185
  }
188
- if fastDecodeAccessor(for: returnType) != nil {
189
- return ["return result.toJavaScriptValue(in: runtime)"]
190
- }
191
- return ["return try \(expressionType(returnType)).getDynamicType().castToJS(result, appContext: appContext, in: runtime)"]
186
+ return ["return try \(expressionType(returnType)).encode(result, in: runtime)"]
192
187
  }
193
188
 
194
189
  /// The `setProperty` statement that installs this function on the JS object. The decode-call-encode
@@ -201,10 +196,6 @@ internal struct JSFunction {
201
196
  /// the host-function closure is what keeps the native callable alive for as long as JS can invoke
202
197
  /// it; its lifetime is bounded by the JS VM's garbage collection of the object. A shared object
203
198
  /// captures nothing of the instance: it recovers the typed receiver from the JS `this` per call.
204
- /// `appContext` is captured **weak** (and guarded) so it doesn't form a real retain cycle through
205
- /// the app context. When no argument or return value goes through the dynamic-type converter the
206
- /// body never references `appContext`, so the capture and guard are omitted to avoid the
207
- /// unused-capture warning.
208
199
  func decorateStatements(receiver: Receiver) -> String {
209
200
  // Synchronous `@JS` bindings bind through the unowned-`this` `setProperty` overload, which hands
210
201
  // `this` in as a borrowed `JavaScriptUnownedValue` instead of allocating an owning
@@ -215,42 +206,19 @@ internal struct JSFunction {
215
206
  // on a shorthand `{ [capture] name, name in }` parameter. Async functions keep the untyped
216
207
  // shorthand and the owning-`this` overload: there is no unowned-`this` async variant and the buffer
217
208
  // escapes into the task anyway.
218
- let captures = receiver.captureClause(usesAppContext: usesAppContext)
209
+ let captures = receiver.captureClause
219
210
  let parameters =
220
211
  isAsync
221
212
  ? "this, arguments"
222
213
  : "(this: borrowing JavaScriptUnownedValue, arguments: consuming JavaScriptValuesBuffer)"
223
214
 
224
215
  let object = receiver.decoratedObject
225
- if usesAppContext {
226
- return """
227
- \(object).setProperty("\(jsName)") { \(captures)\(parameters) in
228
- guard let appContext else {
229
- throw Exceptions.AppContextLost()
230
- }
231
- \(bodyStatements(receiver: receiver, indent: " "))
232
- }
233
- """
234
- }
235
216
  return """
236
217
  \(object).setProperty("\(jsName)") { \(captures)\(parameters) in
237
218
  \(bodyStatements(receiver: receiver, indent: " "))
238
219
  }
239
220
  """
240
221
  }
241
-
242
- /// True when the host-function body references `appContext` — i.e. some parameter or the return
243
- /// type lacks a fast accessor and decodes/encodes through `getDynamicType()`, which threads
244
- /// `appContext` in.
245
- private var usesAppContext: Bool {
246
- if parameters.contains(where: { fastDecodeAccessor(for: $0.type.trimmedDescription) == nil }) {
247
- return true
248
- }
249
- if let returnType, fastDecodeAccessor(for: returnType) == nil {
250
- return true
251
- }
252
- return false
253
- }
254
222
  }
255
223
 
256
224
  /// A `@JS var` collected for **direct JSI binding**. Instead of describing the property with a
@@ -263,9 +231,8 @@ internal struct JSFunction {
263
231
  ///
264
232
  /// The receiver (see `Receiver`) is the module's `self` for a module binding, or the per-call `_self`
265
233
  /// unwrapped from the JS `this` for a shared object. The getter reads `<callee>.<name>` and the setter
266
- /// writes `<callee>.<name> = …`. Decode/encode of the value reuse the same static-type fast path as
267
- /// functions (primitives through a direct typed accessor / `toJavaScriptValue`, other types through
268
- /// the `getDynamicType()` converter).
234
+ /// writes `<callee>.<name> = …`. Decode/encode of the value go through `JavaScriptDecodable.decode` /
235
+ /// `JavaScriptEncodable.encode`, the same uniform path as functions.
269
236
  internal struct JSProperty {
270
237
  let swiftName: String
271
238
  let jsName: String
@@ -282,18 +249,11 @@ internal struct JSProperty {
282
249
  /// the closure-taking `setProperty(_:)` overload — with the read/write body inlined into each
283
250
  /// closure — and installs it with `object.defineProperty(name, descriptor:)`. Capture matches the
284
251
  /// function bindings: a module captures `self` strong, a shared object captures nothing of the
285
- /// instance; `appContext` is weak + guarded — and, like functions, the `appContext` capture + guard
286
- /// are omitted from an accessor whose body never references it (a primitive value, decoded/encoded
287
- /// without the dynamic converter), to avoid the unused-capture warning. Getter and setter are gated
288
- /// independently.
252
+ /// instance. Getter and setter are gated independently.
289
253
  func decorateStatements(receiver: Receiver) -> String {
290
254
  let descriptorName = "\(swiftName)Descriptor"
291
255
  let callee = receiver.callee
292
256
  let object = receiver.decoratedObject
293
- // A primitive value type encodes/decodes without `getDynamicType()`, so its accessor body never
294
- // references `appContext`. `nil` (untyped) goes through the dynamic-less `toJavaScriptValue`
295
- // getter, which also doesn't use it.
296
- let usesAppContext = valueType.map { fastDecodeAccessor(for: $0) == nil } ?? false
297
257
  // A shared object's accessors unwrap the JS `this` into `_self` before reading/writing; a module
298
258
  // reads `self` directly. The unwrap leads each accessor body.
299
259
  let unwrap = receiver.unwrapStatement.map { "\($0)\n" } ?? ""
@@ -302,39 +262,28 @@ internal struct JSProperty {
302
262
  lines.append("let \(descriptorName) = runtime.createObject()")
303
263
  lines.append("\(descriptorName).setProperty(\"enumerable\", value: true)")
304
264
 
305
- // Getter: read `<callee>.<name>` and encode the result back to JS.
265
+ // Getter: read `<callee>.<name>` and encode the result back to JS through `encode`. When the value
266
+ // type couldn't be inferred (no annotation and no literal default, rare for a stored var) there's
267
+ // no static type to call `encode` on, so the value's own `toJavaScriptValue(in:)` is the fallback.
306
268
  let getEncode: String
307
- if let valueType, fastDecodeAccessor(for: valueType) != nil {
308
- getEncode = "return \(callee).\(swiftName).toJavaScriptValue(in: runtime)"
309
- } else if let valueType {
310
- getEncode =
311
- "return try \(expressionType(valueType)).getDynamicType().castToJS(\(callee).\(swiftName), appContext: appContext, in: runtime)"
269
+ if let valueType {
270
+ getEncode = "return try \(expressionType(valueType)).encode(\(callee).\(swiftName), in: runtime)"
312
271
  } else {
313
- // No known type: fall back to converting whatever `<callee>.<name>` is. This only happens when
314
- // the declaration has neither an annotation nor a literal default, which is rare for a stored
315
- // var.
316
272
  getEncode = "return \(callee).\(swiftName).toJavaScriptValue(in: runtime)"
317
273
  }
318
274
  lines.append(
319
- accessorClosure(
320
- descriptorName, "get", receiver: receiver, usesAppContext: usesAppContext, body: "\(unwrap)\(getEncode)"))
275
+ accessorClosure(descriptorName, "get", receiver: receiver, body: "\(unwrap)\(getEncode)"))
321
276
 
322
- // Setter: decode argument 0 by the static type and write `<callee>.<name>`. A typed setter needs
323
- // a known value type; when the type couldn't be inferred the property is bound getter-only (a
324
- // settable var with neither an annotation nor a literal default is rare and can't be decoded).
277
+ // Setter: decode argument 0 by the static type through `decode` and write `<callee>.<name>`. A
278
+ // typed setter needs a known value type; when the type couldn't be inferred the property is bound
279
+ // getter-only (a settable var with neither an annotation nor a literal default is rare and can't
280
+ // be decoded).
325
281
  if isSettable, let valueType {
326
- let setDecode: String
327
- if let accessor = fastDecodeAccessor(for: valueType) {
328
- setDecode = "\(callee).\(swiftName) = try arguments.unownedValue(at: 0).\(accessor)()"
329
- } else {
330
- let exprType = expressionType(valueType)
331
- setDecode =
332
- "\(callee).\(swiftName) = try \(exprType).getDynamicType().cast(jsValue: arguments[0], appContext: appContext) as! \(exprType)"
333
- }
282
+ let exprType = expressionType(valueType)
283
+ let setDecode = "\(callee).\(swiftName) = try \(exprType).decode(arguments.unownedValue(at: 0), in: runtime)"
334
284
  lines.append(
335
285
  accessorClosure(
336
- descriptorName, "set", receiver: receiver, usesAppContext: usesAppContext,
337
- body: "\(unwrap)\(setDecode)\nreturn .undefined"))
286
+ descriptorName, "set", receiver: receiver, body: "\(unwrap)\(setDecode)\nreturn .undefined"))
338
287
  }
339
288
 
340
289
  lines.append("\(object).defineProperty(\"\(jsName)\", descriptor: \(descriptorName))")
@@ -346,15 +295,13 @@ internal struct JSProperty {
346
295
  }
347
296
 
348
297
  /// One `descriptor.setProperty("get"/"set") { … }` accessor entry. The capture list follows the
349
- /// receiver (a module captures `self` strong; a shared object captures nothing of the instance) and,
350
- /// when `usesAppContext`, adds `appContext` weak + guarded (matching the function bindings);
351
- /// otherwise the guard is omitted so a primitive accessor doesn't warn on an unused capture.
298
+ /// receiver: a module captures `self` strong; a shared object captures nothing of the instance.
352
299
  private func accessorClosure(
353
- _ descriptorName: String, _ key: String, receiver: Receiver, usesAppContext: Bool, body: String
300
+ _ descriptorName: String, _ key: String, receiver: Receiver, body: String
354
301
  ) -> String {
355
- let captures = receiver.captureClause(usesAppContext: usesAppContext)
356
- // Indent each line of a (possibly multi-line) body to sit one level inside the closure, aligned
357
- // with the `guard`; a bare `\(body)` interpolation would only indent the first line.
302
+ let captures = receiver.captureClause
303
+ // Indent each line of a (possibly multi-line) body to sit one level inside the closure; a bare
304
+ // `\(body)` interpolation would only indent the first line.
358
305
  let indentedBody = body
359
306
  .split(separator: "\n", omittingEmptySubsequences: false)
360
307
  .map { " \($0)" }
@@ -365,16 +312,6 @@ internal struct JSProperty {
365
312
  // `borrowing JavaScriptUnownedValue` selects the unowned-`this` overload. A module ignores `this`;
366
313
  // a shared object unwraps it in the body.
367
314
  let parameters = "(this: borrowing JavaScriptUnownedValue, arguments: consuming JavaScriptValuesBuffer)"
368
- if usesAppContext {
369
- return """
370
- \(descriptorName).setProperty("\(key)") { \(captures)\(parameters) in
371
- guard let appContext else {
372
- throw Exceptions.AppContextLost()
373
- }
374
- \(indentedBody)
375
- }
376
- """
377
- }
378
315
  return """
379
316
  \(descriptorName).setProperty("\(key)") { \(captures)\(parameters) in
380
317
  \(indentedBody)
@@ -12,8 +12,9 @@ internal struct JSConstructor {
12
12
  self.parameters = Array(initDecl.signature.parameterClause.parameters)
13
13
  }
14
14
 
15
- /// The body statements, indented with `indent`: arity guard, per-argument decode (primitives via a
16
- /// typed accessor, others via the dynamic converter), then `return <Type>(label: arg0, …)`.
15
+ /// The body statements, indented with `indent`: arity guard, per-argument decode through
16
+ /// `JavaScriptDecodable.decode` on a zero-copy `arguments.unownedValue(at:)` (the arity guard proves
17
+ /// each index is in bounds), then `return <Type>(label: arg0, …)`.
17
18
  private func bodyStatements(typeName: String, indent: String) -> String {
18
19
  var lines: [String] = []
19
20
 
@@ -26,15 +27,8 @@ internal struct JSConstructor {
26
27
 
27
28
  var callArguments: [String] = []
28
29
  for (index, parameter) in parameters.enumerated() {
29
- let type = parameter.type.trimmedDescription
30
-
31
- if let accessor = fastDecodeAccessor(for: type) {
32
- lines.append("let arg\(index) = try arguments.unownedValue(at: \(index)).\(accessor)()")
33
- } else {
34
- let exprType = expressionType(type)
35
- lines.append(
36
- "let arg\(index) = try \(exprType).getDynamicType().cast(jsValue: arguments[\(index)], appContext: appContext) as! \(exprType)")
37
- }
30
+ let exprType = expressionType(parameter.type.trimmedDescription)
31
+ lines.append("let arg\(index) = try \(exprType).decode(arguments.unownedValue(at: \(index)), in: runtime)")
38
32
 
39
33
  let label = parameter.firstName.text
40
34
  callArguments.append(label == "_" ? "arg\(index)" : "\(label): arg\(index)")
@@ -6,11 +6,13 @@ import SwiftSyntaxMacros
6
6
  /// corresponding `Function` / `AsyncFunction` / `Property` / `Constructor` registrations; that part
7
7
  /// of the expansion lives in those macros.
8
8
  ///
9
- /// On its own, `@JS` emits one thing: a never-called peer that asserts every type crossing the JS
10
- /// boundary is JS-convertible. Because it's a **peer** of the marked member, a non-conforming type
11
- /// produces a compile error located on the user's own `@JS` declaration rather than on the enclosing
12
- /// `@ExpoModule`. The assertion mechanism itself is shared (see `typeConformanceAssertion`); `@JS`
13
- /// only supplies the boundary types it reads off the declaration.
9
+ /// On its own, `@JS` emits one thing: a never-called peer that asserts each type crossing the JS
10
+ /// boundary is convertible in the direction it travels arguments are `JavaScriptDecodable`, return
11
+ /// values are `JavaScriptEncodable` (a settable property's value type is both). Because it's a
12
+ /// **peer** of the marked member, a non-conforming type produces a compile error located on the
13
+ /// user's own `@JS` declaration rather than on the enclosing `@ExpoModule`. The assertion mechanism
14
+ /// itself is shared (see `directionalConformanceAssertion`); `@JS` only supplies the boundary types,
15
+ /// split by direction, that it reads off the declaration.
14
16
  ///
15
17
  /// Usage:
16
18
  ///
@@ -29,8 +31,10 @@ public struct JSMacro: PeerMacro {
29
31
  in context: some MacroExpansionContext
30
32
  ) throws -> [DeclSyntax] {
31
33
  guard let member = boundaryMember(of: declaration),
32
- let assertion = typeConformanceAssertion(
33
- for: ConformanceAssertion(name: member.name, types: member.types),
34
+ let assertion = directionalConformanceAssertion(
35
+ name: member.name,
36
+ decodableTypes: member.decodableTypes,
37
+ encodableType: member.encodableType,
34
38
  isStatic: member.isStatic
35
39
  ) else {
36
40
  return []
@@ -39,37 +43,54 @@ public struct JSMacro: PeerMacro {
39
43
  }
40
44
  }
41
45
 
42
- /// What an assertion peer needs about the `@JS` member it sits beside: a name (to keep the peer
43
- /// unique among siblings), the types crossing the JS boundary, and whether the member is type-level.
46
+ /// What an assertion peer needs about the `@JS` member it sits beside: a name (to keep the peer unique
47
+ /// among siblings), the boundary types split by conversion direction, and whether the member is
48
+ /// type-level. Arguments (and a settable property's incoming value) are decoded; return values (and a
49
+ /// property's outgoing value) are encoded, so each is asserted against the protocol for its direction.
44
50
  private struct BoundaryMember {
45
51
  let name: String
46
- /// Boundary types as written. Composed types (`[Int]`, `String?`, …) are kept verbatim — their
47
- /// conditional conformances transitively constrain the elements.
48
- let types: [String]
52
+ /// Types decoded from JS: function/constructor arguments, and a settable property's value type.
53
+ let decodableTypes: [String]
54
+ /// The single type encoded to JS: a function's return type, or a property's value type on read;
55
+ /// `nil` when the member produces nothing JS-visible (a `Void` function).
56
+ let encodableType: String?
49
57
  /// True for `static`/`class` members, so the peer is emitted in the same metatype context.
50
58
  let isStatic: Bool
51
59
  }
52
60
 
53
- /// Reads the boundary member off a `@JS` declaration. A function contributes its parameter types
54
- /// plus the return type (when non-Void); a property contributes its declared type. Returns `nil` for
55
- /// declaration kinds `@JS` doesn't read types from, or a property whose type isn't spelled out (a
56
- /// syntactic macro can't recover it), so no assertion is emitted there.
61
+ /// Reads the boundary member off a `@JS` declaration, splitting its types by conversion direction. A
62
+ /// function contributes its parameter types (decodable) and its return type when non-Void (encodable);
63
+ /// a property contributes its value type as encodable (the getter) and also as decodable when settable
64
+ /// (the setter). Composed types (`[Int]`, `String?`, …) are kept verbatim, their conditional
65
+ /// conformances transitively constraining the elements. Returns `nil` for declaration kinds `@JS`
66
+ /// doesn't read types from, or a property whose type isn't spelled out (a syntactic macro can't
67
+ /// recover it).
57
68
  private func boundaryMember(of declaration: some DeclSyntaxProtocol) -> BoundaryMember? {
58
69
  if let funcDecl = declaration.as(FunctionDeclSyntax.self) {
59
- var types = funcDecl.signature.parameterClause.parameters.map { $0.type.trimmedDescription }
60
- if let returnType = funcDecl.signature.returnClause?.type, !isVoidType(returnType) {
61
- types.append(returnType.trimmedDescription)
62
- }
63
- return BoundaryMember(name: funcDecl.name.text, types: types, isStatic: isTypeLevel(funcDecl.modifiers))
70
+ let decodableTypes = funcDecl.signature.parameterClause.parameters.map { $0.type.trimmedDescription }
71
+ let returnType = funcDecl.signature.returnClause?.type
72
+ let encodableType = returnType.flatMap { isVoidType($0) ? nil : $0.trimmedDescription }
73
+ return BoundaryMember(
74
+ name: funcDecl.name.text,
75
+ decodableTypes: decodableTypes,
76
+ encodableType: encodableType,
77
+ isStatic: isTypeLevel(funcDecl.modifiers)
78
+ )
64
79
  }
65
80
 
66
81
  if let varDecl = declaration.as(VariableDeclSyntax.self),
67
82
  let binding = varDecl.bindings.first,
68
83
  let identifier = binding.pattern.as(IdentifierPatternSyntax.self),
69
84
  let type = binding.typeAnnotation?.type {
85
+ // A `let`, or a `var` with no setter, is read-only (encodable only). A settable `var` is also
86
+ // decoded on write, so its value type is asserted in both directions.
87
+ let typeText = type.trimmedDescription
88
+ let isVar = varDecl.bindingSpecifier.tokenKind == .keyword(.var)
89
+ let isSettable = isVar && bindingIsSettable(binding)
70
90
  return BoundaryMember(
71
91
  name: identifier.identifier.text,
72
- types: [type.trimmedDescription],
92
+ decodableTypes: isSettable ? [typeText] : [],
93
+ encodableType: typeText,
73
94
  isStatic: isTypeLevel(varDecl.modifiers)
74
95
  )
75
96
  }
@@ -246,10 +246,11 @@ extension AttributeListSyntax {
246
246
  }
247
247
  }
248
248
 
249
- /// A type spelled so it's valid in expression position (before `.getDynamicType()` or after `as!`).
250
- /// Implicitly-unwrapped optionals (`T!`) are only allowed in type-annotation position, so a trailing
251
- /// `!` is rewritten to `?` (`T!` and `T?` are both `Optional<T>`, which the dynamic-type / cast layer
252
- /// treats identically). Other type spellings pass through unchanged.
249
+ /// A type spelled so it's valid in expression position (before `.decode`/`.encode`, before
250
+ /// `.getDynamicType()`, or after `as!`). Implicitly-unwrapped optionals (`T!`) are only allowed in
251
+ /// type-annotation position, so a trailing `!` is rewritten to `?` (`T!` and `T?` are both
252
+ /// `Optional<T>`, which the conversion layer treats identically). Other type spellings pass through
253
+ /// unchanged.
253
254
  internal func expressionType(_ type: String) -> String {
254
255
  guard type.hasSuffix("!") else {
255
256
  return type
@@ -292,7 +293,7 @@ internal func collectProperties(
292
293
  /// a computed property is settable only when it declares an explicit `set` accessor. A getter-only
293
294
  /// computed property (`{ get }` or a single getter body) stays read-only. `willSet`/`didSet`
294
295
  /// observers imply stored storage, which is also settable.
295
- private func bindingIsSettable(_ binding: PatternBindingSyntax) -> Bool {
296
+ internal func bindingIsSettable(_ binding: PatternBindingSyntax) -> Bool {
296
297
  guard let accessorBlock = binding.accessorBlock else {
297
298
  return true
298
299
  }
@@ -310,22 +311,3 @@ private func bindingIsSettable(_ binding: PatternBindingSyntax) -> Bool {
310
311
  return false
311
312
  }
312
313
  }
313
-
314
- /// The throwing `JavaScriptUnownedValue` accessor that decodes the given primitive type directly
315
- /// (`asDouble()` for `Double`, etc.), bypassing the dynamic-type converter. Returns `nil` for types
316
- /// without a dedicated accessor (arrays, records, optionals, shared objects, other numeric widths),
317
- /// which decode through `getDynamicType().cast(...)`.
318
- func fastDecodeAccessor(for type: String) -> String? {
319
- switch type {
320
- case "Bool":
321
- return "asBool"
322
- case "Int":
323
- return "asInt"
324
- case "Double":
325
- return "asDouble"
326
- case "String":
327
- return "asString"
328
- default:
329
- return nil
330
- }
331
- }
@@ -45,14 +45,13 @@ internal enum Receiver {
45
45
  }
46
46
 
47
47
  /// The capture-clause fragment (with a trailing space, or empty when nothing is captured). A module
48
- /// captures `self` strong; a shared object captures nothing of the instance. `appContext`, when used,
49
- /// is captured weak in both cases.
50
- func captureClause(usesAppContext: Bool) -> String {
48
+ /// captures `self` strong; a shared object captures nothing of the instance.
49
+ var captureClause: String {
51
50
  switch self {
52
51
  case .module:
53
- return usesAppContext ? "[weak appContext, self] " : "[self] "
52
+ return "[self] "
54
53
  case .sharedObject:
55
- return usesAppContext ? "[weak appContext] " : ""
54
+ return ""
56
55
  }
57
56
  }
58
57
  }
@@ -29,10 +29,13 @@ import SwiftSyntaxMacros
29
29
  an optional type makes it nullable and optional, and a non-optional property without a
30
30
  default is required (the factories throw when the source omits it).
31
31
 
32
- Conversions go through the **public** dynamic-type API `T.getDynamicType()` plus
33
- `cast(jsValue:appContext:)` / `cast(_:appContext:)` / `convertToJS(_:appContext:)`
34
- so the synthesized code compiles inside user modules without any internal core symbols.
35
- Every property type must therefore conform to `AnyArgument`.
32
+ The JS-value paths convert through `JavaScriptDecodable.decode` / `JavaScriptEncodable.encode`
33
+ (`from(object:)` / `toObject(appContext:)`); the native-`Any` dictionary paths still go through the
34
+ public dynamic-type API `T.getDynamicType()` plus `cast(_:appContext:)` / `convertToJS(_:appContext:)`
35
+ (`from(dictionary:)` / `toDictionary(appContext:)`), since `JavaScriptCodable` only converts JS
36
+ values, not native `Any`. Both spell types as `public` symbols so the synthesized code compiles inside
37
+ user modules without any internal core symbols. Every property type must therefore conform to both
38
+ `AnyArgument` and `JavaScriptDecodable & JavaScriptEncodable`.
36
39
 
37
40
  For classes that inherit from another `@Record`-annotated class, the synthesized
38
41
  methods chain to `super` so inherited properties are handled first.
@@ -60,14 +63,15 @@ public struct RecordMacro: MemberMacro, ExtensionMacro {
60
63
 
61
64
  var members: [DeclSyntax] = []
62
65
 
63
- // A single never-called member that makes the compiler verify each property type is
64
- // JS-convertible (the conversions below go through its dynamic-type API). Each property keeps its
65
- // own named assertion inside, so the compiler's conformance diagnostic names the offending
66
- // property (see `typeConformanceAssertions`). Emitted first so that, for a non-conforming type,
67
- // this clear "requires that '…' conform to '…'" error is reported ahead of the noisier
68
- // "no member 'getDynamicType'" errors from the conversion code below.
66
+ // A single never-called member that makes the compiler verify each property type is convertible
67
+ // both ways (the JS-value paths call `decode`/`encode`; the native-`Any` dictionary paths call
68
+ // the dynamic-type API). Each property keeps its own named assertion inside, so the compiler's
69
+ // conformance diagnostic names the offending property (see `typeConformanceAssertions`). Emitted
70
+ // first so that, for a non-conforming type, this clear "requires that '…' conform to '…'" error
71
+ // is reported ahead of the noisier "no member 'decode'"/"getDynamicType" errors from the
72
+ // conversion code below.
69
73
  let assertions = properties.map { ConformanceAssertion(name: $0.name, types: [$0.type]) }
70
- if let assertionMember = typeConformanceAssertions(for: assertions) {
74
+ if let assertionMember = typeConformanceAssertions(for: assertions, constraint: recordFieldProtocolName) {
71
75
  members.append(assertionMember)
72
76
  }
73
77
 
@@ -131,8 +135,9 @@ public struct RecordMacro: MemberMacro, ExtensionMacro {
131
135
  */
132
136
  private struct RecordProperty {
133
137
  let name: String
134
- /// The property's declared type, verbatim (e.g. `String`, `Int`, `String?`). Used to build
135
- /// the memberwise-init parameter and to look up the dynamic type via `T.getDynamicType()`.
138
+ /// The property's declared type, verbatim (e.g. `String`, `Int`, `String?`). Used to build the
139
+ /// memberwise-init parameter and as the receiver of the per-property `decode`/`encode` (and, on the
140
+ /// dictionary paths, `getDynamicType()`) conversions.
136
141
  let type: String
137
142
  /// The default-value expression verbatim (`0`, `""`, `[]`), or `nil` when the property has none.
138
143
  /// Inlined into the memberwise init and the factories' omitted-property branch so the synthesized
@@ -155,7 +160,7 @@ private struct RecordProperty {
155
160
  /**
156
161
  Discovers the record's properties: every stored `var`/`let` binding that is not `static`,
157
162
  `private`, `fileprivate`, `lazy`, or computed. Each property must declare an explicit type
158
- annotation, since the synthesized conversions reference `Type.getDynamicType()`.
163
+ annotation, since the synthesized conversions name the type (e.g. `Type.decode()`).
159
164
  */
160
165
  private func recordProperties(
161
166
  of declaration: some DeclGroupSyntax
@@ -271,11 +276,20 @@ private func memberwiseInit(properties: [RecordProperty]) -> DeclSyntax {
271
276
  /**
272
277
  `from(object:appContext:)` — reads each property off the `JavaScriptObject` into a local,
273
278
  then constructs the record through the memberwise init. Required properties throw when
274
- undefined; defaulted properties fall back to the property's declared default (read from a
275
- throwaway `Self()`) when undefined; optional properties become `nil` when undefined/null.
279
+ undefined; defaulted properties fall back to the property's declared default (inlined) when
280
+ undefined; optional properties become `nil` when undefined/null. Each property is decoded with
281
+ `JavaScriptDecodable.decode`, so the factory binds the `runtime` from the app context once and
282
+ threads it to every read.
276
283
  */
277
284
  private func fromJSObjectFactory(properties: [RecordProperty]) -> DeclSyntax {
278
- let body = factoryBody(properties: properties, readLines: jsObjectReadLines(properties: properties))
285
+ var lines: [String] = []
286
+ // Only bind the runtime when there's a property to decode — an empty record's factory would
287
+ // otherwise leave it unused.
288
+ if !properties.isEmpty {
289
+ lines.append(" let runtime = try appContext.runtime")
290
+ }
291
+ lines.append(factoryBody(properties: properties, readLines: jsObjectReadLines(properties: properties)))
292
+ let body = lines.joined(separator: "\n")
279
293
  return """
280
294
  @JavaScriptActor
281
295
  public static func from(object: borrowing JavaScriptObject, appContext: AppContext) throws -> Self {
@@ -311,23 +325,25 @@ private func factoryBody(properties: [RecordProperty], readLines: [String]) -> S
311
325
  return lines.joined(separator: "\n")
312
326
  }
313
327
 
314
- /// Per-property read statements for the JS-object factory, each producing a `let <name>`.
328
+ /// Per-property read statements for the JS-object factory, each producing a `let <name>` by decoding
329
+ /// the JS value with `JavaScriptDecodable.decode` (recovering the app context from `runtime` itself).
315
330
  private func jsObjectReadLines(properties: [RecordProperty]) -> [String] {
316
331
  var lines: [String] = []
317
332
  for property in properties {
318
333
  let valueVar = "\(property.name)JSValue"
319
334
  let exprType = expressionType(property.type)
320
- let cast = "try \(exprType).getDynamicType().cast(jsValue: \(valueVar), appContext: appContext) as! \(exprType)"
335
+ let decode = "try \(exprType).decode(\(valueVar), in: runtime)"
321
336
  lines.append(" let \(valueVar) = object.getProperty(\"\(property.name)\")")
322
337
  if property.isRequired {
323
338
  lines.append(" guard !\(valueVar).isUndefined() else {")
324
339
  lines.append(" throw RecordPropertyRequiredException(\"\(property.name)\")")
325
340
  lines.append(" }")
326
- lines.append(" let \(property.name) = \(cast)")
341
+ lines.append(" let \(property.name) = \(decode)")
327
342
  } else if property.isOptional {
328
- lines.append(" let \(property.name): \(property.type) = (\(valueVar).isUndefined() || \(valueVar).isNull()) ? nil : \(cast)")
343
+ // `Optional.decode` already maps `undefined`/`null` to `nil`, so the read is a plain decode.
344
+ lines.append(" let \(property.name) = \(decode)")
329
345
  } else {
330
- lines.append(" let \(property.name) = \(valueVar).isUndefined() ? \(property.defaultValue!) : \(cast)")
346
+ lines.append(" let \(property.name) = \(valueVar).isUndefined() ? \(property.defaultValue!) : \(decode)")
331
347
  }
332
348
  }
333
349
  return lines
@@ -388,20 +404,27 @@ private func toDictionaryMethod(properties: [RecordProperty], inheritsRecord: Bo
388
404
  }
389
405
 
390
406
  /**
391
- `toObject(appContext:)` — builds a `JavaScriptObject` directly, converting each property via
392
- `convertToJS`. The fast write path mirroring `from(object:)`. Subclasses chain to `super` so
393
- inherited properties are written first.
407
+ `toObject(appContext:)` — builds a `JavaScriptObject` directly, encoding each property with
408
+ `JavaScriptEncodable.encode`. The fast write path mirroring `from(object:)`. The `runtime` is bound
409
+ from the app context once and threaded to every write. Subclasses chain to `super` so inherited
410
+ properties are written first.
394
411
  */
395
412
  private func toObjectMethod(properties: [RecordProperty], inheritsRecord: Bool) -> DeclSyntax {
396
413
  let overrideKeyword = inheritsRecord ? "override " : ""
397
414
  var lines: [String] = []
415
+ // The base case needs the runtime to create the object; the inheriting case only needs it when
416
+ // there's a property to encode (it chains to `super` for the object itself). Binding it when unused
417
+ // would warn.
418
+ if !inheritsRecord || !properties.isEmpty {
419
+ lines.append(" let runtime = try appContext.runtime")
420
+ }
398
421
  if inheritsRecord {
399
422
  lines.append(" let object = try super.toObject(appContext: appContext)")
400
423
  } else {
401
- lines.append(" let object = try appContext.runtime.createObject()")
424
+ lines.append(" let object = runtime.createObject()")
402
425
  }
403
426
  for property in properties {
404
- lines.append(" object.setProperty(\"\(property.name)\", value: try \(expressionType(property.type)).getDynamicType().convertToJS(self.\(property.name), appContext: appContext))")
427
+ lines.append(" object.setProperty(\"\(property.name)\", value: try \(expressionType(property.type)).encode(self.\(property.name), in: runtime))")
405
428
  }
406
429
  lines.append(" return object")
407
430
  let body = lines.joined(separator: "\n")