expo-modules-jsi 57.0.8 → 57.1.0

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
@@ -10,6 +10,25 @@
10
10
 
11
11
  ### 💡 Others
12
12
 
13
+ ## 57.1.0 — 2026-09-08
14
+
15
+ ### 🎉 New features
16
+
17
+ - [iOS] Add a `JavaScriptEncodable` conformance for `Task` that encodes it to a JS `Promise` settling with the task's result, so native code can hand JavaScript a promise as a value. ([#47861](https://github.com/expo/expo/pull/47861) by [@tsapeta](https://github.com/tsapeta))
18
+ - [iOS] Split the `Array`, `Optional`, and `Dictionary` `JavaScriptCodable` conformances into separate `JavaScriptDecodable` and `JavaScriptEncodable` halves so an encode-only element type such as `Task` can be carried through a container's encode. ([#47861](https://github.com/expo/expo/pull/47861) by [@tsapeta](https://github.com/tsapeta))
19
+ - [iOS] Add a `JavaScriptPromise.resolve` overload that takes a `JavaScriptEncodable` value, encoding it on the JavaScript thread and rejecting the promise if encoding or the resolver call throws. ([#47862](https://github.com/expo/expo/pull/47862) by [@tsapeta](https://github.com/tsapeta))
20
+
21
+ ### 🐛 Bug fixes
22
+
23
+ - [iOS] Fixed a use-after-free when a non-owning `JavaScriptRuntime` wrapper outlives its runtime (e.g. it is captured by a task abandoned on reload): its cached `jsi::PropNameID`s were destroyed against the freed runtime when the wrapper deallocated. The teardown sweep now flushes the cache on the JavaScript thread while the runtime is still valid. ([#47927](https://github.com/expo/expo/pull/47927) by [@tsapeta](https://github.com/tsapeta))
24
+ - [iOS] `JavaScriptPromise` no longer traps when a resolve or reject call throws, which can realistically only happen against a runtime that is being torn down: a failed resolver call rejects the promise instead and a failed rejecter call is dropped. ([#47862](https://github.com/expo/expo/pull/47862) by [@tsapeta](https://github.com/tsapeta))
25
+
26
+ ### 💡 Others
27
+
28
+ - [iOS] Made creating a deferred `JavaScriptPromise` ~1.2× faster by building it from a cached JavaScript closure instead of a host function executor. ([#49714](https://github.com/expo/expo/pull/49714) by [@tsapeta](https://github.com/tsapeta))
29
+ - [iOS] Reduced the native overhead of synchronous host function calls and host object property accessors that return `undefined`, `null`, a boolean or a number: the result is written into the engine's slot without engine calls, and errors are reported only when one was actually thrown instead of being checked on every call. ([#49761](https://github.com/expo/expo/pull/49761) by [@tsapeta](https://github.com/tsapeta))
30
+ - [iOS] Reduced the native overhead of synchronous host function calls whose closure receives `this` as a `JavaScriptValue`, by letting the calling module destroy the arguments buffer directly. ([#49769](https://github.com/expo/expo/pull/49769) by [@tsapeta](https://github.com/tsapeta))
31
+
13
32
  ## 57.0.8 — 2026-09-04
14
33
 
15
34
  ### 🎉 New features
@@ -18,6 +37,7 @@
18
37
 
19
38
  ### 🐛 Bug fixes
20
39
 
40
+ - [iOS] Fixed the build against React Native older than 0.86 (e.g. react-native-macos 0.81), where `jsi::Runtime::getStringData` is a protected member that Swift cannot call. String decoding now goes through a C++ wrapper that uses the public `jsi::String::getStringData` helper on those versions. ([#49790](https://github.com/expo/expo/pull/49790) by [@tsapeta](https://github.com/tsapeta))
21
41
  - [iOS] Fixed `JavaScriptPropNameID(_:string:)` and the array's string-keyed subscript truncating non-ASCII property keys: they passed `String.count` (the grapheme-cluster count) as the UTF-8 byte length to `PropNameID::forUtf8`, so keys like `"café"` or `"🎉"` were built from mangled bytes and no longer matched the intended property. ([#48329](https://github.com/expo/expo/pull/48329) by [@tsapeta](https://github.com/tsapeta))
22
42
  - [iOS] Fixed the prebuilt `ExpoModulesJSI.xcframework` shipping with code coverage instrumentation: building through the auto-generated SwiftPM scheme made Xcode pass `-profile-generate -profile-coverage-mapping` to swiftc even for a plain Release `build`, adding a counter increment to every function on the host function call path and about 40% to the binary size. The benchmark target had the same instrumentation and now runs without it. ([#49637](https://github.com/expo/expo/pull/49637) by [@tsapeta](https://github.com/tsapeta))
23
43
  - [iOS] Fixed property names with non-ASCII characters being mangled when accessed by name from Swift, such as `getProperty`, `setProperty`, `hasProperty`, the array string subscript and dictionary conversions. ([#49679](https://github.com/expo/expo/pull/49679) by [@tsapeta](https://github.com/tsapeta))
@@ -0,0 +1,43 @@
1
+ // Copyright 2026-present 650 Industries. All rights reserved.
2
+
3
+ import ExpoModulesJSI
4
+ import Testing
5
+
6
+ /// Benchmarks for creating and settling deferred promises from Swift. The engine floor is measured
7
+ /// from JavaScript for comparison, so the wrapper's own overhead can be read off directly.
8
+ extension JSIBenchmarks {
9
+ @Test
10
+ func `engine floor: new Promise from JavaScript`() async throws {
11
+ try await benchmarkCase { runtime in
12
+ let driver = try runtime.eval(
13
+ "(function(n) { for (var i = 0; i < n; i++) new Promise(function (a, b) {}); })"
14
+ ).getFunction()
15
+ try benchmark("engine floor: new Promise from JS", runtime: runtime) { iterations in
16
+ _ = try driver.call(arguments: iterations)
17
+ }
18
+ }
19
+ }
20
+
21
+ @Test
22
+ func `create deferred promise`() async throws {
23
+ try await benchmarkCase { runtime in
24
+ try benchmark("JavaScriptPromise(runtime): create deferred", runtime: runtime) { iterations in
25
+ for _ in 0..<iterations {
26
+ _ = try JavaScriptPromise(runtime)
27
+ }
28
+ }
29
+ }
30
+ }
31
+
32
+ @Test
33
+ func `create and resolve deferred promise`() async throws {
34
+ try await benchmarkCase { runtime in
35
+ try benchmark("JavaScriptPromise(runtime): create and resolve", runtime: runtime) { iterations in
36
+ for _ in 0..<iterations {
37
+ let promise = try JavaScriptPromise(runtime)
38
+ promise.resolve(42.0)
39
+ }
40
+ }
41
+ }
42
+ }
43
+ }
@@ -1,15 +1,15 @@
1
1
  // Copyright 2025-present 650 Industries. All rights reserved.
2
2
 
3
- // `JavaScriptCodable` conformances for the standard container and wrapper types — `Array`,
4
- // `Optional`, and `Dictionary` — each conditional on its element/wrapped type conforming, and
5
- // each recursing statically into that element's conversion.
3
+ // `JavaScriptCodable` conformances for the standard container and wrapper types (`Array`,
4
+ // `Optional`, `Dictionary`), each recursing statically into its element/wrapped type's conversion.
6
5
  //
7
- // `JavaScriptCodable` is a composition type alias, so a conformance clause spells out both halves:
8
- // `extension Array: JavaScriptDecodable, JavaScriptEncodable where Element: JavaScriptCodable`.
6
+ // The decodable and encodable halves are separate conditional conformances, each gated only on the
7
+ // half it needs. A type conforming to both still gets both halves, but an encode-only element type
8
+ // (e.g. `Task`, which has no `decode`) can still be carried through a container's encode.
9
9
 
10
10
  // MARK: - Array
11
11
 
12
- extension Array: JavaScriptDecodable, JavaScriptEncodable where Element: JavaScriptCodable {
12
+ extension Array: JavaScriptDecodable where Element: JavaScriptDecodable {
13
13
  @JavaScriptActor
14
14
  @inlinable
15
15
  public static func decode(_ value: borrowing JavaScriptValue, in runtime: borrowing JavaScriptRuntime) throws
@@ -26,7 +26,9 @@ extension Array: JavaScriptDecodable, JavaScriptEncodable where Element: JavaScr
26
26
  return try Element.decode(element, in: runtime)
27
27
  }
28
28
  }
29
+ }
29
30
 
31
+ extension Array: JavaScriptEncodable where Element: JavaScriptEncodable {
30
32
  @JavaScriptActor
31
33
  @inlinable
32
34
  public static func encode(_ value: [Element], in runtime: borrowing JavaScriptRuntime) throws
@@ -42,7 +44,7 @@ extension Array: JavaScriptDecodable, JavaScriptEncodable where Element: JavaScr
42
44
 
43
45
  // MARK: - Optional
44
46
 
45
- extension Optional: JavaScriptDecodable, JavaScriptEncodable where Wrapped: JavaScriptCodable {
47
+ extension Optional: JavaScriptDecodable where Wrapped: JavaScriptDecodable {
46
48
  // Optional copies nothing itself, so it overrides the zero-copy overload too and forwards the
47
49
  // borrowed value straight through — a wrapped primitive argument stays fully zero-copy.
48
50
  @JavaScriptActor
@@ -66,7 +68,9 @@ extension Optional: JavaScriptDecodable, JavaScriptEncodable where Wrapped: Java
66
68
  }
67
69
  return try Wrapped.decode(value, in: runtime)
68
70
  }
71
+ }
69
72
 
73
+ extension Optional: JavaScriptEncodable where Wrapped: JavaScriptEncodable {
70
74
  @JavaScriptActor
71
75
  @inlinable
72
76
  public static func encode(_ value: Wrapped?, in runtime: borrowing JavaScriptRuntime) throws
@@ -82,7 +86,7 @@ extension Optional: JavaScriptDecodable, JavaScriptEncodable where Wrapped: Java
82
86
 
83
87
  // MARK: - Dictionary
84
88
 
85
- extension Dictionary: JavaScriptDecodable, JavaScriptEncodable where Key == String, Value: JavaScriptCodable {
89
+ extension Dictionary: JavaScriptDecodable where Key == String, Value: JavaScriptDecodable {
86
90
  @JavaScriptActor
87
91
  @inlinable
88
92
  public static func decode(_ value: borrowing JavaScriptValue, in runtime: borrowing JavaScriptRuntime) throws
@@ -102,7 +106,9 @@ extension Dictionary: JavaScriptDecodable, JavaScriptEncodable where Key == Stri
102
106
  }
103
107
  return result
104
108
  }
109
+ }
105
110
 
111
+ extension Dictionary: JavaScriptEncodable where Key == String, Value: JavaScriptEncodable {
106
112
  @JavaScriptActor
107
113
  @inlinable
108
114
  public static func encode(_ value: [String: Value], in runtime: borrowing JavaScriptRuntime) throws
@@ -0,0 +1,35 @@
1
+ // Copyright 2025-present 650 Industries. All rights reserved.
2
+
3
+ // `JavaScriptEncodable` conformance for `Task`, encoding it to a JavaScript `Promise`. This lets
4
+ // native code hand JavaScript a promise as a value: the return of a synchronous `@JS func` whose
5
+ // result type is `Task`, or a `Task` nested inside another encoded value. `createAsyncFunction`
6
+ // only wraps a function's own return in a promise, so a `Task` is how to produce one anywhere else.
7
+ //
8
+ // Encode-only: a promise decodes by awaiting it through `JavaScriptPromise`, not by reconstructing
9
+ // a `Task`. One conditional conformance covers throwing and non-throwing tasks (Swift forbids two
10
+ // conformances even with disjoint bounds, and `Task.value` is `async throws` for any `Failure`).
11
+
12
+ /// Encodes a `Task` to a JavaScript promise that settles with the task's result.
13
+ extension Task: JavaScriptEncodable where Success: JavaScriptEncodable, Failure: Error {
14
+ /// Returns a pending promise immediately and settles it on the JavaScript thread once the task
15
+ /// completes: fulfilling with the encoded `Success` value, or rejecting with the thrown error.
16
+ @JavaScriptActor
17
+ public static func encode(_ value: Task<Success, Failure>, in runtime: borrowing JavaScriptRuntime) throws
18
+ -> JavaScriptValue
19
+ {
20
+ let promise = try JavaScriptPromise(copy runtime)
21
+ // A detached task awaits the result off the JavaScript thread, then settles the promise. It must
22
+ // not inherit `@JavaScriptActor`: that actor runs jobs synchronously on the current thread rather
23
+ // than hopping to the JS thread, so a resumed continuation would land on whatever thread the task
24
+ // finished on. `resolve`/`reject` hop to the JS thread internally (and the encodable `resolve`
25
+ // encodes there), so settling from here is safe regardless of this task's thread.
26
+ Task<Void, Never>.detached {
27
+ do {
28
+ promise.resolve(try await value.value)
29
+ } catch {
30
+ promise.reject(error)
31
+ }
32
+ }
33
+ return promise.asValue()
34
+ }
35
+ }
@@ -12,17 +12,17 @@ internal protocol HostCallbackContext: AnyObject {
12
12
  /// `_withUnsafeGuaranteedRef` promises the compiler that both objects outlive the closure, so no
13
13
  /// retain or release is emitted for either. Both promises hold for a JSI callback: the JSI owner
14
14
  /// of the context is the caller, and a synchronous callback runs while the runtime executes JS,
15
- /// with the wrapper owned for the runtime's whole lifetime. The body returns nothing because
16
- /// `_withUnsafeGuaranteedRef` needs a `Copyable` result; callbacks write their `jsi::Value`
17
- /// result into the slot the C++ caller provides instead.
15
+ /// with the wrapper owned for the runtime's whole lifetime. The body's result must be `Copyable`
16
+ /// because `_withUnsafeGuaranteedRef` requires it; callbacks write their `jsi::Value` result into the
17
+ /// slot the C++ caller provides and return only whether an error was stored.
18
18
  @inline(__always)
19
- internal func withGuaranteedContext<Context: HostCallbackContext>(
19
+ internal func withGuaranteedContext<Context: HostCallbackContext, Result>(
20
20
  _ pointer: UnsafeMutableRawPointer,
21
- _ body: (_ context: Context, _ runtime: JavaScriptRuntime) -> Void
22
- ) {
23
- Unmanaged<Context>.fromOpaque(pointer)._withUnsafeGuaranteedRef { context in
24
- context.runtime._withUnsafeGuaranteedRef { runtime in
25
- body(context, runtime)
21
+ _ body: (_ context: Context, _ runtime: JavaScriptRuntime) -> Result
22
+ ) -> Result {
23
+ return Unmanaged<Context>.fromOpaque(pointer)._withUnsafeGuaranteedRef { context in
24
+ return context.runtime._withUnsafeGuaranteedRef { runtime in
25
+ return body(context, runtime)
26
26
  }
27
27
  }
28
28
  }
@@ -133,6 +133,7 @@ open class JavaScriptRuntime: Equatable, Identifiable, @unchecked Sendable {
133
133
  // when the last reference is gone. `deinit` is `nonisolated`, so it can touch the actor-isolated
134
134
  // registry directly given that exclusive access.
135
135
  propNameIdsRegistry.removeAll()
136
+ cachedDeferredPromiseFactory = nil
136
137
  expo.destroyRuntime(runtimePointee)
137
138
  }
138
139
 
@@ -182,14 +183,14 @@ open class JavaScriptRuntime: Equatable, Identifiable, @unchecked Sendable {
182
183
  context: UnsafeMutableRawPointer,
183
184
  propertyName: UnsafePointer<CChar>,
184
185
  resultPtr: UnsafeMutablePointer<facebook.jsi.Value>
185
- ) {
186
+ ) -> Bool {
186
187
  let propertyName = String(cString: propertyName)
187
188
  nonisolated(unsafe) let resultPtr = resultPtr
188
189
 
189
- withGuaranteedContext(context) { (context: HostObjectContext, runtime) in
190
- resultPtr.pointee = JavaScriptActor.assumeIsolated {
190
+ return withGuaranteedContext(context) { (context: HostObjectContext, runtime) in
191
+ return JavaScriptActor.assumeIsolated {
191
192
  return forwardingSwiftErrorsToJS(runtime: runtime) {
192
- return try context.get(propertyName).asJSIValue()
193
+ try context.get(propertyName).writeJSIValue(to: resultPtr)
193
194
  }
194
195
  }
195
196
  }
@@ -197,10 +198,10 @@ open class JavaScriptRuntime: Equatable, Identifiable, @unchecked Sendable {
197
198
 
198
199
  func setter(
199
200
  context: UnsafeMutableRawPointer, propertyName: UnsafePointer<CChar>, valuePointer: UnsafeMutableRawPointer
200
- ) {
201
+ ) -> Bool {
201
202
  let propertyName = String(cString: propertyName)
202
203
 
203
- withGuaranteedContext(context) { (context: HostObjectContext, runtime) in
204
+ return withGuaranteedContext(context) { (context: HostObjectContext, runtime) in
204
205
  guard let set = context.set else {
205
206
  // Unreachable in practice: when the user passed `nil` for `set`, the call site
206
207
  // below at `expo.HostObjectCallbacks(...)` also passes `nil` to C++, and
@@ -211,8 +212,8 @@ open class JavaScriptRuntime: Equatable, Identifiable, @unchecked Sendable {
211
212
  }
212
213
  let value = JavaScriptValue(runtime, valuePointer.assumingMemoryBound(to: facebook.jsi.Value.self).move())
213
214
 
214
- JavaScriptActor.assumeIsolated {
215
- forwardingSwiftErrorsToJS(runtime: runtime) {
215
+ return JavaScriptActor.assumeIsolated {
216
+ return forwardingSwiftErrorsToJS(runtime: runtime) {
216
217
  try set(propertyName, value)
217
218
  }
218
219
  }
@@ -253,7 +254,7 @@ open class JavaScriptRuntime: Equatable, Identifiable, @unchecked Sendable {
253
254
  let context = Unmanaged.passRetained(HostObjectContext(runtime: self, get, set, getPropertyNames, dealloc))
254
255
  .toOpaque()
255
256
  let setterPointer:
256
- (@convention(c) (UnsafeMutableRawPointer, UnsafePointer<CChar>, UnsafeMutableRawPointer) -> Void)? = setter
257
+ (@convention(c) (UnsafeMutableRawPointer, UnsafePointer<CChar>, UnsafeMutableRawPointer) -> Bool)? = setter
257
258
  // Pass a null setter to C++ when the Swift setter is nil so that JS assignment
258
259
  // raises a `jsi::JSError` directly, without crossing the Swift boundary.
259
260
  let callbacks = expo.HostObjectCallbacks(
@@ -693,6 +694,13 @@ open class JavaScriptRuntime: Equatable, Identifiable, @unchecked Sendable {
693
694
  @JavaScriptActor
694
695
  internal var propNameIdsRegistry: [String: JavaScriptPropNameID] = [:]
695
696
 
697
+ // MARK: - Deferred promise factory
698
+
699
+ /// The JavaScript function ``JavaScriptPromise`` uses to create deferred promises, built on first
700
+ /// use and released with the runtime. See `JavaScriptPromise.init(_:)` for why it exists.
701
+ @JavaScriptActor
702
+ internal var cachedDeferredPromiseFactory: JavaScriptValue?
703
+
696
704
  // MARK: - Long-lived objects
697
705
 
698
706
  /// Registry of JSI objects (such as in-flight promises) that must outlive the native call that
@@ -716,13 +724,19 @@ open class JavaScriptRuntime: Equatable, Identifiable, @unchecked Sendable {
716
724
  // collection keeps it alive for the sweep; it does not retain the runtime.
717
725
  let longLivedObjects = self.longLivedObjects
718
726
  let nativeState = JavaScriptNativeState()
719
- nativeState.setDeallocator { nativeState in
727
+ nativeState.setDeallocator { [weak self] nativeState in
720
728
  // Fires as the teardown object is released on the JavaScript thread with the runtime still
721
729
  // valid, so releasing JSI state here is safe. Mirrors the caveat on `AppContext.NativeState`:
722
730
  // a future cross-runtime path that could drop this state from another thread would have to
723
731
  // hop back to the JavaScript thread first.
724
732
  JavaScriptActor.assumeIsolated {
725
733
  longLivedObjects.clear()
734
+ // Also flush the cached `jsi::PropNameID`s and the deferred-promise factory: a non-owning
735
+ // wrapper can outlive its runtime (e.g. captured by a task abandoned on reload) and would
736
+ // otherwise destroy them against the freed runtime when it deallocates. `self` is weak so
737
+ // the teardown object doesn't retain the wrapper; the owning wrapper clears both in `deinit`.
738
+ self?.propNameIdsRegistry.removeAll()
739
+ self?.cachedDeferredPromiseFactory = nil
726
740
  }
727
741
  }
728
742
  let object = createObject()
@@ -750,7 +764,7 @@ private func createFunctionClosure(
750
764
  argumentsPtr: UnsafePointer<facebook.jsi.Value>,
751
765
  argumentsCount: Int,
752
766
  resultPtr: UnsafeMutablePointer<facebook.jsi.Value>
753
- ) {
767
+ ) -> Bool {
754
768
  // `assumeIsolated` runs `operation` synchronously, in this very scope — it never escapes and never
755
769
  // hops threads (see `JavaScriptActor.assumeIsolated`). So rather than materializing the move-only
756
770
  // `JavaScriptValuesBuffer` out here and smuggling it across the closure boundary through a
@@ -766,13 +780,13 @@ private func createFunctionClosure(
766
780
 
767
781
  // See `withGuaranteedContext` for why neither the context nor the runtime is retained here, and
768
782
  // why the result is written to the caller's slot instead of being returned.
769
- withGuaranteedContext(context) { (context: HostFunctionContext, runtime) in
770
- resultPtr.pointee = JavaScriptActor.assumeIsolated {
783
+ return withGuaranteedContext(context) { (context: HostFunctionContext, runtime) in
784
+ return JavaScriptActor.assumeIsolated {
771
785
  return forwardingSwiftErrorsToJS(runtime: runtime) {
772
786
  let this = UnsafeMutablePointer(mutating: thisPtr).move()
773
787
  let arguments = JavaScriptValuesBuffer(runtime, start: argumentsPtr, count: argumentsCount)
774
788
  let thisValue = JavaScriptValue(runtime, this)
775
- return try context.call(thisValue, consume arguments).asJSIValue()
789
+ try context.call(thisValue, consume arguments).writeJSIValue(to: resultPtr)
776
790
  }
777
791
  }
778
792
  }
@@ -797,7 +811,7 @@ private func createFunctionClosure(
797
811
  argumentsPtr: UnsafePointer<facebook.jsi.Value>,
798
812
  argumentsCount: Int,
799
813
  resultPtr: UnsafeMutablePointer<facebook.jsi.Value>
800
- ) {
814
+ ) -> Bool {
801
815
  // Same call-scoped reasoning as the owning-`this` overload above (see its comment) for why the
802
816
  // buffer is built inside the synchronous `assumeIsolated` closure. Here `this` is additionally
803
817
  // handed in as a borrowed `JavaScriptUnownedValue` pointing straight at the C++-owned `this` slot:
@@ -809,12 +823,12 @@ private func createFunctionClosure(
809
823
 
810
824
  // See `withGuaranteedContext` for why neither the context nor the runtime is retained here, and
811
825
  // why the result is written to the caller's slot instead of being returned.
812
- withGuaranteedContext(context) { (context: UnownedThisHostFunctionContext, runtime) in
813
- resultPtr.pointee = JavaScriptActor.assumeIsolated {
826
+ return withGuaranteedContext(context) { (context: UnownedThisHostFunctionContext, runtime) in
827
+ return JavaScriptActor.assumeIsolated {
814
828
  return forwardingSwiftErrorsToJS(runtime: runtime) {
815
829
  let arguments = JavaScriptValuesBuffer(runtime, start: argumentsPtr, count: argumentsCount)
816
830
  let thisValue = JavaScriptUnownedValue(runtime.pointee, thisPtr)
817
- return try context.call(thisValue, consume arguments).asJSIValue()
831
+ try context.call(thisValue, consume arguments).writeJSIValue(to: resultPtr)
818
832
  }
819
833
  }
820
834
  }
@@ -3,24 +3,44 @@ internal import jsi
3
3
  /// A buffer that stores instances of `facebook.jsi.Value` with the ability to convert them to `JavaScriptValue` on element access
4
4
  /// without the need to create a new container (e.g. `std.vector<facebook.jsi.Value>` or `[JavaScriptValue]`).
5
5
  /// Used mainly to pass function arguments from C++ to Swift.
6
+ // `@frozen` so client modules, which consume a buffer in every host function closure, can destroy it
7
+ // with a direct call to its `deinit` instead of a metadata accessor plus a value witness dispatch.
8
+ @frozen
6
9
  public struct JavaScriptValuesBuffer: JavaScriptType, ~Copyable {
7
- // Safe to use unowned — the buffer's lifetime is scoped to a host function call,
10
+ // MARK: - Layout
11
+
12
+ // Every stored property below is part of the frozen layout: adding, removing or reordering one
13
+ // changes the ABI that client modules compiled against, and each type has to be public, which is
14
+ // why the storage is kept as a raw pointer and rebuilt into its C++ view on access.
15
+
16
+ // Safe to use unowned: the buffer's lifetime is scoped to a host function call,
8
17
  // so the runtime is always alive while the buffer exists.
9
18
  //
10
19
  // `unowned(unsafe)` rather than `unowned`: the runtime object carries a refcount side table (other
11
20
  // wrappers hold it `weak`), so a safe `unowned` retain/release on it takes the slow side-table path
12
21
  // on every buffer construction and destruction. Profiling showed that pair as roughly a third of the
13
- // native-side cost of a no-op `@JS` host call. `unowned(unsafe)` is a plain pointer store/load.
22
+ // native-side cost of a no-op host function call. `unowned(unsafe)` is a plain pointer store/load.
14
23
  internal unowned(unsafe) let runtime: JavaScriptRuntime
15
24
 
16
- // The raw `facebook.jsi.IRuntime`, cached alongside the `JavaScriptRuntime` wrapper. `IRuntime` is
17
- // an immortal reference (`jsi.apinotes`), so reading it costs no ARC, whereas reading `.pointee`
18
- // off the `unowned` wrapper emits an unowned retain/release on every access. The hot decode path
19
- // (`unownedValue(at:)`, `set`) reads this; `subscript`/`copy` still need the wrapper.
20
- internal nonisolated(unsafe) let iRuntime: facebook.jsi.IRuntime
25
+ // The start of the storage, type-erased because `facebook.jsi.Value` is not a public type.
26
+ internal nonisolated(unsafe) let start: UnsafeMutableRawPointer?
27
+
28
+ /// The number of values in the buffer.
29
+ public let count: Int
30
+
31
+ internal let ownsMemory: Bool
32
+
33
+ // MARK: - C++ views
21
34
 
22
- internal nonisolated(unsafe) let bufferPointer: UnsafeMutableBufferPointer<facebook.jsi.Value>
23
- private let ownsMemory: Bool
35
+ // `IRuntime` is an immortal reference (`jsi.apinotes`) and `runtime` is `unowned(unsafe)`, so this is
36
+ // two plain loads with no reference counting.
37
+ internal var iRuntime: facebook.jsi.IRuntime {
38
+ return runtime.pointee
39
+ }
40
+
41
+ internal var bufferPointer: UnsafeMutableBufferPointer<facebook.jsi.Value> {
42
+ return UnsafeMutableBufferPointer(start: start?.assumingMemoryBound(to: facebook.jsi.Value.self), count: count)
43
+ }
24
44
 
25
45
  /// A pointer to the first value of the buffer.
26
46
  /// If the baseAddress of this buffer is `nil`, the `count` is zero.
@@ -32,12 +52,7 @@ public struct JavaScriptValuesBuffer: JavaScriptType, ~Copyable {
32
52
  /// passing across Swift/ObjC++ boundaries where `facebook.jsi.Value` cannot
33
53
  /// appear in a public signature.
34
54
  public var rawBaseAddress: UnsafeRawPointer? {
35
- return baseAddress.map { UnsafeRawPointer($0) }
36
- }
37
-
38
- /// The number of values in the buffer.
39
- public var count: Int {
40
- return bufferPointer.count
55
+ return start.map { UnsafeRawPointer($0) }
41
56
  }
42
57
 
43
58
  internal init(
@@ -45,8 +60,8 @@ public struct JavaScriptValuesBuffer: JavaScriptType, ~Copyable {
45
60
  ownsMemory: Bool = false
46
61
  ) {
47
62
  self.runtime = runtime
48
- self.iRuntime = runtime.pointee
49
- self.bufferPointer = buffer
63
+ self.start = UnsafeMutableRawPointer(buffer.baseAddress)
64
+ self.count = buffer.count
50
65
  self.ownsMemory = ownsMemory
51
66
  }
52
67
 
@@ -80,19 +80,15 @@ public struct JavaScriptPromise: JavaScriptType, ~Copyable {
80
80
  public init(_ runtime: JavaScriptRuntime) throws {
81
81
  self.runtime = runtime
82
82
 
83
- // Create function that is the promise setup. It is called immediately on `callAsConstructor`.
84
- let setup = runtime.createFunction { [weak longLivedState] this, arguments in
85
- longLivedState?.resolveFunction.reset(arguments[0])
86
- longLivedState?.rejectFunction.reset(arguments[1])
87
- return .undefined
88
- }
89
-
90
- let object =
91
- try runtime
92
- .global()
93
- .getPropertyAsFunction(.cached(runtime, "Promise"))
94
- .callAsConstructor(setup.asValue())
95
- longLivedState.object.reset(object)
83
+ // The promise and its two settle functions come from a JavaScript closure that returns all three
84
+ // at once, rather than from `new Promise(executor)` with a host function as the executor. The
85
+ // host function route costs a native function and a Swift context object per promise, a re-entry
86
+ // from the engine into Swift while the constructor runs, and an owning copy of each settle
87
+ // function on the way back. The closure route is one JS call and three array reads.
88
+ let triple = try runtime.deferredPromiseFactory().getFunction().call().getArray()
89
+ longLivedState.object.reset(try triple.getValue(at: 0))
90
+ longLivedState.resolveFunction.reset(try triple.getValue(at: 1))
91
+ longLivedState.rejectFunction.reset(try triple.getValue(at: 2))
96
92
  try setUpCallbacks()
97
93
  // Register only after setup succeeds, so a failed initializer (e.g. `then` unavailable) doesn't
98
94
  // leave the state pinned in the collection until teardown. Owns the promise's JSI values from
@@ -121,6 +117,13 @@ public struct JavaScriptPromise: JavaScriptType, ~Copyable {
121
117
  } ?? .undefined
122
118
  }
123
119
 
120
+ /// Resolves the promise with a value that has a direct JavaScript representation.
121
+ ///
122
+ /// Preferred over the encodable overload for a type that is both ``JavaScriptRepresentable`` and
123
+ /// ``JavaScriptEncodable``, so existing values (primitives, containers, JSI value wrappers) keep
124
+ /// their established representation. For example a 64-bit integer stays a JS `number` here rather
125
+ /// than encoding to a `bigint` or rejecting for exceeding the safe-integer range.
126
+ /// If the resolver call throws, the promise is rejected instead.
124
127
  public func resolve<V: JavaScriptRepresentable>(_ value: V) {
125
128
  guard let runtime else {
126
129
  return
@@ -132,13 +135,58 @@ public struct JavaScriptPromise: JavaScriptType, ~Copyable {
132
135
  guard let resolver = longLivedState.resolveFunction.take() else {
133
136
  return
134
137
  }
135
- // Call the actual resolver given in the Promise setup.
136
- // This will also call `deferredPromise.resolve` in the `then` handler.
137
- _ = try! resolver.getFunction().call(arguments: value)
138
+ do {
139
+ // Call the actual resolver given in the Promise setup.
140
+ // This will also call `deferredPromise.resolve` in the `then` handler.
141
+ _ = try resolver.getFunction().call(arguments: value)
138
142
 
139
- // The rejecter can't be called anymore. The state stays registered so it keeps owning the
140
- // object until the wrapper is dropped (or the teardown sweep runs).
141
- longLivedState.rejectFunction.release()
143
+ // The rejecter can't be called anymore. The state stays registered so it keeps owning the
144
+ // object until the wrapper is dropped (or the teardown sweep runs).
145
+ longLivedState.rejectFunction.release()
146
+ } catch {
147
+ // The resolver call failed; reject with the error instead. The rejecter call itself can
148
+ // realistically only throw when the runtime is being torn down, where dropping the settle
149
+ // is harmless because the JS world is going away.
150
+ let errorValue = JavaScriptError.from(error, in: runtime).toValue()
151
+ _ = try? longLivedState.rejectFunction.take()?.getFunction().call(arguments: errorValue)
152
+ longLivedState.resolveFunction.release()
153
+ }
154
+ }
155
+ }
156
+
157
+ /// Resolves the promise with a ``JavaScriptEncodable`` value, encoding it on the JavaScript thread.
158
+ ///
159
+ /// Encoding runs where `encode` is isolated to `@JavaScriptActor` and may touch the runtime, so a
160
+ /// caller need not hop there itself. If encoding or the resolver call throws, the promise is
161
+ /// rejected instead.
162
+ ///
163
+ /// Disfavored so a type that is both ``JavaScriptRepresentable`` and ``JavaScriptEncodable`` keeps
164
+ /// resolving through the representable overload above; this serves the encodable-only types.
165
+ @_disfavoredOverload
166
+ public func resolve<V: JavaScriptEncodable>(_ value: sending V) {
167
+ guard let runtime else {
168
+ return
169
+ }
170
+ // `resolve` is not isolated, so make sure to jump to JS thread; the encode happens there too.
171
+ runtime.schedule(priority: .immediate) { [longLivedState] in
172
+ // If the promise is already settled, do nothing.
173
+ guard let resolver = longLivedState.resolveFunction.take() else {
174
+ return
175
+ }
176
+ do {
177
+ let encoded = try V.encode(value, in: runtime)
178
+ // Call the actual resolver, which also calls `deferredPromise.resolve` in the `then` handler.
179
+ _ = try resolver.getFunction().call(arguments: encoded)
180
+ // The state stays registered so it keeps owning the object until the wrapper is dropped.
181
+ longLivedState.rejectFunction.release()
182
+ } catch {
183
+ // Encoding or the resolver call failed; reject with the error instead. The rejecter call
184
+ // itself can realistically only throw when the runtime is being torn down, where dropping
185
+ // the settle is harmless because the JS world is going away.
186
+ let errorValue = JavaScriptError.from(error, in: runtime).toValue()
187
+ _ = try? longLivedState.rejectFunction.take()?.getFunction().call(arguments: errorValue)
188
+ longLivedState.resolveFunction.release()
189
+ }
142
190
  }
143
191
  }
144
192
 
@@ -161,7 +209,9 @@ public struct JavaScriptPromise: JavaScriptType, ~Copyable {
161
209
 
162
210
  // Call the actual rejecter given in the Promise setup.
163
211
  // This will also call `deferredPromise.reject` in the `then` handler.
164
- _ = try! rejecter.getFunction().call(arguments: errorValue)
212
+ // The call can realistically only throw when the runtime is being torn down, where dropping
213
+ // the settle is harmless because the JS world is going away.
214
+ _ = try? rejecter.getFunction().call(arguments: errorValue)
165
215
 
166
216
  // The resolver can't be called anymore. The state stays registered so it keeps owning the
167
217
  // object until the wrapper is dropped (or the teardown sweep runs).
@@ -201,3 +251,29 @@ public struct JavaScriptPromise: JavaScriptType, ~Copyable {
201
251
  }
202
252
  }
203
253
  }
254
+
255
+ // MARK: - Deferred promise factory
256
+
257
+ extension JavaScriptRuntime {
258
+ /// Returns the cached `() => [promise, resolve, reject]` function as a value, creating it on first use.
259
+ /// Evaluated from source so it captures nothing native; it is plain JavaScript the engine can
260
+ /// optimize like any other closure.
261
+ @JavaScriptActor
262
+ fileprivate func deferredPromiseFactory() throws -> JavaScriptValue {
263
+ if let factory = cachedDeferredPromiseFactory {
264
+ return factory
265
+ }
266
+ let factory = try eval(
267
+ label: "expo-modules-jsi/deferred-promise.js",
268
+ """
269
+ (function () {
270
+ let resolve, reject;
271
+ const promise = new Promise(function (a, b) { resolve = a; reject = b; });
272
+ return [promise, resolve, reject];
273
+ })
274
+ """
275
+ )
276
+ cachedDeferredPromiseFactory = factory
277
+ return factory
278
+ }
279
+ }