expo-modules-jsi 58.0.5 → 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.
Files changed (23) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/apple/Benchmarks/FreeFormBenchmarks.swift +61 -0
  3. package/apple/Benchmarks/HostObjectBenchmarks.swift +57 -0
  4. package/apple/Sources/ExpoModulesJSI/Coding/JavaScriptCodable+Any.swift +500 -0
  5. package/apple/Sources/ExpoModulesJSI/Coding/JavaScriptCodable+Primitives.swift +4 -6
  6. package/apple/Sources/ExpoModulesJSI/Protocols/JSIRepresentable.swift +2 -2
  7. package/apple/Sources/ExpoModulesJSI/Protocols/JavaScriptRepresentable.swift +2 -3
  8. package/apple/Sources/ExpoModulesJSI/Runtime/JavaScriptRuntime.swift +24 -17
  9. package/apple/Sources/ExpoModulesJSI/Runtime/JavaScriptRuntimeHandle.swift +67 -0
  10. package/apple/Sources/ExpoModulesJSI/Runtime/JavaScriptValuesBuffer.swift +3 -2
  11. package/apple/Sources/ExpoModulesJSI/Runtime/Values/JavaScriptArray.swift +53 -35
  12. package/apple/Sources/ExpoModulesJSI/Runtime/Values/JavaScriptFunction.swift +2 -2
  13. package/apple/Sources/ExpoModulesJSI/Runtime/Values/JavaScriptObject.swift +86 -58
  14. package/apple/Sources/ExpoModulesJSI/Runtime/Values/JavaScriptValue.swift +73 -47
  15. package/apple/Sources/ExpoModulesJSI/Utilities/String+JSI.swift +10 -0
  16. package/apple/Sources/ExpoModulesJSI-Cxx/include/HostObject.h +2 -2
  17. package/apple/Sources/ExpoModulesJSI-Cxx/include/HostObjectCallbacks.h +17 -8
  18. package/apple/Sources/ExpoModulesJSI-Cxx/include/JSIUtils.h +20 -0
  19. package/apple/Tests/JavaScriptCodableAnyTests.swift +531 -0
  20. package/apple/Tests/JavaScriptRuntimeTests.swift +40 -0
  21. package/apple/Tests/JavaScriptValueTests.swift +18 -0
  22. package/apple/Tests/JavaScriptValuesBufferTests.swift +14 -0
  23. package/package.json +1 -1
@@ -4,6 +4,7 @@
4
4
 
5
5
  #include <swift/bridging>
6
6
  #include <jsi/jsi.h>
7
+ #include <string>
7
8
 
8
9
  #include "RetainedSwiftPointer.h"
9
10
 
@@ -20,16 +21,24 @@ public:
20
21
  // Swift side can fill the caller's slot from inside its guaranteed-reference scope.
21
22
  // Both return whether the Swift side stored an error in `CppError`'s thread-local slot, so the
22
23
  // caller reads that slot only when needed.
23
- using Getter = bool(Context, const char *_Nonnull name, facebook::jsi::Value *_Nonnull result);
24
- using Setter = bool(Context, const char *_Nonnull name, void *_Nonnull value);
24
+ using Getter = bool(Context, const facebook::jsi::PropNameID *_Nonnull name, facebook::jsi::Value *_Nonnull result);
25
+ using Setter = bool(Context, const facebook::jsi::PropNameID *_Nonnull name, void *_Nonnull value);
25
26
  using PropertyNamesGetter = PropNameIds(Context);
26
27
  using Deallocator = void(Context);
27
28
 
28
- explicit HostObjectCallbacks(Context context, Getter getter, Setter *_Nullable setter, PropertyNamesGetter propertyNamesGetter, Deallocator deallocator)
29
+ explicit HostObjectCallbacks(Context context, Getter getter, Setter setter, PropertyNamesGetter propertyNamesGetter, Deallocator deallocator)
29
30
  : _context(context), _getter(getter), _setter(setter), _propertyNamesGetter(propertyNamesGetter), _deallocator(deallocator) {}
30
31
 
31
- inline bool get(const char *_Nonnull name, facebook::jsi::Value &result) const {
32
- return _getter(_context, name, &result);
32
+ /**
33
+ Creates callbacks for a read-only host object: assignment from JavaScript throws a `jsi::JSError`
34
+ without calling into Swift. A separate constructor rather than a nullable setter, because Swift can't
35
+ pass a function reference where an optional C function pointer is expected.
36
+ */
37
+ explicit HostObjectCallbacks(Context context, Getter getter, PropertyNamesGetter propertyNamesGetter, Deallocator deallocator)
38
+ : _context(context), _getter(getter), _setter(nullptr), _propertyNamesGetter(propertyNamesGetter), _deallocator(deallocator) {}
39
+
40
+ inline bool get(const facebook::jsi::PropNameID &name, facebook::jsi::Value &result) const {
41
+ return _getter(_context, &name, &result);
33
42
  }
34
43
 
35
44
  /**
@@ -41,16 +50,16 @@ public:
41
50
  JSI call frame above this on the stack; calling outside that context will surface
42
51
  the throw as an unhandled C++ exception.
43
52
  */
44
- inline bool set(facebook::jsi::Runtime &runtime, const char *_Nonnull name, const facebook::jsi::Value &value) const {
53
+ inline bool set(facebook::jsi::Runtime &runtime, const facebook::jsi::PropNameID &name, const facebook::jsi::Value &value) const {
45
54
  if (_setter == nullptr) {
46
55
  throw facebook::jsi::JSError(
47
56
  runtime,
48
- std::string("Cannot set property '") + name + "' on a read-only host object: "
57
+ std::string("Cannot set property '") + name.utf8(runtime) + "' on a read-only host object: "
49
58
  "no setter was provided when the host object was created. "
50
59
  "Pass a `set` closure to `createHostObject` to make this property writable."
51
60
  );
52
61
  }
53
- return _setter(_context, name, (void *)(&value));
62
+ return _setter(_context, &name, (void *)(&value));
54
63
  }
55
64
 
56
65
  inline PropNameIds getPropertyNames() const {
@@ -228,6 +228,26 @@ inline void getStringData(
228
228
  #endif
229
229
  }
230
230
 
231
+ /**
232
+ Same as `getStringData`, but for a `jsi::PropNameID`.
233
+ */
234
+ inline void getPropNameIdData(
235
+ jsi::IRuntime &runtime,
236
+ const jsi::PropNameID &propNameId,
237
+ void *ctx,
238
+ void (*callback)(void *_Nullable ctx, bool ascii, const void *_Nullable data, size_t num)
239
+ ) {
240
+ #if defined(REACT_NATIVE_VERSION_MAJOR) && defined(REACT_NATIVE_VERSION_MINOR) && \
241
+ (REACT_NATIVE_VERSION_MAJOR > 0 || REACT_NATIVE_VERSION_MINOR >= 86)
242
+ runtime.getPropNameIdData(propNameId, ctx, callback);
243
+ #else
244
+ auto forward = [ctx, callback](bool ascii, const void *data, size_t num) {
245
+ callback(ctx, ascii, data, num);
246
+ };
247
+ propNameId.getPropNameIdData(runtime, forward);
248
+ #endif
249
+ }
250
+
231
251
  // MARK: - ArrayBuffer
232
252
 
233
253
  /**
@@ -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
+ }
@@ -280,6 +280,46 @@ struct JavaScriptRuntimeTests {
280
280
  #expect(result.getString() == "hello")
281
281
  }
282
282
 
283
+ @Test
284
+ func `host object receives non-ASCII property names`() throws {
285
+ var receivedNames: [String] = []
286
+ var setNames: [String] = []
287
+
288
+ let hostObject = runtime.createHostObject(
289
+ get: { name in
290
+ receivedNames.append(name)
291
+ return .undefined
292
+ },
293
+ set: { name, _ in
294
+ setNames.append(name)
295
+ }
296
+ )
297
+
298
+ runtime.global().setProperty("hostObj", value: hostObject.asValue())
299
+ _ = try runtime.eval("globalThis.hostObj['właściwość']; globalThis.hostObj['🚀'] = 1")
300
+
301
+ #expect(receivedNames == ["właściwość"])
302
+ #expect(setNames == ["🚀"])
303
+ }
304
+
305
+ @Test
306
+ func `host object receives long property names`() throws {
307
+ var receivedName: String?
308
+ let longName = String(repeating: "a", count: 300)
309
+
310
+ let hostObject = runtime.createHostObject(
311
+ get: { name in
312
+ receivedName = name
313
+ return .undefined
314
+ }
315
+ )
316
+
317
+ runtime.global().setProperty("hostObj", value: hostObject.asValue())
318
+ _ = try runtime.eval("globalThis.hostObj['\(longName)']")
319
+
320
+ #expect(receivedName == longName)
321
+ }
322
+
283
323
  @Test
284
324
  func `isHostObject distinguishes host objects from plain ones`() {
285
325
  let hostObject = runtime.createHostObject(
@@ -726,3 +726,21 @@ struct JavaScriptValueTests {
726
726
  #expect(pointee1 == pointee2)
727
727
  }
728
728
  }
729
+
730
+ @Suite
731
+ @JavaScriptActor
732
+ struct JavaScriptValueRuntimeLifetimeTests {
733
+ @Test
734
+ func `runtime-free value kinds keep working after the runtime is gone`() throws {
735
+ var runtime: JavaScriptRuntime? = JavaScriptRuntime()
736
+ let bool = JavaScriptValue(runtime!, true)
737
+
738
+ // Values don't keep the runtime alive, so this deallocates it.
739
+ runtime = nil
740
+
741
+ // A boolean doesn't need the runtime, so these take the runtime-free paths instead of trapping.
742
+ #expect(try bool.jsonStringify() == "true")
743
+ #expect(bool.copy().getBool() == true)
744
+ #expect(bool.isEqual(to: .true()))
745
+ }
746
+ }