expo-modules-jsi 57.0.0 → 57.0.2

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,26 @@
10
10
 
11
11
  ### 💡 Others
12
12
 
13
+ ## 57.0.2 — 2026-07-15
14
+
15
+ ### 🎉 New features
16
+
17
+ - [iOS] Add `JavaScriptRef.withValue`, a non-consuming borrow accessor that reads the referenced value without taking it, so a long-lived reference can be read repeatedly. ([#47238](https://github.com/expo/expo/pull/47238) by [@tsapeta](https://github.com/tsapeta))
18
+ - [iOS] Add `JavaScriptRuntime.longLivedObjects`, a `LongLivedObjectCollection` that keeps `LongLivedObject`s (such as in-flight promises) alive across asynchronous boundaries and releases any that remain when the runtime is torn down. ([#47511](https://github.com/expo/expo/pull/47511) by [@tsapeta](https://github.com/tsapeta))
19
+ - [iOS] Add a `JavaScriptCodable` conformance for `Date`: it encodes to a JS `Date` and decodes from a JS `Date`, a number of milliseconds since the epoch, or a string parsed by the JS engine's `Date` constructor. ([#47602](https://github.com/expo/expo/pull/47602) by [@tsapeta](https://github.com/tsapeta))
20
+
21
+ ### 🐛 Bug fixes
22
+
23
+ - [iOS] Fixed a use-after-free when a `JavaScriptPromise` outlives its runtime (e.g. an async function's promise held by a completion handler that fires after `reloadAsync()`) by having the runtime's `LongLivedObjectCollection` own its JSI values and release them on the JavaScript thread when the wrapper is dropped or at teardown, instead of against a freed runtime. ([#47521](https://github.com/expo/expo/pull/47521) by [@tsapeta](https://github.com/tsapeta))
24
+ - [iOS] Preserve the `code` on the JavaScript error when an async function rejects with a `JavaScriptThrowable` (e.g. an `Exception`), instead of stringifying it and dropping the `code`, mirroring the synchronous throw path. ([#47259](https://github.com/expo/expo/pull/47259) by [@wwdrew](https://github.com/wwdrew))
25
+ - [iOS] Fixed a standalone `JavaScriptRuntime` leaking its underlying Hermes runtime: a runtime it creates itself is now destroyed on `deinit`, while runtimes adopted from elsewhere (e.g. React Native) are left untouched. ([#47515](https://github.com/expo/expo/pull/47515) by [@tsapeta](https://github.com/tsapeta))
26
+
27
+ ## 57.0.1 — 2026-07-07
28
+
29
+ ### 🐛 Bug fixes
30
+
31
+ - [iOS] Return `NSNull` instead of trapping in the deprecated `JavaScriptValue.getAny()` when it encounters a unrepresentable value. ([#47381](https://github.com/expo/expo/pull/47381) by [@alanjhughes](https://github.com/alanjhughes))
32
+
13
33
  ## 57.0.0 — 2026-06-25
14
34
 
15
35
  ### 🛠 Breaking changes
@@ -52,6 +72,7 @@
52
72
 
53
73
  ### 🐛 Bug fixes
54
74
 
75
+ - [iOS] Fixed `Build ExpoModulesJSI xcframework` build phase failing on Xcode 26 because the nested SwiftPM build ignored `-derivedDataPath` and wrote products outside the expected location. ([#46326](https://github.com/expo/expo/issues/46326) by [@Kurogoma4D](https://github.com/Kurogoma4D))
55
76
  - [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))
56
77
  - [iOS] Propagate `JavaScriptPromise` setup failures instead of trapping the app. ([#46106](https://github.com/expo/expo/issues/46106) by [@qutrek](https://github.com/qutrek)) ([#46145](https://github.com/expo/expo/pull/46145) by [@mvincentong](https://github.com/mvincentong))
57
78
  - Fix build framework for macOS ([#46413](https://github.com/expo/expo/pull/46413) by [@gabrieldonadel](https://github.com/gabrieldonadel))
@@ -0,0 +1,70 @@
1
+ // Copyright 2025-present 650 Industries. All rights reserved.
2
+
3
+ import Foundation
4
+
5
+ // `Date` encodes to a JS `Date` and decodes from a JS `Date`, a number of milliseconds since the epoch,
6
+ // or a string. Strings are parsed by the runtime's own `new Date(str)` (so parsing is identical on every
7
+ // platform, with no native date parser to maintain), which inherits JS's quirks: a zone-less string is
8
+ // read as local time, and an unparseable one becomes an `Invalid Date` (`NaN`), decoded as a thrown error.
9
+ // A `Date` is an absolute instant with no timezone/calendar; resolution is milliseconds.
10
+
11
+ extension Date: JavaScriptCodable {
12
+ @JavaScriptActor
13
+ @inlinable
14
+ public static func decode(_ value: borrowing JavaScriptValue, in runtime: borrowing JavaScriptRuntime) throws -> Date
15
+ {
16
+ // The cheap tag checks come before `is("Date")`, which does a global lookup plus an `instanceof`
17
+ // walk; a number or string can't be a `Date`, so the order is behavior-neutral.
18
+ if value.isNumber() {
19
+ return try dateFromMilliseconds(value.getDouble())
20
+ }
21
+ if value.isString() {
22
+ let dateConstructor = try runtime.global().getPropertyAsFunction("Date")
23
+ let constructed = try dateConstructor.callAsConstructor(value.copy()).asObject()
24
+ return try dateFromMilliseconds(constructed.callFunction("getTime").asDouble())
25
+ }
26
+ if value.is("Date") {
27
+ return try dateFromMilliseconds(value.asObject().callFunction("getTime").asDouble())
28
+ }
29
+ throw InvalidDateException()
30
+ }
31
+
32
+ @JavaScriptActor
33
+ @inlinable
34
+ public static func encode(_ value: Date, in runtime: borrowing JavaScriptRuntime) throws -> JavaScriptValue {
35
+ let milliseconds = value.timeIntervalSince1970 * 1000.0
36
+ let dateConstructor = try runtime.global().getPropertyAsFunction("Date")
37
+ // Typed explicitly so the variadic `callAsConstructor` overload is chosen over the `JavaScriptValuesBuffer?` one.
38
+ let millisecondsValue: JavaScriptValue = .number(milliseconds)
39
+ return try dateConstructor.callAsConstructor(millisecondsValue)
40
+ }
41
+ }
42
+
43
+ /// The largest magnitude, in milliseconds, a JS `Date` can represent (100,000,000 days from the epoch,
44
+ /// ECMAScript TimeClip); a value beyond it is an `Invalid Date`.
45
+ @usableFromInline
46
+ let maxJavaScriptDateMilliseconds: Double = 8_640_000_000_000_000
47
+
48
+ /// Builds a `Date` from a milliseconds value, applying the JS `Date` constructor's TimeClip: a non-finite
49
+ /// or out-of-range value throws, an in-range one is truncated toward zero. This keeps the number branch
50
+ /// faithful to `new Date(number)`; the `Date`/string branches pass an already-clipped `getTime()` through.
51
+ @usableFromInline
52
+ func dateFromMilliseconds(_ milliseconds: Double) throws -> Date {
53
+ guard milliseconds.isFinite, abs(milliseconds) <= maxJavaScriptDateMilliseconds else {
54
+ throw InvalidDateException()
55
+ }
56
+ return Date(timeIntervalSince1970: milliseconds.rounded(.towardZero) / 1000.0)
57
+ }
58
+
59
+ /// Thrown when a JavaScript value can't be converted to a `Date`. Named after JS's own "Invalid Date".
60
+ public struct InvalidDateException: JavaScriptThrowable {
61
+ @usableFromInline
62
+ init() {}
63
+
64
+ public var code: String {
65
+ "ERR_INVALID_DATE"
66
+ }
67
+ public var message: String {
68
+ "Cannot convert the JavaScript value to a Date because it is not a Date, a number of milliseconds since the epoch, or a parseable date string"
69
+ }
70
+ }
@@ -52,6 +52,18 @@ public final class JavaScriptRef<T: JavaScriptType & ~Copyable>: JavaScriptType,
52
52
  return value.take()
53
53
  }
54
54
 
55
+ /// Borrows the referenced value for the duration of `body` without consuming it, so the reference
56
+ /// keeps holding the value and can be read again. `body` receives the value as a borrow, or `nil` when
57
+ /// the reference is empty (already taken, released, or never set), and its result is returned as-is.
58
+ /// Use this instead of `take()` when the value must stay in the reference (e.g. a long-lived ref read
59
+ /// repeatedly).
60
+ ///
61
+ /// `body` returns `R` directly (rather than this method wrapping it in `R?`) so the result can itself
62
+ /// be a non-`Copyable` optional like `JavaScriptObject?`, which can't be nested inside another optional.
63
+ public func withValue<R: ~Copyable>(_ body: (borrowing T?) throws -> R?) rethrows -> R? {
64
+ return try body(value)
65
+ }
66
+
55
67
  /// Takes the value as a `JavaScriptValue`. Returns `undefined` value if the reference does not hold any value.
56
68
  public func asValue() -> JavaScriptValue {
57
69
  return take()?.asValue() ?? .undefined
@@ -38,6 +38,12 @@ open class JavaScriptRuntime: Equatable, Identifiable, @unchecked Sendable {
38
38
  internal let runtimePointee: facebook.jsi.Runtime
39
39
  internal let scheduler: expo.RuntimeScheduler
40
40
 
41
+ /// Whether this wrapper owns the underlying `jsi::Runtime` and must destroy it on `deinit`. True
42
+ /// only for the standalone `init()`, which creates the runtime via `createHermesRuntime()`. The
43
+ /// other initializers adopt a runtime owned elsewhere (e.g. React Native), which must never be
44
+ /// freed here, matching the immortal (no-op) release semantics of the imported reference type.
45
+ private let ownsRuntime: Bool
46
+
41
47
  /// Thread ID of the JavaScript thread, captured at construction time. Used by `isOnJavaScriptThread()`
42
48
  /// for a fast integer comparison instead of `Thread.current.name == "..."`.
43
49
  /// Assumes runtime initializers always run on the JS thread.
@@ -57,6 +63,8 @@ open class JavaScriptRuntime: Equatable, Identifiable, @unchecked Sendable {
57
63
  self.runtimePointee = runtime
58
64
  self.pointee = expo.iruntime(runtime)
59
65
  self.scheduler = expo.RuntimeScheduler()
66
+ self.ownsRuntime = false
67
+ installLongLivedObjectsTeardown()
60
68
  }
61
69
 
62
70
  /// Creates a standalone Hermes runtime. Scheduled tasks run synchronously —
@@ -66,6 +74,8 @@ open class JavaScriptRuntime: Equatable, Identifiable, @unchecked Sendable {
66
74
  self.runtimePointee = runtime
67
75
  self.pointee = expo.iruntime(runtime)
68
76
  self.scheduler = expo.RuntimeScheduler()
77
+ self.ownsRuntime = true
78
+ installLongLivedObjectsTeardown()
69
79
  }
70
80
 
71
81
  /// Creates a runtime from a raw pointer to the underlying `facebook.jsi.Runtime`.
@@ -76,6 +86,8 @@ open class JavaScriptRuntime: Equatable, Identifiable, @unchecked Sendable {
76
86
  self.runtimePointee = runtime
77
87
  self.pointee = expo.iruntime(runtime)
78
88
  self.scheduler = expo.RuntimeScheduler()
89
+ self.ownsRuntime = false
90
+ installLongLivedObjectsTeardown()
79
91
  }
80
92
 
81
93
  /// Creates a runtime bound to a host-provided React `RuntimeScheduler`. Calls to
@@ -84,7 +96,11 @@ open class JavaScriptRuntime: Equatable, Identifiable, @unchecked Sendable {
84
96
  /// React Native factory uses.
85
97
  ///
86
98
  /// - `unsafePointer`: raw pointer to the underlying `facebook::jsi::Runtime`.
87
- /// - `scheduler`: raw pointer to the `react::RuntimeScheduler` instance.
99
+ /// - `scheduler`: opaque host-owned handle that `dispatch` resolves to the real
100
+ /// scheduler. The React Native factory passes a handle that references the
101
+ /// `react::RuntimeScheduler` weakly (see `EXReactSchedulerDispatch.h` in
102
+ /// `ExpoModulesCore`), so dispatching after the React instance tore the
103
+ /// scheduler down safely drops the task.
88
104
  /// - `dispatch`: raw pointer to a C function with signature
89
105
  /// `void (*)(void *scheduler, int priority, void (^callback)())`.
90
106
  public init(
@@ -97,6 +113,27 @@ open class JavaScriptRuntime: Equatable, Identifiable, @unchecked Sendable {
97
113
  self.runtimePointee = runtime
98
114
  self.pointee = expo.iruntime(runtime)
99
115
  self.scheduler = expo.RuntimeScheduler(scheduler, fn)
116
+ self.ownsRuntime = false
117
+ installLongLivedObjectsTeardown()
118
+ }
119
+
120
+ deinit {
121
+ // Destroy the runtime only if this wrapper created it (standalone `init()`); adopted runtimes
122
+ // are owned elsewhere (e.g. React Native) and must not be freed here.
123
+ guard ownsRuntime else {
124
+ return
125
+ }
126
+ // Release cached JSI objects (e.g. `PropNameID`s) before the runtime goes away. Swift tears
127
+ // down stored properties only after `deinit` returns, so leaving them for that phase would
128
+ // destroy them against an already-freed runtime, which JSI forbids: all objects associated with
129
+ // a runtime must be destroyed before the runtime itself.
130
+ //
131
+ // No thread hop is needed. JSI documents that destructors are safe to call from any thread; the
132
+ // requirement is only that there is no concurrent access, which holds here since `deinit` runs
133
+ // when the last reference is gone. `deinit` is `nonisolated`, so it can touch the actor-isolated
134
+ // registry directly given that exclusive access.
135
+ propNameIdsRegistry.removeAll()
136
+ expo.destroyRuntime(runtimePointee)
100
137
  }
101
138
 
102
139
  /// Provides scoped access to a raw pointer to the underlying `facebook.jsi.Runtime`.
@@ -637,6 +674,51 @@ open class JavaScriptRuntime: Equatable, Identifiable, @unchecked Sendable {
637
674
 
638
675
  @JavaScriptActor
639
676
  internal var propNameIdsRegistry: [String: JavaScriptPropNameID] = [:]
677
+
678
+ // MARK: - Long-lived objects
679
+
680
+ /// Registry of JSI objects (such as in-flight promises) that must outlive the native call that
681
+ /// created them. Cleared when the runtime is torn down so their JSI state is released on the
682
+ /// JavaScript thread while the runtime is still valid.
683
+ @JavaScriptActor
684
+ public let longLivedObjects = LongLivedObjectCollection()
685
+
686
+ /// Attaches a native state to a dedicated JS object whose deallocator clears ``longLivedObjects``
687
+ /// when the runtime is torn down. The object is pinned by storing it as a property on `global`, so
688
+ /// it stays reachable within the JavaScript heap for the runtime's whole life (no Swift-side strong
689
+ /// reference) and its native state drops only when the runtime's object graph is destroyed. That
690
+ /// fires the deallocator on the JavaScript thread while the runtime is still valid, the point at
691
+ /// which any surviving long-lived objects can safely release their JSI state.
692
+ private func installLongLivedObjectsTeardown() {
693
+ // Runtime initializers run on the JavaScript thread but aren't actor-isolated, so reach the
694
+ // isolated object/native-state/`clear()` APIs through the actor.
695
+ JavaScriptActor.assumeIsolated {
696
+ // Capture the collection strongly, not `self`: teardown may run as the runtime itself
697
+ // deallocates, and the sweep must still call `allowRelease()` on survivors. Holding the
698
+ // collection keeps it alive for the sweep; it does not retain the runtime.
699
+ let longLivedObjects = self.longLivedObjects
700
+ let nativeState = JavaScriptNativeState()
701
+ nativeState.setDeallocator { nativeState in
702
+ // Fires as the teardown object is released on the JavaScript thread with the runtime still
703
+ // valid, so releasing JSI state here is safe. Mirrors the caveat on `AppContext.NativeState`:
704
+ // a future cross-runtime path that could drop this state from another thread would have to
705
+ // hop back to the JavaScript thread first.
706
+ JavaScriptActor.assumeIsolated {
707
+ longLivedObjects.clear()
708
+ }
709
+ }
710
+ let object = createObject()
711
+ object.setNativeState(nativeState)
712
+ // Pin via a JS-heap reference on `global` rather than a Swift property, so the object's
713
+ // lifetime is governed by the runtime's object graph. The property name is unique per wrapper
714
+ // (keyed by this wrapper's address), so several wrappers of the same underlying runtime (e.g.
715
+ // via `init(unsafePointer:)`) each pin their own teardown object instead of overwriting a
716
+ // shared slot, which would let one wrapper's collection be swept early while the runtime is
717
+ // still alive.
718
+ let wrapperAddress = UInt(bitPattern: Unmanaged.passUnretained(self).toOpaque())
719
+ global().setProperty("__expo_long_lived_objects_teardown_\(wrapperAddress)__", value: object.asValue())
720
+ }
721
+ }
640
722
  }
641
723
 
642
724
  private func createFunctionClosure(
@@ -0,0 +1,46 @@
1
+ /// An object whose lifetime must extend past the native call that created it, such as an in-flight
2
+ /// promise holding its resolve/reject state. Register it with a ``LongLivedObjectCollection``.
3
+ @JavaScriptActor
4
+ public protocol LongLivedObject: AnyObject {
5
+ /// Called on the JavaScript thread when the runtime tears down while this object is still
6
+ /// registered, to release any held JSI state. Not called on the normal completion path, where the
7
+ /// object removes itself after releasing its own state.
8
+ func allowRelease()
9
+ }
10
+
11
+ /// Keeps ``LongLivedObject``s alive across asynchronous boundaries and releases any that remain when
12
+ /// the runtime is torn down. Isolated to ``JavaScriptActor``, so it needs no lock.
13
+ @JavaScriptActor
14
+ public final class LongLivedObjectCollection {
15
+ // Keyed by identity, effectively a set. A dictionary rather than a `Set` because
16
+ // `any LongLivedObject` is an existential and cannot conform to `Hashable`.
17
+ private var registeredObjects: [ObjectIdentifier: any LongLivedObject] = [:]
18
+
19
+ // `nonisolated` so ``JavaScriptRuntime`` can create one as a stored-property default.
20
+ internal nonisolated init() {}
21
+
22
+ /// Registers an object, keeping it alive until it is removed or the collection is cleared.
23
+ public func add(_ object: any LongLivedObject) {
24
+ registeredObjects[ObjectIdentifier(object)] = object
25
+ }
26
+
27
+ /// Removes an object that finished on its own, without calling ``LongLivedObject/allowRelease()``.
28
+ public func remove(_ object: any LongLivedObject) {
29
+ registeredObjects[ObjectIdentifier(object)] = nil
30
+ }
31
+
32
+ /// Teardown sweep: calls ``LongLivedObject/allowRelease()`` on every remaining object and empties
33
+ /// the collection. Must run on the JavaScript thread while the runtime is still valid.
34
+ public func clear() {
35
+ let survivors = registeredObjects.values
36
+ registeredObjects.removeAll()
37
+ for object in survivors {
38
+ object.allowRelease()
39
+ }
40
+ }
41
+
42
+ /// Number of currently registered objects.
43
+ public var count: Int {
44
+ return registeredObjects.count
45
+ }
46
+ }
@@ -50,6 +50,21 @@ public final class JavaScriptError: Error, Sendable {
50
50
  }
51
51
  }
52
52
 
53
+ /// Returns the `JavaScriptError` representing an arbitrary native error. An existing
54
+ /// `JavaScriptError` is returned unchanged so its wrapped value reaches JS as-is; a
55
+ /// `JavaScriptThrowable` is routed through the code-preserving initializer above; any
56
+ /// other error is stringified into a generic `Error`.
57
+ @inlinable
58
+ public static func from(_ error: any Error, in runtime: JavaScriptRuntime) -> JavaScriptError {
59
+ if let jsError = error as? JavaScriptError {
60
+ return jsError
61
+ }
62
+ if let throwable = error as? JavaScriptThrowable {
63
+ return JavaScriptError(runtime, from: throwable)
64
+ }
65
+ return JavaScriptError(runtime, message: String(describing: error))
66
+ }
67
+
53
68
  /// Returns the error as a `JavaScriptValue`, which may be an arbitrary value rather than an
54
69
  /// `Error` instance when the error was created from one.
55
70
  public func toValue() -> JavaScriptValue {
@@ -11,46 +11,93 @@ public struct JavaScriptPromise: JavaScriptType, ~Copyable {
11
11
  private typealias PromiseContinuation = CheckedContinuation<JavaScriptValue.Ref, any Error>
12
12
 
13
13
  private weak let runtime: JavaScriptRuntime?
14
- private var object: JavaScriptObject
15
14
  private let deferredPromise = DeferredPromise()
16
15
 
17
- // Create refs for resolve and reject functions.
18
- // They will be set in the Promise setup function.
19
- private let resolveFunction = JavaScriptValue.Ref()
20
- private let rejectFunction = JavaScriptValue.Ref()
16
+ /// Owns the promise's JSI values (the object and, for a deferred promise, its resolve/reject
17
+ /// functions). Registered with the runtime's ``LongLivedObjectCollection`` so they're released on
18
+ /// the JS thread rather than against a freed runtime when a wrapper outlives its runtime (e.g. an
19
+ /// async function's promise held by a URLSession delegate).
20
+ ///
21
+ /// There are two release paths, both on the JS thread while the runtime is alive:
22
+ /// - When the ``JavaScriptPromise`` wrapper is dropped, its `deinit` schedules a job that
23
+ /// deregisters this state and releases the values, so a stream of promises doesn't pin their
24
+ /// objects (and resolution values) until teardown.
25
+ /// - If the wrapper outlives the runtime, the teardown sweep (``allowRelease()``) releases whatever
26
+ /// is still registered before the runtime is destroyed.
27
+ ///
28
+ /// The state stays registered for as long as the wrapper is alive, even after the promise settles,
29
+ /// so ``asValue()`` keeps returning a valid object. Settling only releases the resolve/reject
30
+ /// functions, which can no longer be called and are the bulk of a deferred promise's held state.
31
+ @JavaScriptActor
32
+ private final class LongLivedState: LongLivedObject {
33
+ // Stored as `JavaScriptValue` (a reference type), not `JavaScriptObject`: a `Copyable` value read
34
+ // back through `JavaScriptRef.withValue` avoids the copy a borrowed `~Copyable` object would trap on.
35
+ let object = JavaScriptValue.Ref()
36
+ let resolveFunction = JavaScriptValue.Ref()
37
+ let rejectFunction = JavaScriptValue.Ref()
38
+
39
+ func allowRelease() {
40
+ object.release()
41
+ resolveFunction.release()
42
+ rejectFunction.release()
43
+ }
44
+ }
45
+
46
+ private let longLivedState = LongLivedState()
47
+
48
+ /// Dropping the wrapper means no native code can settle or read this promise anymore, so release
49
+ /// its long-lived state. The values can only be touched on the JS thread while the runtime is
50
+ /// alive, so schedule the work there; if the runtime is already gone, the teardown sweep has
51
+ /// released everything and there is nothing to do (this is the `#47454` off-thread-drop case).
52
+ deinit {
53
+ guard let runtime else {
54
+ return
55
+ }
56
+ // Capture the collection, not the runtime, so a wrapper's deinit can't prolong the runtime's
57
+ // lifetime by keeping it alive until the scheduled job drains.
58
+ let longLivedObjects = runtime.longLivedObjects
59
+ runtime.schedule { [longLivedState] in
60
+ longLivedObjects.remove(longLivedState)
61
+ longLivedState.allowRelease()
62
+ }
63
+ }
21
64
 
22
65
  /// Initializes a promise from the existing object. The promise may already be settled.
23
66
  /// It cannot be resolved/rejected from the outside, i.e. `resolve` and `reject` functions are no-op.
24
67
  @JavaScriptActor
25
68
  public init(_ runtime: JavaScriptRuntime, _ object: consuming JavaScriptObject) throws {
26
69
  self.runtime = runtime
27
- self.object = object
70
+ longLivedState.object.reset(object.asValue())
28
71
  try setUpCallbacks()
72
+ // Register only after setup succeeds, so a failed initializer (e.g. `then` unavailable) doesn't
73
+ // leave the state pinned in the collection until teardown. Owns the promise's JSI values from
74
+ // here until teardown (see `LongLivedState`).
75
+ runtime.longLivedObjects.add(longLivedState)
29
76
  }
30
77
 
31
78
  /// Creates a new promise whose resolver or rejecter must be called from the outside (also known as a deferred promise).
32
79
  @JavaScriptActor
33
80
  public init(_ runtime: JavaScriptRuntime) throws {
34
81
  self.runtime = runtime
35
- // Initialize the non-copyable field before any throwing work. Swift requires a
36
- // consistently initialized value on every throwing initializer path.
37
- self.object = runtime.createObject()
38
82
 
39
83
  // Create function that is the promise setup. It is called immediately on `callAsConstructor`.
40
- let setup = runtime.createFunction { [weak resolveFunction, weak rejectFunction] this, arguments in
41
- resolveFunction?.reset(arguments[0])
42
- rejectFunction?.reset(arguments[1])
84
+ let setup = runtime.createFunction { [weak longLivedState] this, arguments in
85
+ longLivedState?.resolveFunction.reset(arguments[0])
86
+ longLivedState?.rejectFunction.reset(arguments[1])
43
87
  return .undefined
44
88
  }
45
89
 
46
- self.object =
90
+ let object =
47
91
  try runtime
48
92
  .global()
49
93
  .getPropertyAsFunction(.cached(runtime, "Promise"))
50
94
  .callAsConstructor(setup.asValue())
51
- .getObject()
52
-
95
+ longLivedState.object.reset(object)
53
96
  try setUpCallbacks()
97
+ // Register only after setup succeeds, so a failed initializer (e.g. `then` unavailable) doesn't
98
+ // leave the state pinned in the collection until teardown. Owns the promise's JSI values from
99
+ // here until teardown (see `LongLivedState`).
100
+ runtime.longLivedObjects.add(longLivedState)
54
101
  }
55
102
 
56
103
  @JavaScriptActor
@@ -59,7 +106,7 @@ public struct JavaScriptPromise: JavaScriptType, ~Copyable {
59
106
  }
60
107
 
61
108
  public var isDeferred: Bool {
62
- return !resolveFunction.isEmpty && !rejectFunction.isEmpty
109
+ return !longLivedState.resolveFunction.isEmpty && !longLivedState.rejectFunction.isEmpty
63
110
  }
64
111
 
65
112
  @JavaScriptActor
@@ -68,7 +115,10 @@ public struct JavaScriptPromise: JavaScriptType, ~Copyable {
68
115
  }
69
116
 
70
117
  public func asValue() -> JavaScriptValue {
71
- return object.asValue()
118
+ // Read without consuming, so the state keeps owning the object (unlike `Ref.asValue()`).
119
+ return longLivedState.object.withValue { object in
120
+ return object
121
+ } ?? .undefined
72
122
  }
73
123
 
74
124
  public func resolve<V: JavaScriptRepresentable>(_ value: V) {
@@ -77,17 +127,18 @@ public struct JavaScriptPromise: JavaScriptType, ~Copyable {
77
127
  }
78
128
 
79
129
  // `resolve` is not isolated, so make sure to jump to JS thread.
80
- runtime.schedule(priority: .immediate) { [resolveFunction, rejectFunction] in
130
+ runtime.schedule(priority: .immediate) { [longLivedState] in
81
131
  // If the promise is already settled, do nothing.
82
- guard let resolver = resolveFunction.take() else {
132
+ guard let resolver = longLivedState.resolveFunction.take() else {
83
133
  return
84
134
  }
85
135
  // Call the actual resolver given in the Promise setup.
86
136
  // This will also call `deferredPromise.resolve` in the `then` handler.
87
137
  _ = try! resolver.getFunction().call(arguments: value)
88
138
 
89
- // Release the rejecter, we cannot call it anymore.
90
- rejectFunction.release()
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()
91
142
  }
92
143
  }
93
144
 
@@ -97,23 +148,24 @@ public struct JavaScriptPromise: JavaScriptType, ~Copyable {
97
148
  }
98
149
 
99
150
  // `reject` is not isolated, so make sure to jump to JS thread.
100
- runtime.schedule(priority: .immediate) { [resolveFunction, rejectFunction] in
151
+ runtime.schedule(priority: .immediate) { [longLivedState] in
101
152
  // If the promise is already settled, do nothing.
102
- guard let rejecter = rejectFunction.take() else {
153
+ guard let rejecter = longLivedState.rejectFunction.take() else {
103
154
  return
104
155
  }
105
- // A `JavaScriptError` already carries the value to reject with (which may be an arbitrary JS
106
- // value rather than an `Error`), so reuse it. Any other native error is stringified into a
107
- // generic `Error`.
108
- let jsError = error as? JavaScriptError ?? JavaScriptError(runtime, message: String(describing: error))
109
- let errorValue = jsError.toValue()
156
+ // Convert the error to its JavaScript representation. This preserves an existing
157
+ // `JavaScriptError`'s wrapped value and a `JavaScriptThrowable`'s structured `code`
158
+ // (mirroring the synchronous throw path in `forwardingSwiftErrorsToJS`), so the `code`
159
+ // is not lost on async rejection. See `JavaScriptError.from(_:in:)`.
160
+ let errorValue = JavaScriptError.from(error, in: runtime).toValue()
110
161
 
111
162
  // Call the actual rejecter given in the Promise setup.
112
163
  // This will also call `deferredPromise.reject` in the `then` handler.
113
164
  _ = try! rejecter.getFunction().call(arguments: errorValue)
114
165
 
115
- // Release the resolver, we cannot call it anymore.
116
- resolveFunction.release()
166
+ // The resolver can't be called anymore. The state stays registered so it keeps owning the
167
+ // object until the wrapper is dropped (or the teardown sweep runs).
168
+ longLivedState.resolveFunction.release()
117
169
  }
118
170
  }
119
171
 
@@ -140,6 +192,12 @@ public struct JavaScriptPromise: JavaScriptType, ~Copyable {
140
192
  }
141
193
  return .undefined
142
194
  }
143
- try object.callFunction(.cached(runtime, "then"), arguments: onFulfilled.asValue(), onRejected.asValue())
195
+ _ = try longLivedState.object.withValue { object in
196
+ try object?.getObject().callFunction(
197
+ .cached(runtime, "then"),
198
+ arguments: onFulfilled.asValue(),
199
+ onRejected.asValue()
200
+ )
201
+ }
144
202
  }
145
203
  }
@@ -208,7 +208,8 @@ public final class JavaScriptValue: JavaScriptType, Equatable, Escapable {
208
208
  return result
209
209
  }
210
210
  if object.isFunction() {
211
- FatalError.unimplemented()
211
+ // Don't trap, callers convert speculatively under `try?`, which can't catch a `fatalError`.
212
+ return NSNull()
212
213
  }
213
214
  var result = [String: Any]()
214
215
 
@@ -222,7 +223,8 @@ public final class JavaScriptValue: JavaScriptType, Equatable, Escapable {
222
223
  }
223
224
  return result
224
225
  }
225
- fatalError("Unsupported value kind: \(kind)")
226
+ // Unrepresentable kind (e.g. symbol). Don't trap, for the same reason as above.
227
+ return NSNull()
226
228
  }
227
229
 
228
230
  /// Returns the value as a boolean, or asserts if not a boolean.
@@ -125,6 +125,16 @@ inline bool isHostObject(jsi::IRuntime &runtime, const jsi::Object &object) {
125
125
 
126
126
  jsi::Runtime* createHermesRuntime();
127
127
 
128
+ /**
129
+ Destroys a `jsi::Runtime` created by `createHermesRuntime()`. Since the runtime is imported into
130
+ Swift as an immortal reference type (no ARC-managed lifetime), Swift can't `delete` it directly, so
131
+ the standalone `JavaScriptRuntime` calls this from its `deinit` to free the runtime it owns. Must
132
+ not be called on a runtime owned elsewhere (e.g. the React Native-provided one).
133
+ */
134
+ inline void destroyRuntime(jsi::Runtime &runtime) {
135
+ delete &runtime;
136
+ }
137
+
128
138
  inline jsi::Value evaluateJavaScript(jsi::IRuntime &runtime, const std::shared_ptr<const jsi::Buffer>& buffer, const std::string& sourceURL) {
129
139
  return expo::CppError::tryCatch(runtime, ^{
130
140
  return runtime.evaluateJavaScript(buffer, sourceURL);
@@ -31,8 +31,9 @@ public:
31
31
  using ScheduleTaskCallback = void(^)();
32
32
 
33
33
  /**
34
- Trampoline implemented by the host casts `nativeScheduler` back to
35
- react::RuntimeScheduler* and calls scheduleTask on it. Keeping it as a
34
+ Trampoline implemented by the host. It resolves `nativeScheduler` (an opaque
35
+ host-owned handle) to the real react::RuntimeScheduler and calls scheduleTask
36
+ on it, or drops the task when the scheduler no longer exists. Keeping it as a
36
37
  function pointer keeps React types out of this header.
37
38
  */
38
39
  using ScheduleFn = void (*)(void *nativeScheduler, int priority, ScheduleTaskCallback callback);