From e4eb9228040dec83a151d2bef3ae6e991d2457fc Mon Sep 17 00:00:00 2001 From: Tomasz Sapeta Date: Fri, 19 Jun 2026 23:42:58 +0200 Subject: [PATCH] Normalize implicitly-unwrapped optional types before splicing into expressions The `@ExpoModule`/`@JS` and `@Record` macros splice a member's type (`type.trimmedDescription`) into expression position to reach the dynamic-type converter, e.g. `\(type).getDynamicType().cast(...) as! \(type)`. For an implicitly-unwrapped optional (`var x: SomeType!`, `func f() -> SomeType!`, a `@Record` property of type `T!`) the spliced string is `SomeType!`, which is invalid Swift in expression position ("using '!' is not allowed here") and produces code that doesn't compile. Plain optionals (`T?`) and `Optional` were unaffected; only the IUO spelling broke. Add `expressionType(_:)`, which rewrites a trailing `!` to `?` (`T!` and `T?` are both `Optional`, treated identically by the dynamic-type / cast layer), and apply it at every site that splices a type into expression position: the function argument decode and result encode, the property get/set accessors, and the `@Record` `from(object:)` / `from(dictionary:)` / `toObject()` conversions. Type-annotation positions (where `T!` is valid) keep the original spelling. The existing snapshot tests didn't catch this because none exercised an IUO type through a splice, and `assertMacroExpansion` checks the generated string, not that it compiles. Add IUO coverage for a module property, a function return type, and a `@Record` property, asserting the cast/convert expressions use `T?` while annotations stay `T!`. --- .../DecorateModuleBuilder.swift | 10 +- .../ExpoModulesMacros/MacroHelpers.swift | 11 +++ .../ExpoModulesMacros/RecordMacro.swift | 8 +- .../ExpoModuleMacroTests.swift | 97 +++++++++++++++++++ .../RecordMacroTests.swift | 59 +++++++++++ 5 files changed, 178 insertions(+), 7 deletions(-) diff --git a/apple/Sources/ExpoModulesMacros/DecorateModuleBuilder.swift b/apple/Sources/ExpoModulesMacros/DecorateModuleBuilder.swift index f2037e6..ee9d61b 100644 --- a/apple/Sources/ExpoModulesMacros/DecorateModuleBuilder.swift +++ b/apple/Sources/ExpoModulesMacros/DecorateModuleBuilder.swift @@ -127,7 +127,8 @@ internal struct JSFunction { if let accessor = fastDecodeAccessor(for: type) { return "let arg\(index) = try arguments.unownedValue(at: \(index)).\(accessor)()" } - return "let arg\(index) = try \(type).getDynamicType().cast(jsValue: arguments[\(index)], appContext: appContext) as! \(type)" + let exprType = expressionType(type) + return "let arg\(index) = try \(exprType).getDynamicType().cast(jsValue: arguments[\(index)], appContext: appContext) as! \(exprType)" } /// The `self.(...)` call for the given arity. Slots `0..` is. This only happens when the // declaration has neither an annotation nor a literal default, which is rare for a stored var. @@ -311,8 +312,9 @@ internal struct JSProperty { if let accessor = fastDecodeAccessor(for: valueType) { setDecode = "self.\(swiftName) = try arguments.unownedValue(at: 0).\(accessor)()" } else { + let exprType = expressionType(valueType) setDecode = - "self.\(swiftName) = try \(valueType).getDynamicType().cast(jsValue: arguments[0], appContext: appContext) as! \(valueType)" + "self.\(swiftName) = try \(exprType).getDynamicType().cast(jsValue: arguments[0], appContext: appContext) as! \(exprType)" } lines.append( accessorClosure(descriptorName, "set", usesAppContext: usesAppContext, body: "\(setDecode)\nreturn .undefined")) diff --git a/apple/Sources/ExpoModulesMacros/MacroHelpers.swift b/apple/Sources/ExpoModulesMacros/MacroHelpers.swift index 7f0d446..6a9104b 100644 --- a/apple/Sources/ExpoModulesMacros/MacroHelpers.swift +++ b/apple/Sources/ExpoModulesMacros/MacroHelpers.swift @@ -245,3 +245,14 @@ extension AttributeListSyntax { return nil } } + +/// A type spelled so it's valid in expression position (before `.getDynamicType()` or after `as!`). +/// Implicitly-unwrapped optionals (`T!`) are only allowed in type-annotation position, so a trailing +/// `!` is rewritten to `?` (`T!` and `T?` are both `Optional`, which the dynamic-type / cast layer +/// treats identically). Other type spellings pass through unchanged. +internal func expressionType(_ type: String) -> String { + guard type.hasSuffix("!") else { + return type + } + return type.dropLast() + "?" +} diff --git a/apple/Sources/ExpoModulesMacros/RecordMacro.swift b/apple/Sources/ExpoModulesMacros/RecordMacro.swift index 8049156..060d7dd 100644 --- a/apple/Sources/ExpoModulesMacros/RecordMacro.swift +++ b/apple/Sources/ExpoModulesMacros/RecordMacro.swift @@ -316,7 +316,8 @@ private func jsObjectReadLines(properties: [RecordProperty]) -> [String] { var lines: [String] = [] for property in properties { let valueVar = "\(property.name)JSValue" - let cast = "try \(property.type).getDynamicType().cast(jsValue: \(valueVar), appContext: appContext) as! \(property.type)" + let exprType = expressionType(property.type) + let cast = "try \(exprType).getDynamicType().cast(jsValue: \(valueVar), appContext: appContext) as! \(exprType)" lines.append(" let \(valueVar) = object.getProperty(\"\(property.name)\")") if property.isRequired { lines.append(" guard !\(valueVar).isUndefined() else {") @@ -337,7 +338,8 @@ private func dictionaryReadLines(properties: [RecordProperty]) -> [String] { var lines: [String] = [] for property in properties { let valueVar = "\(property.name)Value" - let cast = "try \(property.type).getDynamicType().cast(\(valueVar), appContext: appContext) as! \(property.type)" + let exprType = expressionType(property.type) + let cast = "try \(exprType).getDynamicType().cast(\(valueVar), appContext: appContext) as! \(exprType)" lines.append(" let \(valueVar) = dictionary[\"\(property.name)\"]") if property.isRequired { lines.append(" guard let \(valueVar) else {") @@ -399,7 +401,7 @@ private func toObjectMethod(properties: [RecordProperty], inheritsRecord: Bool) lines.append(" let object = try appContext.runtime.createObject()") } for property in properties { - lines.append(" object.setProperty(\"\(property.name)\", value: try \(property.type).getDynamicType().convertToJS(self.\(property.name), appContext: appContext))") + lines.append(" object.setProperty(\"\(property.name)\", value: try \(expressionType(property.type)).getDynamicType().convertToJS(self.\(property.name), appContext: appContext))") } lines.append(" return object") let body = lines.joined(separator: "\n") diff --git a/apple/Tests/ExpoModulesMacrosTests/ExpoModuleMacroTests.swift b/apple/Tests/ExpoModulesMacrosTests/ExpoModuleMacroTests.swift index 6acd760..2f281bc 100644 --- a/apple/Tests/ExpoModulesMacrosTests/ExpoModuleMacroTests.swift +++ b/apple/Tests/ExpoModulesMacrosTests/ExpoModuleMacroTests.swift @@ -335,6 +335,52 @@ struct ExpoModuleMacroTests { ) } + @Test + func `Implicitly-unwrapped optional return normalizes to optional in the cast expression`() { + assertExpansion( + """ + @ExpoModule + final class MyModule: Module { + @JS + func make(count: Int) -> MyRecord! { nil } + } + """, + expandedSource: """ + final class MyModule: Module { + @JavaScriptActor + func make(count: Int) -> MyRecord! { nil } + + private func _assertTypesConformance_make() { + func make(_: T.Type) { + } + make(MyRecord.self) + } + + public static let _jsName = "MyModule" + + public func _synthesizedDefinition() -> [AnyDefinition] { + return [] + } + + @JavaScriptActor + public func _decorateModule(object: borrowing JavaScriptObject, in runtime: JavaScriptRuntime, appContext: AppContext) throws { + object.setProperty("make") { [weak appContext, self] (this: borrowing JavaScriptUnownedValue, arguments: consuming JavaScriptValuesBuffer) in + guard let appContext else { + throw Exceptions.AppContextLost() + } + guard arguments.count == 1 else { + throw Exceptions.ArgumentsRangeMismatch((functionName: "make", received: arguments.count, required: 1, maximum: 1)) + } + let arg0 = try arguments.unownedValue(at: 0).asInt() + let result = self.make(count: arg0) + return try MyRecord?.getDynamicType().castToJS(result, appContext: appContext, in: runtime) + } + } + } + """ + ) + } + @Test func `Static function emits a static conformance-assertion peer`() { assertExpansion( @@ -613,6 +659,57 @@ struct ExpoModuleMacroTests { ) } + @Test + func `Implicitly-unwrapped optional property normalizes the type to optional in the cast expressions`() { + assertExpansion( + """ + @ExpoModule + final class MyModule: Module { + @JS + var config: MyRecord! + } + """, + expandedSource: """ + final class MyModule: Module { + @JavaScriptActor + var config: MyRecord! + + private func _assertTypesConformance_config() { + func config(_: T.Type) { + } + config(MyRecord.self) + } + + public static let _jsName = "MyModule" + + public func _synthesizedDefinition() -> [AnyDefinition] { + return [] + } + + @JavaScriptActor + public func _decorateModule(object: borrowing JavaScriptObject, in runtime: JavaScriptRuntime, appContext: AppContext) throws { + let configDescriptor = runtime.createObject() + configDescriptor.setProperty("enumerable", value: true) + configDescriptor.setProperty("get") { [weak appContext, self] (this: borrowing JavaScriptUnownedValue, arguments: consuming JavaScriptValuesBuffer) in + guard let appContext else { + throw Exceptions.AppContextLost() + } + return try MyRecord?.getDynamicType().castToJS(self.config, appContext: appContext, in: runtime) + } + configDescriptor.setProperty("set") { [weak appContext, self] (this: borrowing JavaScriptUnownedValue, arguments: consuming JavaScriptValuesBuffer) in + guard let appContext else { + throw Exceptions.AppContextLost() + } + self.config = try MyRecord?.getDynamicType().cast(jsValue: arguments[0], appContext: appContext) as! MyRecord? + return .undefined + } + object.defineProperty("config", descriptor: configDescriptor) + } + } + """ + ) + } + @Test func `Mixed members: only @JS-marked ones are picked up`() { assertExpansion( diff --git a/apple/Tests/ExpoModulesMacrosTests/RecordMacroTests.swift b/apple/Tests/ExpoModulesMacrosTests/RecordMacroTests.swift index 460f094..06dae9e 100644 --- a/apple/Tests/ExpoModulesMacrosTests/RecordMacroTests.swift +++ b/apple/Tests/ExpoModulesMacrosTests/RecordMacroTests.swift @@ -115,6 +115,65 @@ struct RecordMacroTests { ) } + @Test + func `Implicitly-unwrapped optional property normalizes to optional in cast and convert expressions`() { + assertExpansion( + """ + @Record + struct Options { + var owner: MyRecord! + } + """, + expandedSource: """ + struct Options { + var owner: MyRecord! + + private func _assertTypesConformance() { + func owner(_: T.Type) { + } + owner(MyRecord.self) + } + + public init() { + } + + public init(owner: MyRecord! = nil) { + self.owner = owner + } + + @JavaScriptActor + public static func from(object: borrowing JavaScriptObject, appContext: AppContext) throws -> Self { + let ownerJSValue = object.getProperty("owner") + let owner: MyRecord! = (ownerJSValue.isUndefined() || ownerJSValue.isNull()) ? nil : try MyRecord?.getDynamicType().cast(jsValue: ownerJSValue, appContext: appContext) as! MyRecord? + return Self(owner: owner) + } + + public static func from(dictionary: [String: Any], appContext: AppContext) throws -> Self { + let ownerValue = dictionary["owner"] + let owner: MyRecord! = (ownerValue == nil || ownerValue! is NSNull) ? nil : try MyRecord?.getDynamicType().cast(ownerValue, appContext: appContext) as! MyRecord? + return Self(owner: owner) + } + + public func toDictionary(appContext: AppContext? = nil) -> [String: Any] { + var dictionary: [String: Any] = [:] + dictionary["owner"] = self.owner + return dictionary + } + + @JavaScriptActor + public func toObject(appContext: AppContext) throws -> JavaScriptObject { + let object = try appContext.runtime.createObject() + object.setProperty("owner", value: try MyRecord?.getDynamicType().convertToJS(self.owner, appContext: appContext)) + return object + } + } + + extension Options: Record { + } + """ + ) + } + @Test func `Non-primitive properties are checked in a single conformance-assertion peer`() { assertExpansion(