From 8a107e65e3ff2305ee49e022bf421b5585cf336c Mon Sep 17 00:00:00 2001 From: Tomasz Sapeta Date: Sat, 6 Jun 2026 16:44:23 +0200 Subject: [PATCH] [ios][jsi] Add JavaScriptUnownedValue for zero-copy argument decoding (#46616) --- packages/expo-modules-jsi/CHANGELOG.md | 2 + .../Runtime/JavaScriptValuesBuffer.swift | 11 ++ .../Values/JavaScriptUnownedValue.swift | 137 ++++++++++++++++++ .../Tests/JavaScriptUnownedValueTests.swift | 78 ++++++++++ 4 files changed, 228 insertions(+) create mode 100644 packages/expo-modules-jsi/apple/Sources/ExpoModulesJSI/Runtime/Values/JavaScriptUnownedValue.swift create mode 100644 packages/expo-modules-jsi/apple/Tests/JavaScriptUnownedValueTests.swift diff --git a/packages/expo-modules-jsi/CHANGELOG.md b/packages/expo-modules-jsi/CHANGELOG.md index 6a7025472d044e..9efcee3d6644d1 100644 --- a/packages/expo-modules-jsi/CHANGELOG.md +++ b/packages/expo-modules-jsi/CHANGELOG.md @@ -6,6 +6,8 @@ ### 🎉 New features +- [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)) + ### 🐛 Bug fixes - [iOS] Fixed the xcframework build failing with a `sed` error when building in an environment that uses GNU `sed` instead of BSD `sed` (e.g. a Nix shell). ([#46389](https://github.com/expo/expo/pull/46389) by [@niteshbalusu11](https://github.com/niteshbalusu11)) diff --git a/packages/expo-modules-jsi/apple/Sources/ExpoModulesJSI/Runtime/JavaScriptValuesBuffer.swift b/packages/expo-modules-jsi/apple/Sources/ExpoModulesJSI/Runtime/JavaScriptValuesBuffer.swift index ad029e28d5985d..fcd23aa6d842ac 100644 --- a/packages/expo-modules-jsi/apple/Sources/ExpoModulesJSI/Runtime/JavaScriptValuesBuffer.swift +++ b/packages/expo-modules-jsi/apple/Sources/ExpoModulesJSI/Runtime/JavaScriptValuesBuffer.swift @@ -61,6 +61,17 @@ public struct JavaScriptValuesBuffer: JavaScriptType, ~Copyable { return JavaScriptValue(runtime, bufferPointer[index]) } + /// Returns a non-owning, non-copyable value borrowing the element at `index` for the zero-copy decode + /// path. It borrows the `jsi::Value` this buffer owns, so it is valid only while the buffer is alive + /// and must not be stored or escaped — see ``JavaScriptUnownedValue``. + /// + /// Unlike `subscript(_:)`, this is unchecked: `index` must be in `0.. JavaScriptUnownedValue { + return JavaScriptUnownedValue(runtime, bufferPointer.baseAddress! + index) + } + @discardableResult internal consuming func set(value: borrowing T, atIndex index: Int) -> JavaScriptValuesBuffer where T: ~Copyable { diff --git a/packages/expo-modules-jsi/apple/Sources/ExpoModulesJSI/Runtime/Values/JavaScriptUnownedValue.swift b/packages/expo-modules-jsi/apple/Sources/ExpoModulesJSI/Runtime/Values/JavaScriptUnownedValue.swift new file mode 100644 index 00000000000000..53635646ad207c --- /dev/null +++ b/packages/expo-modules-jsi/apple/Sources/ExpoModulesJSI/Runtime/Values/JavaScriptUnownedValue.swift @@ -0,0 +1,137 @@ +// Copyright 2025-present 650 Industries. All rights reserved. + +internal import ExpoModulesJSI_Cxx +import Foundation +internal import jsi + +/// A non-owning, non-copyable `JavaScriptValue` that borrows a `facebook.jsi.Value` owned elsewhere — +/// typically an argument still living in the `JavaScriptValuesBuffer` for the duration of a host +/// function call. +/// +/// Unlike ``JavaScriptValue`` (a `final class` that owns its `jsi::Value`) and ``JavaScriptRef`` +/// (an owning reference that promotes a value to reference semantics so it *can* escape), an unowned +/// value owns nothing: it borrows a `jsi::Value` whose lifetime is guaranteed by someone else. It +/// exists to feed the argument-decode fast path, where wrapping each argument in a heap-allocated +/// owning `JavaScriptValue` (ARC plus a real `jsi::Value` copy, per argument, per call) is pure +/// overhead — `decode` only needs to *read* the argument long enough to extract a `Double`/`String`/etc. +/// +/// > Warning: Lifetime safety rests on convention, not the compiler. `~Copyable` prevents *aliasing* +/// > the value but does not enforce that the owning buffer outlives it. Unlike Swift's `unowned(safe)` +/// > class refs, there is no trap on use-after-free. The contract is "valid only within the synchronous +/// > decode call, while the owner is alive" — which the buffer-driven decode path honors because it +/// > reads the value inline on the JS thread before the buffer is torn down. Do not store, capture, or +/// > escape it; call ``copied()`` to materialize an owning value when escape is needed. +public struct JavaScriptUnownedValue: ~Copyable { + // Borrows the `jsi::Value` at this address; it does not own it and must not outlive the owner. + internal let pointer: UnsafePointer + + // Non-optional `unowned`, matching `JavaScriptValuesBuffer.runtime`: it is strictly call-scoped and + // cannot outlive the runtime executing the call, so we skip both the ARC traffic and the optional unwrap + // that the owning `JavaScriptValue` pays. + internal unowned let runtime: JavaScriptRuntime + + internal init(_ runtime: JavaScriptRuntime, _ pointer: UnsafePointer) { + self.runtime = runtime + self.pointer = pointer + } + + /// Materializes an owning ``JavaScriptValue`` by copying the borrowed `jsi::Value`. Use it when the + /// value must outlive the decode call (stored, captured, handed to a `Promise`). + public func copied() -> JavaScriptValue { + return JavaScriptValue(runtime, pointer.pointee) + } + + // MARK: - Type checks + + public func isUndefined() -> Bool { + return pointer.pointee.isUndefined() + } + + public func isNull() -> Bool { + return pointer.pointee.isNull() + } + + public func isBool() -> Bool { + return pointer.pointee.isBool() + } + + public func isNumber() -> Bool { + return pointer.pointee.isNumber() + } + + public func isString() -> Bool { + return pointer.pointee.isString() + } + + public func isSymbol() -> Bool { + return pointer.pointee.isSymbol() + } + + public func isBigInt() -> Bool { + return pointer.pointee.isBigInt() + } + + public func isObject() -> Bool { + return pointer.pointee.isObject() + } + + // MARK: - Primitive accessors + + /// Returns the value as a boolean, or asserts if not a boolean. + public func getBool() -> Bool { + assert(isBool(), "Value is not a boolean") + return pointer.pointee.getBool() + } + + /// Returns the value as an integer, or asserts if not a number. + public func getInt() -> Int { + assert(isNumber(), "Value is not a number") + return Int(pointer.pointee.getNumber()) + } + + /// Returns the value as a double, or asserts if not a number. + public func getDouble() -> Double { + assert(isNumber(), "Value is not a number") + return pointer.pointee.getNumber() + } + + /// Returns the value as a string, or asserts if not a string. + public func getString() -> String { + assert(isString(), "Value is not a string") + return String(pointer.pointee.getString(runtime.pointee).utf8(runtime.pointee)) + } + + // MARK: - Throwing conversions ("as functions") + + /// Returns the value as a boolean, or throws `TypeError` if it is not a boolean. + public func asBool() throws(JavaScriptValue.TypeError) -> Bool { + guard isBool() else { + throw JavaScriptValue.TypeError(type: Bool.self) + } + return getBool() + } + + /// Returns the value as an integer, or throws `TypeError` if it is not a number. + public func asInt() throws(JavaScriptValue.TypeError) -> Int { + guard isNumber() else { + throw JavaScriptValue.TypeError(type: Int.self) + } + return getInt() + } + + /// Returns the value as a double, or throws `TypeError` if it is not a number. + public func asDouble() throws(JavaScriptValue.TypeError) -> Double { + guard isNumber() else { + throw JavaScriptValue.TypeError(type: Double.self) + } + return getDouble() + } + + /// Returns the value as a string, or throws `TypeError` if it is not a string. + public func asString() throws(JavaScriptValue.TypeError) -> String { + guard isString() else { + throw JavaScriptValue.TypeError(type: String.self) + } + return getString() + } +} diff --git a/packages/expo-modules-jsi/apple/Tests/JavaScriptUnownedValueTests.swift b/packages/expo-modules-jsi/apple/Tests/JavaScriptUnownedValueTests.swift new file mode 100644 index 00000000000000..f5c1924bdc60da --- /dev/null +++ b/packages/expo-modules-jsi/apple/Tests/JavaScriptUnownedValueTests.swift @@ -0,0 +1,78 @@ +import ExpoModulesJSI +import Testing + +@Suite +@JavaScriptActor +struct JavaScriptUnownedValueTests { + let runtime = JavaScriptRuntime() + + @Test + func `reads primitives without copying`() { + let buffer = JavaScriptValuesBuffer.allocate(in: runtime, with: 42, "hello", true) + + // The view is `~Copyable`, so compare accessor results against literals rather than passing the + // view into `#expect` — the macro captures its operand and would otherwise require `Copyable`. + let number = buffer.unownedValue(at: 0) + #expect(number.isNumber() == true) + #expect(number.getInt() == 42) + #expect(number.getDouble() == 42) + + let string = buffer.unownedValue(at: 1) + #expect(string.isString() == true) + #expect(string.getString() == "hello") + + let bool = buffer.unownedValue(at: 2) + #expect(bool.isBool() == true) + #expect(bool.getBool() == true) + } + + @Test + func `recognizes null and undefined`() { + let buffer = JavaScriptValuesBuffer.allocate(in: runtime, with: JavaScriptValue.null, JavaScriptValue.undefined) + + let null = buffer.unownedValue(at: 0) + #expect(null.isNull() == true) + #expect(null.isUndefined() == false) + + let undefined = buffer.unownedValue(at: 1) + #expect(undefined.isUndefined() == true) + #expect(undefined.isNull() == false) + } + + @Test + func `throwing accessors validate the type`() throws { + let buffer = JavaScriptValuesBuffer.allocate(in: runtime, with: 42, "hello") + + #expect(try buffer.unownedValue(at: 0).asInt() == 42) + #expect(try buffer.unownedValue(at: 0).asDouble() == 42) + #expect(try buffer.unownedValue(at: 1).asString() == "hello") + + #expect(throws: JavaScriptValue.TypeError.self) { + try buffer.unownedValue(at: 0).asString() + } + #expect(throws: JavaScriptValue.TypeError.self) { + try buffer.unownedValue(at: 1).asInt() + } + #expect(throws: JavaScriptValue.TypeError.self) { + try buffer.unownedValue(at: 1).asBool() + } + } + + @Test + func `copied materializes an owning value`() throws { + let buffer = JavaScriptValuesBuffer.allocate(in: runtime, with: "owned") + let owning = buffer.unownedValue(at: 0).copied() + + #expect(try owning.asString() == "owned") + } + + @Test + func `recognizes objects`() { + let object = runtime.createObject() + let buffer = JavaScriptValuesBuffer.allocate(in: runtime, with: object.asValue()) + + let view = buffer.unownedValue(at: 0) + #expect(view.isObject() == true) + #expect(view.isNumber() == false) + } +}