expo-modules-jsi 57.0.1 → 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,20 @@
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
+
13
27
  ## 57.0.1 — 2026-07-07
14
28
 
15
29
  ### 🐛 Bug fixes
@@ -58,6 +72,7 @@
58
72
 
59
73
  ### 🐛 Bug fixes
60
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))
61
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))
62
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))
63
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
  }
@@ -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);
@@ -0,0 +1,270 @@
1
+ // Copyright 2025-present 650 Industries. All rights reserved.
2
+
3
+ import ExpoModulesJSI
4
+ import Foundation
5
+ import Testing
6
+
7
+ @Suite("JavaScriptCodable+Date")
8
+ @JavaScriptActor
9
+ struct JavaScriptCodableDateTests {
10
+ let runtime = JavaScriptRuntime()
11
+
12
+ // MARK: - Decode
13
+
14
+ @Test
15
+ func `decodes a JS Date to the same instant`() throws {
16
+ // 2021-01-01T00:00:00.000Z expressed as an absolute UTC millisecond instant. `Date.UTC` returns
17
+ // that instant directly, so no local timezone enters the JS side.
18
+ let value = try runtime.eval("new Date(Date.UTC(2021, 0, 1, 0, 0, 0, 0))")
19
+ let decoded = try Date.decode(value, in: runtime)
20
+ #expect(decoded.timeIntervalSince1970 == 1_609_459_200.0)
21
+ }
22
+
23
+ @Test
24
+ func `decodes the Unix epoch`() throws {
25
+ let decoded = try Date.decode(runtime.eval("new Date(0)"), in: runtime)
26
+ #expect(decoded.timeIntervalSince1970 == 0.0)
27
+ }
28
+
29
+ @Test
30
+ func `decodes a pre-epoch Date`() throws {
31
+ // A negative millisecond instant (1960-01-01T00:00:00Z) must decode to a negative interval.
32
+ let value = try runtime.eval("new Date(Date.UTC(1960, 0, 1))")
33
+ let decoded = try Date.decode(value, in: runtime)
34
+ #expect(decoded.timeIntervalSince1970 == -315_619_200.0)
35
+ }
36
+
37
+ @Test
38
+ func `decode preserves sub-second milliseconds`() throws {
39
+ // Assert on the whole-millisecond instant (an integer-valued Double, always exact) rather than the
40
+ // reconstructed fractional seconds, whose last bit can differ by an ULP across the JS boundary.
41
+ let decoded = try Date.decode(runtime.eval("new Date(1234)"), in: runtime)
42
+ #expect((decoded.timeIntervalSince1970 * 1000.0).rounded() == 1234.0)
43
+ }
44
+
45
+ @Test
46
+ func `decodes a JS Date through the borrowed-value overload`() throws {
47
+ // `Date` does not override the zero-copy overload (it needs a `getTime()` call), so this goes
48
+ // through the default that copies the borrowed value into an owning one and forwards.
49
+ let value = try runtime.eval("new Date(5000)")
50
+ let buffer = JavaScriptValuesBuffer.copying(in: runtime, values: [value])
51
+ let decoded = try Date.decode(buffer.unownedValue(at: 0), in: runtime)
52
+ #expect(decoded.timeIntervalSince1970 == 5.0)
53
+ }
54
+
55
+ // MARK: - Encode
56
+
57
+ @Test
58
+ func `encodes a Date to a JS Date`() throws {
59
+ let encoded = try Date.encode(Date(timeIntervalSince1970: 1_609_459_200.0), in: runtime)
60
+ #expect(encoded.is("Date") == true)
61
+ }
62
+
63
+ @Test
64
+ func `encoded Date carries the same instant into JS`() throws {
65
+ // Cross-check the encoded value against JS's own `getTime()`, so the assertion is that JS and
66
+ // Swift agree on the absolute millisecond instant — not merely that a Date was produced.
67
+ let date = Date(timeIntervalSince1970: 1_609_459_200.0)
68
+ let encoded = try Date.encode(date, in: runtime)
69
+ let millisecondsInJS = try encoded.asObject().callFunction("getTime").asDouble()
70
+ #expect(millisecondsInJS == 1_609_459_200_000.0)
71
+ }
72
+
73
+ // MARK: - Timezone / calendar independence
74
+
75
+ @Test
76
+ func `instant is independent of the JS Date's local-time getters`() throws {
77
+ // Build the same absolute instant two ways: from a UTC millisecond value, and from local-time
78
+ // components. `Date.UTC` and the `new Date(y, m, d, …)` local constructor produce different
79
+ // instants unless the runtime is at UTC, so we decode the explicitly-UTC one and assert the
80
+ // instant matches its `getTime()` exactly. The point: decode reads `getTime()` (absolute UTC ms),
81
+ // never a local-time getter, so no timezone offset is folded in.
82
+ let value = try runtime.eval("new Date(Date.UTC(2021, 5, 15, 12, 30, 45, 123))")
83
+ let expectedMilliseconds = try value.asObject().callFunction("getTime").asDouble()
84
+ let decoded = try Date.decode(value, in: runtime)
85
+ #expect((decoded.timeIntervalSince1970 * 1000.0).rounded() == expectedMilliseconds)
86
+ }
87
+
88
+ @Test
89
+ func `round-trip is calendar and timezone agnostic`() throws {
90
+ // A `Date` is an absolute instant with no stored calendar or timezone; both JS `Date` and Swift
91
+ // `Date` are epoch-relative. This encodes to JS and decodes back, asserting the instant is
92
+ // unchanged at millisecond granularity regardless of the ambient calendar/timezone.
93
+ let original = Date(timeIntervalSince1970: 1_655_296_245.123)
94
+ let encoded = try Date.encode(original, in: runtime)
95
+ let roundTripped = try Date.decode(encoded, in: runtime)
96
+ // Compare at whole-millisecond granularity (JS `Date`'s resolution); the original is already an
97
+ // exact number of milliseconds, so nothing is lost, but the comparison avoids float-equality ULP noise.
98
+ #expect(
99
+ (roundTripped.timeIntervalSince1970 * 1000.0).rounded() == (original.timeIntervalSince1970 * 1000.0).rounded())
100
+ }
101
+
102
+ @Test
103
+ func `round-trip drops sub-millisecond precision`() throws {
104
+ // JS `Date` is integer-millisecond resolution, so the microsecond tail of a Swift `Date` is lost
105
+ // on the way through JS. The round-trip lands on the truncated whole millisecond (JS's `Date`
106
+ // constructor truncates the fractional millisecond toward zero), and no longer equals the original.
107
+ let original = Date(timeIntervalSince1970: 1.2345678)
108
+ let roundTripped = try Date.decode(Date.encode(original, in: runtime), in: runtime)
109
+ #expect((roundTripped.timeIntervalSince1970 * 1000.0).rounded() == 1234.0)
110
+ #expect(roundTripped.timeIntervalSince1970 != original.timeIntervalSince1970)
111
+ }
112
+
113
+ @Test
114
+ func `Date current time round-trips to the millisecond`() throws {
115
+ let now = Date()
116
+ let roundTripped = try Date.decode(Date.encode(now, in: runtime), in: runtime)
117
+ // Truncate the original to whole milliseconds (JS's resolution) and compare on the millisecond
118
+ // instant, which is an exact integer-valued Double on both sides.
119
+ let nowMilliseconds = (now.timeIntervalSince1970 * 1000.0).rounded(.towardZero)
120
+ #expect((roundTripped.timeIntervalSince1970 * 1000.0).rounded() == nowMilliseconds)
121
+ }
122
+
123
+ // MARK: - Decode from a JS number (milliseconds since the epoch)
124
+
125
+ @Test
126
+ func `decodes a number as milliseconds since the epoch`() throws {
127
+ // A JS number is interpreted the same way `new Date(ms)` interprets it, so `getTime()` round-trips.
128
+ let decoded = try Date.decode(runtime.eval("1609459200000"), in: runtime)
129
+ #expect(decoded.timeIntervalSince1970 == 1_609_459_200.0)
130
+ }
131
+
132
+ @Test
133
+ func `decodes zero as the Unix epoch`() throws {
134
+ let decoded = try Date.decode(runtime.eval("0"), in: runtime)
135
+ #expect(decoded.timeIntervalSince1970 == 0.0)
136
+ }
137
+
138
+ @Test
139
+ func `decodes a negative number as a pre-epoch instant`() throws {
140
+ let decoded = try Date.decode(runtime.eval("-315619200000"), in: runtime)
141
+ #expect(decoded.timeIntervalSince1970 == -315_619_200.0)
142
+ }
143
+
144
+ @Test
145
+ func `a JS Date's getTime decodes to the same instant as the Date`() throws {
146
+ // Passing `someDate` and passing `someDate.getTime()` must decode to the same Swift `Date`.
147
+ let fromDate = try Date.decode(runtime.eval("new Date(Date.UTC(2021, 0, 1))"), in: runtime)
148
+ let fromMilliseconds = try Date.decode(runtime.eval("new Date(Date.UTC(2021, 0, 1)).getTime()"), in: runtime)
149
+ #expect(fromDate.timeIntervalSince1970 == fromMilliseconds.timeIntervalSince1970)
150
+ }
151
+
152
+ @Test
153
+ func `decode truncates a fractional number toward zero like the Date constructor`() throws {
154
+ // `new Date(1.9).getTime()` is `1` and `new Date(-1.9).getTime()` is `-1`: the constructor applies
155
+ // TimeClip (truncate toward zero), so the number branch must too rather than keeping 1.9 ms.
156
+ let positive = try Date.decode(runtime.eval("1.9"), in: runtime)
157
+ #expect((positive.timeIntervalSince1970 * 1000.0).rounded() == 1.0)
158
+ let negative = try Date.decode(runtime.eval("-1.9"), in: runtime)
159
+ #expect((negative.timeIntervalSince1970 * 1000.0).rounded() == -1.0)
160
+ }
161
+
162
+ @Test
163
+ func `decode accepts the maximum representable JS date`() throws {
164
+ // ±8.64e15 ms (100,000,000 days from the epoch) is the inclusive bound of a valid JS `Date`.
165
+ let maximum = try Date.decode(runtime.eval("8640000000000000"), in: runtime)
166
+ #expect((maximum.timeIntervalSince1970 * 1000.0) == 8_640_000_000_000_000.0)
167
+ let minimum = try Date.decode(runtime.eval("-8640000000000000"), in: runtime)
168
+ #expect((minimum.timeIntervalSince1970 * 1000.0) == -8_640_000_000_000_000.0)
169
+ }
170
+
171
+ @Test
172
+ func `decode rejects a number beyond the JS date range`() throws {
173
+ // One millisecond past the bound is an `Invalid Date` in JS (`new Date(8640000000000001).getTime()`
174
+ // is `NaN`), so decode must reject it rather than accept an instant JS `Date` can't hold.
175
+ #expect(throws: InvalidDateException.self) {
176
+ _ = try Date.decode(runtime.eval("8640000000000001"), in: runtime)
177
+ }
178
+ #expect(throws: InvalidDateException.self) {
179
+ _ = try Date.decode(runtime.eval("-8640000000000001"), in: runtime)
180
+ }
181
+ }
182
+
183
+ @Test
184
+ func `decode matches new Date for the same number`() throws {
185
+ // Cross-check the number branch against the constructor for values that exercise TimeClip.
186
+ for source in ["1.9", "-1.9", "0.4", "1234.5678", "8640000000000000"] {
187
+ let expected = try runtime.eval("new Date(\(source)).getTime()").asDouble()
188
+ let decoded = try Date.decode(runtime.eval(source), in: runtime)
189
+ #expect((decoded.timeIntervalSince1970 * 1000.0).rounded() == expected)
190
+ }
191
+ }
192
+
193
+ // MARK: - Decode from a string (parsed by the JS `Date` constructor)
194
+
195
+ @Test
196
+ func `decodes an ISO 8601 string with fractional seconds`() throws {
197
+ let decoded = try Date.decode(runtime.eval("\"2021-01-01T00:00:00.123Z\""), in: runtime)
198
+ #expect((decoded.timeIntervalSince1970 * 1000.0).rounded() == 1_609_459_200_123.0)
199
+ }
200
+
201
+ @Test
202
+ func `decodes an ISO 8601 string without fractional seconds`() throws {
203
+ let decoded = try Date.decode(runtime.eval("\"2021-01-01T00:00:00Z\""), in: runtime)
204
+ #expect(decoded.timeIntervalSince1970 == 1_609_459_200.0)
205
+ }
206
+
207
+ @Test
208
+ func `decodes an ISO 8601 string with a timezone offset to the correct UTC instant`() throws {
209
+ // 01:00 at +01:00 is the same absolute instant as 00:00 UTC; the offset must be applied, not ignored.
210
+ let decoded = try Date.decode(runtime.eval("\"2021-01-01T01:00:00+01:00\""), in: runtime)
211
+ #expect(decoded.timeIntervalSince1970 == 1_609_459_200.0)
212
+ }
213
+
214
+ @Test
215
+ func `decodes a date-only string as UTC midnight`() throws {
216
+ // The JS `Date` constructor reads a date-only ISO string as UTC midnight (unlike a zone-less
217
+ // date-time, which it reads as local). This form is accepted here because `new Date(str)` accepts it.
218
+ let decoded = try Date.decode(runtime.eval("\"2021-01-01\""), in: runtime)
219
+ #expect(decoded.timeIntervalSince1970 == 1_609_459_200.0)
220
+ }
221
+
222
+ @Test
223
+ func `decodes a string the same way new Date parses it`() throws {
224
+ // The decode contract for a string is "exactly what `new Date(str)` produces". Cross-check the
225
+ // decoded instant against the engine's own parse of the same string, so this holds for whatever
226
+ // date grammar the runtime supports (ISO, RFC-2822-ish, etc.), identically on every platform.
227
+ let source = "\"2021-06-15T12:30:45.500Z\""
228
+ let expectedMilliseconds = try runtime.eval("new Date(\(source)).getTime()").asDouble()
229
+ let decoded = try Date.decode(runtime.eval(source), in: runtime)
230
+ #expect((decoded.timeIntervalSince1970 * 1000.0).rounded() == expectedMilliseconds)
231
+ }
232
+
233
+ // MARK: - Error paths
234
+
235
+ @Test
236
+ func `Date decode throws on an unparseable string`() throws {
237
+ // `new Date(...)` yields an `Invalid Date` (NaN time) rather than throwing; decode turns that NaN
238
+ // into a thrown error.
239
+ #expect(throws: InvalidDateException.self) {
240
+ _ = try Date.decode(runtime.eval("\"not a date\""), in: runtime)
241
+ }
242
+ #expect(throws: InvalidDateException.self) {
243
+ _ = try Date.decode(runtime.eval("\"\""), in: runtime)
244
+ }
245
+ }
246
+
247
+ @Test
248
+ func `Date decode throws on NaN`() throws {
249
+ // A NaN number produces an `Invalid Date` in JS; decode must reject it rather than yield a bogus instant.
250
+ #expect(throws: InvalidDateException.self) {
251
+ _ = try Date.decode(runtime.eval("NaN"), in: runtime)
252
+ }
253
+ }
254
+
255
+ @Test
256
+ func `Date decode throws on an unsupported type`() throws {
257
+ #expect(throws: InvalidDateException.self) {
258
+ _ = try Date.decode(runtime.eval("({})"), in: runtime)
259
+ }
260
+ #expect(throws: InvalidDateException.self) {
261
+ _ = try Date.decode(runtime.eval("null"), in: runtime)
262
+ }
263
+ #expect(throws: InvalidDateException.self) {
264
+ _ = try Date.decode(runtime.eval("true"), in: runtime)
265
+ }
266
+ #expect(throws: InvalidDateException.self) {
267
+ _ = try Date.decode(runtime.eval("[1, 2, 3]"), in: runtime)
268
+ }
269
+ }
270
+ }
@@ -179,6 +179,39 @@ struct JavaScriptErrorTests {
179
179
  #expect(caught.getObject().getProperty("message").getString() == "native failure")
180
180
  }
181
181
 
182
+ // MARK: - JavaScriptError.from(_:in:) helper
183
+
184
+ @Test
185
+ func `from returns an existing JavaScriptError unchanged`() {
186
+ // An existing `JavaScriptError` must be returned as the very same instance so its wrapped
187
+ // value (which may be an arbitrary JS value) reaches JS unchanged.
188
+ let original = JavaScriptError(runtime, value: JavaScriptValue(runtime, "just a string"))
189
+ let result = JavaScriptError.from(original, in: runtime)
190
+
191
+ #expect(result === original)
192
+ #expect(result.toValue().getString() == "just a string")
193
+ }
194
+
195
+ @Test
196
+ func `from routes a JavaScriptThrowable through the code-preserving initializer`() {
197
+ let throwable = CodedError(message: "Not found", code: "ERR_NOT_FOUND")
198
+ let object = JavaScriptError.from(throwable, in: runtime).toValue().getObject()
199
+
200
+ #expect(object.getProperty("message").getString() == "Not found")
201
+ #expect(object.getProperty("code").getString() == "ERR_NOT_FOUND")
202
+ }
203
+
204
+ @Test
205
+ func `from stringifies any other native error into a generic Error`() {
206
+ struct TestError: Error, CustomStringConvertible {
207
+ var description: String { "native failure" }
208
+ }
209
+ let object = JavaScriptError.from(TestError(), in: runtime).toValue().getObject()
210
+
211
+ #expect(object.getProperty("message").getString() == "native failure")
212
+ #expect(object.getProperty("code").isUndefined() == true)
213
+ }
214
+
182
215
  @Test
183
216
  func `nested host function errors are independent`() throws {
184
217
  let outer = runtime.createFunction("outer") { [self] _, _ in
@@ -106,6 +106,9 @@ struct JavaScriptPromiseTests {
106
106
  #expect(throws: Error.self) {
107
107
  _ = try promiseValue.getPromise()
108
108
  }
109
+ // A failed initializer must not leave its state registered, or it would pin the promise object
110
+ // in the collection until teardown even though no `JavaScriptPromise` escaped.
111
+ #expect(runtime.longLivedObjects.count == 0)
109
112
  }
110
113
 
111
114
  @Test
@@ -370,4 +373,126 @@ struct JavaScriptPromiseTests {
370
373
 
371
374
  #expect(promise.isDeferred == false)
372
375
  }
376
+
377
+ // MARK: - Long-lived object registration
378
+
379
+ @Test
380
+ func `deferred promise registers as a long-lived object`() throws {
381
+ let runtime = JavaScriptRuntime()
382
+ #expect(runtime.longLivedObjects.count == 0)
383
+
384
+ let promise = try JavaScriptPromise(runtime)
385
+ _ = promise.isDeferred
386
+
387
+ #expect(runtime.longLivedObjects.count == 1)
388
+ }
389
+
390
+ @Test
391
+ func `wrapping an existing promise registers to own its object`() throws {
392
+ let runtime = JavaScriptRuntime()
393
+ let promise = try runtime.eval("Promise.resolve(42)").getPromise()
394
+ #expect(promise.isDeferred == false)
395
+
396
+ // Even a wrapped promise owns a JSI object that must not be released against a freed runtime,
397
+ // so it registers to have that object swept at teardown.
398
+ #expect(runtime.longLivedObjects.count == 1)
399
+ }
400
+
401
+ @Test
402
+ func `settling keeps the promise registered while the wrapper is alive`() async throws {
403
+ let runtime = JavaScriptRuntime()
404
+ let promise = try JavaScriptPromise(runtime)
405
+ #expect(runtime.longLivedObjects.count == 1)
406
+
407
+ promise.resolve(JavaScriptValue(runtime, 42))
408
+ _ = try await promise.await()
409
+
410
+ // Settling releases the resolve/reject functions, but the state stays registered so it continues
411
+ // to own the promise object for as long as the wrapper is alive (it is only deregistered when the
412
+ // wrapper is dropped, see the tests below, or by the teardown sweep).
413
+ #expect(promise.isDeferred == false)
414
+ #expect(runtime.longLivedObjects.count == 1)
415
+ }
416
+
417
+ @Test
418
+ func `dropping a settled promise deregisters its state`() async throws {
419
+ let runtime = JavaScriptRuntime()
420
+
421
+ do {
422
+ let promise = try JavaScriptPromise(runtime)
423
+ promise.resolve(JavaScriptValue(runtime, 42))
424
+ _ = try await promise.await()
425
+ #expect(runtime.longLivedObjects.count == 1)
426
+ // Leaving the scope drops the last owner of the wrapper.
427
+ }
428
+
429
+ // Dropping the wrapper deregisters its state and releases the promise object, so a stream of
430
+ // short-lived promises doesn't pin their objects (and resolution values) until teardown.
431
+ #expect(runtime.longLivedObjects.count == 0)
432
+ }
433
+
434
+ @Test
435
+ func `dropping an unsettled promise deregisters its state`() throws {
436
+ let runtime = JavaScriptRuntime()
437
+
438
+ do {
439
+ let promise = try JavaScriptPromise(runtime)
440
+ #expect(promise.isDeferred == true)
441
+ #expect(runtime.longLivedObjects.count == 1)
442
+ // The promise is never settled; dropping the wrapper here is the only owner going away.
443
+ }
444
+
445
+ // Even an unsettled promise releases its state when its wrapper is dropped: nothing outside can
446
+ // settle it anymore, so keeping it registered would only pin the object until teardown.
447
+ #expect(runtime.longLivedObjects.count == 0)
448
+ }
449
+
450
+ @Test
451
+ func `dropping a wrapped promise deregisters its state`() throws {
452
+ let runtime = JavaScriptRuntime()
453
+
454
+ do {
455
+ let promise = try runtime.eval("Promise.resolve(42)").getPromise()
456
+ #expect(promise.isDeferred == false)
457
+ #expect(runtime.longLivedObjects.count == 1)
458
+ }
459
+
460
+ #expect(runtime.longLivedObjects.count == 0)
461
+ }
462
+
463
+ @Test
464
+ func `an unsettled deferred promise is released by the teardown sweep`() throws {
465
+ let runtime = JavaScriptRuntime()
466
+ let promise = try JavaScriptPromise(runtime)
467
+ #expect(runtime.longLivedObjects.count == 1)
468
+
469
+ // The promise is never settled; the runtime's teardown sweep must release its long-lived state.
470
+ runtime.longLivedObjects.clear()
471
+
472
+ #expect(runtime.longLivedObjects.count == 0)
473
+ // After the sweep the settle functions are released, so it can no longer be settled.
474
+ #expect(promise.isDeferred == false)
475
+ }
476
+
477
+ @Test
478
+ func `an unsettled deferred promise outliving its runtime does not crash on teardown`() throws {
479
+ // Reproduces the shape of the promise-teardown crash (#47454): a deferred promise is still in
480
+ // flight when its runtime is torn down. Before the state was owned by the runtime's
481
+ // `LongLivedObjectCollection`, its JSI values were released after the Hermes runtime was already
482
+ // destroyed, a use-after-free. Now the runtime's teardown sweep releases the state on the JS
483
+ // thread while the runtime is still valid.
484
+ var promise: JavaScriptPromise? = nil
485
+
486
+ do {
487
+ let runtime = JavaScriptRuntime()
488
+ promise = try JavaScriptPromise(runtime)
489
+ #expect(promise?.isDeferred == true)
490
+ // Leaving the scope releases the runtime while the promise is still unsettled. Its teardown
491
+ // sweep must release the promise's state before Hermes is destroyed.
492
+ }
493
+
494
+ // Dropping the promise wrapper here must not touch a freed runtime. This is the crash point in
495
+ // #47454; reaching the end of the test without a crash is the assertion.
496
+ promise = nil
497
+ }
373
498
  }
@@ -790,6 +790,73 @@ struct JavaScriptRuntimeTests {
790
790
  let otherRuntime = JavaScriptRuntime()
791
791
  #expect(otherRuntime.id != runtime.id)
792
792
  }
793
+
794
+ @Test
795
+ func `creating and releasing standalone runtimes repeatedly does not crash`() throws {
796
+ // Each standalone runtime owns its Hermes runtime and destroys it on `deinit`. Cycling through
797
+ // many create/use/release rounds exercises that teardown and would surface a use-after-free or
798
+ // double-free (destroying a runtime must not corrupt a subsequently created one). Calling `is`
799
+ // caches a `PropNameID` on the runtime, so this also covers releasing cached JSI objects before
800
+ // the runtime is freed.
801
+ for index in 0..<20 {
802
+ let localRuntime = JavaScriptRuntime()
803
+ let value = try localRuntime.eval("({ index: \(index) })")
804
+ #expect(value.is("Object") == true)
805
+ #expect(value.getObject().getProperty("index").getInt() == index)
806
+ }
807
+ }
808
+
809
+ // MARK: - Long-lived objects teardown
810
+
811
+ /// Records whether `allowRelease()` was called.
812
+ final class TrackedObject: LongLivedObject {
813
+ private(set) var released = false
814
+
815
+ func allowRelease() {
816
+ released = true
817
+ }
818
+ }
819
+
820
+ @Test
821
+ func `tearing down the runtime clears its long-lived objects`() {
822
+ let tracked = TrackedObject()
823
+
824
+ do {
825
+ let localRuntime = JavaScriptRuntime()
826
+ localRuntime.longLivedObjects.add(tracked)
827
+ #expect(localRuntime.longLivedObjects.count == 1)
828
+ // Leaving the scope releases the runtime. Its `deinit` destroys the owned Hermes runtime,
829
+ // tearing down the JS heap, which drops the teardown object's native state and fires its
830
+ // deallocator, sweeping the collection.
831
+ }
832
+
833
+ #expect(tracked.released == true)
834
+ }
835
+
836
+ @Test
837
+ func `an object removed before teardown is not released by the sweep`() {
838
+ let tracked = TrackedObject()
839
+
840
+ do {
841
+ let localRuntime = JavaScriptRuntime()
842
+ localRuntime.longLivedObjects.add(tracked)
843
+ localRuntime.longLivedObjects.remove(tracked)
844
+ }
845
+
846
+ #expect(tracked.released == false)
847
+ }
848
+
849
+ @Test
850
+ func `wrapping the same runtime again does not sweep the first wrapper's objects`() {
851
+ // Each wrapper pins its own teardown object under a per-wrapper property name. A second wrapper
852
+ // of the same underlying runtime must not overwrite the first's pinned object (which would let
853
+ // it be collected early and sweep the first wrapper's collection while the runtime is alive).
854
+ let tracked = TrackedObject()
855
+ runtime.longLivedObjects.add(tracked)
856
+ _ = runtime.withUnsafePointee { JavaScriptRuntime(unsafePointer: $0) }
857
+ #expect(tracked.released == false)
858
+ #expect(runtime.longLivedObjects.count == 1)
859
+ }
793
860
  }
794
861
 
795
862
  /// Runs `body` on a freshly spawned synchronous thread and bridges the result back into the
@@ -0,0 +1,228 @@
1
+ import ExpoModulesJSI
2
+ import Testing
3
+
4
+ @Suite
5
+ @JavaScriptActor
6
+ struct LongLivedObjectCollectionTests {
7
+ /// Minimal conformer that counts how many times `allowRelease()` was called, so tests can assert
8
+ /// it fires exactly once (and not at all on the remove path).
9
+ final class TrackedObject: LongLivedObject {
10
+ private(set) var releaseCount = 0
11
+
12
+ func allowRelease() {
13
+ releaseCount += 1
14
+ }
15
+ }
16
+
17
+ // MARK: - add / remove
18
+
19
+ @Test
20
+ func `a new collection is empty`() {
21
+ let collection = JavaScriptRuntime().longLivedObjects
22
+
23
+ #expect(collection.count == 0)
24
+ }
25
+
26
+ @Test
27
+ func `add registers the object`() {
28
+ let collection = JavaScriptRuntime().longLivedObjects
29
+
30
+ collection.add(TrackedObject())
31
+
32
+ #expect(collection.count == 1)
33
+ }
34
+
35
+ @Test
36
+ func `remove deregisters the object`() {
37
+ let collection = JavaScriptRuntime().longLivedObjects
38
+ let object = TrackedObject()
39
+
40
+ collection.add(object)
41
+ collection.remove(object)
42
+
43
+ #expect(collection.count == 0)
44
+ }
45
+
46
+ @Test
47
+ func `remove does not call allowRelease`() {
48
+ let collection = JavaScriptRuntime().longLivedObjects
49
+ let object = TrackedObject()
50
+
51
+ collection.add(object)
52
+ collection.remove(object)
53
+
54
+ #expect(object.releaseCount == 0)
55
+ }
56
+
57
+ @Test
58
+ func `removing an object that was never added is a no-op`() {
59
+ let collection = JavaScriptRuntime().longLivedObjects
60
+ let added = TrackedObject()
61
+ let neverAdded = TrackedObject()
62
+
63
+ collection.add(added)
64
+ collection.remove(neverAdded)
65
+
66
+ #expect(collection.count == 1)
67
+ #expect(neverAdded.releaseCount == 0)
68
+ }
69
+
70
+ @Test
71
+ func `removing one object leaves the others registered`() {
72
+ let collection = JavaScriptRuntime().longLivedObjects
73
+ let first = TrackedObject()
74
+ let second = TrackedObject()
75
+
76
+ collection.add(first)
77
+ collection.add(second)
78
+ collection.remove(first)
79
+
80
+ #expect(collection.count == 1)
81
+ }
82
+
83
+ // MARK: - identity semantics
84
+
85
+ @Test
86
+ func `distinct objects are distinct entries`() {
87
+ let collection = JavaScriptRuntime().longLivedObjects
88
+
89
+ collection.add(TrackedObject())
90
+ collection.add(TrackedObject())
91
+
92
+ #expect(collection.count == 2)
93
+ }
94
+
95
+ @Test
96
+ func `adding the same object twice keeps a single entry`() {
97
+ let collection = JavaScriptRuntime().longLivedObjects
98
+ let object = TrackedObject()
99
+
100
+ collection.add(object)
101
+ collection.add(object)
102
+
103
+ #expect(collection.count == 1)
104
+ }
105
+
106
+ @Test
107
+ func `re-adding after remove registers again`() {
108
+ let collection = JavaScriptRuntime().longLivedObjects
109
+ let object = TrackedObject()
110
+
111
+ collection.add(object)
112
+ collection.remove(object)
113
+ collection.add(object)
114
+
115
+ #expect(collection.count == 1)
116
+ }
117
+
118
+ // MARK: - clear
119
+
120
+ @Test
121
+ func `clear empties the collection`() {
122
+ let collection = JavaScriptRuntime().longLivedObjects
123
+
124
+ collection.add(TrackedObject())
125
+ collection.add(TrackedObject())
126
+ collection.clear()
127
+
128
+ #expect(collection.count == 0)
129
+ }
130
+
131
+ @Test
132
+ func `clear calls allowRelease exactly once on every survivor`() {
133
+ let collection = JavaScriptRuntime().longLivedObjects
134
+ let first = TrackedObject()
135
+ let second = TrackedObject()
136
+
137
+ collection.add(first)
138
+ collection.add(second)
139
+ collection.clear()
140
+
141
+ #expect(first.releaseCount == 1)
142
+ #expect(second.releaseCount == 1)
143
+ }
144
+
145
+ @Test
146
+ func `clear does not call allowRelease on a removed object`() {
147
+ let collection = JavaScriptRuntime().longLivedObjects
148
+ let removed = TrackedObject()
149
+ let survivor = TrackedObject()
150
+
151
+ collection.add(removed)
152
+ collection.add(survivor)
153
+ collection.remove(removed)
154
+ collection.clear()
155
+
156
+ #expect(removed.releaseCount == 0)
157
+ #expect(survivor.releaseCount == 1)
158
+ }
159
+
160
+ @Test
161
+ func `clear on an empty collection is a no-op`() {
162
+ let collection = JavaScriptRuntime().longLivedObjects
163
+
164
+ collection.clear()
165
+
166
+ #expect(collection.count == 0)
167
+ }
168
+
169
+ @Test
170
+ func `the collection is reusable after clear`() {
171
+ let collection = JavaScriptRuntime().longLivedObjects
172
+
173
+ collection.add(TrackedObject())
174
+ collection.clear()
175
+ collection.add(TrackedObject())
176
+
177
+ #expect(collection.count == 1)
178
+ }
179
+
180
+ // MARK: - ownership
181
+
182
+ @Test
183
+ func `the collection stops retaining an object after clear`() {
184
+ let collection = JavaScriptRuntime().longLivedObjects
185
+ weak var weakObject: TrackedObject?
186
+
187
+ do {
188
+ let object = TrackedObject()
189
+ weakObject = object
190
+ collection.add(object)
191
+ collection.clear()
192
+ }
193
+
194
+ // With no external strong reference and the collection's own reference dropped by `clear()`,
195
+ // the object must have been deallocated.
196
+ #expect(weakObject == nil)
197
+ }
198
+
199
+ @Test
200
+ func `the collection stops retaining an object after remove`() {
201
+ let collection = JavaScriptRuntime().longLivedObjects
202
+ weak var weakObject: TrackedObject?
203
+
204
+ do {
205
+ let object = TrackedObject()
206
+ weakObject = object
207
+ collection.add(object)
208
+ collection.remove(object)
209
+ }
210
+
211
+ #expect(weakObject == nil)
212
+ }
213
+
214
+ @Test
215
+ func `the collection retains a registered object`() {
216
+ let collection = JavaScriptRuntime().longLivedObjects
217
+ weak var weakObject: TrackedObject?
218
+
219
+ do {
220
+ let object = TrackedObject()
221
+ weakObject = object
222
+ collection.add(object)
223
+ }
224
+
225
+ // The external strong reference is gone, but the collection still holds one.
226
+ #expect(weakObject != nil)
227
+ }
228
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-modules-jsi",
3
- "version": "57.0.1",
3
+ "version": "57.0.2",
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": "b31bd70c8873eee2894bc5cc3b3460abca0cdea0",
44
+ "gitHead": "70af1caf83d8a324b46e02e18cd5c8c4e310da20",
45
45
  "scripts": {
46
46
  "build": "apple/scripts/build-xcframework.sh",
47
47
  "swift:format": "../../scripts/swift-format.sh",