From 1a65e6911ee4a52a2e20e7a7f6ea0f498e744c73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Klocek?= Date: Mon, 2 Mar 2026 09:59:39 +0100 Subject: [PATCH] [core][ios] Update Dynamic array/dict converters to convert nested results (#42641) # Why When returning arrays/dictionaries to JS, their dynamic types only shallow-convert arguments, which breaks for more complex returned types. - When an array of mixed values is returned, the [`elementType`](https://github.com/expo/expo/blob/bf5c4aefbf6b67f37f5f6870a6f80c2efa4c8171/packages/expo-modules-core/ios/Core/DynamicTypes/DynamicArrayType.swift#L41) is of type [`DynamicRawType`](https://github.com/expo/expo/blob/main/packages/expo-modules-core/ios/Core/DynamicTypes/DynamicRawType.swift) even if array elements have their own dynamic type converters - The [`DynamicDictionaryType`](https://github.com/expo/expo/blob/main/packages/expo-modules-core/ios/Core/DynamicTypes/DynamicDictionaryType.swift) is missing the `convertResults` at all Example where this matters: [This return](https://github.com/expo/expo/blob/bf5c4aefbf6b67f37f5f6870a6f80c2efa4c8171/packages/expo-sqlite/ios/SQLiteModule.swift#L419) can be of type: ```js { lastInsertRowId: 1, changes: 0, firstRowValues: [1, <>] } ``` Primitives and nested arrays are converted, but ArrayBuffer is being `undefined` because, without this change, the `value` inside [`convertObjCObjectToJSIValue`](https://github.com/expo/expo/blob/bf5c4aefbf6b67f37f5f6870a6f80c2efa4c8171/packages/expo-modules-core/ios/JSI/EXJSIConversions.mm#L100) is of type [`ExpoModulesCore.NativeArrayBuffer`](https://github.com/expo/expo/blob/7cac70926cb456f8a1a7673d68d4a84800099071/packages/expo-modules-core/ios/Core/ArrayBuffers/ArrayBuffer.swift#L7) instead of `EXArrayBuffer` and the conditional is skipped. This is caused by [`DynamicArrayBufferType.convertResult()`](https://github.com/expo/expo/blob/7cac70926cb456f8a1a7673d68d4a84800099071/packages/expo-modules-core/ios/Core/DynamicTypes/DynamicArrayBufferType.swift#L35) not being called when converting array/dict # How **Note**: this change might negatively affect conversion performance, but I'm unsure how big the impact of using [`convertFunctionResult()`](https://github.com/expo/expo/blob/bf5c4aefbf6b67f37f5f6870a6f80c2efa4c8171/packages/expo-modules-core/ios/Core/Conversions.swift#L182) is. - Updated array dynamic type's convertResult to use `Conversions.convertFunctionResult()` which respects value types - Added `convertResult` to dictionary dynamic type as it was missing there # Test Plan - Tested on the SQLite stack (#42642) which required this change. - Added unit test for `DynamicArrayType` and a test suite for `DynamicDictionaryType` # Checklist - [x] I added a `changelog.md` entry and rebuilt the package sources according to [this short guide](https://github.com/expo/expo/blob/main/CONTRIBUTING.md#-before-submitting) - [ ] This diff will work correctly for `npx expo prebuild` & EAS Build (eg: updated a module plugin). - [ ] Conforms with the [Documentation Writing Style Guide](https://github.com/expo/expo/blob/main/guides/Expo%20Documentation%20Writing%20Style%20Guide.md) --- packages/expo-modules-core/CHANGELOG.md | 2 + .../Core/DynamicTypes/DynamicArrayType.swift | 4 +- .../DynamicTypes/DynamicDictionaryType.swift | 7 ++ .../ios/Tests/DynamicTypeTests.swift | 104 ++++++++++++++++++ 4 files changed, 114 insertions(+), 3 deletions(-) diff --git a/packages/expo-modules-core/CHANGELOG.md b/packages/expo-modules-core/CHANGELOG.md index 7e190bdcd4edc9..f3753fdfdefbe7 100644 --- a/packages/expo-modules-core/CHANGELOG.md +++ b/packages/expo-modules-core/CHANGELOG.md @@ -14,6 +14,8 @@ ### 💡 Others +- [iOS] Improved conversions of returned arrays and dictionaries with mixed element types. ([#42641](https://github.com/expo/expo/pull/42641) by [@barthap](https://github.com/barthap)) + ## 55.0.12 — 2026-02-25 _This version does not introduce any user-facing changes._ diff --git a/packages/expo-modules-core/ios/Core/DynamicTypes/DynamicArrayType.swift b/packages/expo-modules-core/ios/Core/DynamicTypes/DynamicArrayType.swift index 82849e75b67ec3..21137147525af7 100644 --- a/packages/expo-modules-core/ios/Core/DynamicTypes/DynamicArrayType.swift +++ b/packages/expo-modules-core/ios/Core/DynamicTypes/DynamicArrayType.swift @@ -37,9 +37,7 @@ internal struct DynamicArrayType: AnyDynamicType { func convertResult(_ result: ResultType, appContext: AppContext) throws -> Any { if let result = result as? [Any] { - return try result.map({ element in - return try elementType.convertResult(element, appContext: appContext) - }) + return result.map { Conversions.convertFunctionResult($0, appContext: appContext) } } return result } diff --git a/packages/expo-modules-core/ios/Core/DynamicTypes/DynamicDictionaryType.swift b/packages/expo-modules-core/ios/Core/DynamicTypes/DynamicDictionaryType.swift index 1e9bdf0378f60d..8d0e8fa4b50c77 100644 --- a/packages/expo-modules-core/ios/Core/DynamicTypes/DynamicDictionaryType.swift +++ b/packages/expo-modules-core/ios/Core/DynamicTypes/DynamicDictionaryType.swift @@ -41,6 +41,13 @@ internal struct DynamicDictionaryType: AnyDynamicType { throw Conversions.CastingException<[AnyHashable: Any]>(value) } + func convertResult(_ result: ResultType, appContext: AppContext) throws -> Any { + if let result = result as? [AnyHashable: Any] { + return result.mapValues { Conversions.convertFunctionResult($0, appContext: appContext) } + } + return result + } + var description: String { "[Hashable: \(valueType.description)]" } diff --git a/packages/expo-modules-core/ios/Tests/DynamicTypeTests.swift b/packages/expo-modules-core/ios/Tests/DynamicTypeTests.swift index a99dc3fec66f32..1738e1d5e8ef79 100644 --- a/packages/expo-modules-core/ios/Tests/DynamicTypeTests.swift +++ b/packages/expo-modules-core/ios/Tests/DynamicTypeTests.swift @@ -251,6 +251,19 @@ struct DynamicTypeTests { try (~[String].self).cast(84, appContext: appContext) } } + + @Test + func `returns mixed elements to JS`() throws { + let mixedArray: [Any] = [1, ArrayBuffer.allocate(size: 3)] + + let converted = try (~[Any].self).convertResult(mixedArray, appContext: appContext) + let jsValue = try (~[Any].self).castToJS(converted, appContext: appContext) + + #expect(jsValue.kind == .object) + let jsArray = try jsValue.asArray() + #expect(try jsArray.first??.getInt() == 1) + #expect(try jsArray.last??.isArrayBuffer() == true) + } @Test func `wraps is true`() { @@ -280,6 +293,97 @@ struct DynamicTypeTests { } } + // MARK: - DynamicDictionaryType + + @Suite("DynamicDictionaryType") + struct DynamicDictionaryTypeTests { + let appContext: AppContext + + init() { + appContext = AppContext.create() + } + + @Test + func `is created`() { + #expect(~[String: Double].self is DynamicDictionaryType) + #expect(~[String: String?].self is DynamicDictionaryType) + #expect(~[String: [Int]].self is DynamicDictionaryType) + #expect(~[String: Any].self is DynamicDictionaryType) + } + + @Test + func `casts succeeds`() throws { + #expect(try (~[String: Double].self).cast(["a": 1.2, "b": 3.4], appContext: appContext) as? [String: Double] == ["a": 1.2, "b": 3.4]) + #expect(try (~[String: [String]].self).cast(["key": ["hello", "expo"]], appContext: appContext) as? [String: [String]] == ["key": ["hello", "expo"]]) + } + + @Test + func `casts from JS value`() throws { + let appContext = AppContext.create() + let jsValue = try appContext.runtime.eval("({a: 1.2, b: 3.4})") + #expect(try (~[String: Double].self).cast(jsValue: jsValue, appContext: appContext) as? [String: Double] == ["a": 1.2, "b": 3.4]) + } + + @Test + func `casts dictionary values`() throws { + let value = 9.9 + let anyValue: [String: Any] = ["key": value] + let result = try (~[String: Double].self).cast(anyValue, appContext: appContext) as! [AnyHashable: Any] + + #expect(result is [String: Double]) + #expect(result as? [String: Double] == ["key": value]) + } + + @Test + func `returns mixed elements to JS`() throws { + let mixedDict: [String: Any] = ["num": 1, "buf": ArrayBuffer.allocate(size: 3)] + + let converted = try (~[String: Any].self).convertResult(mixedDict, appContext: appContext) + let jsValue = try (~[String: Any].self).castToJS(converted, appContext: appContext) + + #expect(jsValue.kind == .object) + let jsObject = try jsValue.asObject() + #expect(try jsObject.getProperty("num").getInt() == 1) + #expect(jsObject.getProperty("buf").isArrayBuffer() == true) + } + + @Test + func `throws CastingException`() { + #expect(throws: Conversions.CastingException<[AnyHashable: Any]>.self) { + try (~[String: String].self).cast(84, appContext: appContext) + } + } + + @Test + func `wraps is true`() { + #expect((~[String: Double].self ~> [String: Double].self) == true) + #expect((~[String: [String]].self ~> [String: [String]].self) == true) + #expect((~[String: CGPoint].self ~> [String: CGPoint].self) == true) + #expect((~[String: Any].self ~> [String: Any].self) == true) + } + + @Test + func `wraps is false`() { + #expect((~[String: String].self !~> [String: Int].self) == true) + #expect((~[String: Double].self !~> Double.self) == true) + } + + @Test + func `equals is true`() { + #expect((~[String: String].self == ~[String: String].self) == true) + #expect((~[String: CGSize].self == ~[String: CGSize].self) == true) + #expect((~[String: [[Double]]].self == ~[String: [[Double]]].self) == true) + #expect((~[String: Any].self == ~[String: Any].self) == true) + } + + @Test + func `equals is false`() { + #expect((~[String: Int].self != ~[String: Double].self) == true) + #expect((~[String: [String]].self != ~[String: String].self) == true) + #expect((~[String: URL].self != ~[String: String].self) == true) + } + } + // MARK: - DynamicConvertibleType @Suite("DynamicConvertibleType")