expo-modules-jsi 56.0.8 → 56.0.10

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,19 @@
10
10
 
11
11
  ### 💡 Others
12
12
 
13
+ ## 56.0.10 — 2026-06-15
14
+
15
+ ### 🐛 Bug fixes
16
+
17
+ - [iOS] Ignore already-settled promises. ([#46765](https://github.com/expo/expo/pull/46765) by [@jakex7](https://github.com/jakex7))
18
+
19
+ ## 56.0.9 — 2026-06-10
20
+
21
+ ### 🎉 New features
22
+
23
+ - [iOS] Add closure-taking `JavaScriptObject.setProperty(_:function:)` overloads that create a sync or async host function from the given closure. ([#46622](https://github.com/expo/expo/pull/46622) by [@tsapeta](https://github.com/tsapeta))
24
+ - [iOS] Add `JavaScriptUnownedValue`, a non-owning, non-copyable value that borrows a `jsi::Value` for the zero-copy argument-decode fast path. ([#46616](https://github.com/expo/expo/pull/46616) by [@tsapeta](https://github.com/tsapeta))
25
+
13
26
  ## 56.0.8 — 2026-06-05
14
27
 
15
28
  ### 🐛 Bug fixes
@@ -61,6 +61,17 @@ public struct JavaScriptValuesBuffer: JavaScriptType, ~Copyable {
61
61
  return JavaScriptValue(runtime, bufferPointer[index])
62
62
  }
63
63
 
64
+ /// Returns a non-owning, non-copyable value borrowing the element at `index` for the zero-copy decode
65
+ /// path. It borrows the `jsi::Value` this buffer owns, so it is valid only while the buffer is alive
66
+ /// and must not be stored or escaped — see ``JavaScriptUnownedValue``.
67
+ ///
68
+ /// Unlike `subscript(_:)`, this is unchecked: `index` must be in `0..<count`. The accessor force-unwraps
69
+ /// `baseAddress`, so passing any index into an empty buffer crashes, and an out-of-range index reads past
70
+ /// the buffer. The caller is responsible for the bounds check.
71
+ public func unownedValue(at index: Int) -> JavaScriptUnownedValue {
72
+ return JavaScriptUnownedValue(runtime, bufferPointer.baseAddress! + index)
73
+ }
74
+
64
75
  @discardableResult
65
76
  internal consuming func set<T: JSIRepresentable>(value: borrowing T, atIndex index: Int) -> JavaScriptValuesBuffer
66
77
  where T: ~Copyable {
@@ -256,6 +256,30 @@ public struct JavaScriptObject: JavaScriptType, Sendable, ~Copyable {
256
256
  expo.setProperty(runtime.pointee, pointee, name, facebook.jsi.Value(runtime.pointee, object.pointee))
257
257
  }
258
258
 
259
+ /// Sets a property to a synchronous host function created from the given closure. The function
260
+ /// is named after the property and runs the closure when called from JavaScript, returning its
261
+ /// result synchronously. Equivalent to `setProperty(name, runtime.createFunction(name) { … })`.
262
+ @JavaScriptActor
263
+ public func setProperty(_ name: String, function: sending @escaping JavaScriptRuntime.SyncFunctionClosure) {
264
+ guard let runtime else {
265
+ FatalError.runtimeLost()
266
+ }
267
+ setProperty(name, value: runtime.createFunction(name, function))
268
+ }
269
+
270
+ /// Sets a property to an asynchronous host function created from the given closure. The function
271
+ /// is named after the property and runs the closure when called from JavaScript, returning a
272
+ /// promise that resolves with its result. Equivalent to
273
+ /// `setProperty(name, runtime.createAsyncFunction(name) { … })`. The `async` closure body
274
+ /// selects this overload over the synchronous `setProperty(_:_:)`.
275
+ @JavaScriptActor
276
+ public func setProperty(_ name: String, function: sending @escaping JavaScriptRuntime.AsyncFunctionClosure) {
277
+ guard let runtime else {
278
+ FatalError.runtimeLost()
279
+ }
280
+ setProperty(name, value: runtime.createAsyncFunction(name, function))
281
+ }
282
+
259
283
  /// Deletes a property with the given name. After calling this function,
260
284
  /// `hasProperty` will return `false`, and `getProperty` will return `undefined` value.
261
285
  #if !os(macOS)
@@ -75,15 +75,16 @@ public struct JavaScriptPromise: JavaScriptType, ~Copyable {
75
75
  guard let runtime else {
76
76
  return
77
77
  }
78
- guard !resolveFunction.isEmpty else {
79
- preconditionFailure("Cannot settle a promise more than once")
80
- }
81
78
 
82
79
  // `resolve` is not isolated, so make sure to jump to JS thread.
83
80
  runtime.schedule(priority: .immediate) { [resolveFunction, rejectFunction] in
81
+ // If the promise is already settled, do nothing.
82
+ guard let resolver = resolveFunction.take() else {
83
+ return
84
+ }
84
85
  // Call the actual resolver given in the Promise setup.
85
86
  // This will also call `deferredPromise.resolve` in the `then` handler.
86
- _ = try! resolveFunction.take().getFunction().call(arguments: value)
87
+ _ = try! resolver.getFunction().call(arguments: value)
87
88
 
88
89
  // Release the rejecter, we cannot call it anymore.
89
90
  rejectFunction.release()
@@ -94,19 +95,20 @@ public struct JavaScriptPromise: JavaScriptType, ~Copyable {
94
95
  guard let runtime else {
95
96
  return
96
97
  }
97
- guard !rejectFunction.isEmpty else {
98
- preconditionFailure("Cannot settle a promise more than once")
99
- }
100
98
 
101
99
  // `reject` is not isolated, so make sure to jump to JS thread.
102
100
  runtime.schedule(priority: .immediate) { [resolveFunction, rejectFunction] in
101
+ // If the promise is already settled, do nothing.
102
+ guard let rejecter = rejectFunction.take() else {
103
+ return
104
+ }
103
105
  // Create a JS error from any (native) error.
104
106
  let errorMessage = String(describing: error)
105
107
  let errorValue = JavaScriptError(runtime, message: errorMessage).asValue()
106
108
 
107
109
  // Call the actual rejecter given in the Promise setup.
108
110
  // This will also call `deferredPromise.reject` in the `then` handler.
109
- _ = try! rejectFunction.take().getFunction().call(arguments: errorValue)
111
+ _ = try! rejecter.getFunction().call(arguments: errorValue)
110
112
 
111
113
  // Release the resolver, we cannot call it anymore.
112
114
  resolveFunction.release()
@@ -0,0 +1,137 @@
1
+ // Copyright 2025-present 650 Industries. All rights reserved.
2
+
3
+ internal import ExpoModulesJSI_Cxx
4
+ import Foundation
5
+ internal import jsi
6
+
7
+ /// A non-owning, non-copyable `JavaScriptValue` that borrows a `facebook.jsi.Value` owned elsewhere —
8
+ /// typically an argument still living in the `JavaScriptValuesBuffer` for the duration of a host
9
+ /// function call.
10
+ ///
11
+ /// Unlike ``JavaScriptValue`` (a `final class` that owns its `jsi::Value`) and ``JavaScriptRef``
12
+ /// (an owning reference that promotes a value to reference semantics so it *can* escape), an unowned
13
+ /// value owns nothing: it borrows a `jsi::Value` whose lifetime is guaranteed by someone else. It
14
+ /// exists to feed the argument-decode fast path, where wrapping each argument in a heap-allocated
15
+ /// owning `JavaScriptValue` (ARC plus a real `jsi::Value` copy, per argument, per call) is pure
16
+ /// overhead — `decode` only needs to *read* the argument long enough to extract a `Double`/`String`/etc.
17
+ ///
18
+ /// > Warning: Lifetime safety rests on convention, not the compiler. `~Copyable` prevents *aliasing*
19
+ /// > the value but does not enforce that the owning buffer outlives it. Unlike Swift's `unowned(safe)`
20
+ /// > class refs, there is no trap on use-after-free. The contract is "valid only within the synchronous
21
+ /// > decode call, while the owner is alive" — which the buffer-driven decode path honors because it
22
+ /// > reads the value inline on the JS thread before the buffer is torn down. Do not store, capture, or
23
+ /// > escape it; call ``copied()`` to materialize an owning value when escape is needed.
24
+ public struct JavaScriptUnownedValue: ~Copyable {
25
+ // Borrows the `jsi::Value` at this address; it does not own it and must not outlive the owner.
26
+ internal let pointer: UnsafePointer<facebook.jsi.Value>
27
+
28
+ // Non-optional `unowned`, matching `JavaScriptValuesBuffer.runtime`: it is strictly call-scoped and
29
+ // cannot outlive the runtime executing the call, so we skip both the ARC traffic and the optional unwrap
30
+ // that the owning `JavaScriptValue` pays.
31
+ internal unowned let runtime: JavaScriptRuntime
32
+
33
+ internal init(_ runtime: JavaScriptRuntime, _ pointer: UnsafePointer<facebook.jsi.Value>) {
34
+ self.runtime = runtime
35
+ self.pointer = pointer
36
+ }
37
+
38
+ /// Materializes an owning ``JavaScriptValue`` by copying the borrowed `jsi::Value`. Use it when the
39
+ /// value must outlive the decode call (stored, captured, handed to a `Promise`).
40
+ public func copied() -> JavaScriptValue {
41
+ return JavaScriptValue(runtime, pointer.pointee)
42
+ }
43
+
44
+ // MARK: - Type checks
45
+
46
+ public func isUndefined() -> Bool {
47
+ return pointer.pointee.isUndefined()
48
+ }
49
+
50
+ public func isNull() -> Bool {
51
+ return pointer.pointee.isNull()
52
+ }
53
+
54
+ public func isBool() -> Bool {
55
+ return pointer.pointee.isBool()
56
+ }
57
+
58
+ public func isNumber() -> Bool {
59
+ return pointer.pointee.isNumber()
60
+ }
61
+
62
+ public func isString() -> Bool {
63
+ return pointer.pointee.isString()
64
+ }
65
+
66
+ public func isSymbol() -> Bool {
67
+ return pointer.pointee.isSymbol()
68
+ }
69
+
70
+ public func isBigInt() -> Bool {
71
+ return pointer.pointee.isBigInt()
72
+ }
73
+
74
+ public func isObject() -> Bool {
75
+ return pointer.pointee.isObject()
76
+ }
77
+
78
+ // MARK: - Primitive accessors
79
+
80
+ /// Returns the value as a boolean, or asserts if not a boolean.
81
+ public func getBool() -> Bool {
82
+ assert(isBool(), "Value is not a boolean")
83
+ return pointer.pointee.getBool()
84
+ }
85
+
86
+ /// Returns the value as an integer, or asserts if not a number.
87
+ public func getInt() -> Int {
88
+ assert(isNumber(), "Value is not a number")
89
+ return Int(pointer.pointee.getNumber())
90
+ }
91
+
92
+ /// Returns the value as a double, or asserts if not a number.
93
+ public func getDouble() -> Double {
94
+ assert(isNumber(), "Value is not a number")
95
+ return pointer.pointee.getNumber()
96
+ }
97
+
98
+ /// Returns the value as a string, or asserts if not a string.
99
+ public func getString() -> String {
100
+ assert(isString(), "Value is not a string")
101
+ return String(pointer.pointee.getString(runtime.pointee).utf8(runtime.pointee))
102
+ }
103
+
104
+ // MARK: - Throwing conversions ("as functions")
105
+
106
+ /// Returns the value as a boolean, or throws `TypeError` if it is not a boolean.
107
+ public func asBool() throws(JavaScriptValue.TypeError) -> Bool {
108
+ guard isBool() else {
109
+ throw JavaScriptValue.TypeError(type: Bool.self)
110
+ }
111
+ return getBool()
112
+ }
113
+
114
+ /// Returns the value as an integer, or throws `TypeError` if it is not a number.
115
+ public func asInt() throws(JavaScriptValue.TypeError) -> Int {
116
+ guard isNumber() else {
117
+ throw JavaScriptValue.TypeError(type: Int.self)
118
+ }
119
+ return getInt()
120
+ }
121
+
122
+ /// Returns the value as a double, or throws `TypeError` if it is not a number.
123
+ public func asDouble() throws(JavaScriptValue.TypeError) -> Double {
124
+ guard isNumber() else {
125
+ throw JavaScriptValue.TypeError(type: Double.self)
126
+ }
127
+ return getDouble()
128
+ }
129
+
130
+ /// Returns the value as a string, or throws `TypeError` if it is not a string.
131
+ public func asString() throws(JavaScriptValue.TypeError) -> String {
132
+ guard isString() else {
133
+ throw JavaScriptValue.TypeError(type: String.self)
134
+ }
135
+ return getString()
136
+ }
137
+ }
@@ -223,6 +223,29 @@ struct JavaScriptObjectTests {
223
223
  #expect(try object.getPropertyAsObject("nested").getProperty("value").getInt() == 42)
224
224
  }
225
225
 
226
+ @Test
227
+ @JavaScriptActor
228
+ func `set property with sync function closure`() throws {
229
+ let object = JavaScriptObject(runtime)
230
+ object.setProperty("double") { this, arguments in
231
+ return JavaScriptValue(self.runtime, arguments[0].getInt() * 2)
232
+ }
233
+ let fn = try object.getPropertyAsFunction("double")
234
+ #expect(try fn.call(arguments: 21).getInt() == 42)
235
+ }
236
+
237
+ @Test
238
+ @JavaScriptActor
239
+ func `set property with async function closure`() async throws {
240
+ let object = JavaScriptObject(runtime)
241
+ object.setProperty("addAsync") { this, arguments async throws in
242
+ return JavaScriptValue(self.runtime, arguments[0].getInt() + arguments[1].getInt())
243
+ }
244
+ let fn = try object.getPropertyAsFunction("addAsync")
245
+ let result = try await fn.call(arguments: 20, 22).getPromise().await()
246
+ #expect(result.getInt() == 42)
247
+ }
248
+
226
249
  @Test
227
250
  func `delete property`() {
228
251
  let object = JavaScriptObject(runtime)
@@ -279,6 +279,23 @@ struct JavaScriptPromiseTests {
279
279
  #expect(result.getInt() == 99)
280
280
  }
281
281
 
282
+ @Test
283
+ func `settling promise more than once is ignored`() async throws {
284
+ struct TestError: Error, Sendable {}
285
+
286
+ let runtime = JavaScriptRuntime()
287
+ let promise = try JavaScriptPromise(runtime)
288
+
289
+ runtime.global().setProperty("testPromise", value: promise.asValue())
290
+ promise.resolve(42)
291
+ promise.reject(TestError())
292
+ promise.resolve(100)
293
+
294
+ let result = try await promise.await()
295
+
296
+ #expect(result.getInt() == 42)
297
+ }
298
+
282
299
  @Test
283
300
  func `promise all`() async throws {
284
301
  let runtime = JavaScriptRuntime()
@@ -0,0 +1,78 @@
1
+ import ExpoModulesJSI
2
+ import Testing
3
+
4
+ @Suite
5
+ @JavaScriptActor
6
+ struct JavaScriptUnownedValueTests {
7
+ let runtime = JavaScriptRuntime()
8
+
9
+ @Test
10
+ func `reads primitives without copying`() {
11
+ let buffer = JavaScriptValuesBuffer.allocate(in: runtime, with: 42, "hello", true)
12
+
13
+ // The view is `~Copyable`, so compare accessor results against literals rather than passing the
14
+ // view into `#expect` — the macro captures its operand and would otherwise require `Copyable`.
15
+ let number = buffer.unownedValue(at: 0)
16
+ #expect(number.isNumber() == true)
17
+ #expect(number.getInt() == 42)
18
+ #expect(number.getDouble() == 42)
19
+
20
+ let string = buffer.unownedValue(at: 1)
21
+ #expect(string.isString() == true)
22
+ #expect(string.getString() == "hello")
23
+
24
+ let bool = buffer.unownedValue(at: 2)
25
+ #expect(bool.isBool() == true)
26
+ #expect(bool.getBool() == true)
27
+ }
28
+
29
+ @Test
30
+ func `recognizes null and undefined`() {
31
+ let buffer = JavaScriptValuesBuffer.allocate(in: runtime, with: JavaScriptValue.null, JavaScriptValue.undefined)
32
+
33
+ let null = buffer.unownedValue(at: 0)
34
+ #expect(null.isNull() == true)
35
+ #expect(null.isUndefined() == false)
36
+
37
+ let undefined = buffer.unownedValue(at: 1)
38
+ #expect(undefined.isUndefined() == true)
39
+ #expect(undefined.isNull() == false)
40
+ }
41
+
42
+ @Test
43
+ func `throwing accessors validate the type`() throws {
44
+ let buffer = JavaScriptValuesBuffer.allocate(in: runtime, with: 42, "hello")
45
+
46
+ #expect(try buffer.unownedValue(at: 0).asInt() == 42)
47
+ #expect(try buffer.unownedValue(at: 0).asDouble() == 42)
48
+ #expect(try buffer.unownedValue(at: 1).asString() == "hello")
49
+
50
+ #expect(throws: JavaScriptValue.TypeError.self) {
51
+ try buffer.unownedValue(at: 0).asString()
52
+ }
53
+ #expect(throws: JavaScriptValue.TypeError.self) {
54
+ try buffer.unownedValue(at: 1).asInt()
55
+ }
56
+ #expect(throws: JavaScriptValue.TypeError.self) {
57
+ try buffer.unownedValue(at: 1).asBool()
58
+ }
59
+ }
60
+
61
+ @Test
62
+ func `copied materializes an owning value`() throws {
63
+ let buffer = JavaScriptValuesBuffer.allocate(in: runtime, with: "owned")
64
+ let owning = buffer.unownedValue(at: 0).copied()
65
+
66
+ #expect(try owning.asString() == "owned")
67
+ }
68
+
69
+ @Test
70
+ func `recognizes objects`() {
71
+ let object = runtime.createObject()
72
+ let buffer = JavaScriptValuesBuffer.allocate(in: runtime, with: object.asValue())
73
+
74
+ let view = buffer.unownedValue(at: 0)
75
+ #expect(view.isObject() == true)
76
+ #expect(view.isNumber() == false)
77
+ }
78
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-modules-jsi",
3
- "version": "56.0.8",
3
+ "version": "56.0.10",
4
4
  "description": "The JavaScript Interface for Expo Modules",
5
5
  "main": "index.js",
6
6
  "sideEffects": [],
@@ -41,7 +41,7 @@
41
41
  "./apple/scripts/test.sh"
42
42
  ]
43
43
  },
44
- "gitHead": "175f1e78e3444ca99ddea473faea6777a0656668",
44
+ "gitHead": "812dc007aefed0c432c0439fdfe05ee2f4f21da2",
45
45
  "scripts": {
46
46
  "build": "apple/scripts/build-xcframework.sh",
47
47
  "swift:format": "../../scripts/swift-format.sh",