expo-modules-jsi 58.0.8 → 58.0.9

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 CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 58.0.9
4
+
5
+ ### Patch Changes
6
+
7
+ - [iOS] Add a `JavaScriptCodable` conformance for `Set`: it encodes to a JS `Set` and decodes from a JS `Set` or an array, collapsing duplicate elements. ([#50859](https://github.com/expo/expo/pull/50859) by [@tsapeta](https://github.com/tsapeta))
8
+ - `JavaScriptRuntime.evalAsync` now evaluates on the JavaScript thread when called from another thread on a runtime with a scheduler, instead of on the calling thread. ([#51015](https://github.com/expo/expo/pull/51015) by [@tsapeta](https://github.com/tsapeta))
9
+ - [iOS] Speed up `JavaScriptObject.defineProperty` by looking up `Object.defineProperty` once per runtime and building the descriptor in C++. ([#51004](https://github.com/expo/expo/pull/51004) by [@tsapeta](https://github.com/tsapeta))
10
+ - [iOS] Fix the xcframework failing to build with Xcode 26 (Swift 6.2): `RuntimeScheduler` constructors annotated with `SWIFT_RETURNS_RETAINED` were rejected, and host function and host object getter callbacks failed with `sending '...' risks causing data races`. ([#51040](https://github.com/expo/expo/pull/51040) by [@tsapeta](https://github.com/tsapeta))
11
+ - [iOS] Speed up integer arguments and results of native functions by running the integer conversions specialized inside `ExpoModulesJSI` instead of unspecialized in the calling module. A host function that adds two `Int`s runs about 7× faster. ([#51055](https://github.com/expo/expo/pull/51055) by [@tsapeta](https://github.com/tsapeta))
12
+
3
13
  ## 58.0.8
4
14
 
5
15
  ### Patch Changes
@@ -0,0 +1,74 @@
1
+ // Copyright 2026-present 650 Industries. All rights reserved.
2
+
3
+ import ExpoModulesJSI
4
+ import Testing
5
+
6
+ /// Benchmarks comparing the `Array` and `Set` `JavaScriptCodable` conformances. `Set` decoding from a
7
+ /// JS array shares the `Array` fast path and adds only the hashing, while decoding from a JS `Set` and
8
+ /// encoding to one go through the global `Array.from` and `Set` constructor, so each case is measured
9
+ /// against its `Array` counterpart at a small and a large element count.
10
+ extension JSIBenchmarks {
11
+ // MARK: - Decode
12
+
13
+ @Test(arguments: [10, 1000])
14
+ func `decode an array of ints as Array`(count: Int) async throws {
15
+ try await benchmarkCase { runtime in
16
+ let value = try runtime.eval("Array.from({ length: \(count) }, (_, i) => i)")
17
+ try benchmark("[Int].decode: JS array of \(count)", runtime: runtime) { iterations in
18
+ for _ in 0..<iterations {
19
+ _ = try [Int].decode(value, in: runtime)
20
+ }
21
+ }
22
+ }
23
+ }
24
+
25
+ @Test(arguments: [10, 1000])
26
+ func `decode an array of ints as Set`(count: Int) async throws {
27
+ try await benchmarkCase { runtime in
28
+ let value = try runtime.eval("Array.from({ length: \(count) }, (_, i) => i)")
29
+ try benchmark("Set<Int>.decode: JS array of \(count)", runtime: runtime) { iterations in
30
+ for _ in 0..<iterations {
31
+ _ = try Set<Int>.decode(value, in: runtime)
32
+ }
33
+ }
34
+ }
35
+ }
36
+
37
+ @Test(arguments: [10, 1000])
38
+ func `decode a set of ints as Set`(count: Int) async throws {
39
+ try await benchmarkCase { runtime in
40
+ let value = try runtime.eval("new Set(Array.from({ length: \(count) }, (_, i) => i))")
41
+ try benchmark("Set<Int>.decode: JS Set of \(count)", runtime: runtime) { iterations in
42
+ for _ in 0..<iterations {
43
+ _ = try Set<Int>.decode(value, in: runtime)
44
+ }
45
+ }
46
+ }
47
+ }
48
+
49
+ // MARK: - Encode
50
+
51
+ @Test(arguments: [10, 1000])
52
+ func `encode an Array of ints`(count: Int) async throws {
53
+ try await benchmarkCase { runtime in
54
+ let array = Array(0..<count)
55
+ try benchmark("[Int].encode: \(count) elements", runtime: runtime) { iterations in
56
+ for _ in 0..<iterations {
57
+ _ = try [Int].encode(array, in: runtime)
58
+ }
59
+ }
60
+ }
61
+ }
62
+
63
+ @Test(arguments: [10, 1000])
64
+ func `encode a Set of ints`(count: Int) async throws {
65
+ try await benchmarkCase { runtime in
66
+ let set = Set(0..<count)
67
+ try benchmark("Set<Int>.encode: \(count) elements", runtime: runtime) { iterations in
68
+ for _ in 0..<iterations {
69
+ _ = try Set<Int>.encode(set, in: runtime)
70
+ }
71
+ }
72
+ }
73
+ }
74
+ }
@@ -87,6 +87,47 @@ extension JSIBenchmarks {
87
87
  }
88
88
  }
89
89
 
90
+ // The integer codables, as bindings call them: `Int.decode` for an `Int` argument and `Int?.decode`
91
+ // for an optional one.
92
+
93
+ @Test
94
+ func `host function adding two Ints`() async throws {
95
+ try await benchmarkCase { runtime in
96
+ let fn = runtime.createFunction("add") {
97
+ [runtime] (this: borrowing JavaScriptUnownedValue, arguments: consuming JavaScriptValuesBuffer) in
98
+ let a = try Int.decode(arguments.unownedValue(at: 0), in: runtime)
99
+ let b = try Int.decode(arguments.unownedValue(at: 1), in: runtime)
100
+ return try Int.encode(a + b, in: runtime)
101
+ }
102
+ runtime.global().setProperty("benchFn", value: fn)
103
+ let driver = try runtime.eval(
104
+ "(function(n) { for (var i = 0; i < n; i++) benchFn(3, 38); })"
105
+ ).getFunction()
106
+ try benchmark("host function: add two Ints", runtime: runtime) { iterations in
107
+ _ = try driver.call(arguments: iterations)
108
+ }
109
+ }
110
+ }
111
+
112
+ @Test
113
+ func `host function adding two optional Ints`() async throws {
114
+ try await benchmarkCase { runtime in
115
+ let fn = runtime.createFunction("add") {
116
+ [runtime] (this: borrowing JavaScriptUnownedValue, arguments: consuming JavaScriptValuesBuffer) in
117
+ let a = try Int?.decode(arguments.unownedValue(at: 0), in: runtime) ?? 0
118
+ let b = try Int?.decode(arguments.unownedValue(at: 1), in: runtime) ?? 0
119
+ return try Int.encode(a + b, in: runtime)
120
+ }
121
+ runtime.global().setProperty("benchFn", value: fn)
122
+ let driver = try runtime.eval(
123
+ "(function(n) { for (var i = 0; i < n; i++) benchFn(3, 38); })"
124
+ ).getFunction()
125
+ try benchmark("host function: add two optional Ints", runtime: runtime) { iterations in
126
+ _ = try driver.call(arguments: iterations)
127
+ }
128
+ }
129
+ }
130
+
90
131
  // MARK: - Unowned-this form with string arguments
91
132
 
92
133
  @Test
@@ -129,4 +129,27 @@ extension JSIBenchmarks {
129
129
  }
130
130
  }
131
131
  }
132
+
133
+ @Test
134
+ func `define a property`() async throws {
135
+ try await benchmarkCase { runtime in
136
+ try benchmark("JavaScriptObject.defineProperty(_:value:options:)", runtime: runtime) { iterations in
137
+ for index in 0..<iterations {
138
+ let object = runtime.createObject()
139
+ object.defineProperty("__expo_shared_object_id__", value: index, options: [.writable])
140
+ }
141
+ }
142
+ }
143
+ }
144
+
145
+ @Test
146
+ func `create an object`() async throws {
147
+ try await benchmarkCase { runtime in
148
+ try benchmark("JavaScriptRuntime.createObject()", runtime: runtime) { iterations in
149
+ for _ in 0..<iterations {
150
+ _ = runtime.createObject()
151
+ }
152
+ }
153
+ }
154
+ }
132
155
  }
@@ -482,13 +482,134 @@ extension UInt64: JavaScriptCodable {
482
482
  }
483
483
  }
484
484
 
485
+ // MARK: - Integer conversion entry points
486
+
487
+ // The inlinable integer witnesses call these non-generic overloads rather than the generic
488
+ // implementations below. A client can devirtualize a witness and inline it only when its body calls
489
+ // non-generic functions; through a generic, non-inlinable call, it falls back to dynamic dispatch, and a
490
+ // direct call runs the generic implementation unspecialized. Each overload runs a copy of the generic
491
+ // implementation specialized inside the framework.
492
+
493
+ @usableFromInline
494
+ @JavaScriptActor
495
+ func decodeInteger(_ number: Double, as type: Int8.Type) throws -> Int8 {
496
+ return try genericDecodeInteger(number, as: Int8.self)
497
+ }
498
+
499
+ @usableFromInline
500
+ @JavaScriptActor
501
+ func decodeInteger(_ number: Double, as type: Int16.Type) throws -> Int16 {
502
+ return try genericDecodeInteger(number, as: Int16.self)
503
+ }
504
+
505
+ @usableFromInline
506
+ @JavaScriptActor
507
+ func decodeInteger(_ number: Double, as type: Int32.Type) throws -> Int32 {
508
+ return try genericDecodeInteger(number, as: Int32.self)
509
+ }
510
+
511
+ @usableFromInline
512
+ @JavaScriptActor
513
+ func decodeInteger(_ number: Double, as type: UInt8.Type) throws -> UInt8 {
514
+ return try genericDecodeInteger(number, as: UInt8.self)
515
+ }
516
+
517
+ @usableFromInline
518
+ @JavaScriptActor
519
+ func decodeInteger(_ number: Double, as type: UInt16.Type) throws -> UInt16 {
520
+ return try genericDecodeInteger(number, as: UInt16.self)
521
+ }
522
+
523
+ @usableFromInline
524
+ @JavaScriptActor
525
+ func decodeInteger(_ number: Double, as type: UInt32.Type) throws -> UInt32 {
526
+ return try genericDecodeInteger(number, as: UInt32.self)
527
+ }
528
+
529
+ @usableFromInline
530
+ @JavaScriptActor
531
+ func decodeWideInteger(_ value: borrowing JavaScriptValue, as type: Int.Type) throws -> Int {
532
+ return try genericDecodeWideInteger(value, as: Int.self)
533
+ }
534
+
535
+ @usableFromInline
536
+ @JavaScriptActor
537
+ func decodeWideInteger(
538
+ _ value: borrowing JavaScriptUnownedValue,
539
+ as type: Int.Type,
540
+ runtime: borrowing JavaScriptRuntime
541
+ ) throws -> Int {
542
+ return try genericDecodeWideInteger(value, as: Int.self, runtime: runtime)
543
+ }
544
+
545
+ @usableFromInline
546
+ @JavaScriptActor
547
+ func decodeWideInteger(_ value: borrowing JavaScriptValue, as type: Int64.Type) throws -> Int64 {
548
+ return try genericDecodeWideInteger(value, as: Int64.self)
549
+ }
550
+
551
+ @usableFromInline
552
+ @JavaScriptActor
553
+ func decodeWideInteger(
554
+ _ value: borrowing JavaScriptUnownedValue,
555
+ as type: Int64.Type,
556
+ runtime: borrowing JavaScriptRuntime
557
+ ) throws -> Int64 {
558
+ return try genericDecodeWideInteger(value, as: Int64.self, runtime: runtime)
559
+ }
560
+
561
+ @usableFromInline
562
+ @JavaScriptActor
563
+ func decodeWideInteger(_ value: borrowing JavaScriptValue, as type: UInt.Type) throws -> UInt {
564
+ return try genericDecodeWideInteger(value, as: UInt.self)
565
+ }
566
+
567
+ @usableFromInline
568
+ @JavaScriptActor
569
+ func decodeWideInteger(
570
+ _ value: borrowing JavaScriptUnownedValue,
571
+ as type: UInt.Type,
572
+ runtime: borrowing JavaScriptRuntime
573
+ ) throws -> UInt {
574
+ return try genericDecodeWideInteger(value, as: UInt.self, runtime: runtime)
575
+ }
576
+
577
+ @usableFromInline
578
+ @JavaScriptActor
579
+ func decodeWideInteger(_ value: borrowing JavaScriptValue, as type: UInt64.Type) throws -> UInt64 {
580
+ return try genericDecodeWideInteger(value, as: UInt64.self)
581
+ }
582
+
583
+ @usableFromInline
584
+ @JavaScriptActor
585
+ func decodeWideInteger(
586
+ _ value: borrowing JavaScriptUnownedValue,
587
+ as type: UInt64.Type,
588
+ runtime: borrowing JavaScriptRuntime
589
+ ) throws -> UInt64 {
590
+ return try genericDecodeWideInteger(value, as: UInt64.self, runtime: runtime)
591
+ }
592
+
593
+ @usableFromInline
594
+ @JavaScriptActor
595
+ func encodeSafeInteger(_ value: Int) throws -> JavaScriptValue {
596
+ return try genericEncodeSafeInteger(value)
597
+ }
598
+
599
+ @usableFromInline
600
+ @JavaScriptActor
601
+ func encodeSafeInteger(_ value: UInt) throws -> JavaScriptValue {
602
+ return try genericEncodeSafeInteger(value)
603
+ }
604
+
605
+ // MARK: - Generic integer conversions
606
+
485
607
  /// Rounds a JavaScript number to the nearest integer and narrows it to `T`, throwing instead of
486
608
  /// trapping when the value is non-finite or outside `T`'s representable range. `T(exactly:)` on the
487
609
  /// already-rounded value is nil only when out of range, sidestepping the lossy `Double(T.max)`
488
610
  /// boundary comparison for 64-bit widths.
489
- @usableFromInline
490
611
  @JavaScriptActor
491
- func decodeInteger<T: FixedWidthInteger>(_ number: Double, as type: T.Type) throws -> T {
612
+ func genericDecodeInteger<T: FixedWidthInteger>(_ number: Double, as type: T.Type) throws -> T {
492
613
  guard number.isFinite, let result = T(exactly: number.rounded()) else {
493
614
  throw IntegerOutOfRangeException(value: number, type: "\(T.self)")
494
615
  }
@@ -499,47 +620,41 @@ func decodeInteger<T: FixedWidthInteger>(_ number: Double, as type: T.Type) thro
499
620
  /// case is checked first since it's the common one: a single `isNumber()` tag check, then the
500
621
  /// assert-only `getDouble()` reads the value without re-checking the tag. A `bigint` is read losslessly
501
622
  /// through its 64-bit accessor; anything else throws the same `TypeError` the `number` read would.
502
- @usableFromInline
503
623
  @JavaScriptActor
504
- func decodeWideInteger<T: FixedWidthInteger>(_ value: borrowing JavaScriptValue, as type: T.Type) throws -> T {
624
+ func genericDecodeWideInteger<T: FixedWidthInteger>(_ value: borrowing JavaScriptValue, as type: T.Type) throws -> T {
505
625
  guard value.isNumber() else {
506
626
  guard value.isBigInt() else {
507
- // Neither a number nor a bigint: `asDouble()` throws the canonical `TypeError`. Throwing it
508
- // through the accessor (rather than constructing it here) keeps this inlinable helper from
509
- // referencing `TypeError`'s internal memberwise initializer.
510
- return try decodeInteger(value.asDouble(), as: T.self)
627
+ // Neither a number nor a bigint: `asDouble()` throws the canonical `TypeError`.
628
+ return try genericDecodeInteger(value.asDouble(), as: T.self)
511
629
  }
512
- return try decodeBigInt(value.getBigInt(), as: T.self)
630
+ return try genericDecodeBigInt(value.getBigInt(), as: T.self)
513
631
  }
514
- return try decodeInteger(value.getDouble(), as: T.self)
632
+ return try genericDecodeInteger(value.getDouble(), as: T.self)
515
633
  }
516
634
 
517
635
  /// `JavaScriptUnownedValue` overload of `decodeWideInteger`. The common `number` path stays zero-copy
518
636
  /// and pays a single tag check (see the owning overload); only a `bigint` materializes an owning value
519
637
  /// (the borrowed value exposes no BigInt accessor), which is acceptable on this rare branch.
520
- @usableFromInline
521
638
  @JavaScriptActor
522
- func decodeWideInteger<T: FixedWidthInteger>(
639
+ func genericDecodeWideInteger<T: FixedWidthInteger>(
523
640
  _ value: borrowing JavaScriptUnownedValue,
524
641
  as type: T.Type,
525
642
  runtime: borrowing JavaScriptRuntime
526
643
  ) throws -> T {
527
644
  guard value.isNumber() else {
528
645
  guard value.isBigInt() else {
529
- // See the owning overload: route the not-a-number throw through `asDouble()` rather than
530
- // constructing `TypeError` here, which this inlinable helper can't reference.
531
- return try decodeInteger(value.asDouble(), as: T.self)
646
+ // Neither a number nor a bigint: `asDouble()` throws the canonical `TypeError`.
647
+ return try genericDecodeInteger(value.asDouble(), as: T.self)
532
648
  }
533
- return try decodeBigInt(value.copied(in: runtime).getBigInt(), as: T.self)
649
+ return try genericDecodeBigInt(value.copied(in: runtime).getBigInt(), as: T.self)
534
650
  }
535
- return try decodeInteger(value.getDouble(), as: T.self)
651
+ return try genericDecodeInteger(value.getDouble(), as: T.self)
536
652
  }
537
653
 
538
654
  /// Narrows a `JavaScriptBigInt` to `T`, reading it through the signed or unsigned 64-bit accessor to
539
655
  /// match `T`'s signedness and throwing rather than truncating when it falls outside `T`'s range.
540
- @usableFromInline
541
656
  @JavaScriptActor
542
- func decodeBigInt<T: FixedWidthInteger>(_ bigInt: borrowing JavaScriptBigInt, as type: T.Type) throws -> T {
657
+ func genericDecodeBigInt<T: FixedWidthInteger>(_ bigInt: borrowing JavaScriptBigInt, as type: T.Type) throws -> T {
543
658
  let wide: T? =
544
659
  if T.isSigned {
545
660
  bigInt.isInt64() ? T(exactly: bigInt.getInt64()) : nil
@@ -555,9 +670,8 @@ func decodeBigInt<T: FixedWidthInteger>(_ bigInt: borrowing JavaScriptBigInt, as
555
670
  /// Encodes a 64-bit-wide integer to a JS `number`, throwing when it falls outside JavaScript's
556
671
  /// safe-integer range where a `Double` could no longer represent it exactly. Used by `Int`/`UInt`,
557
672
  /// whose JS mapping stays a `number`; the explicitly-sized 64-bit types encode as a `bigint` instead.
558
- @usableFromInline
559
673
  @JavaScriptActor
560
- func encodeSafeInteger<T: FixedWidthInteger>(_ value: T) throws -> JavaScriptValue {
674
+ func genericEncodeSafeInteger<T: FixedWidthInteger>(_ value: T) throws -> JavaScriptValue {
561
675
  // `Number.MAX_SAFE_INTEGER` (2^53 - 1): the largest magnitude where every integer up to it, and the
562
676
  // next one, is representable as a JS number. 2^53 itself is representable but unsafe (it collides with
563
677
  // 2^53 + 1), so the bound is exclusive above this. Used by `Int`/`UInt`, both 64-bit, so 2^53 fits.
@@ -0,0 +1,79 @@
1
+ // Copyright 2026-present 650 Industries. All rights reserved.
2
+
3
+ internal import ExpoModulesJSI_Cxx
4
+ internal import jsi
5
+
6
+ // `Set` encodes to a JS `Set` and decodes from a JS `Set` or, like `Array`, from an array or an arrayized
7
+ // scalar. JSI has no `Set` API, so the conversions go through the runtime's global `Set` and `Array`
8
+ // constructors. Equality differs between the two sides: a JS `Set` compares objects by reference while
9
+ // a Swift `Set` compares by `Hashable`, so two distinct JS objects that decode to equal values collapse
10
+ // into a single element.
11
+
12
+ extension Set: JavaScriptDecodable where Element: JavaScriptDecodable {
13
+ @JavaScriptActor
14
+ @inlinable
15
+ public static func decode(_ value: borrowing JavaScriptValue, in runtime: borrowing JavaScriptRuntime) throws
16
+ -> Set<Element>
17
+ {
18
+ // The cheap tag checks come first: an array takes the `Array` path directly, and a primitive
19
+ // can't be a JS `Set`, so both skip the JS call below. Any other value that is not a JS `Set` is
20
+ // arrayized, as in `Array`. Duplicates in an array collapse without an error.
21
+ guard !value.isArray(), value.isObject(), let entries = try runtime.setEntries(of: value) else {
22
+ return Set(try [Element].decode(value, in: runtime))
23
+ }
24
+ return Set(try [Element].decode(entries, in: runtime))
25
+ }
26
+ }
27
+
28
+ extension Set: JavaScriptEncodable where Element: JavaScriptEncodable {
29
+ @JavaScriptActor
30
+ @inlinable
31
+ public static func encode(_ value: Set<Element>, in runtime: borrowing JavaScriptRuntime) throws
32
+ -> JavaScriptValue
33
+ {
34
+ // Constructing from an array fills the set in a single call, rather than one `add` call per element.
35
+ let entries = try [Element].encode(Array(value), in: runtime)
36
+ return try runtime.setConstructor().callAsConstructor(entries)
37
+ }
38
+ }
39
+
40
+ extension JavaScriptRuntime {
41
+ /// Copies the entries of a JS `Set` out into a JS array, or returns `nil` when the value is not a
42
+ /// JS `Set`. The instance check and the copy run in a single call to a cached JS function, which
43
+ /// saves the global lookups and the separate `instanceof` call of `value.is("Set")` followed by
44
+ /// `Array.from`. For a small `Set` those fixed costs are most of the decode time.
45
+ @usableFromInline
46
+ @JavaScriptActor
47
+ func setEntries(of value: borrowing JavaScriptValue) throws -> JavaScriptValue? {
48
+ let function = try cached(setEntriesFunctionKey) {
49
+ return try eval(
50
+ label: "expo-modules-jsi/set-entries.js",
51
+ "(function (value) { return value instanceof Set ? Array.from(value) : undefined; })"
52
+ )
53
+ }
54
+ let setEntries = function.getFunction()
55
+ // Pass the borrowed `jsi::Value` straight to the call, rather than copying it into an owned value
56
+ // and again into an arguments buffer.
57
+ let entries = try withUnsafePointer(to: value.pointee) { argument in
58
+ return try capturingCppErrors {
59
+ return JavaScriptValue(self, expo.callFunction(pointee, setEntries.pointee, argument, 1))
60
+ }
61
+ }
62
+ return entries.isUndefined() ? nil : entries
63
+ }
64
+
65
+ /// Returns the global `Set` constructor, looked up once and cached, so an encode doesn't pay for
66
+ /// the global property lookup.
67
+ @usableFromInline
68
+ @JavaScriptActor
69
+ func setConstructor() throws -> JavaScriptFunction {
70
+ let constructor = try cached(setConstructorKey) {
71
+ return try global().getPropertyAsFunction("Set").asValue()
72
+ }
73
+ return constructor.getFunction()
74
+ }
75
+ }
76
+
77
+ /// Keys of the `Set` entries function and the `Set` constructor in each runtime's cache.
78
+ private let setEntriesFunctionKey = JavaScriptRuntime.Cache.Key<JavaScriptValue>()
79
+ private let setConstructorKey = JavaScriptRuntime.Cache.Key<JavaScriptValue>()
@@ -67,7 +67,7 @@ open class JavaScriptRuntime: Equatable, Identifiable, @unchecked Sendable {
67
67
  self.runtimePointee = runtime
68
68
  self.pointee = expo.iruntime(runtime)
69
69
  self.handle = JavaScriptRuntimeHandle(self.pointee)
70
- self.scheduler = expo.RuntimeScheduler()
70
+ self.scheduler = expo.RuntimeScheduler.create()
71
71
  self.ownsRuntime = false
72
72
  handle.attach(self)
73
73
  installLongLivedObjectsTeardown()
@@ -80,7 +80,7 @@ open class JavaScriptRuntime: Equatable, Identifiable, @unchecked Sendable {
80
80
  self.runtimePointee = runtime
81
81
  self.pointee = expo.iruntime(runtime)
82
82
  self.handle = JavaScriptRuntimeHandle(self.pointee)
83
- self.scheduler = expo.RuntimeScheduler()
83
+ self.scheduler = expo.RuntimeScheduler.create()
84
84
  self.ownsRuntime = true
85
85
  handle.attach(self)
86
86
  installLongLivedObjectsTeardown()
@@ -94,7 +94,7 @@ open class JavaScriptRuntime: Equatable, Identifiable, @unchecked Sendable {
94
94
  self.runtimePointee = runtime
95
95
  self.pointee = expo.iruntime(runtime)
96
96
  self.handle = JavaScriptRuntimeHandle(self.pointee)
97
- self.scheduler = expo.RuntimeScheduler()
97
+ self.scheduler = expo.RuntimeScheduler.create()
98
98
  self.ownsRuntime = false
99
99
  handle.attach(self)
100
100
  installLongLivedObjectsTeardown()
@@ -123,7 +123,7 @@ open class JavaScriptRuntime: Equatable, Identifiable, @unchecked Sendable {
123
123
  self.runtimePointee = runtime
124
124
  self.pointee = expo.iruntime(runtime)
125
125
  self.handle = JavaScriptRuntimeHandle(self.pointee)
126
- self.scheduler = expo.RuntimeScheduler(scheduler, fn)
126
+ self.scheduler = expo.RuntimeScheduler.create(scheduler, fn)
127
127
  self.ownsRuntime = false
128
128
  handle.attach(self)
129
129
  installLongLivedObjectsTeardown()
@@ -197,14 +197,14 @@ open class JavaScriptRuntime: Equatable, Identifiable, @unchecked Sendable {
197
197
  propertyName: UnsafePointer<facebook.jsi.PropNameID>,
198
198
  resultPtr: UnsafeMutablePointer<facebook.jsi.Value>
199
199
  ) -> Bool {
200
- nonisolated(unsafe) let resultPtr = resultPtr
200
+ let resultPtr = UncheckedSendable(resultPtr)
201
201
 
202
202
  return withGuaranteedContext(context) { (context: HostObjectContext, runtime) in
203
203
  let propertyName = String(jsiPropNameID: propertyName.pointee, in: runtime.pointee)
204
204
  return JavaScriptActor.assumeIsolated {
205
205
  return forwardingSwiftErrorsToJS(runtime: runtime) {
206
206
  var result = try context.get(propertyName)
207
- JavaScriptValue.write(&result, to: resultPtr)
207
+ JavaScriptValue.write(&result, to: resultPtr.value)
208
208
  }
209
209
  }
210
210
  }
@@ -693,8 +693,15 @@ open class JavaScriptRuntime: Equatable, Identifiable, @unchecked Sendable {
693
693
  @discardableResult
694
694
  @JavaScriptActor
695
695
  public func evalAsync(label: String? = nil, _ source: String) async throws -> JavaScriptValue {
696
- let result = try eval(label: label, source)
697
- return result.is("Promise") ? try await result.getPromise().await() : result
696
+ // `@JavaScriptActor` runs this on the caller's thread, so go through `execute` to evaluate on the
697
+ // JavaScript thread. It runs the closure in place when already there, or when the runtime has no
698
+ // scheduler and thus no other thread to go to.
699
+ // The result is boxed because `execute` needs a `Sendable` result and `JavaScriptValue` is not one.
700
+ let result = try await execute { () async throws -> NonisolatedUnsafeVar<JavaScriptValue> in
701
+ let value = try self.eval(label: label, source)
702
+ return NonisolatedUnsafeVar(value.is("Promise") ? try await value.getPromise().await() : value)
703
+ }
704
+ return result.value
698
705
  }
699
706
 
700
707
  // MARK: - Garbage collection
@@ -871,23 +878,23 @@ private func createFunctionClosure(
871
878
  // heap-allocated `JavaScriptRef` (Swift 6.2 rejects capturing/consuming a `~Copyable` value in the
872
879
  // escaping closure that `withoutActuallyEscaping` synthesizes), the closure constructs the buffer
873
880
  // locally from the raw pointer + count. Those are read-only call-scoped inputs that never outlive the
874
- // synchronous call, so the `nonisolated(unsafe)` capture is sound. This removes a per-call class
881
+ // synchronous call, so capturing them through `UncheckedSendable` is sound. This removes a per-call class
875
882
  // allocation + retain/release + dealloc that profiling showed dominating the no-op `@JS` host-call
876
883
  // floor.
877
- nonisolated(unsafe) let thisPtr = thisPtr
878
- nonisolated(unsafe) let argumentsPtr = argumentsPtr
879
- nonisolated(unsafe) let resultPtr = resultPtr
884
+ let thisPtr = UncheckedSendable(thisPtr)
885
+ let argumentsPtr = UncheckedSendable(argumentsPtr)
886
+ let resultPtr = UncheckedSendable(resultPtr)
880
887
 
881
888
  // See `withGuaranteedContext` for why neither the context nor the runtime is retained here, and
882
889
  // why the result is written to the caller's slot instead of being returned.
883
890
  return withGuaranteedContext(context) { (context: HostFunctionContext, runtime) in
884
891
  return JavaScriptActor.assumeIsolated {
885
892
  return forwardingSwiftErrorsToJS(runtime: runtime) {
886
- let this = UnsafeMutablePointer(mutating: thisPtr).move()
887
- let arguments = JavaScriptValuesBuffer(runtime, start: argumentsPtr, count: argumentsCount)
893
+ let this = UnsafeMutablePointer(mutating: thisPtr.value).move()
894
+ let arguments = JavaScriptValuesBuffer(runtime, start: argumentsPtr.value, count: argumentsCount)
888
895
  let thisValue = JavaScriptValue(runtime, this)
889
896
  var result = try context.call(thisValue, consume arguments)
890
- JavaScriptValue.write(&result, to: resultPtr)
897
+ JavaScriptValue.write(&result, to: resultPtr.value)
891
898
  }
892
899
  }
893
900
  }
@@ -919,19 +926,19 @@ private func createFunctionClosure(
919
926
  // handed in as a borrowed `JavaScriptUnownedValue` pointing straight at the C++-owned `this` slot:
920
927
  // it is not moved out and no owning `JavaScriptValue` is allocated, so the closure avoids the
921
928
  // per-call `weak`-runtime form/destroy and heap object that the owning `this` pays.
922
- nonisolated(unsafe) let thisPtr = thisPtr
923
- nonisolated(unsafe) let argumentsPtr = argumentsPtr
924
- nonisolated(unsafe) let resultPtr = resultPtr
929
+ let thisPtr = UncheckedSendable(thisPtr)
930
+ let argumentsPtr = UncheckedSendable(argumentsPtr)
931
+ let resultPtr = UncheckedSendable(resultPtr)
925
932
 
926
933
  // See `withGuaranteedContext` for why neither the context nor the runtime is retained here, and
927
934
  // why the result is written to the caller's slot instead of being returned.
928
935
  return withGuaranteedContext(context) { (context: UnownedThisHostFunctionContext, runtime) in
929
936
  return JavaScriptActor.assumeIsolated {
930
937
  return forwardingSwiftErrorsToJS(runtime: runtime) {
931
- let arguments = JavaScriptValuesBuffer(runtime, start: argumentsPtr, count: argumentsCount)
932
- let thisValue = JavaScriptUnownedValue(runtime.pointee, thisPtr)
938
+ let arguments = JavaScriptValuesBuffer(runtime, start: argumentsPtr.value, count: argumentsCount)
939
+ let thisValue = JavaScriptUnownedValue(runtime.pointee, thisPtr.value)
933
940
  var result = try context.call(thisValue, consume arguments)
934
- JavaScriptValue.write(&result, to: resultPtr)
941
+ JavaScriptValue.write(&result, to: resultPtr.value)
935
942
  }
936
943
  }
937
944
  }
@@ -375,19 +375,50 @@ public struct JavaScriptObject: JavaScriptType, Sendable, ~Copyable {
375
375
  guard let runtime else {
376
376
  FatalError.runtimeLost()
377
377
  }
378
- try! runtime
379
- .global()
380
- .getPropertyAsObject("Object")
381
- .getPropertyAsFunction("defineProperty")
382
- .call(arguments: self.asValue().ref(), JavaScriptValue(runtime, name).ref(), descriptor.ref())
378
+ do {
379
+ try definePropertyFunction(in: runtime).function
380
+ .call(arguments: self.asValue().ref(), JavaScriptValue(runtime, name).ref(), descriptor.ref())
381
+ } catch {
382
+ FatalError.definePropertyFailed(name, error)
383
+ }
383
384
  }
384
385
 
385
386
  public func defineProperty(_ name: String, descriptor: consuming PropertyDescriptor = .init()) {
386
387
  guard let runtime else {
387
388
  FatalError.runtimeLost()
388
389
  }
389
- let descriptorObject = descriptor.toObject(runtime)
390
- defineProperty(name, descriptor: descriptorObject)
390
+ let defineProperty = definePropertyFunction(in: runtime)
391
+ let jsiRuntime = runtime.pointee
392
+ let hasValue = descriptor.value != nil
393
+ let value = descriptor.value?.toJSIValue(in: jsiRuntime) ?? facebook.jsi.Value.undefined()
394
+ var utf8Name = name
395
+ utf8Name.withUTF8 { nameUtf8 in
396
+ // A JS error can't propagate through Swift, so `capturingCppErrors` captures it and rethrows it
397
+ // here; `defineProperty` doesn't throw, so it stops execution with that error.
398
+ do {
399
+ try capturingCppErrors {
400
+ expo.defineProperty(
401
+ jsiRuntime,
402
+ defineProperty.function.pointee,
403
+ defineProperty.configurableKey.pointee,
404
+ defineProperty.enumerableKey.pointee,
405
+ defineProperty.writableKey.pointee,
406
+ defineProperty.valueKey.pointee,
407
+ pointee,
408
+ // An empty buffer may have no base address; any non-null pointer works with a zero length.
409
+ nameUtf8.baseAddress ?? UnsafePointer(bitPattern: 1)!,
410
+ nameUtf8.count,
411
+ value,
412
+ hasValue,
413
+ descriptor.writable,
414
+ descriptor.enumerable,
415
+ descriptor.configurable
416
+ )
417
+ }
418
+ } catch {
419
+ FatalError.definePropertyFailed(name, error)
420
+ }
421
+ }
391
422
  }
392
423
 
393
424
  public func defineProperty<T: JavaScriptRepresentable & ~Copyable>(
@@ -674,3 +705,40 @@ extension JavaScriptObject {
674
705
  }
675
706
  }
676
707
  }
708
+
709
+ // MARK: - Object.defineProperty
710
+
711
+ /// Holds the runtime's `Object.defineProperty` and the property names of a descriptor, created once per
712
+ /// runtime instead of on every `defineProperty` call.
713
+ private final class DefinePropertyFunction {
714
+ let function: JavaScriptFunction
715
+ let configurableKey: JavaScriptPropNameID
716
+ let enumerableKey: JavaScriptPropNameID
717
+ let writableKey: JavaScriptPropNameID
718
+ let valueKey: JavaScriptPropNameID
719
+
720
+ init(_ function: consuming JavaScriptFunction, in runtime: JavaScriptRuntime) {
721
+ self.function = function
722
+ self.configurableKey = JavaScriptPropNameID(runtime, string: "configurable")
723
+ self.enumerableKey = JavaScriptPropNameID(runtime, string: "enumerable")
724
+ self.writableKey = JavaScriptPropNameID(runtime, string: "writable")
725
+ self.valueKey = JavaScriptPropNameID(runtime, string: "value")
726
+ }
727
+ }
728
+
729
+ private let definePropertyFunctionKey = JavaScriptRuntime.Cache.Key<DefinePropertyFunction>()
730
+
731
+ /// Returns the runtime's `Object.defineProperty`, cached in the runtime. Property definitions run on the
732
+ /// JavaScript thread like every other JSI call, which is where the runtime's cache is isolated.
733
+ private func definePropertyFunction(in runtime: JavaScriptRuntime) -> DefinePropertyFunction {
734
+ return JavaScriptActor.assumeIsolated {
735
+ runtime.cached(definePropertyFunctionKey) {
736
+ do {
737
+ let function = try runtime.global().getPropertyAsObject("Object").getPropertyAsFunction("defineProperty")
738
+ return DefinePropertyFunction(function, in: runtime)
739
+ } catch {
740
+ FatalError.definePropertyUnavailable(error)
741
+ }
742
+ }
743
+ }
744
+ }
@@ -30,4 +30,23 @@ internal struct FatalError {
30
30
  + "Check that its null-setter short-circuit is still in place."
31
31
  )
32
32
  }
33
+
34
+ /// Stops program execution when `Object.defineProperty` throws, for example when the property already
35
+ /// exists and isn't configurable, or the object isn't extensible. `defineProperty` doesn't throw, so
36
+ /// its callers are expected to define properties that JavaScript accepts.
37
+ internal static func definePropertyFailed(_ name: String, _ error: any Error) -> Never {
38
+ fatalError(
39
+ "Object.defineProperty failed to define '\(name)': \(error). "
40
+ + "Make sure the object is extensible and doesn't already have a non-configurable property with this name."
41
+ )
42
+ }
43
+
44
+ /// Stops program execution when the runtime has no `Object.defineProperty` function, which every
45
+ /// JavaScript engine provides unless the global `Object` has been replaced.
46
+ internal static func definePropertyUnavailable(_ error: any Error) -> Never {
47
+ fatalError(
48
+ "Couldn't look up Object.defineProperty in the JavaScript runtime: \(error). "
49
+ + "Check whether the global Object has been replaced."
50
+ )
51
+ }
33
52
  }
@@ -0,0 +1,13 @@
1
+ /// Immutable wrapper that makes a non-Sendable value capturable by a `@Sendable` closure without compiler enforcement.
2
+ /// Unlike `NonisolatedUnsafeVar`, it is a struct, so wrapping a value doesn't allocate.
3
+ ///
4
+ /// Use it instead of a `nonisolated(unsafe) let` local captured by such a closure: Swift 6.2 still reports
5
+ /// "sending '...' risks causing data races" for those, while a capture of a `Sendable` value passes on every version.
6
+ /// The caller is responsible for making sure the value is never accessed concurrently.
7
+ internal struct UncheckedSendable<Value>: @unchecked Sendable {
8
+ let value: Value
9
+
10
+ init(_ value: Value) {
11
+ self.value = value
12
+ }
13
+ }
@@ -220,6 +220,47 @@ inline jsi::Value callFunctionWithThis(jsi::IRuntime &runtime, const jsi::Functi
220
220
  });
221
221
  }
222
222
 
223
+ /**
224
+ Calls `defineProperty`, the runtime's `Object.defineProperty`, on `object` with a new descriptor that holds
225
+ `value` (when `hasValue` is set) and only the attributes that are `true`, like a descriptor built in
226
+ JavaScript. Taking the function and the descriptor keys from the caller lets it create them once per runtime,
227
+ and building the descriptor and the arguments here avoids a round trip through Swift for each of them.
228
+ */
229
+ inline void defineProperty(
230
+ jsi::IRuntime &runtime,
231
+ const jsi::Function &defineProperty,
232
+ const jsi::PropNameID &configurableKey,
233
+ const jsi::PropNameID &enumerableKey,
234
+ const jsi::PropNameID &writableKey,
235
+ const jsi::PropNameID &valueKey,
236
+ const jsi::Object &object,
237
+ const uint8_t *_Nonnull nameUtf8,
238
+ size_t nameLength,
239
+ const jsi::Value &value,
240
+ bool hasValue,
241
+ bool writable,
242
+ bool enumerable,
243
+ bool configurable
244
+ ) {
245
+ expo::CppError::tryCatch(runtime, [&] {
246
+ jsi::Object descriptor(runtime);
247
+ if (configurable) {
248
+ descriptor.setProperty(runtime, configurableKey, true);
249
+ }
250
+ if (enumerable) {
251
+ descriptor.setProperty(runtime, enumerableKey, true);
252
+ }
253
+ if (writable) {
254
+ descriptor.setProperty(runtime, writableKey, true);
255
+ }
256
+ if (hasValue) {
257
+ descriptor.setProperty(runtime, valueKey, value);
258
+ }
259
+ defineProperty.call(runtime, object, jsi::String::createFromUtf8(runtime, nameUtf8, nameLength), descriptor);
260
+ return jsi::Value::undefined();
261
+ });
262
+ }
263
+
223
264
  inline jsi::Value callAsConstructor(jsi::IRuntime &runtime, const jsi::Function &function, const jsi::Value *_Nullable args, size_t count) {
224
265
  return expo::CppError::tryCatch(runtime, [&] {
225
266
  return function.callAsConstructor(runtime, args, count);
@@ -44,21 +44,34 @@ private:
44
44
 
45
45
  std::atomic<int> refCount{1};
46
46
 
47
+ RuntimeScheduler(void *scheduler, ScheduleFn fn) noexcept
48
+ : nativeScheduler(scheduler), scheduleFn(fn) {}
49
+
50
+ RuntimeScheduler() noexcept {}
51
+
47
52
  public:
53
+ // Swift creates instances through the static `create` functions rather than the constructors.
54
+ // Annotating the constructors with `SWIFT_RETURNS_RETAINED` silences the Swift 6.4 "cannot infer
55
+ // ownership" warning, but Swift 6.2 rejects that annotation on a constructor as an error. Static
56
+ // functions returning a shared reference accept the annotation on every Swift version.
57
+
48
58
  /**
49
- Constructs a scheduler bound to a host-provided native RuntimeScheduler.
59
+ Creates a scheduler bound to a host-provided native RuntimeScheduler.
50
60
  `scheduleTask` dispatches through `fn`, which the host implements against
51
61
  the real react::RuntimeScheduler.
52
62
  */
53
- SWIFT_RETURNS_RETAINED RuntimeScheduler(void *scheduler, ScheduleFn fn) noexcept
54
- : nativeScheduler(scheduler), scheduleFn(fn) {}
63
+ static RuntimeScheduler *create(void *scheduler, ScheduleFn fn) noexcept SWIFT_RETURNS_RETAINED {
64
+ return new RuntimeScheduler(scheduler, fn);
65
+ }
55
66
 
56
67
  /**
57
- Constructs a no-op scheduler. Scheduled tasks run synchronously on the
68
+ Creates a no-op scheduler. Scheduled tasks run synchronously on the
58
69
  caller's thread — intended for standalone runtimes (e.g. tests) that have
59
70
  no React scheduler.
60
71
  */
61
- SWIFT_RETURNS_RETAINED RuntimeScheduler() {}
72
+ static RuntimeScheduler *create() noexcept SWIFT_RETURNS_RETAINED {
73
+ return new RuntimeScheduler();
74
+ }
62
75
 
63
76
  RuntimeScheduler(const RuntimeScheduler &) = delete;
64
77
 
@@ -0,0 +1,115 @@
1
+ // Copyright 2026-present 650 Industries. All rights reserved.
2
+
3
+ import ExpoModulesJSI
4
+ import Testing
5
+
6
+ @Suite("JavaScriptCodable+Set")
7
+ @JavaScriptActor
8
+ struct JavaScriptCodableSetTests {
9
+ let runtime = JavaScriptRuntime()
10
+
11
+ @Test
12
+ func `decodes a set of strings from a JS Set`() throws {
13
+ let decoded = try Set<String>.decode(runtime.eval("new Set(['a', 'b'])"), in: runtime)
14
+ #expect(decoded == ["a", "b"])
15
+ }
16
+
17
+ @Test
18
+ func `decodes a set from a subclass of Set`() throws {
19
+ let decoded = try Set<Int>.decode(runtime.eval("new (class extends Set {})([1, 2])"), in: runtime)
20
+ #expect(decoded == [1, 2])
21
+ }
22
+
23
+ @Test
24
+ func `arrayizes a non-set object into a single-element set`() throws {
25
+ let decoded = try Set<[String: Int]>.decode(runtime.eval("({ a: 1 })"), in: runtime)
26
+ #expect(decoded == [["a": 1]])
27
+ }
28
+
29
+ @Test
30
+ func `decoding many sets leaves no helper on the global object`() throws {
31
+ let runtime = JavaScriptRuntime()
32
+ for _ in 0..<100 {
33
+ _ = try Set<Int>.decode(runtime.eval("new Set([1, 2, 3])"), in: runtime)
34
+ }
35
+ // The only object the wrapper may leave on `globalThis` is the long-lived-objects anchor.
36
+ let ownGlobals = try runtime.eval("Object.getOwnPropertyNames(globalThis).length").getInt()
37
+ let freshGlobals = try JavaScriptRuntime().eval("Object.getOwnPropertyNames(globalThis).length").getInt()
38
+ #expect(ownGlobals - freshGlobals <= 1)
39
+ }
40
+
41
+ @Test
42
+ func `decodes a set from an array, collapsing duplicates`() throws {
43
+ let decoded = try Set<Int>.decode(runtime.eval("[1, 2, 2, 3, 1]"), in: runtime)
44
+ #expect(decoded == [1, 2, 3])
45
+ }
46
+
47
+ @Test
48
+ func `arrayizes a non-array, non-set scalar into a set`() throws {
49
+ #expect(try Set<Int>.decode(runtime.eval("42"), in: runtime) == [42])
50
+ }
51
+
52
+ @Test
53
+ func `encodes a set to a JS Set`() throws {
54
+ let encoded = try Set<Int>.encode([1, 2, 3], in: runtime)
55
+ #expect(encoded.is("Set"))
56
+ let object = encoded.getObject()
57
+ #expect(object.getProperty("size").getInt() == 3)
58
+ #expect(try object.callFunction("has", arguments: 2).getBool() == true)
59
+ #expect(try object.callFunction("has", arguments: 4).getBool() == false)
60
+ }
61
+
62
+ @Test
63
+ func `round-trips a set of sets`() throws {
64
+ let value: Set<Set<String>> = [["a"], ["b", "c"]]
65
+ let encoded = try Set<Set<String>>.encode(value, in: runtime)
66
+ #expect(try Set<Set<String>>.decode(encoded, in: runtime) == value)
67
+ }
68
+
69
+ @Test
70
+ func `round-trips an empty set`() throws {
71
+ #expect(try Set<Int>.decode(runtime.eval("new Set()"), in: runtime) == [])
72
+ let encoded = try Set<Int>.encode([], in: runtime)
73
+ #expect(encoded.is("Set"))
74
+ #expect(encoded.getObject().getProperty("size").getInt() == 0)
75
+ }
76
+
77
+ @Test
78
+ func `set decode rejects an element of the wrong type`() throws {
79
+ #expect(throws: (any Error).self) {
80
+ try Set<Int>.decode(runtime.eval("new Set([1, 'a'])"), in: runtime)
81
+ }
82
+ }
83
+
84
+ @Test
85
+ func `set decode collapses distinct JS objects that decode to equal values`() throws {
86
+ // JS `Set` compares objects by reference, so it holds both objects; Swift compares the decoded
87
+ // dictionaries by value, so they collapse into one element.
88
+ let decoded = try Set<[String: Int]>.decode(runtime.eval("new Set([{ a: 1 }, { a: 1 }, { b: 2 }])"), in: runtime)
89
+ #expect(decoded == [["a": 1], ["b": 2]])
90
+ }
91
+
92
+ @Test
93
+ func `encodes a set of dictionaries to a JS Set of distinct objects`() throws {
94
+ let encoded = try Set<[String: Int]>.encode([["a": 1], ["b": 2]], in: runtime)
95
+ runtime.global().setProperty("encodedSet", value: encoded)
96
+ let summary = try runtime.eval(
97
+ "Array.from(encodedSet, (entry) => JSON.stringify(entry)).sort().join(',')"
98
+ )
99
+ #expect(summary.getString() == #"{"a":1},{"b":2}"#)
100
+ }
101
+
102
+ @Test
103
+ func `decodes a set argument and encodes a set result through a host function`() throws {
104
+ let fn = runtime.createFunction("uppercased") { [self] _, arguments in
105
+ let strings = try Set<String>.decode(arguments.unownedValue(at: 0), in: runtime)
106
+ return try Set<String>.encode(Set(strings.map { $0.uppercased() }), in: runtime)
107
+ }
108
+ runtime.global().setProperty("uppercased", value: fn.asValue())
109
+ let result = try runtime.eval(
110
+ "const result = uppercased(new Set(['a', 'b']))",
111
+ "result instanceof Set && result.size === 2 && result.has('A') && result.has('B')"
112
+ )
113
+ #expect(result.getBool() == true)
114
+ }
115
+ }
@@ -92,6 +92,23 @@ struct JavaScriptRuntimeTests {
92
92
  #expect(result.getString() == "hello")
93
93
  }
94
94
 
95
+ @Test
96
+ func `evaluate to promise on the JavaScript thread when called from another thread`() async throws {
97
+ // The test body runs on a cooperative thread, not on the scheduler's thread. Evaluating there
98
+ // would run JavaScript concurrently with the work the scheduler runs, such as settling a promise.
99
+ let testRuntime = await TestRuntimeScheduler().makeRuntime()
100
+ let runtime = testRuntime.runtime
101
+ try await testRuntime.scheduler.runIsolated {
102
+ let isOnJavaScriptThread = runtime.createFunction("isOnJavaScriptThread") { _, _ in
103
+ return JavaScriptValue(runtime, runtime.isOnJavaScriptThread())
104
+ }
105
+ runtime.global().setProperty("isOnJavaScriptThread", value: isOnJavaScriptThread.asValue())
106
+ }
107
+
108
+ let result = try await runtime.evalAsync("Promise.resolve(isOnJavaScriptThread())")
109
+ #expect(result.getBool() == true)
110
+ }
111
+
95
112
  @Test
96
113
  func `is equatable`() {
97
114
  #expect((runtime == runtime) == true)
@@ -170,11 +187,11 @@ struct JavaScriptRuntimeTests {
170
187
  }
171
188
 
172
189
  await scheduler.run {
173
- nonisolated(unsafe) var didRunInline = false
190
+ let didRunInline = InlineRunFlag()
174
191
  runtime.runOrSchedule {
175
- didRunInline = true
192
+ didRunInline.value = true
176
193
  }
177
- #expect(didRunInline)
194
+ #expect(didRunInline.value)
178
195
  }
179
196
 
180
197
  await withCheckedContinuation { continuation in
@@ -1148,6 +1165,13 @@ struct JavaScriptRuntimeTests {
1148
1165
  }
1149
1166
  }
1150
1167
 
1168
+ /// Records whether a `runOrSchedule` block ran. A class instead of a `nonisolated(unsafe) var` captured
1169
+ /// by the block, which Swift 6.2 rejects as a data race. Safe without synchronization: the test only
1170
+ /// reads it after the block ran inline on the same thread.
1171
+ private final class InlineRunFlag: @unchecked Sendable {
1172
+ var value = false
1173
+ }
1174
+
1151
1175
  /// Tasks captured by `holdSchedulerTask` instead of being executed, emulating a React
1152
1176
  /// `RuntimeScheduler` that is torn down with work still queued (the #47716 reload scenario).
1153
1177
  /// Safe without synchronization: the dispatch always runs synchronously on the test's thread.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-modules-jsi",
3
- "version": "58.0.8",
3
+ "version": "58.0.9",
4
4
  "description": "The JavaScript Interface for Expo Modules",
5
5
  "main": "index.js",
6
6
  "sideEffects": [],