expo-modules-jsi 57.0.7 → 57.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/CLAUDE.md +35 -0
  3. package/apple/Benchmarks/BenchmarkRunner.swift +115 -0
  4. package/apple/Benchmarks/FunctionCallBenchmarks.swift +45 -0
  5. package/apple/Benchmarks/HostFunctionBenchmarks.swift +217 -0
  6. package/apple/Benchmarks/StringConversionBenchmarks.swift +245 -0
  7. package/apple/Benchmarks/ValueAccessBenchmarks.swift +132 -0
  8. package/apple/Package.swift +10 -0
  9. package/apple/Sources/ExpoModulesJSI/Contexts/HostCallbackContext.swift +28 -0
  10. package/apple/Sources/ExpoModulesJSI/Contexts/HostFunctionContext.swift +18 -6
  11. package/apple/Sources/ExpoModulesJSI/Contexts/HostObjectContext.swift +7 -3
  12. package/apple/Sources/ExpoModulesJSI/Protocols/JSIRepresentable.swift +20 -13
  13. package/apple/Sources/ExpoModulesJSI/Protocols/JavaScriptRepresentable.swift +15 -3
  14. package/apple/Sources/ExpoModulesJSI/Runtime/JavaScriptPropNameID.swift +11 -7
  15. package/apple/Sources/ExpoModulesJSI/Runtime/JavaScriptRuntime.swift +80 -58
  16. package/apple/Sources/ExpoModulesJSI/Runtime/JavaScriptValuesBuffer.swift +7 -2
  17. package/apple/Sources/ExpoModulesJSI/Runtime/Values/JavaScriptArray.swift +2 -2
  18. package/apple/Sources/ExpoModulesJSI/Runtime/Values/JavaScriptBigInt.swift +1 -2
  19. package/apple/Sources/ExpoModulesJSI/Runtime/Values/JavaScriptObject.swift +18 -8
  20. package/apple/Sources/ExpoModulesJSI/Runtime/Values/JavaScriptUnownedValue.swift +2 -2
  21. package/apple/Sources/ExpoModulesJSI/Runtime/Values/JavaScriptValue.swift +18 -7
  22. package/apple/Sources/ExpoModulesJSI/Utilities/String+JSI.swift +130 -0
  23. package/apple/Sources/ExpoModulesJSI-Cxx/include/HostFunctionClosure.h +6 -3
  24. package/apple/Sources/ExpoModulesJSI-Cxx/include/HostObject.h +2 -1
  25. package/apple/Sources/ExpoModulesJSI-Cxx/include/HostObjectCallbacks.h +5 -3
  26. package/apple/Sources/ExpoModulesJSI-Cxx/include/JSIUtils.h +14 -3
  27. package/apple/Tests/JavaScriptArrayBufferTests.swift +2 -2
  28. package/apple/Tests/JavaScriptNativeStateTests.swift +10 -10
  29. package/apple/Tests/JavaScriptObjectTests.swift +79 -0
  30. package/apple/Tests/JavaScriptPropNameIDTests.swift +123 -0
  31. package/apple/Tests/JavaScriptRuntimeTests.swift +29 -0
  32. package/apple/Tests/JavaScriptTypedArrayTests.swift +1 -1
  33. package/apple/Tests/JavaScriptUnownedValueTests.swift +9 -0
  34. package/apple/Tests/JavaScriptValueTests.swift +102 -0
  35. package/apple/Tests/JavaScriptValuesBufferTests.swift +2 -2
  36. package/apple/Tests/JavaScriptWeakObjectTests.swift +2 -2
  37. package/apple/scripts/build-xcframework.sh +10 -0
  38. package/apple/scripts/generate-modulemap.sh +10 -1
  39. package/package.json +3 -2
@@ -178,16 +178,19 @@ open class JavaScriptRuntime: Equatable, Identifiable, @unchecked Sendable {
178
178
  getPropertyNames: @escaping @JavaScriptActor () -> [String] = { [] },
179
179
  dealloc: @escaping @JavaScriptActor () -> Void = {}
180
180
  ) -> JavaScriptObject {
181
- func getter(context: UnsafeMutableRawPointer, propertyName: UnsafePointer<CChar>) -> facebook.jsi.Value {
182
- let context = Unmanaged<HostObjectContext>.fromOpaque(context).takeUnretainedValue()
181
+ func getter(
182
+ context: UnsafeMutableRawPointer,
183
+ propertyName: UnsafePointer<CChar>,
184
+ resultPtr: UnsafeMutablePointer<facebook.jsi.Value>
185
+ ) {
183
186
  let propertyName = String(cString: propertyName)
187
+ nonisolated(unsafe) let resultPtr = resultPtr
184
188
 
185
- guard let runtime = context.runtime else {
186
- FatalError.runtimeLost()
187
- }
188
- return JavaScriptActor.assumeIsolated {
189
- return forwardingSwiftErrorsToJS(runtime: runtime) {
190
- return try context.get(propertyName).asJSIValue()
189
+ withGuaranteedContext(context) { (context: HostObjectContext, runtime) in
190
+ resultPtr.pointee = JavaScriptActor.assumeIsolated {
191
+ return forwardingSwiftErrorsToJS(runtime: runtime) {
192
+ return try context.get(propertyName).asJSIValue()
193
+ }
191
194
  }
192
195
  }
193
196
  }
@@ -195,34 +198,33 @@ open class JavaScriptRuntime: Equatable, Identifiable, @unchecked Sendable {
195
198
  func setter(
196
199
  context: UnsafeMutableRawPointer, propertyName: UnsafePointer<CChar>, valuePointer: UnsafeMutableRawPointer
197
200
  ) {
198
- let context = Unmanaged<HostObjectContext>.fromOpaque(context).takeUnretainedValue()
199
-
200
- guard let runtime = context.runtime else {
201
- FatalError.runtimeLost()
202
- }
203
- guard let set = context.set else {
204
- // Unreachable in practice: when the user passed `nil` for `set`, the call site
205
- // below at `expo.HostObjectCallbacks(...)` also passes `nil` to C++, and
206
- // `HostObjectCallbacks::set` throws a `jsi::JSError` directly instead of
207
- // calling back into Swift. Trap loudly so a future C++ refactor can't silently
208
- // swallow assignments.
209
- FatalError.readOnlyHostObjectSetterInvoked()
210
- }
211
- let value = JavaScriptValue(runtime, valuePointer.assumingMemoryBound(to: facebook.jsi.Value.self).move())
212
201
  let propertyName = String(cString: propertyName)
213
202
 
214
- JavaScriptActor.assumeIsolated {
215
- forwardingSwiftErrorsToJS(runtime: runtime) {
216
- try set(propertyName, value)
203
+ withGuaranteedContext(context) { (context: HostObjectContext, runtime) in
204
+ guard let set = context.set else {
205
+ // Unreachable in practice: when the user passed `nil` for `set`, the call site
206
+ // below at `expo.HostObjectCallbacks(...)` also passes `nil` to C++, and
207
+ // `HostObjectCallbacks::set` throws a `jsi::JSError` directly instead of
208
+ // calling back into Swift. Trap loudly so a future C++ refactor can't silently
209
+ // swallow assignments.
210
+ FatalError.readOnlyHostObjectSetterInvoked()
211
+ }
212
+ let value = JavaScriptValue(runtime, valuePointer.assumingMemoryBound(to: facebook.jsi.Value.self).move())
213
+
214
+ JavaScriptActor.assumeIsolated {
215
+ forwardingSwiftErrorsToJS(runtime: runtime) {
216
+ try set(propertyName, value)
217
+ }
217
218
  }
218
219
  }
219
220
  }
220
221
 
221
222
  func propertyNamesGetter(context: UnsafeMutableRawPointer) -> expo.HostObjectCallbacks.PropNameIds {
222
223
  let context = Unmanaged<HostObjectContext>.fromOpaque(context).takeUnretainedValue()
223
-
224
- guard let runtime = context.runtime else {
225
- FatalError.runtimeLost()
224
+ // `IRuntime` is an immortal reference, so reading it through the guaranteed wrapper
225
+ // reference costs no reference counting here either.
226
+ let iRuntime = context.runtime._withUnsafeGuaranteedRef { runtime in
227
+ return runtime.pointee
226
228
  }
227
229
  // Get property names within the actor isolation, but build the vector outside
228
230
  // to avoid returning a non-copyable C++ type through `assumeIsolated`
@@ -235,7 +237,7 @@ open class JavaScriptRuntime: Equatable, Identifiable, @unchecked Sendable {
235
237
  vector.reserve(propertyNames.count)
236
238
 
237
239
  for propertyName in propertyNames {
238
- let propNameId = facebook.jsi.PropNameID.forUtf8(runtime.pointee, std.string(propertyName))
240
+ let propNameId = facebook.jsi.PropNameID.forUtf8(iRuntime, std.string(propertyName))
239
241
  vector.push_back(consuming: propNameId)
240
242
  }
241
243
  return vector
@@ -646,6 +648,22 @@ open class JavaScriptRuntime: Equatable, Identifiable, @unchecked Sendable {
646
648
  return result.is("Promise") ? try await result.getPromise().await() : result
647
649
  }
648
650
 
651
+ // MARK: - Garbage collection
652
+
653
+ /// Requests a full, synchronous garbage collection of the JavaScript heap.
654
+ ///
655
+ /// Intended for tests and memory diagnostics. The engine collects on its own, so calling this in
656
+ /// production code usually costs more than it saves.
657
+ ///
658
+ /// - Note: This is a no-op on engines whose runtime doesn't implement GC instrumentation. JSI's
659
+ /// default implementation does nothing; Hermes overrides it with a real collection.
660
+ /// - Parameter cause: Reason for the collection, as the engine should report it in its logs.
661
+ /// Defaults to the calling function's name.
662
+ @JavaScriptActor
663
+ public func collectGarbage(cause: String = #function) {
664
+ expo.collectGarbage(pointee, std.string(cause))
665
+ }
666
+
649
667
  // MARK: - Equatable
650
668
 
651
669
  public static func == (lhs: JavaScriptRuntime, rhs: JavaScriptRuntime) -> Bool {
@@ -727,15 +745,12 @@ private func createFunctionClosure(
727
745
  let context = Unmanaged.passRetained(HostFunctionContext(runtime: runtime, name: name, closure)).toOpaque()
728
746
 
729
747
  func call(
730
- context: UnsafeMutableRawPointer, thisPtr: UnsafePointer<facebook.jsi.Value>,
731
- argumentsPtr: UnsafePointer<facebook.jsi.Value>, argumentsCount: Int
732
- ) -> facebook.jsi.Value {
733
- let context = Unmanaged<HostFunctionContext>.fromOpaque(context).takeUnretainedValue()
734
-
735
- guard let runtime = context.runtime else {
736
- FatalError.runtimeLost()
737
- }
738
-
748
+ context: UnsafeMutableRawPointer,
749
+ thisPtr: UnsafePointer<facebook.jsi.Value>,
750
+ argumentsPtr: UnsafePointer<facebook.jsi.Value>,
751
+ argumentsCount: Int,
752
+ resultPtr: UnsafeMutablePointer<facebook.jsi.Value>
753
+ ) {
739
754
  // `assumeIsolated` runs `operation` synchronously, in this very scope — it never escapes and never
740
755
  // hops threads (see `JavaScriptActor.assumeIsolated`). So rather than materializing the move-only
741
756
  // `JavaScriptValuesBuffer` out here and smuggling it across the closure boundary through a
@@ -747,13 +762,18 @@ private func createFunctionClosure(
747
762
  // floor.
748
763
  nonisolated(unsafe) let thisPtr = thisPtr
749
764
  nonisolated(unsafe) let argumentsPtr = argumentsPtr
765
+ nonisolated(unsafe) let resultPtr = resultPtr
750
766
 
751
- return JavaScriptActor.assumeIsolated {
752
- return forwardingSwiftErrorsToJS(runtime: runtime) {
753
- let this = UnsafeMutablePointer(mutating: thisPtr).move()
754
- let arguments = JavaScriptValuesBuffer(runtime, start: argumentsPtr, count: argumentsCount)
755
- let thisValue = JavaScriptValue(runtime, this)
756
- return try context.call(thisValue, consume arguments).asJSIValue()
767
+ // See `withGuaranteedContext` for why neither the context nor the runtime is retained here, and
768
+ // 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 {
771
+ return forwardingSwiftErrorsToJS(runtime: runtime) {
772
+ let this = UnsafeMutablePointer(mutating: thisPtr).move()
773
+ let arguments = JavaScriptValuesBuffer(runtime, start: argumentsPtr, count: argumentsCount)
774
+ let thisValue = JavaScriptValue(runtime, this)
775
+ return try context.call(thisValue, consume arguments).asJSIValue()
776
+ }
757
777
  }
758
778
  }
759
779
  }
@@ -772,15 +792,12 @@ private func createFunctionClosure(
772
792
  let context = Unmanaged.passRetained(UnownedThisHostFunctionContext(runtime: runtime, name: name, closure)).toOpaque()
773
793
 
774
794
  func call(
775
- context: UnsafeMutableRawPointer, thisPtr: UnsafePointer<facebook.jsi.Value>,
776
- argumentsPtr: UnsafePointer<facebook.jsi.Value>, argumentsCount: Int
777
- ) -> facebook.jsi.Value {
778
- let context = Unmanaged<UnownedThisHostFunctionContext>.fromOpaque(context).takeUnretainedValue()
779
-
780
- guard let runtime = context.runtime else {
781
- FatalError.runtimeLost()
782
- }
783
-
795
+ context: UnsafeMutableRawPointer,
796
+ thisPtr: UnsafePointer<facebook.jsi.Value>,
797
+ argumentsPtr: UnsafePointer<facebook.jsi.Value>,
798
+ argumentsCount: Int,
799
+ resultPtr: UnsafeMutablePointer<facebook.jsi.Value>
800
+ ) {
784
801
  // Same call-scoped reasoning as the owning-`this` overload above (see its comment) for why the
785
802
  // buffer is built inside the synchronous `assumeIsolated` closure. Here `this` is additionally
786
803
  // handed in as a borrowed `JavaScriptUnownedValue` pointing straight at the C++-owned `this` slot:
@@ -788,12 +805,17 @@ private func createFunctionClosure(
788
805
  // per-call `weak`-runtime form/destroy and heap object that the owning `this` pays.
789
806
  nonisolated(unsafe) let thisPtr = thisPtr
790
807
  nonisolated(unsafe) let argumentsPtr = argumentsPtr
808
+ nonisolated(unsafe) let resultPtr = resultPtr
791
809
 
792
- return JavaScriptActor.assumeIsolated {
793
- return forwardingSwiftErrorsToJS(runtime: runtime) {
794
- let arguments = JavaScriptValuesBuffer(runtime, start: argumentsPtr, count: argumentsCount)
795
- let thisValue = JavaScriptUnownedValue(runtime.pointee, thisPtr)
796
- return try context.call(thisValue, consume arguments).asJSIValue()
810
+ // See `withGuaranteedContext` for why neither the context nor the runtime is retained here, and
811
+ // 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 {
814
+ return forwardingSwiftErrorsToJS(runtime: runtime) {
815
+ let arguments = JavaScriptValuesBuffer(runtime, start: argumentsPtr, count: argumentsCount)
816
+ let thisValue = JavaScriptUnownedValue(runtime.pointee, thisPtr)
817
+ return try context.call(thisValue, consume arguments).asJSIValue()
818
+ }
797
819
  }
798
820
  }
799
821
  }
@@ -6,7 +6,12 @@ internal import jsi
6
6
  public struct JavaScriptValuesBuffer: JavaScriptType, ~Copyable {
7
7
  // Safe to use unowned — the buffer's lifetime is scoped to a host function call,
8
8
  // so the runtime is always alive while the buffer exists.
9
- internal unowned let runtime: JavaScriptRuntime
9
+ //
10
+ // `unowned(unsafe)` rather than `unowned`: the runtime object carries a refcount side table (other
11
+ // wrappers hold it `weak`), so a safe `unowned` retain/release on it takes the slow side-table path
12
+ // 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.
14
+ internal unowned(unsafe) let runtime: JavaScriptRuntime
10
15
 
11
16
  // The raw `facebook.jsi.IRuntime`, cached alongside the `JavaScriptRuntime` wrapper. `IRuntime` is
12
17
  // an immortal reference (`jsi.apinotes`), so reading it costs no ARC, whereas reading `.pointee`
@@ -65,7 +70,7 @@ public struct JavaScriptValuesBuffer: JavaScriptType, ~Copyable {
65
70
  }
66
71
 
67
72
  public subscript(index: Int) -> JavaScriptValue {
68
- return JavaScriptValue(runtime, bufferPointer[index])
73
+ return JavaScriptValue(runtime, copying: bufferPointer[index])
69
74
  }
70
75
 
71
76
  /// Returns a non-owning, non-copyable value borrowing the element at `index` for the zero-copy decode
@@ -404,7 +404,7 @@ public struct JavaScriptArray: JavaScriptType, ~Copyable {
404
404
  guard let runtime else {
405
405
  FatalError.runtimeLost()
406
406
  }
407
- let jsiValue = expo.getProperty(runtime.pointee, pointee, .forUtf8(runtime.pointee, key, key.count))
407
+ let jsiValue = expo.getProperty(runtime.pointee, pointee, key.toJSIPropNameID(in: runtime.pointee))
408
408
  return JavaScriptValue(runtime, jsiValue)
409
409
  }
410
410
  nonmutating set(newValue) {
@@ -412,7 +412,7 @@ public struct JavaScriptArray: JavaScriptType, ~Copyable {
412
412
  FatalError.runtimeLost()
413
413
  }
414
414
  let jsiValue = newValue.toJSIValue(in: runtime.pointee)
415
- expo.setProperty(runtime.pointee, pointee, key, jsiValue)
415
+ expo.setProperty(runtime.pointee, pointee, key.toJSIPropNameID(in: runtime.pointee), jsiValue)
416
416
  }
417
417
  }
418
418
 
@@ -231,8 +231,7 @@ public struct JavaScriptBigInt: JavaScriptType, Sendable, ~Copyable {
231
231
  guard radix >= 2 && radix <= 36 else {
232
232
  throw BigIntConversionError.invalidRadix(radix)
233
233
  }
234
- let jsiString = pointee.toString(runtime.pointee, Int32(radix))
235
- return String(jsiString.utf16(runtime.pointee))
234
+ return String(jsiString: pointee.toString(runtime.pointee, Int32(radix)), in: runtime.pointee)
236
235
  }
237
236
 
238
237
  // MARK: - JavaScriptType
@@ -105,7 +105,7 @@ public struct JavaScriptObject: JavaScriptType, Sendable, ~Copyable {
105
105
  guard let runtime else {
106
106
  FatalError.runtimeLost()
107
107
  }
108
- return pointee.hasProperty(runtime.pointee, name)
108
+ return pointee.hasProperty(runtime.pointee, name.toJSIPropNameID(in: runtime.pointee))
109
109
  }
110
110
 
111
111
  /// Returns the property of the object with the given name,
@@ -114,7 +114,7 @@ public struct JavaScriptObject: JavaScriptType, Sendable, ~Copyable {
114
114
  guard let runtime else {
115
115
  FatalError.runtimeLost()
116
116
  }
117
- return JavaScriptValue(runtime, pointee.getProperty(runtime.pointee, name))
117
+ return JavaScriptValue(runtime, pointee.getProperty(runtime.pointee, name.toJSIPropNameID(in: runtime.pointee)))
118
118
  }
119
119
 
120
120
  /// Returns the property of the object with the given prop name id,
@@ -163,7 +163,7 @@ public struct JavaScriptObject: JavaScriptType, Sendable, ~Copyable {
163
163
  let count = propertyNames.size(jsiRuntime)
164
164
 
165
165
  return (0..<count).map { i in
166
- return String(propertyNames.getValueAtIndex(jsiRuntime, i).getString(jsiRuntime).utf8(jsiRuntime))
166
+ return String(jsiString: propertyNames.getValueAtIndex(jsiRuntime, i).getString(jsiRuntime), in: jsiRuntime)
167
167
  }
168
168
  }
169
169
 
@@ -230,7 +230,12 @@ public struct JavaScriptObject: JavaScriptType, Sendable, ~Copyable {
230
230
  FatalError.runtimeLost()
231
231
  }
232
232
  // This specialization is to avoid copying the value; `asValue()` on `JavaScriptValue` needs to do a copy.
233
- expo.setProperty(runtime.pointee, pointee, name, value.toJSIValue(in: runtime.pointee))
233
+ expo.setProperty(
234
+ runtime.pointee,
235
+ pointee,
236
+ name.toJSIPropNameID(in: runtime.pointee),
237
+ value.toJSIValue(in: runtime.pointee)
238
+ )
234
239
  }
235
240
 
236
241
  public func setProperty<T: JavaScriptRepresentable & ~Copyable>(_ name: String, value: consuming T) {
@@ -238,7 +243,7 @@ public struct JavaScriptObject: JavaScriptType, Sendable, ~Copyable {
238
243
  FatalError.runtimeLost()
239
244
  }
240
245
  let jsiValue = value.toJavaScriptValue(in: runtime).toJSIValue(in: runtime.pointee)
241
- expo.setProperty(runtime.pointee, pointee, name, jsiValue)
246
+ expo.setProperty(runtime.pointee, pointee, name.toJSIPropNameID(in: runtime.pointee), jsiValue)
242
247
  }
243
248
 
244
249
  internal func setProperty<T: JavaScriptRepresentable>(_ name: String, value: consuming T) where T: JSIRepresentable {
@@ -246,14 +251,19 @@ public struct JavaScriptObject: JavaScriptType, Sendable, ~Copyable {
246
251
  FatalError.runtimeLost()
247
252
  }
248
253
  let jsiValue = value.toJSIValue(in: runtime.pointee)
249
- expo.setProperty(runtime.pointee, pointee, name, jsiValue)
254
+ expo.setProperty(runtime.pointee, pointee, name.toJSIPropNameID(in: runtime.pointee), jsiValue)
250
255
  }
251
256
 
252
257
  public func setProperty(_ name: String, _ object: consuming JavaScriptObject) {
253
258
  guard let runtime else {
254
259
  FatalError.runtimeLost()
255
260
  }
256
- expo.setProperty(runtime.pointee, pointee, name, facebook.jsi.Value(runtime.pointee, object.pointee))
261
+ expo.setProperty(
262
+ runtime.pointee,
263
+ pointee,
264
+ name.toJSIPropNameID(in: runtime.pointee),
265
+ facebook.jsi.Value(runtime.pointee, object.pointee)
266
+ )
257
267
  }
258
268
 
259
269
  /// Sets a property to a synchronous host function created from the given closure. The function
@@ -307,7 +317,7 @@ public struct JavaScriptObject: JavaScriptType, Sendable, ~Copyable {
307
317
  guard let runtime else {
308
318
  FatalError.runtimeLost()
309
319
  }
310
- pointee.deleteProperty(runtime.pointee, name)
320
+ pointee.deleteProperty(runtime.pointee, name.toJSIPropNameID(in: runtime.pointee))
311
321
  }
312
322
  #endif
313
323
 
@@ -48,7 +48,7 @@ public struct JavaScriptUnownedValue: ~Copyable {
48
48
  assert(
49
49
  Unmanaged.passUnretained(runtime.pointee).toOpaque() == Unmanaged.passUnretained(self.runtime).toOpaque(),
50
50
  "`copied(in:)` must be passed the runtime that owns the borrowed value")
51
- return JavaScriptValue(runtime, pointer.pointee)
51
+ return JavaScriptValue(runtime, copying: pointer.pointee)
52
52
  }
53
53
 
54
54
  // MARK: - Type checks
@@ -108,7 +108,7 @@ public struct JavaScriptUnownedValue: ~Copyable {
108
108
  /// Returns the value as a string, or asserts if not a string.
109
109
  public func getString() -> String {
110
110
  assert(isString(), "Value is not a string")
111
- return String(pointer.pointee.getString(runtime).utf8(runtime))
111
+ return String(jsiString: pointer.pointee.getString(runtime), in: runtime)
112
112
  }
113
113
 
114
114
  /// Returns the value as a ``JavaScriptObject`` *without* materializing an owning ``JavaScriptValue``
@@ -10,14 +10,26 @@ public final class JavaScriptValue: JavaScriptType, Equatable, Escapable {
10
10
  internal weak let runtime: JavaScriptRuntime?
11
11
  internal let pointee: facebook.jsi.Value
12
12
 
13
- /// Initializer from the existing JSI value.
13
+ /// Takes ownership of the given JSI value. Only runtime-free values (undefined, null, booleans and
14
+ /// numbers) pass `nil` here.
14
15
  internal init(_ runtime: JavaScriptRuntime?, _ pointee: consuming facebook.jsi.Value) {
15
16
  self.runtime = runtime
16
17
  self.pointee = pointee
17
18
  }
18
19
 
19
- /// Copy initializer from the existing JSI value.
20
- internal init(_ runtime: JavaScriptRuntime, _ pointee: borrowing facebook.jsi.Value) {
20
+ /// Takes ownership of the given JSI value. Same as the optional-runtime overload, but the exact
21
+ /// parameter type keeps the caller from promoting its runtime to an optional, which costs a
22
+ /// retain/release pair around every call on the hot paths (property reads, function results).
23
+ internal init(_ runtime: JavaScriptRuntime, _ pointee: consuming facebook.jsi.Value) {
24
+ self.runtime = runtime
25
+ self.pointee = pointee
26
+ }
27
+
28
+ /// Copies the given JSI value, which asks the engine to clone the handle for pointer values.
29
+ /// The `copying:` label keeps this initializer from being picked for temporaries: with a plain
30
+ /// second argument, a non-optional runtime used to win overload resolution over the consuming
31
+ /// initializer above, cloning every freshly returned value instead of taking it over.
32
+ internal init(_ runtime: JavaScriptRuntime, copying pointee: borrowing facebook.jsi.Value) {
21
33
  self.runtime = runtime
22
34
  self.pointee = facebook.jsi.Value(runtime.pointee, pointee)
23
35
  }
@@ -31,8 +43,7 @@ public final class JavaScriptValue: JavaScriptType, Equatable, Escapable {
31
43
  /// Creates a string JS value.
32
44
  public init(_ runtime: JavaScriptRuntime, _ string: String) {
33
45
  self.runtime = runtime
34
- self.pointee = facebook.jsi.Value(
35
- runtime.pointee, facebook.jsi.String.createFromUtf8(runtime.pointee, std.string(string)))
46
+ self.pointee = string.toJSIValue(in: runtime.pointee)
36
47
  }
37
48
 
38
49
  /// Creates a BigInt JS value from an Int64.
@@ -251,7 +262,7 @@ public final class JavaScriptValue: JavaScriptType, Equatable, Escapable {
251
262
  FatalError.runtimeLost()
252
263
  }
253
264
  assert(isString(), "Value is not a string")
254
- return String(pointee.getString(jsiRuntime).utf8(jsiRuntime))
265
+ return String(jsiString: pointee.getString(jsiRuntime), in: jsiRuntime)
255
266
  }
256
267
 
257
268
  /// Returns the value as a BigInt, or asserts if not a BigInt.
@@ -418,7 +429,7 @@ public final class JavaScriptValue: JavaScriptType, Equatable, Escapable {
418
429
  guard let jsiRuntime = runtime?.pointee else {
419
430
  FatalError.runtimeLost()
420
431
  }
421
- return String(pointee.toString(jsiRuntime).utf8(jsiRuntime))
432
+ return String(jsiString: pointee.toString(jsiRuntime), in: jsiRuntime)
422
433
  }
423
434
 
424
435
  /// Converts the JavaScript value to a JSON string representation.
@@ -0,0 +1,130 @@
1
+ // Copyright 2026-present 650 Industries. All rights reserved.
2
+
3
+ internal import ExpoModulesJSI_Cxx
4
+ import Foundation
5
+ internal import jsi
6
+
7
+ extension String {
8
+ /// Builds a Swift `String` from a `jsi::String` by reading the engine's internal representation
9
+ /// through `getStringData`, without materializing a `std::string` first.
10
+ ///
11
+ /// Hermes stores strings as either ASCII bytes or UTF-16 code units and hands over whichever it
12
+ /// has, chunk by chunk (only rope strings arrive in several chunks). ASCII bytes are valid UTF-8 and
13
+ /// are copied as-is with no validation. UTF-16 is transcoded to UTF-8 on the Swift side, which is
14
+ /// several times cheaper than letting the engine produce UTF-8 through `utf8()` and validating it
15
+ /// again in Swift. Measured against `utf8()`: 12-byte ASCII unchanged, 4 KB ASCII 3.7x faster,
16
+ /// 4 KB non-ASCII 2.4x faster, 12-byte non-ASCII unchanged.
17
+ internal init(jsiString: borrowing facebook.jsi.String, in runtime: facebook.jsi.IRuntime) {
18
+ var result = ""
19
+ withUnsafeMutablePointer(to: &result) { resultPtr in
20
+ runtime.getStringData(jsiString, UnsafeMutableRawPointer(resultPtr), appendEngineStringChunk)
21
+ }
22
+ self = result
23
+ }
24
+
25
+ /// Builds a `jsi::PropNameID` from the string's UTF-8 bytes. This is how every String-keyed
26
+ /// property API turns its name into a key: JSI's `const char*` overloads treat the bytes as ASCII
27
+ /// and would mangle any non-ASCII name.
28
+ internal func toJSIPropNameID(in runtime: facebook.jsi.IRuntime) -> facebook.jsi.PropNameID {
29
+ // The `(pointer, length)` overload needs the UTF-8 *byte* count, which `withUTF8` provides
30
+ // exactly; `String.count` would be wrong here because it counts grapheme clusters (e.g. `"café"`
31
+ // reports 4 for 5 UTF-8 bytes). `withUTF8` is mutating (it makes a bridged string contiguous
32
+ // first), hence the local copy, and the result leaves the closure through an optional because
33
+ // it has to be a `Copyable` type.
34
+ var string = self
35
+ var propNameID: facebook.jsi.PropNameID? = nil
36
+ string.withUTF8 { utf8 in
37
+ guard let base = utf8.baseAddress else {
38
+ propNameID = facebook.jsi.PropNameID.forAscii(runtime, "", 0)
39
+ return
40
+ }
41
+ propNameID = facebook.jsi.PropNameID.forUtf8(runtime, base, utf8.count)
42
+ }
43
+ return propNameID.take()!
44
+ }
45
+ }
46
+
47
+ /// Callback for `getStringData`. `ctx` points at the Swift `String` being built; each call appends
48
+ /// one chunk of the engine's internal representation to it.
49
+ private func appendEngineStringChunk(ctx: UnsafeMutableRawPointer?, ascii: Bool, data: UnsafeRawPointer?, count: Int) {
50
+ guard count > 0, let data else {
51
+ // An empty chunk adds nothing to the result. Guarding here also keeps the pointer
52
+ // arithmetic below free of optionals on both paths.
53
+ return
54
+ }
55
+ let out = ctx!.assumingMemoryBound(to: String.self)
56
+ let chunk: String
57
+ if ascii {
58
+ let bytes = UnsafeBufferPointer(start: data.assumingMemoryBound(to: UInt8.self), count: count)
59
+ chunk = String(unsafeUninitializedCapacity: count) { buffer in
60
+ return buffer.initialize(fromContentsOf: bytes)
61
+ }
62
+ } else {
63
+ let units = UnsafeBufferPointer(start: data.assumingMemoryBound(to: UInt16.self), count: count)
64
+ if count < 512 {
65
+ // Short strings: transcode straight into the string's UTF-8 storage, sized for the worst case
66
+ // of 3 UTF-8 bytes per UTF-16 code unit (a surrogate pair is 4 bytes for 2 units). This is
67
+ // 1.5-1.6x faster than `String(decoding:as:)` up to a few hundred code units, which carries a
68
+ // fixed setup cost of a few hundred nanoseconds.
69
+ chunk = String(unsafeUninitializedCapacity: count * 3) { buffer in
70
+ return transcodeUTF16(units, into: buffer)
71
+ }
72
+ } else {
73
+ // Long strings: the loop and the standard library's bulk decoder run at the same speed here,
74
+ // but the bulk decoder sizes the storage exactly, while the string above keeps its 3x worst-case
75
+ // capacity. That matters once strings are kilobytes rather than bytes.
76
+ chunk = String(decoding: units, as: UTF16.self)
77
+ }
78
+ }
79
+ if out.pointee.isEmpty {
80
+ out.pointee = chunk
81
+ } else {
82
+ out.pointee += chunk
83
+ }
84
+ }
85
+
86
+ /// Writes the UTF-8 encoding of `units` into `buffer` and returns the number of bytes written.
87
+ /// Unpaired surrogates become U+FFFD, one per code unit, which is what `String(decoding:as:)` and
88
+ /// `transcode(stoppingOnError: false)` produce. It replaces the standard library's `transcode`, which
89
+ /// calls back once per output byte; this loop writes each scalar's bytes directly.
90
+ /// `buffer` must hold at least 3 bytes per code unit.
91
+ private func transcodeUTF16(
92
+ _ units: UnsafeBufferPointer<UInt16>,
93
+ into buffer: UnsafeMutableBufferPointer<UInt8>
94
+ ) -> Int {
95
+ var index = 0
96
+ var written = 0
97
+ while index < units.count {
98
+ let unit = UInt32(units[index])
99
+ index += 1
100
+ let scalar: UInt32
101
+ if unit < 0xD800 || unit > 0xDFFF {
102
+ scalar = unit
103
+ } else if unit < 0xDC00, index < units.count, (0xDC00...0xDFFF).contains(units[index]) {
104
+ scalar = 0x10000 + ((unit - 0xD800) << 10) + (UInt32(units[index]) - 0xDC00)
105
+ index += 1
106
+ } else {
107
+ scalar = 0xFFFD
108
+ }
109
+ if scalar < 0x80 {
110
+ buffer[written] = UInt8(scalar)
111
+ written += 1
112
+ } else if scalar < 0x800 {
113
+ buffer[written] = UInt8(0xC0 | (scalar >> 6))
114
+ buffer[written + 1] = UInt8(0x80 | (scalar & 0x3F))
115
+ written += 2
116
+ } else if scalar < 0x10000 {
117
+ buffer[written] = UInt8(0xE0 | (scalar >> 12))
118
+ buffer[written + 1] = UInt8(0x80 | ((scalar >> 6) & 0x3F))
119
+ buffer[written + 2] = UInt8(0x80 | (scalar & 0x3F))
120
+ written += 3
121
+ } else {
122
+ buffer[written] = UInt8(0xF0 | (scalar >> 18))
123
+ buffer[written + 1] = UInt8(0x80 | ((scalar >> 12) & 0x3F))
124
+ buffer[written + 2] = UInt8(0x80 | ((scalar >> 6) & 0x3F))
125
+ buffer[written + 3] = UInt8(0x80 | (scalar & 0x3F))
126
+ written += 4
127
+ }
128
+ }
129
+ return written
130
+ }
@@ -12,7 +12,10 @@ namespace expo {
12
12
  */
13
13
  class HostFunctionClosure final : public RetainedSwiftPointer {
14
14
  public:
15
- using Closure = facebook::jsi::Value(Context context, const facebook::jsi::Value *_Nonnull thisValue, const facebook::jsi::Value *_Nonnull args, size_t count);
15
+ // The result travels through an out-parameter instead of a return value: the Swift side can then
16
+ // write it straight into the caller's slot from inside its guaranteed-reference scope, with no
17
+ // placeholder value to construct and no extra move of the `jsi::Value`.
18
+ using Closure = void(Context context, const facebook::jsi::Value *_Nonnull thisValue, const facebook::jsi::Value *_Nonnull args, size_t count, facebook::jsi::Value *_Nonnull result);
16
19
 
17
20
  explicit HostFunctionClosure(Context context, Closure closure, Deallocator deallocator) : RetainedSwiftPointer(context, deallocator), _closure(closure) {};
18
21
 
@@ -23,8 +26,8 @@ public:
23
26
  /**
24
27
  Calls the Swift closure with given `this` value and arguments.
25
28
  */
26
- inline facebook::jsi::Value call(const facebook::jsi::Value &thisValue, const facebook::jsi::Value *_Nonnull args, size_t count) const {
27
- return _closure(_context, &thisValue, args, count);
29
+ inline void call(const facebook::jsi::Value &thisValue, const facebook::jsi::Value *_Nonnull args, size_t count, facebook::jsi::Value &result) const {
30
+ _closure(_context, &thisValue, args, count, &result);
28
31
  }
29
32
 
30
33
  private:
@@ -23,7 +23,8 @@ public:
23
23
  }
24
24
 
25
25
  inline jsi::Value get(jsi::Runtime &runtime, const jsi::PropNameID &name) override {
26
- auto result = _callbacks.get(name.utf8(runtime).c_str());
26
+ jsi::Value result;
27
+ _callbacks.get(name.utf8(runtime).c_str(), result);
27
28
  // If the Swift getter stored a pending error, rethrow its JSError directly
28
29
  // to preserve all properties (message, code, stack, etc.).
29
30
  if (auto *error = CppError::getCurrent()) {
@@ -16,7 +16,9 @@ class HostObjectCallbacks final {
16
16
  public:
17
17
  using Context = void *_Nonnull;
18
18
  using PropNameIds = std::vector<facebook::jsi::PropNameID>;
19
- using Getter = facebook::jsi::Value(Context, const char *_Nonnull name);
19
+ // The getter writes its result through an out-parameter, like `HostFunctionClosure`, so the
20
+ // Swift side can fill the caller's slot from inside its guaranteed-reference scope.
21
+ using Getter = void(Context, const char *_Nonnull name, facebook::jsi::Value *_Nonnull result);
20
22
  using Setter = void(Context, const char *_Nonnull name, void *_Nonnull value);
21
23
  using PropertyNamesGetter = PropNameIds(Context);
22
24
  using Deallocator = void(Context);
@@ -24,8 +26,8 @@ public:
24
26
  explicit HostObjectCallbacks(Context context, Getter getter, Setter *_Nullable setter, PropertyNamesGetter propertyNamesGetter, Deallocator deallocator)
25
27
  : _context(context), _getter(getter), _setter(setter), _propertyNamesGetter(propertyNamesGetter), _deallocator(deallocator) {}
26
28
 
27
- inline facebook::jsi::Value get(const char *_Nonnull name) const {
28
- return _getter(_context, name);
29
+ inline void get(const char *_Nonnull name, facebook::jsi::Value &result) const {
30
+ _getter(_context, name, &result);
29
31
  }
30
32
 
31
33
  /**
@@ -5,6 +5,9 @@
5
5
 
6
6
  #include <TargetConditionals.h>
7
7
 
8
+ // `jsi.h` only forward-declares `jsi::Instrumentation`.
9
+ #include <jsi/instrumentation.h>
10
+
8
11
  #include "HostFunctionClosure.h"
9
12
  #include "CppError.h"
10
13
  #include "IRuntimeCompat.h"
@@ -36,11 +39,13 @@ inline jsi::Value valueFromFunction(jsi::IRuntime &runtime, const jsi::Function
36
39
  }
37
40
 
38
41
  // `jsi::Object::setProperty` is a template function that Swift does not support. We need to provide specialized versions.
39
- inline void setProperty(jsi::IRuntime &runtime, const jsi::Object &object, const char *name, const jsi::Value value) {
42
+ // They take a `jsi::PropNameID` rather than a `const char *`: JSI treats a `const char *` name as ASCII,
43
+ // which mangles non-ASCII property names coming from Swift strings.
44
+ inline void setProperty(jsi::IRuntime &runtime, const jsi::Object &object, const jsi::PropNameID &name, const jsi::Value &value) {
40
45
  object.setProperty(runtime, name, value);
41
46
  }
42
47
 
43
- inline void setProperty(jsi::IRuntime &runtime, const jsi::Array &array, const char *name, const jsi::Value &value) {
48
+ inline void setProperty(jsi::IRuntime &runtime, const jsi::Array &array, const jsi::PropNameID &name, const jsi::Value &value) {
44
49
  array.setProperty(runtime, name, value);
45
50
  }
46
51
 
@@ -97,7 +102,8 @@ inline std::shared_ptr<const jsi::Buffer> makeSharedStringBuffer(const std::stri
97
102
  inline jsi::Function createHostFunction(jsi::IRuntime &runtime, const jsi::PropNameID &propName, HostFunctionClosure *closure) {
98
103
  auto closurePtr = std::shared_ptr<HostFunctionClosure>(closure);
99
104
  return jsi::Function::createFromHostFunction(runtime, propName, 0, [closurePtr](jsi::Runtime &runtime, const jsi::Value &thisValue, const jsi::Value *_Nonnull args, size_t count) -> jsi::Value {
100
- auto result = closurePtr->call(thisValue, args, count);
105
+ jsi::Value result;
106
+ closurePtr->call(thisValue, args, count, result);
101
107
 
102
108
  // If the Swift closure stored a pending error, rethrow its JSError directly
103
109
  // to preserve all properties (message, code, stack, etc.).
@@ -141,6 +147,11 @@ inline jsi::Value evaluateJavaScript(jsi::IRuntime &runtime, const std::shared_p
141
147
  });
142
148
  }
143
149
 
150
+ // `jsi::Instrumentation` is not imported into Swift, so reach it from here.
151
+ inline void collectGarbage(jsi::IRuntime &runtime, const std::string &cause) {
152
+ runtime.instrumentation().collectGarbage(cause);
153
+ }
154
+
144
155
  inline jsi::Value callFunction(jsi::IRuntime &runtime, const jsi::Function &function, const jsi::Value *_Nullable args, size_t count) {
145
156
  return expo::CppError::tryCatch(runtime, [&] {
146
157
  return function.call(runtime, args, count);