expo-modules-jsi 58.0.6 → 58.0.7
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.
- package/CHANGELOG.md +7 -0
- package/apple/Benchmarks/FreeFormBenchmarks.swift +61 -0
- package/apple/Sources/ExpoModulesJSI/Coding/JavaScriptCodable+Any.swift +500 -0
- package/apple/Sources/ExpoModulesJSI/Runtime/Values/JavaScriptObject.swift +8 -0
- package/apple/Sources/ExpoModulesJSI/Runtime/Values/JavaScriptValue.swift +10 -1
- package/apple/Tests/JavaScriptCodableAnyTests.swift +531 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 58.0.7
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Bump precompiled artifacts due to ABI breakage in expo-modules-jsi@58.0.6. ([#50881](https://github.com/expo/expo/pull/50881) by [@kitten](https://github.com/kitten))
|
|
8
|
+
- [iOS] Add conversions between JavaScript values and free-form `Any`, `[Any]` and `[String: Any]` values. ([#50660](https://github.com/expo/expo/pull/50660) by [@tsapeta](https://github.com/tsapeta))
|
|
9
|
+
|
|
3
10
|
## 58.0.6
|
|
4
11
|
|
|
5
12
|
### Patch Changes
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Copyright 2026-present 650 Industries. All rights reserved.
|
|
2
|
+
|
|
3
|
+
import ExpoModulesJSI
|
|
4
|
+
import Testing
|
|
5
|
+
|
|
6
|
+
/// Benchmarks for the free-form (`Any`) conversions. Each pair compares the dedicated fast path for
|
|
7
|
+
/// `[String: Any]` against the generic path that converts through `Any` and casts, on the same
|
|
8
|
+
/// attributes-like object of string, number and boolean values.
|
|
9
|
+
extension JSIBenchmarks {
|
|
10
|
+
private static let attributesSource = """
|
|
11
|
+
({ screen: 'home', build: 'release', region: 'eu', retries: 3, latency: 12.5, cached: true, visible: false, count: 42 })
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
@Test
|
|
15
|
+
func `decode free-form dictionary`() async throws {
|
|
16
|
+
try await benchmarkCase { runtime in
|
|
17
|
+
let value = try runtime.eval(Self.attributesSource)
|
|
18
|
+
try benchmark("JavaScriptValue.decodeAnyDictionary(_:in:)", runtime: runtime) { iterations in
|
|
19
|
+
for _ in 0..<iterations {
|
|
20
|
+
_ = try JavaScriptValue.decodeAnyDictionary(value, in: runtime)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
@Test
|
|
27
|
+
func `decode free-form dictionary through the generic path`() async throws {
|
|
28
|
+
try await benchmarkCase { runtime in
|
|
29
|
+
let value = try runtime.eval(Self.attributesSource)
|
|
30
|
+
try benchmark("JavaScriptValue.decodeAny(_:as: [String: Any].self, in:)", runtime: runtime) { iterations in
|
|
31
|
+
for _ in 0..<iterations {
|
|
32
|
+
_ = try JavaScriptValue.decodeAny(value, as: [String: Any].self, in: runtime)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
@Test
|
|
39
|
+
func `encode free-form dictionary`() async throws {
|
|
40
|
+
try await benchmarkCase { runtime in
|
|
41
|
+
let attributes = try JavaScriptValue.decodeAnyDictionary(runtime.eval(Self.attributesSource), in: runtime)
|
|
42
|
+
try benchmark("JavaScriptValue.encodeAnyDictionary(_:in:)", runtime: runtime) { iterations in
|
|
43
|
+
for _ in 0..<iterations {
|
|
44
|
+
_ = try JavaScriptValue.encodeAnyDictionary(attributes, in: runtime)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
@Test
|
|
51
|
+
func `encode free-form dictionary through the generic path`() async throws {
|
|
52
|
+
try await benchmarkCase { runtime in
|
|
53
|
+
let attributes: Any = try JavaScriptValue.decodeAnyDictionary(runtime.eval(Self.attributesSource), in: runtime)
|
|
54
|
+
try benchmark("JavaScriptValue.encodeAny(_:in:): [String: Any]", runtime: runtime) { iterations in
|
|
55
|
+
for _ in 0..<iterations {
|
|
56
|
+
_ = try JavaScriptValue.encodeAny(attributes, in: runtime)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
// Copyright 2026-present 650 Industries. All rights reserved.
|
|
2
|
+
|
|
3
|
+
import Foundation
|
|
4
|
+
|
|
5
|
+
// Conversions for free-form values: `Any`, `[Any]`, `[String: Any]`, and types nested from them.
|
|
6
|
+
//
|
|
7
|
+
// `Any` can't conform to `JavaScriptDecodable`/`JavaScriptEncodable` (it isn't a nominal type), and
|
|
8
|
+
// the container conformances require their elements to conform, so none of these types have a
|
|
9
|
+
// `decode`/`encode` of their own. These static methods fill that gap. The three common shapes each
|
|
10
|
+
// have a dedicated decode and encode pair that reads or writes the container directly. Any other
|
|
11
|
+
// shape that holds `Any` (`[[Any]]`, `[String: [Any]]?`, …) goes through the generic
|
|
12
|
+
// `decodeAny(_:as:in:)` and `encodeAny(_:in:)`, which convert the whole value and cast it.
|
|
13
|
+
//
|
|
14
|
+
// Decoding turns numbers into `Double`, drops `undefined` object properties, and turns `null` and
|
|
15
|
+
// `undefined` into `nil` boxed in `Any`, so a decoded value casts to optional types at any depth.
|
|
16
|
+
// A `Date` decodes as `Date`, a `Set` as an array of its elements and a `Map` with string keys as a
|
|
17
|
+
// dictionary. It differs from the deprecated `getAny()` in two ways: `getAny()` uses `NSNull` for
|
|
18
|
+
// `null`, and it silently turns values it can't represent into `NSNull`. Here, a bigint becomes
|
|
19
|
+
// `Int64` (throwing when it doesn't fit), and a function, symbol, typed array, `ArrayBuffer`,
|
|
20
|
+
// `DataView`, `WeakMap`, `WeakSet` or `Map` with a non-string key anywhere in the value throws
|
|
21
|
+
// `TypeError`, as does a value nested deeper than `maxFreeFormDepth` (usually a cycle). Any other
|
|
22
|
+
// object decodes from its enumerable own properties only, so data it keeps elsewhere is not
|
|
23
|
+
// decoded: an `Error` loses its `message` and `stack`, and a class instance loses values it exposes
|
|
24
|
+
// through getters.
|
|
25
|
+
|
|
26
|
+
extension JavaScriptValue {
|
|
27
|
+
// MARK: - Decoding
|
|
28
|
+
|
|
29
|
+
/// Decodes a JavaScript value into a free-form native value: `Bool`, `Double`, `String`, `Int64`
|
|
30
|
+
/// (from a bigint), `Date`, `[Any]`, `[String: Any]`, or `nil` (boxed in `Any`) for `null` and
|
|
31
|
+
/// `undefined`. A `Set` decodes as an array of its elements and a `Map` with string keys as a
|
|
32
|
+
/// dictionary. Any other object decodes from its enumerable own properties only, so an `Error`, for
|
|
33
|
+
/// example, decodes without its `message` and `stack`.
|
|
34
|
+
///
|
|
35
|
+
/// Throws `TypeError` for a function, symbol, typed array, `ArrayBuffer`, `DataView`, `WeakMap`,
|
|
36
|
+
/// `WeakSet` or `Map` with a non-string key anywhere in the value, or for a value nested more than
|
|
37
|
+
/// 64 levels deep (such as one that contains itself). The error names the path to the failing value.
|
|
38
|
+
/// Throws `BigIntConversionError` for a bigint outside the `Int64` range.
|
|
39
|
+
@JavaScriptActor
|
|
40
|
+
public static func decodeAny(_ value: borrowing JavaScriptValue, in runtime: borrowing JavaScriptRuntime) throws
|
|
41
|
+
-> Any
|
|
42
|
+
{
|
|
43
|
+
return try freeFormValue(of: value, depth: 0, in: runtime)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/// Decodes a borrowed JavaScript value into a free-form native value. See ``decodeAny(_:in:)``.
|
|
47
|
+
@JavaScriptActor
|
|
48
|
+
public static func decodeAny(_ value: borrowing JavaScriptUnownedValue, in runtime: borrowing JavaScriptRuntime)
|
|
49
|
+
throws -> Any
|
|
50
|
+
{
|
|
51
|
+
return try freeFormValue(of: value, depth: 0, in: runtime)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/// Decodes a JavaScript array into an array of free-form native values. A `Set` decodes as an array
|
|
55
|
+
/// of its elements, and any other value that isn't an array is wrapped in a single-element array,
|
|
56
|
+
/// like `Array.decode` does.
|
|
57
|
+
@JavaScriptActor
|
|
58
|
+
public static func decodeAnyArray(_ value: borrowing JavaScriptValue, in runtime: borrowing JavaScriptRuntime)
|
|
59
|
+
throws -> [Any]
|
|
60
|
+
{
|
|
61
|
+
guard value.isArray() else {
|
|
62
|
+
return wrappingInArray(try freeFormValue(of: value, depth: 0, in: runtime))
|
|
63
|
+
}
|
|
64
|
+
return try freeFormElements(of: value.getArray(), depth: 0, in: runtime)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/// Decodes a borrowed JavaScript array into an array of free-form native values. See
|
|
68
|
+
/// ``decodeAnyArray(_:in:)``.
|
|
69
|
+
@JavaScriptActor
|
|
70
|
+
public static func decodeAnyArray(_ value: borrowing JavaScriptUnownedValue, in runtime: borrowing JavaScriptRuntime)
|
|
71
|
+
throws -> [Any]
|
|
72
|
+
{
|
|
73
|
+
guard value.isObject() else {
|
|
74
|
+
return [try freeFormValue(of: value, depth: 0, in: runtime)]
|
|
75
|
+
}
|
|
76
|
+
let object = value.getObject(in: copy runtime)
|
|
77
|
+
guard object.isArray() else {
|
|
78
|
+
return wrappingInArray(try freeFormValue(ofObject: object, depth: 0, in: runtime))
|
|
79
|
+
}
|
|
80
|
+
return try freeFormElements(of: object.getArray(), depth: 0, in: runtime)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/// Decodes a JavaScript object into a string-keyed dictionary of free-form native values, with the
|
|
84
|
+
/// same rules as a nested object in ``decodeAny(_:in:)`` (so a `Map` with string keys converts).
|
|
85
|
+
/// Throws `TypeError` if the value doesn't decode as a dictionary, such as a primitive, an array, a
|
|
86
|
+
/// `Date`, a `Set` or a function, or if anything inside it can't be decoded.
|
|
87
|
+
@JavaScriptActor
|
|
88
|
+
public static func decodeAnyDictionary(_ value: borrowing JavaScriptValue, in runtime: borrowing JavaScriptRuntime)
|
|
89
|
+
throws -> [String: Any]
|
|
90
|
+
{
|
|
91
|
+
return try freeFormDictionary(of: value.asObject(), in: runtime)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/// Decodes a borrowed JavaScript object into a string-keyed dictionary of free-form native values.
|
|
95
|
+
/// See ``decodeAnyDictionary(_:in:)``.
|
|
96
|
+
@JavaScriptActor
|
|
97
|
+
public static func decodeAnyDictionary(
|
|
98
|
+
_ value: borrowing JavaScriptUnownedValue,
|
|
99
|
+
in runtime: borrowing JavaScriptRuntime
|
|
100
|
+
) throws -> [String: Any] {
|
|
101
|
+
return try freeFormDictionary(of: value.asObject(in: copy runtime), in: runtime)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/// Decodes a JavaScript value into any type built from free-form values, such as `[[Any]]` or
|
|
105
|
+
/// `[String: [Any]]?`. The value is decoded with ``decodeAny(_:in:)`` and then
|
|
106
|
+
/// cast to `type`. A `null` or `undefined` at any depth decodes as `nil`, so it fits an optional
|
|
107
|
+
/// in `type` at that position (`[String: [Any]?]` accepts `{ a: null }`).
|
|
108
|
+
///
|
|
109
|
+
/// Throws `TypeError` if the decoded value can't be cast to `type`. Prefer ``decodeAny(_:in:)``,
|
|
110
|
+
/// ``decodeAnyArray(_:in:)`` or ``decodeAnyDictionary(_:in:)`` for the types they cover, because
|
|
111
|
+
/// they don't need the cast.
|
|
112
|
+
@JavaScriptActor
|
|
113
|
+
public static func decodeAny<T>(
|
|
114
|
+
_ value: borrowing JavaScriptValue,
|
|
115
|
+
as type: T.Type,
|
|
116
|
+
in runtime: borrowing JavaScriptRuntime
|
|
117
|
+
) throws -> T {
|
|
118
|
+
guard let result = try decodeAny(value, in: runtime) as? T else {
|
|
119
|
+
throw TypeError(type: T.self, reason: castFailureReason(for: T.self))
|
|
120
|
+
}
|
|
121
|
+
return result
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// MARK: - Encoding
|
|
125
|
+
|
|
126
|
+
/// Encodes a free-form native value into a JavaScript value.
|
|
127
|
+
///
|
|
128
|
+
/// Supports strings, numbers (including `NSNumber`), booleans, `nil` and `NSNull` (encoded as
|
|
129
|
+
/// `null`), `[Any]` and `[String: Any]` with supported elements, and any `JavaScriptEncodable` or
|
|
130
|
+
/// `JavaScriptRepresentable` value. Throws `EncodingError` for any other value.
|
|
131
|
+
@JavaScriptActor
|
|
132
|
+
public static func encodeAny(_ value: Any, in runtime: borrowing JavaScriptRuntime) throws -> JavaScriptValue {
|
|
133
|
+
// Strings and the native number and boolean types are the most common leaf values, so they're
|
|
134
|
+
// checked first, before the slower protocol casts, and each converts through its own `encode` (so
|
|
135
|
+
// an `Int` outside the safe-integer range throws, as `Int.encode` does). The number and boolean
|
|
136
|
+
// checks compare the exact type rather than casting, since an `NSNumber` casts to both `Bool` and
|
|
137
|
+
// `Double` and has to be told apart below.
|
|
138
|
+
if let string = value as? String {
|
|
139
|
+
return try String.encode(string, in: runtime)
|
|
140
|
+
}
|
|
141
|
+
let valueType = type(of: value)
|
|
142
|
+
if valueType == Double.self {
|
|
143
|
+
return try Double.encode(value as! Double, in: runtime)
|
|
144
|
+
}
|
|
145
|
+
if valueType == Bool.self {
|
|
146
|
+
return try Bool.encode(value as! Bool, in: runtime)
|
|
147
|
+
}
|
|
148
|
+
if valueType == Int.self {
|
|
149
|
+
return try Int.encode(value as! Int, in: runtime)
|
|
150
|
+
}
|
|
151
|
+
if let dictionary = value as? [String: Any] {
|
|
152
|
+
return try encodeAnyDictionary(dictionary, in: runtime)
|
|
153
|
+
}
|
|
154
|
+
if let array = value as? [Any] {
|
|
155
|
+
return try encodeAnyArray(array, in: runtime)
|
|
156
|
+
}
|
|
157
|
+
if value is NSNull {
|
|
158
|
+
return .null
|
|
159
|
+
}
|
|
160
|
+
if let optional = value as? any FreeFormOptional {
|
|
161
|
+
guard let wrapped = optional.freeFormWrappedValue else {
|
|
162
|
+
return .null
|
|
163
|
+
}
|
|
164
|
+
return try encodeAny(wrapped, in: runtime)
|
|
165
|
+
}
|
|
166
|
+
if let encodable = value as? any JavaScriptEncodable {
|
|
167
|
+
return try encode(opening: encodable, in: runtime)
|
|
168
|
+
}
|
|
169
|
+
if let number = value as? NSNumber {
|
|
170
|
+
if CFGetTypeID(number) == CFBooleanGetTypeID() {
|
|
171
|
+
return number.boolValue ? .true() : .false()
|
|
172
|
+
}
|
|
173
|
+
return .number(number.doubleValue)
|
|
174
|
+
}
|
|
175
|
+
if let representable = value as? any JavaScriptRepresentable {
|
|
176
|
+
return representable.toJavaScriptValue(in: copy runtime)
|
|
177
|
+
}
|
|
178
|
+
throw EncodingError(valueType: valueType)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/// Encodes an array of free-form native values into a JavaScript array. Each element is encoded
|
|
182
|
+
/// with ``encodeAny(_:in:)``.
|
|
183
|
+
@JavaScriptActor
|
|
184
|
+
public static func encodeAnyArray(_ value: [Any], in runtime: borrowing JavaScriptRuntime) throws -> JavaScriptValue {
|
|
185
|
+
let array = runtime.createArray(length: value.count)
|
|
186
|
+
for (index, element) in value.enumerated() {
|
|
187
|
+
try array.set(value: encodeAny(element, in: runtime), at: index)
|
|
188
|
+
}
|
|
189
|
+
return array.asValue()
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/// Encodes a string-keyed dictionary of free-form native values into a JavaScript object. Each
|
|
193
|
+
/// value is encoded with ``encodeAny(_:in:)``.
|
|
194
|
+
@JavaScriptActor
|
|
195
|
+
public static func encodeAnyDictionary(_ value: [String: Any], in runtime: borrowing JavaScriptRuntime) throws
|
|
196
|
+
-> JavaScriptValue
|
|
197
|
+
{
|
|
198
|
+
let object = runtime.createObject()
|
|
199
|
+
for (key, element) in value {
|
|
200
|
+
object.setProperty(key, value: try encodeAny(element, in: runtime))
|
|
201
|
+
}
|
|
202
|
+
return object.asValue()
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// MARK: - Helpers
|
|
206
|
+
|
|
207
|
+
@JavaScriptActor
|
|
208
|
+
private static func freeFormValue(
|
|
209
|
+
of value: borrowing JavaScriptValue,
|
|
210
|
+
depth: Int,
|
|
211
|
+
in runtime: borrowing JavaScriptRuntime
|
|
212
|
+
) throws -> Any {
|
|
213
|
+
if value.isString() {
|
|
214
|
+
return value.getString()
|
|
215
|
+
}
|
|
216
|
+
if value.isNumber() {
|
|
217
|
+
return value.getDouble()
|
|
218
|
+
}
|
|
219
|
+
if value.isBool() {
|
|
220
|
+
return value.getBool()
|
|
221
|
+
}
|
|
222
|
+
if value.isObject() {
|
|
223
|
+
return try freeFormValue(ofObject: value.getObject(), depth: depth, in: runtime)
|
|
224
|
+
}
|
|
225
|
+
if value.isNull() || value.isUndefined() {
|
|
226
|
+
return Optional<Any>.none as Any
|
|
227
|
+
}
|
|
228
|
+
if value.isBigInt() {
|
|
229
|
+
return try value.getBigInt().asInt64()
|
|
230
|
+
}
|
|
231
|
+
throw TypeError(type: Any.self, reason: unrepresentableValueReason)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
@JavaScriptActor
|
|
235
|
+
private static func freeFormValue(
|
|
236
|
+
of value: borrowing JavaScriptUnownedValue,
|
|
237
|
+
depth: Int,
|
|
238
|
+
in runtime: borrowing JavaScriptRuntime
|
|
239
|
+
) throws -> Any {
|
|
240
|
+
if value.isString() {
|
|
241
|
+
return value.getString()
|
|
242
|
+
}
|
|
243
|
+
if value.isNumber() {
|
|
244
|
+
return value.getDouble()
|
|
245
|
+
}
|
|
246
|
+
if value.isBool() {
|
|
247
|
+
return value.getBool()
|
|
248
|
+
}
|
|
249
|
+
if value.isObject() {
|
|
250
|
+
return try freeFormValue(ofObject: value.getObject(in: copy runtime), depth: depth, in: runtime)
|
|
251
|
+
}
|
|
252
|
+
if value.isNull() || value.isUndefined() {
|
|
253
|
+
return Optional<Any>.none as Any
|
|
254
|
+
}
|
|
255
|
+
if value.isBigInt() {
|
|
256
|
+
// The borrowed value has no bigint accessor, so this rare case goes through an owning copy.
|
|
257
|
+
return try value.copied(in: copy runtime).getBigInt().asInt64()
|
|
258
|
+
}
|
|
259
|
+
throw TypeError(type: Any.self, reason: unrepresentableValueReason)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
@JavaScriptActor
|
|
263
|
+
private static func freeFormValue(
|
|
264
|
+
ofObject object: borrowing JavaScriptObject,
|
|
265
|
+
depth: Int,
|
|
266
|
+
in runtime: borrowing JavaScriptRuntime
|
|
267
|
+
) throws -> Any {
|
|
268
|
+
if object.isArray() {
|
|
269
|
+
return try freeFormElements(of: object.getArray(), depth: depth, in: runtime)
|
|
270
|
+
}
|
|
271
|
+
if object.isFunction() {
|
|
272
|
+
throw TypeError(type: Any.self, reason: unrepresentableValueReason)
|
|
273
|
+
}
|
|
274
|
+
try rejectBinaryData(object)
|
|
275
|
+
let keys = object.getPropertyNames()
|
|
276
|
+
if keys.isEmpty {
|
|
277
|
+
// A `Date`, `Set`, `Map`, `WeakMap`, `WeakSet` or `DataView` has no enumerable properties, so only
|
|
278
|
+
// an object without any can be one. This keeps the global lookup and `instanceof` walk of `is(_:)`
|
|
279
|
+
// off objects that have properties.
|
|
280
|
+
let value = object.asValue()
|
|
281
|
+
if value.is("Date") {
|
|
282
|
+
return try Date.decode(value, in: runtime)
|
|
283
|
+
}
|
|
284
|
+
if value.is("Set") {
|
|
285
|
+
// A `Set` has no native free-form counterpart (a Swift `Set` needs hashable elements), so it
|
|
286
|
+
// decodes as an array of its elements, in insertion order.
|
|
287
|
+
let elements = try runtime.global()
|
|
288
|
+
.getPropertyAsObject("Array")
|
|
289
|
+
.getPropertyAsFunction("from")
|
|
290
|
+
.call(arguments: value)
|
|
291
|
+
return try freeFormElements(of: elements.asArray(), depth: depth, in: runtime)
|
|
292
|
+
}
|
|
293
|
+
if value.is("Map") {
|
|
294
|
+
// A `Map` keeps its entries outside its properties, so it's read through its `[key, value]`
|
|
295
|
+
// pairs. Only string keys have a dictionary counterpart; any other key throws.
|
|
296
|
+
let entries = try runtime.global()
|
|
297
|
+
.getPropertyAsObject("Array")
|
|
298
|
+
.getPropertyAsFunction("from")
|
|
299
|
+
.call(arguments: value)
|
|
300
|
+
return try freeFormMapEntries(of: entries.asArray(), depth: depth, in: runtime)
|
|
301
|
+
}
|
|
302
|
+
if value.is("WeakMap") || value.is("WeakSet") {
|
|
303
|
+
throw TypeError(type: Any.self, reason: weakCollectionReason)
|
|
304
|
+
}
|
|
305
|
+
if value.is("DataView") {
|
|
306
|
+
throw TypeError(type: Any.self, reason: binaryDataReason)
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return try freeFormProperties(of: object, keys: keys, depth: depth, in: runtime)
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/// Decodes a top-level object exactly as it would decode nested inside a value, then requires a
|
|
313
|
+
/// dictionary, so a top-level `Map` converts and a function, `Date`, `Set` or array throws, just as
|
|
314
|
+
/// through `decodeAny(_:as: [String: Any].self)`.
|
|
315
|
+
@JavaScriptActor
|
|
316
|
+
private static func freeFormDictionary(
|
|
317
|
+
of object: borrowing JavaScriptObject,
|
|
318
|
+
in runtime: borrowing JavaScriptRuntime
|
|
319
|
+
) throws -> [String: Any] {
|
|
320
|
+
guard let dictionary = try freeFormValue(ofObject: object, depth: 0, in: runtime) as? [String: Any] else {
|
|
321
|
+
throw TypeError(type: [String: Any].self, reason: castFailureReason(for: [String: Any].self))
|
|
322
|
+
}
|
|
323
|
+
return dictionary
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
@JavaScriptActor
|
|
327
|
+
private static func freeFormElements(
|
|
328
|
+
of array: borrowing JavaScriptArray,
|
|
329
|
+
depth: Int,
|
|
330
|
+
in runtime: borrowing JavaScriptRuntime
|
|
331
|
+
) throws -> [Any] {
|
|
332
|
+
try checkDepth(depth)
|
|
333
|
+
var index = 0
|
|
334
|
+
return try array.map { element in
|
|
335
|
+
defer {
|
|
336
|
+
index += 1
|
|
337
|
+
}
|
|
338
|
+
do {
|
|
339
|
+
return try freeFormValue(of: element, depth: depth + 1, in: runtime)
|
|
340
|
+
} catch let error as TypeError {
|
|
341
|
+
throw error.prependingPath("[\(index)]")
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
@JavaScriptActor
|
|
347
|
+
private static func freeFormProperties(
|
|
348
|
+
of object: borrowing JavaScriptObject,
|
|
349
|
+
keys: [String],
|
|
350
|
+
depth: Int,
|
|
351
|
+
in runtime: borrowing JavaScriptRuntime
|
|
352
|
+
) throws -> [String: Any] {
|
|
353
|
+
try checkDepth(depth)
|
|
354
|
+
var result = [String: Any](minimumCapacity: keys.count)
|
|
355
|
+
for key in keys {
|
|
356
|
+
let property = object.getProperty(key)
|
|
357
|
+
if property.isUndefined() {
|
|
358
|
+
continue
|
|
359
|
+
}
|
|
360
|
+
do {
|
|
361
|
+
result[key] = try freeFormValue(of: property, depth: depth + 1, in: runtime)
|
|
362
|
+
} catch let error as TypeError {
|
|
363
|
+
throw error.prependingPath(".\(key)")
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
return result
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/// Wraps a decoded non-array value in a single-element array, unless it already is an array, which
|
|
370
|
+
/// only a `Set` decodes to. Wrapping a `Set` would add a level that the generic path doesn't.
|
|
371
|
+
private static func wrappingInArray(_ decoded: Any) -> [Any] {
|
|
372
|
+
if let elements = decoded as? [Any] {
|
|
373
|
+
return elements
|
|
374
|
+
}
|
|
375
|
+
return [decoded]
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/// Decodes the `[key, value]` pairs of a `Map` into a dictionary. Throws for a key that isn't a
|
|
379
|
+
/// string, since a dictionary key would have to be a string the entry doesn't have.
|
|
380
|
+
@JavaScriptActor
|
|
381
|
+
private static func freeFormMapEntries(
|
|
382
|
+
of entries: borrowing JavaScriptArray,
|
|
383
|
+
depth: Int,
|
|
384
|
+
in runtime: borrowing JavaScriptRuntime
|
|
385
|
+
) throws -> [String: Any] {
|
|
386
|
+
try checkDepth(depth)
|
|
387
|
+
var result = [String: Any](minimumCapacity: entries.length)
|
|
388
|
+
for index in 0..<entries.length {
|
|
389
|
+
let entry = try entries.getValue(at: index).asArray()
|
|
390
|
+
let key = try entry.getValue(at: 0)
|
|
391
|
+
guard key.isString() else {
|
|
392
|
+
throw TypeError(type: Any.self, reason: nonStringMapKeyReason)
|
|
393
|
+
}
|
|
394
|
+
let name = key.getString()
|
|
395
|
+
let value = try entry.getValue(at: 1)
|
|
396
|
+
// An `undefined` value means "absent", as it does for an object property.
|
|
397
|
+
if value.isUndefined() {
|
|
398
|
+
continue
|
|
399
|
+
}
|
|
400
|
+
do {
|
|
401
|
+
result[name] = try freeFormValue(of: value, depth: depth + 1, in: runtime)
|
|
402
|
+
} catch let error as TypeError {
|
|
403
|
+
throw error.prependingPath(".\(name)")
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
return result
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/// Throws for a typed array or `ArrayBuffer`, whose bytes would otherwise decode as a dictionary
|
|
410
|
+
/// keyed by index, or as an empty one.
|
|
411
|
+
@JavaScriptActor
|
|
412
|
+
private static func rejectBinaryData(_ object: borrowing JavaScriptObject) throws {
|
|
413
|
+
if object.isTypedArray() || object.isArrayBuffer() {
|
|
414
|
+
throw TypeError(type: Any.self, reason: binaryDataReason)
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/// Throws once a value is nested deeper than `maxFreeFormDepth`, which stops a cycle from
|
|
419
|
+
/// recursing until the stack overflows.
|
|
420
|
+
private static func checkDepth(_ depth: Int) throws {
|
|
421
|
+
if depth >= maxFreeFormDepth {
|
|
422
|
+
throw TypeError(type: Any.self, reason: nestingTooDeepReason)
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/// Opens the existential so the conforming type's own `encode` runs.
|
|
427
|
+
@JavaScriptActor
|
|
428
|
+
private static func encode<Value: JavaScriptEncodable>(
|
|
429
|
+
opening value: Value,
|
|
430
|
+
in runtime: borrowing JavaScriptRuntime
|
|
431
|
+
) throws -> JavaScriptValue {
|
|
432
|
+
return try Value.encode(value, in: runtime)
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// MARK: - Errors
|
|
437
|
+
|
|
438
|
+
/// The deepest nesting of arrays and objects that free-form decoding accepts.
|
|
439
|
+
private let maxFreeFormDepth = 64
|
|
440
|
+
|
|
441
|
+
/// The `TypeError` reason for a JavaScript value that has no free-form native form.
|
|
442
|
+
private let unrepresentableValueReason =
|
|
443
|
+
"Cannot convert a JavaScript function or symbol to a native value, because neither has a native representation. Remove it from the value before passing it to native code, or declare the native type as JavaScriptValue to receive it unconverted."
|
|
444
|
+
|
|
445
|
+
/// The `TypeError` reason for a typed array, `ArrayBuffer` or `DataView` inside a free-form value.
|
|
446
|
+
private let binaryDataReason =
|
|
447
|
+
"Cannot convert a typed array, ArrayBuffer or DataView to a free-form native value, because free-form values hold only primitives, plain objects, arrays and dates. Convert it to an array first (for example, with Array.from), or declare the native type as JavaScriptValue to receive it unconverted."
|
|
448
|
+
|
|
449
|
+
/// The `TypeError` reason for a `WeakMap` or `WeakSet` inside a free-form value.
|
|
450
|
+
private let weakCollectionReason =
|
|
451
|
+
"Cannot convert a WeakMap or WeakSet to a native value, because JavaScript doesn't allow reading their entries. Pass a Map or Set instead, or declare the native type as JavaScriptValue to receive it unconverted."
|
|
452
|
+
|
|
453
|
+
/// The `TypeError` reason for a `Map` with a key that isn't a string.
|
|
454
|
+
private let nonStringMapKeyReason =
|
|
455
|
+
"Cannot convert a Map with a key that isn't a string to a native value, because it decodes as a dictionary, whose keys are strings. Use only string keys, convert the Map to an array of entries first (for example, with Array.from), or declare the native type as JavaScriptValue to receive it unconverted."
|
|
456
|
+
|
|
457
|
+
/// The `TypeError` reason for a value nested deeper than `maxFreeFormDepth`.
|
|
458
|
+
private let nestingTooDeepReason =
|
|
459
|
+
"Cannot convert a JavaScript value nested more than \(maxFreeFormDepth) levels deep to a native value, most likely because it contains a reference to itself (a cycle). Remove the cycle before passing the value to native code, or declare the native type as JavaScriptValue to receive it unconverted."
|
|
460
|
+
|
|
461
|
+
/// The `TypeError` reason for a decoded free-form value that doesn't cast to the requested type.
|
|
462
|
+
private func castFailureReason(for type: Any.Type) -> String {
|
|
463
|
+
return
|
|
464
|
+
"Cannot convert the JavaScript value to '\(type)', because the decoded value has a different shape. Free-form decoding produces only Bool, Double (for every number), String, Int64, Date, [Any], [String: Any] (object keys are always String) and nil. Declare the type in terms of these, for example [String: Any] rather than [Int: Any], or pass a value of the expected shape."
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
extension JavaScriptValue.TypeError {
|
|
468
|
+
/// Returns the error with `component` (such as `.key` or `[2]`) added to the front of its path, as
|
|
469
|
+
/// the error passes up through each container on the way out.
|
|
470
|
+
fileprivate func prependingPath(_ component: String) -> Self {
|
|
471
|
+
var error = self
|
|
472
|
+
error.path = component + path
|
|
473
|
+
return error
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
extension JavaScriptValue {
|
|
478
|
+
/// Thrown by ``JavaScriptValue/encodeAny(_:in:)`` for a value it can't convert to JavaScript.
|
|
479
|
+
public struct EncodingError: Error, CustomStringConvertible {
|
|
480
|
+
let valueType: Any.Type
|
|
481
|
+
|
|
482
|
+
public var description: String {
|
|
483
|
+
return
|
|
484
|
+
"Cannot convert a value of type '\(valueType)' to a JavaScript value, because it isn't a string, number, boolean, null, array, dictionary, or a JavaScriptEncodable value. Convert it to one of these types first, or make '\(valueType)' conform to JavaScriptEncodable."
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// MARK: - Optional support
|
|
490
|
+
|
|
491
|
+
/// Lets `encodeAny` unwrap an `Optional` found inside `Any`, whose wrapped type isn't known statically.
|
|
492
|
+
private protocol FreeFormOptional {
|
|
493
|
+
var freeFormWrappedValue: Any? { get }
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
extension Optional: FreeFormOptional {
|
|
497
|
+
var freeFormWrappedValue: Any? {
|
|
498
|
+
return self.map { $0 as Any }
|
|
499
|
+
}
|
|
500
|
+
}
|
|
@@ -92,6 +92,14 @@ public struct JavaScriptObject: JavaScriptType, Sendable, ~Copyable {
|
|
|
92
92
|
return pointee.isArrayBuffer(jsiRuntime)
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
/// Returns `true` if the object is a typed array, such as `Uint8Array`.
|
|
96
|
+
internal func isTypedArray() -> Bool {
|
|
97
|
+
guard let runtime else {
|
|
98
|
+
FatalError.runtimeLost()
|
|
99
|
+
}
|
|
100
|
+
return expo.isTypedArray(runtime.pointee, pointee)
|
|
101
|
+
}
|
|
102
|
+
|
|
95
103
|
/// Returns the object as an array buffer, or asserts if not an array buffer.
|
|
96
104
|
public func getArrayBuffer() -> JavaScriptArrayBuffer {
|
|
97
105
|
guard let runtime else {
|
|
@@ -732,9 +732,18 @@ extension JavaScriptValue: JSIRepresentable {
|
|
|
732
732
|
extension JavaScriptValue {
|
|
733
733
|
public struct TypeError: Error, CustomStringConvertible {
|
|
734
734
|
let type: any ~Copyable.Type
|
|
735
|
+
/// A specific explanation that replaces the generic message, when the failure needs one.
|
|
736
|
+
var reason: String? = nil
|
|
737
|
+
/// Where inside the converted value the failure happened, such as `.user.tags[1]`. Empty when it's
|
|
738
|
+
/// the value itself.
|
|
739
|
+
var path: String = ""
|
|
735
740
|
|
|
736
741
|
public var description: String {
|
|
737
|
-
|
|
742
|
+
let message = "TypeError: \(reason ?? "Value cannot be represented as \(type)")"
|
|
743
|
+
guard !path.isEmpty else {
|
|
744
|
+
return message
|
|
745
|
+
}
|
|
746
|
+
return "\(message) (path: \(path.hasPrefix(".") ? path.dropFirst() : Substring(path)))"
|
|
738
747
|
}
|
|
739
748
|
}
|
|
740
749
|
}
|
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
// Copyright 2026-present 650 Industries. All rights reserved.
|
|
2
|
+
|
|
3
|
+
import ExpoModulesJSI
|
|
4
|
+
import Foundation
|
|
5
|
+
import Testing
|
|
6
|
+
|
|
7
|
+
@Suite("JavaScriptCodable+Any")
|
|
8
|
+
@JavaScriptActor
|
|
9
|
+
struct JavaScriptCodableAnyTests {
|
|
10
|
+
let runtime = JavaScriptRuntime()
|
|
11
|
+
|
|
12
|
+
// MARK: - decodeAny
|
|
13
|
+
|
|
14
|
+
@Test
|
|
15
|
+
func `decodes primitives into their native types`() throws {
|
|
16
|
+
#expect(try JavaScriptValue.decodeAny(runtime.eval("true"), in: runtime) as? Bool == true)
|
|
17
|
+
#expect(try JavaScriptValue.decodeAny(runtime.eval("1.5"), in: runtime) as? Double == 1.5)
|
|
18
|
+
#expect(try JavaScriptValue.decodeAny(runtime.eval("'abc'"), in: runtime) as? String == "abc")
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
@Test
|
|
22
|
+
func `decodes numbers as doubles`() throws {
|
|
23
|
+
let decoded = try JavaScriptValue.decodeAny(runtime.eval("3"), in: runtime)
|
|
24
|
+
#expect(decoded is Double)
|
|
25
|
+
#expect(decoded as? Double == 3)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
@Test
|
|
29
|
+
func `decodes null and undefined as nil`() throws {
|
|
30
|
+
#expect(try isNil(JavaScriptValue.decodeAny(runtime.eval("null"), in: runtime)))
|
|
31
|
+
#expect(try isNil(JavaScriptValue.decodeAny(runtime.eval("undefined"), in: runtime)))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
@Test
|
|
35
|
+
func `decodes nested containers`() throws {
|
|
36
|
+
let decoded = try JavaScriptValue.decodeAny(
|
|
37
|
+
runtime.eval("({ list: [1, 'a', null], inner: { flag: false } })"),
|
|
38
|
+
in: runtime
|
|
39
|
+
)
|
|
40
|
+
let dictionary = try #require(decoded as? [String: Any])
|
|
41
|
+
let list = try #require(dictionary["list"] as? [Any])
|
|
42
|
+
let inner = try #require(dictionary["inner"] as? [String: Any])
|
|
43
|
+
#expect(list.count == 3)
|
|
44
|
+
#expect(list[0] as? Double == 1)
|
|
45
|
+
#expect(list[1] as? String == "a")
|
|
46
|
+
#expect(isNil(list[2]))
|
|
47
|
+
#expect(inner["flag"] as? Bool == false)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
@Test
|
|
51
|
+
func `decodes from a borrowed value`() throws {
|
|
52
|
+
let buffer = try JavaScriptValuesBuffer.allocate(in: runtime, with: runtime.eval("({ a: 'x' })"))
|
|
53
|
+
let decoded = try JavaScriptValue.decodeAny(buffer.unownedValue(at: 0), in: runtime)
|
|
54
|
+
#expect((decoded as? [String: Any])?["a"] as? String == "x")
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
@Test
|
|
58
|
+
func `decodes a bigint within the Int64 range as Int64`() throws {
|
|
59
|
+
#expect(try JavaScriptValue.decodeAny(runtime.eval("42n"), in: runtime) as? Int64 == 42)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
@Test
|
|
63
|
+
func `throws for a bigint outside the Int64 range`() throws {
|
|
64
|
+
#expect(throws: BigIntConversionError.self) {
|
|
65
|
+
try JavaScriptValue.decodeAny(runtime.eval("2n ** 64n"), in: runtime)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
@Test
|
|
70
|
+
func `throws TypeError for a function`() throws {
|
|
71
|
+
#expect(throws: JavaScriptValue.TypeError.self) {
|
|
72
|
+
try JavaScriptValue.decodeAny(runtime.eval("(() => {})"), in: runtime)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
@Test
|
|
77
|
+
func `throws TypeError for a symbol`() throws {
|
|
78
|
+
#expect(throws: JavaScriptValue.TypeError.self) {
|
|
79
|
+
try JavaScriptValue.decodeAny(runtime.eval("Symbol('s')"), in: runtime)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
@Test
|
|
84
|
+
func `throws TypeError for a function nested in a container`() throws {
|
|
85
|
+
#expect(throws: JavaScriptValue.TypeError.self) {
|
|
86
|
+
try JavaScriptValue.decodeAnyDictionary(runtime.eval("({ callback() {} })"), in: runtime)
|
|
87
|
+
}
|
|
88
|
+
#expect(throws: JavaScriptValue.TypeError.self) {
|
|
89
|
+
try JavaScriptValue.decodeAnyArray(runtime.eval("[1, () => {}]"), in: runtime)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
@Test
|
|
94
|
+
func `throws TypeError for a borrowed function`() throws {
|
|
95
|
+
let buffer = try JavaScriptValuesBuffer.allocate(in: runtime, with: runtime.eval("(() => {})"))
|
|
96
|
+
#expect(throws: JavaScriptValue.TypeError.self) {
|
|
97
|
+
try JavaScriptValue.decodeAny(buffer.unownedValue(at: 0), in: runtime)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
@Test
|
|
102
|
+
func `decodes a borrowed bigint as Int64`() throws {
|
|
103
|
+
let buffer = try JavaScriptValuesBuffer.allocate(in: runtime, with: runtime.eval("7n"))
|
|
104
|
+
let decoded = try JavaScriptValue.decodeAny(buffer.unownedValue(at: 0), in: runtime)
|
|
105
|
+
#expect(decoded as? Int64 == 7)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
@Test
|
|
109
|
+
func `decodes a Date as Date`() throws {
|
|
110
|
+
let decoded = try JavaScriptValue.decodeAny(runtime.eval("({ at: new Date(1500) })"), in: runtime)
|
|
111
|
+
#expect((decoded as? [String: Any])?["at"] as? Date == Date(timeIntervalSince1970: 1.5))
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
@Test
|
|
115
|
+
func `decodes a borrowed Date as Date`() throws {
|
|
116
|
+
let buffer = try JavaScriptValuesBuffer.allocate(in: runtime, with: runtime.eval("new Date(1500)"))
|
|
117
|
+
let decoded = try JavaScriptValue.decodeAny(buffer.unownedValue(at: 0), in: runtime)
|
|
118
|
+
#expect(decoded as? Date == Date(timeIntervalSince1970: 1.5))
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
@Test
|
|
122
|
+
func `round-trips a Date through encode and decode`() throws {
|
|
123
|
+
let date = Date(timeIntervalSince1970: 2)
|
|
124
|
+
let decoded = try JavaScriptValue.decodeAny(JavaScriptValue.encodeAny(date, in: runtime), in: runtime)
|
|
125
|
+
#expect(decoded as? Date == date)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
@Test
|
|
129
|
+
func `decodes a Map with string keys as a dictionary and a Set as an array of its elements`() throws {
|
|
130
|
+
let decoded = try JavaScriptValue.decodeAnyDictionary(
|
|
131
|
+
runtime.eval("({ map: new Map([['k', 1], ['n', { x: true }]]), set: new Set([1, 'two', [3]]) })"),
|
|
132
|
+
in: runtime
|
|
133
|
+
)
|
|
134
|
+
let map = try #require(decoded["map"] as? [String: Any])
|
|
135
|
+
#expect(map.count == 2)
|
|
136
|
+
#expect(map["k"] as? Double == 1)
|
|
137
|
+
#expect((map["n"] as? [String: Any])?["x"] as? Bool == true)
|
|
138
|
+
let set = try #require(decoded["set"] as? [Any])
|
|
139
|
+
try #require(set.count == 3)
|
|
140
|
+
#expect(set[0] as? Double == 1)
|
|
141
|
+
#expect(set[1] as? String == "two")
|
|
142
|
+
#expect((set[2] as? [Any])?.first as? Double == 3)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// An `undefined` value means "absent" in a `Map` too, the same as for an object property.
|
|
146
|
+
@Test
|
|
147
|
+
func `drops Map entries whose value is undefined`() throws {
|
|
148
|
+
let decoded = try JavaScriptValue.decodeAnyDictionary(
|
|
149
|
+
runtime.eval("new Map([['gone', undefined], ['kept', 1]])"),
|
|
150
|
+
in: runtime
|
|
151
|
+
)
|
|
152
|
+
#expect(Array(decoded.keys) == ["kept"])
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
@Test
|
|
156
|
+
func `throws TypeError for a Map with a key that isn't a string`() throws {
|
|
157
|
+
let error = #expect(throws: JavaScriptValue.TypeError.self) {
|
|
158
|
+
try JavaScriptValue.decodeAny(runtime.eval("({ lookup: new Map([[1, 'one']]) })"), in: runtime)
|
|
159
|
+
}
|
|
160
|
+
#expect(error?.description.contains("path: lookup") == true)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
@Test
|
|
164
|
+
func `names the path to an unrepresentable value inside a Map`() throws {
|
|
165
|
+
let error = #expect(throws: JavaScriptValue.TypeError.self) {
|
|
166
|
+
try JavaScriptValue.decodeAny(runtime.eval("({ lookup: new Map([['k', () => {}]]) })"), in: runtime)
|
|
167
|
+
}
|
|
168
|
+
#expect(error?.description.contains("path: lookup.k") == true)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
@Test
|
|
172
|
+
func `throws TypeError for a WeakMap, a WeakSet or a DataView`() throws {
|
|
173
|
+
#expect(throws: JavaScriptValue.TypeError.self) {
|
|
174
|
+
try JavaScriptValue.decodeAny(runtime.eval("({ cache: new WeakMap() })"), in: runtime)
|
|
175
|
+
}
|
|
176
|
+
#expect(throws: JavaScriptValue.TypeError.self) {
|
|
177
|
+
try JavaScriptValue.decodeAny(runtime.eval("[new WeakSet()]"), in: runtime)
|
|
178
|
+
}
|
|
179
|
+
#expect(throws: JavaScriptValue.TypeError.self) {
|
|
180
|
+
try JavaScriptValue.decodeAny(runtime.eval("({ view: new DataView(new ArrayBuffer(4)) })"), in: runtime)
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// An `Error` keeps `message` and `stack` in non-enumerable properties, so only what the caller
|
|
185
|
+
// added is decoded. This pins the documented limit of decoding from enumerable own properties.
|
|
186
|
+
@Test
|
|
187
|
+
func `decodes an Error from its enumerable properties only`() throws {
|
|
188
|
+
let decoded = try JavaScriptValue.decodeAnyDictionary(
|
|
189
|
+
runtime.eval("const error = new Error('failed'); error.code = 'E_FAILED'; error"),
|
|
190
|
+
in: runtime
|
|
191
|
+
)
|
|
192
|
+
#expect(Array(decoded.keys) == ["code"])
|
|
193
|
+
#expect(decoded["code"] as? String == "E_FAILED")
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
@Test
|
|
197
|
+
func `throws TypeError for a typed array or an ArrayBuffer`() throws {
|
|
198
|
+
#expect(throws: JavaScriptValue.TypeError.self) {
|
|
199
|
+
try JavaScriptValue.decodeAny(runtime.eval("({ bytes: new Uint8Array(4) })"), in: runtime)
|
|
200
|
+
}
|
|
201
|
+
#expect(throws: JavaScriptValue.TypeError.self) {
|
|
202
|
+
try JavaScriptValue.decodeAny(runtime.eval("[new ArrayBuffer(4)]"), in: runtime)
|
|
203
|
+
}
|
|
204
|
+
#expect(throws: JavaScriptValue.TypeError.self) {
|
|
205
|
+
try JavaScriptValue.decodeAnyDictionary(runtime.eval("new Float64Array(4)"), in: runtime)
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
@Test
|
|
210
|
+
func `throws TypeError for a cyclic value`() throws {
|
|
211
|
+
#expect(throws: JavaScriptValue.TypeError.self) {
|
|
212
|
+
try JavaScriptValue.decodeAnyDictionary(
|
|
213
|
+
runtime.eval("const object = {}; object.self = object; object"),
|
|
214
|
+
in: runtime
|
|
215
|
+
)
|
|
216
|
+
}
|
|
217
|
+
#expect(throws: JavaScriptValue.TypeError.self) {
|
|
218
|
+
try JavaScriptValue.decodeAnyArray(runtime.eval("const array = []; array.push(array); array"), in: runtime)
|
|
219
|
+
}
|
|
220
|
+
#expect(throws: JavaScriptValue.TypeError.self) {
|
|
221
|
+
try JavaScriptValue.decodeAny(runtime.eval("const set = new Set(); set.add(set); set"), in: runtime)
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
@Test
|
|
226
|
+
func `names the path to an unrepresentable value in the error`() throws {
|
|
227
|
+
let error = #expect(throws: JavaScriptValue.TypeError.self) {
|
|
228
|
+
try JavaScriptValue.decodeAny(runtime.eval("({ user: { tags: ['a', () => {}] } })"), in: runtime)
|
|
229
|
+
}
|
|
230
|
+
#expect(error?.description.contains("user.tags[1]") == true)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// MARK: - decodeAnyArray
|
|
234
|
+
|
|
235
|
+
@Test
|
|
236
|
+
func `decodes an array of mixed values`() throws {
|
|
237
|
+
let decoded = try JavaScriptValue.decodeAnyArray(runtime.eval("[true, 2, 'three', [4]]"), in: runtime)
|
|
238
|
+
#expect(decoded.count == 4)
|
|
239
|
+
#expect(decoded[0] as? Bool == true)
|
|
240
|
+
#expect(decoded[1] as? Double == 2)
|
|
241
|
+
#expect(decoded[2] as? String == "three")
|
|
242
|
+
#expect((decoded[3] as? [Any])?.first as? Double == 4)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
@Test
|
|
246
|
+
func `decodes undefined array elements as nil`() throws {
|
|
247
|
+
let decoded = try JavaScriptValue.decodeAnyArray(runtime.eval("[undefined, 1]"), in: runtime)
|
|
248
|
+
#expect(decoded.count == 2)
|
|
249
|
+
#expect(isNil(decoded[0]))
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// A `Set` already decodes as an array of its elements, so it isn't wrapped like other non-array
|
|
253
|
+
// values. This matches `decodeAny(_:as: [Any].self)` and a `Set` nested inside a value.
|
|
254
|
+
@Test
|
|
255
|
+
func `decodes a top-level Set as its elements`() throws {
|
|
256
|
+
let decoded = try JavaScriptValue.decodeAnyArray(runtime.eval("new Set([1, 'two'])"), in: runtime)
|
|
257
|
+
try #require(decoded.count == 2)
|
|
258
|
+
#expect(decoded[0] as? Double == 1)
|
|
259
|
+
#expect(decoded[1] as? String == "two")
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
@Test
|
|
263
|
+
func `decodes a borrowed top-level Set as its elements`() throws {
|
|
264
|
+
let buffer = try JavaScriptValuesBuffer.allocate(in: runtime, with: runtime.eval("new Set([1, 'two'])"))
|
|
265
|
+
let decoded = try JavaScriptValue.decodeAnyArray(buffer.unownedValue(at: 0), in: runtime)
|
|
266
|
+
try #require(decoded.count == 2)
|
|
267
|
+
#expect(decoded[1] as? String == "two")
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
@Test
|
|
271
|
+
func `arrayizes a non-array value`() throws {
|
|
272
|
+
let decoded = try JavaScriptValue.decodeAnyArray(runtime.eval("'solo'"), in: runtime)
|
|
273
|
+
#expect(decoded.count == 1)
|
|
274
|
+
#expect(decoded[0] as? String == "solo")
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
@Test
|
|
278
|
+
func `decodes an array from a borrowed value`() throws {
|
|
279
|
+
let buffer = try JavaScriptValuesBuffer.allocate(in: runtime, with: runtime.eval("[1, 2]"))
|
|
280
|
+
let decoded = try JavaScriptValue.decodeAnyArray(buffer.unownedValue(at: 0), in: runtime)
|
|
281
|
+
#expect(decoded.compactMap { $0 as? Double } == [1, 2])
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// MARK: - decodeAnyDictionary
|
|
285
|
+
|
|
286
|
+
@Test
|
|
287
|
+
func `decodes an object of mixed values`() throws {
|
|
288
|
+
let decoded = try JavaScriptValue.decodeAnyDictionary(runtime.eval("({ a: 1, b: 'two', c: null })"), in: runtime)
|
|
289
|
+
#expect(decoded.count == 3)
|
|
290
|
+
#expect(decoded["a"] as? Double == 1)
|
|
291
|
+
#expect(decoded["b"] as? String == "two")
|
|
292
|
+
#expect(decoded.keys.contains("c"))
|
|
293
|
+
#expect(isNil(decoded["c"]!))
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
@Test
|
|
297
|
+
func `drops undefined properties`() throws {
|
|
298
|
+
let decoded = try JavaScriptValue.decodeAnyDictionary(runtime.eval("({ a: undefined, b: 1 })"), in: runtime)
|
|
299
|
+
#expect(Array(decoded.keys) == ["b"])
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
@Test
|
|
303
|
+
func `throws TypeError for a non-object value`() throws {
|
|
304
|
+
#expect(throws: JavaScriptValue.TypeError.self) {
|
|
305
|
+
try JavaScriptValue.decodeAnyDictionary(runtime.eval("42"), in: runtime)
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// The dedicated dictionary path must treat a top-level value the same way as a nested one, and the
|
|
310
|
+
// same way as the generic `decodeAny(_:as: [String: Any].self)`.
|
|
311
|
+
@Test
|
|
312
|
+
func `decodes a top-level Map with string keys as a dictionary`() throws {
|
|
313
|
+
let decoded = try JavaScriptValue.decodeAnyDictionary(runtime.eval("new Map([['a', 1]])"), in: runtime)
|
|
314
|
+
#expect(decoded.count == 1)
|
|
315
|
+
#expect(decoded["a"] as? Double == 1)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
@Test
|
|
319
|
+
func `decodes a borrowed top-level Map with string keys as a dictionary`() throws {
|
|
320
|
+
let buffer = try JavaScriptValuesBuffer.allocate(in: runtime, with: runtime.eval("new Map([['a', 1]])"))
|
|
321
|
+
let decoded = try JavaScriptValue.decodeAnyDictionary(buffer.unownedValue(at: 0), in: runtime)
|
|
322
|
+
#expect(decoded["a"] as? Double == 1)
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
@Test(arguments: [
|
|
326
|
+
"[1, 2]",
|
|
327
|
+
"new Date(0)",
|
|
328
|
+
"new Set([1])",
|
|
329
|
+
"(() => {})",
|
|
330
|
+
"new WeakMap()",
|
|
331
|
+
"new WeakSet()",
|
|
332
|
+
"new DataView(new ArrayBuffer(4))",
|
|
333
|
+
"new Map([[1, 'one']])",
|
|
334
|
+
])
|
|
335
|
+
func `throws TypeError for a top-level value that isn't a dictionary`(source: String) throws {
|
|
336
|
+
#expect(throws: JavaScriptValue.TypeError.self) {
|
|
337
|
+
try JavaScriptValue.decodeAnyDictionary(runtime.eval(source), in: runtime)
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
@Test
|
|
342
|
+
func `decodes an object from a borrowed value`() throws {
|
|
343
|
+
let buffer = try JavaScriptValuesBuffer.allocate(in: runtime, with: runtime.eval("({ k: true })"))
|
|
344
|
+
let decoded = try JavaScriptValue.decodeAnyDictionary(buffer.unownedValue(at: 0), in: runtime)
|
|
345
|
+
#expect(decoded["k"] as? Bool == true)
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// MARK: - decodeAny(_:as:in:)
|
|
349
|
+
|
|
350
|
+
@Test
|
|
351
|
+
func `decodes a nested free-form type`() throws {
|
|
352
|
+
let decoded = try JavaScriptValue.decodeAny(runtime.eval("[[1], ['a', 'b']]"), as: [[Any]].self, in: runtime)
|
|
353
|
+
#expect(decoded.count == 2)
|
|
354
|
+
#expect(decoded[1].count == 2)
|
|
355
|
+
#expect(decoded[1][0] as? String == "a")
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
@Test
|
|
359
|
+
func `decodes null and undefined as nil for an optional type`() throws {
|
|
360
|
+
#expect(try JavaScriptValue.decodeAny(runtime.eval("null"), as: [String: [Any]]?.self, in: runtime) == nil)
|
|
361
|
+
#expect(try JavaScriptValue.decodeAny(runtime.eval("undefined"), as: [String: [Any]]?.self, in: runtime) == nil)
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
@Test
|
|
365
|
+
func `decodes a present value for an optional type`() throws {
|
|
366
|
+
let decoded = try JavaScriptValue.decodeAny(runtime.eval("({ a: [1] })"), as: [String: [Any]]?.self, in: runtime)
|
|
367
|
+
#expect(decoded?["a"]?.first as? Double == 1)
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
@Test
|
|
371
|
+
func `throws TypeError when the value doesn't match the type`() throws {
|
|
372
|
+
#expect(throws: JavaScriptValue.TypeError.self) {
|
|
373
|
+
try JavaScriptValue.decodeAny(runtime.eval("'text'"), as: [String: [Any]].self, in: runtime)
|
|
374
|
+
}
|
|
375
|
+
#expect(throws: JavaScriptValue.TypeError.self) {
|
|
376
|
+
try JavaScriptValue.decodeAny(runtime.eval("null"), as: [[Any]].self, in: runtime)
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
@Test
|
|
381
|
+
func `explains how free-form values decode when the cast fails`() throws {
|
|
382
|
+
let error = #expect(throws: JavaScriptValue.TypeError.self) {
|
|
383
|
+
try JavaScriptValue.decodeAny(runtime.eval("({ 1: 'one' })"), as: [Int: Any].self, in: runtime)
|
|
384
|
+
}
|
|
385
|
+
#expect(error?.description.contains("Double") == true)
|
|
386
|
+
#expect(error?.description.contains("String") == true)
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
@Test
|
|
390
|
+
func `decodes a nested null as nil for a nested optional`() throws {
|
|
391
|
+
let dictionary = try JavaScriptValue.decodeAny(
|
|
392
|
+
runtime.eval("({ a: null, b: [1] })"),
|
|
393
|
+
as: [String: [Any]?].self,
|
|
394
|
+
in: runtime
|
|
395
|
+
)
|
|
396
|
+
#expect(dictionary.keys.contains("a"))
|
|
397
|
+
#expect(dictionary["a"]! == nil)
|
|
398
|
+
#expect(dictionary["b"]??.first as? Double == 1)
|
|
399
|
+
let list = try JavaScriptValue.decodeAny(runtime.eval("[null, 1]"), as: [Any?].self, in: runtime)
|
|
400
|
+
#expect(list.count == 2)
|
|
401
|
+
#expect(list[0] == nil)
|
|
402
|
+
#expect(list[1] as? Double == 1)
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// MARK: - encodeAny
|
|
406
|
+
|
|
407
|
+
@Test
|
|
408
|
+
func `encodes native primitives`() throws {
|
|
409
|
+
#expect(try JavaScriptValue.encodeAny("abc", in: runtime).getString() == "abc")
|
|
410
|
+
#expect(try JavaScriptValue.encodeAny(1.5, in: runtime).getDouble() == 1.5)
|
|
411
|
+
#expect(try JavaScriptValue.encodeAny(true, in: runtime).getBool() == true)
|
|
412
|
+
#expect(try JavaScriptValue.encodeAny(42, in: runtime).getInt() == 42)
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
@Test
|
|
416
|
+
func `throws for an Int outside the safe-integer range, like Int.encode`() throws {
|
|
417
|
+
#expect(throws: (any Error).self) {
|
|
418
|
+
try JavaScriptValue.encodeAny(9_007_199_254_740_993, in: runtime)
|
|
419
|
+
}
|
|
420
|
+
#expect(throws: (any Error).self) {
|
|
421
|
+
try JavaScriptValue.encodeAnyDictionary(["big": 9_007_199_254_740_993], in: runtime)
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
@Test
|
|
426
|
+
func `encodes boolean NSNumbers as booleans and other NSNumbers as numbers`() throws {
|
|
427
|
+
let yes = try JavaScriptValue.encodeAny(NSNumber(value: true), in: runtime)
|
|
428
|
+
let one = try JavaScriptValue.encodeAny(NSNumber(value: 1), in: runtime)
|
|
429
|
+
#expect(yes.isBool())
|
|
430
|
+
#expect(yes.getBool() == true)
|
|
431
|
+
#expect(one.isNumber())
|
|
432
|
+
#expect(one.getDouble() == 1)
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
@Test
|
|
436
|
+
func `encodes nil and NSNull as null`() throws {
|
|
437
|
+
let none: String? = nil
|
|
438
|
+
#expect(try JavaScriptValue.encodeAny(none as Any, in: runtime).isNull())
|
|
439
|
+
#expect(try JavaScriptValue.encodeAny(NSNull(), in: runtime).isNull())
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
@Test
|
|
443
|
+
func `encodes a present optional as its wrapped value`() throws {
|
|
444
|
+
let some: Any? = "wrapped"
|
|
445
|
+
#expect(try JavaScriptValue.encodeAny(some as Any, in: runtime).getString() == "wrapped")
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
@Test
|
|
449
|
+
func `encodes nested free-form containers`() throws {
|
|
450
|
+
let value: [[Any]] = [[1, "a"], [["k": true] as [String: Any]]]
|
|
451
|
+
let encoded = try JavaScriptValue.encodeAny(value, in: runtime)
|
|
452
|
+
runtime.global().setProperty("encoded", value: encoded)
|
|
453
|
+
#expect(try runtime.eval("JSON.stringify(encoded)").getString() == #"[[1,"a"],[{"k":true}]]"#)
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
@Test
|
|
457
|
+
func `encodes JavaScriptEncodable values`() throws {
|
|
458
|
+
let date = Date(timeIntervalSince1970: 1)
|
|
459
|
+
let encoded = try JavaScriptValue.encodeAny(date, in: runtime)
|
|
460
|
+
#expect(encoded.isObject())
|
|
461
|
+
#expect(try JavaScriptValue.encodeAny([1, 2] as [Int], in: runtime).getArray().length == 2)
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
@Test
|
|
465
|
+
func `round-trips a bigint through Int64`() throws {
|
|
466
|
+
let decoded = try JavaScriptValue.decodeAny(runtime.eval("9007199254740993n"), in: runtime)
|
|
467
|
+
let encoded = try JavaScriptValue.encodeAny(decoded, in: runtime)
|
|
468
|
+
try #require(encoded.isBigInt())
|
|
469
|
+
#expect(try encoded.getBigInt().asInt64() == 9_007_199_254_740_993)
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
@Test
|
|
473
|
+
func `passes a JavaScriptValue through`() throws {
|
|
474
|
+
let object = try runtime.eval("({ marker: 7 })")
|
|
475
|
+
let encoded = try JavaScriptValue.encodeAny(object, in: runtime)
|
|
476
|
+
#expect(encoded.getObject().getProperty("marker").getInt() == 7)
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
@Test
|
|
480
|
+
func `throws for a value with no JavaScript representation`() throws {
|
|
481
|
+
struct Opaque {}
|
|
482
|
+
#expect(throws: JavaScriptValue.EncodingError.self) {
|
|
483
|
+
try JavaScriptValue.encodeAny(Opaque(), in: runtime)
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// MARK: - encodeAnyArray / encodeAnyDictionary
|
|
488
|
+
|
|
489
|
+
@Test
|
|
490
|
+
func `encodes an array of mixed values`() throws {
|
|
491
|
+
let encoded = try JavaScriptValue.encodeAnyArray([true, 2, "three", NSNull()], in: runtime)
|
|
492
|
+
runtime.global().setProperty("encoded", value: encoded)
|
|
493
|
+
#expect(try runtime.eval("JSON.stringify(encoded)").getString() == #"[true,2,"three",null]"#)
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
@Test
|
|
497
|
+
func `encodes a dictionary of mixed values`() throws {
|
|
498
|
+
let encoded = try JavaScriptValue.encodeAnyDictionary(["a": 1, "b": ["x", false] as [Any]], in: runtime)
|
|
499
|
+
let object = encoded.getObject()
|
|
500
|
+
#expect(object.getProperty("a").getInt() == 1)
|
|
501
|
+
runtime.global().setProperty("encoded", value: object.getProperty("b"))
|
|
502
|
+
#expect(try runtime.eval("JSON.stringify(encoded)").getString() == #"["x",false]"#)
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
@Test
|
|
506
|
+
func `round-trips a dictionary through decode and encode`() throws {
|
|
507
|
+
let decoded = try JavaScriptValue.decodeAnyDictionary(
|
|
508
|
+
runtime.eval("({ n: 1.5, s: 's', l: [true, null], o: { z: 0 } })"),
|
|
509
|
+
in: runtime
|
|
510
|
+
)
|
|
511
|
+
let encoded = try JavaScriptValue.encodeAnyDictionary(decoded, in: runtime)
|
|
512
|
+
runtime.global().setProperty("encoded", value: encoded)
|
|
513
|
+
#expect(
|
|
514
|
+
try runtime.eval("JSON.stringify(encoded, ['l', 'n', 'o', 's', 'z'])").getString()
|
|
515
|
+
== #"{"l":[true,null],"n":1.5,"o":{"z":0},"s":"s"}"#
|
|
516
|
+
)
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/// True when a free-form value is `nil`, looking through any optionals it's boxed in (a decoded
|
|
521
|
+
/// `null` is a `nil` boxed in `Any`, which `== nil` doesn't see).
|
|
522
|
+
private func isNil(_ value: Any) -> Bool {
|
|
523
|
+
let mirror = Mirror(reflecting: value)
|
|
524
|
+
guard mirror.displayStyle == .optional else {
|
|
525
|
+
return false
|
|
526
|
+
}
|
|
527
|
+
guard let wrapped = mirror.children.first else {
|
|
528
|
+
return true
|
|
529
|
+
}
|
|
530
|
+
return isNil(wrapped.value)
|
|
531
|
+
}
|