expo-modules-jsi 57.0.8 → 57.1.0

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.
@@ -517,6 +517,24 @@ public final class JavaScriptValue: JavaScriptType, Equatable, Escapable {
517
517
  return copy()
518
518
  }
519
519
 
520
+ /// Writes this value into a host callback's result slot. Undefined, null, booleans and numbers are
521
+ /// emplaced with the engine's inline constructors, so the common results skip the out-of-line
522
+ /// `jsi::Value` move and destroy that assigning to the slot would cost; everything else is copied
523
+ /// through ``asJSIValue()``. The slot must hold a value with nothing to release (see `emplaceUndefined`).
524
+ internal func writeJSIValue(to slot: UnsafeMutablePointer<facebook.jsi.Value>) {
525
+ if pointee.isUndefined() {
526
+ expo.emplaceUndefined(slot)
527
+ } else if pointee.isNumber() {
528
+ expo.emplaceNumber(slot, pointee.getNumber())
529
+ } else if pointee.isBool() {
530
+ expo.emplaceBool(slot, pointee.getBool())
531
+ } else if pointee.isNull() {
532
+ expo.emplaceNull(slot)
533
+ } else {
534
+ slot.pointee = asJSIValue()
535
+ }
536
+ }
537
+
520
538
  internal func asJSIValue() -> facebook.jsi.Value {
521
539
  if let runtime {
522
540
  return facebook.jsi.Value(runtime.pointee, pointee)
@@ -615,21 +633,27 @@ public final class JavaScriptValue: JavaScriptType, Equatable, Escapable {
615
633
  // immutable and these carry no runtime and a trivial `jsi::Value`, so one instance can be safely
616
634
  // handed out from any isolation context. Every void-returning `@JS` function returns `.undefined`,
617
635
  // so a computed getter here would allocate on every host call.
618
- private static let sharedUndefined = JavaScriptValue(nil, facebook.jsi.Value.undefined())
619
- private static let sharedNull = JavaScriptValue(nil, facebook.jsi.Value.null())
636
+ //
637
+ // The instances live in the module-level globals below rather than in static properties: the
638
+ // `undefined` and `null` accessors are `@inlinable`, and an inlined body reaches a global through its
639
+ // addressor alone, whereas a class static, even a `@usableFromInline` one, goes through a getter
640
+ // that takes the class metatype, which costs the caller a metadata accessor call (`objc_opt_self`)
641
+ // on every access. Every void host function call accesses `undefined`.
620
642
 
621
643
  /// This is a lightweight way to create an undefined value that can be used in contexts
622
644
  /// where a runtime is not available or needed. The resulting value can be passed to
623
645
  /// JavaScript functions or used in comparisons.
646
+ @inlinable
624
647
  public static var undefined: JavaScriptValue {
625
- return sharedUndefined
648
+ return sharedUndefinedValue
626
649
  }
627
650
 
628
651
  /// This is a lightweight way to create a null value that can be used in contexts
629
652
  /// where a runtime is not available or needed. The resulting value represents
630
653
  /// JavaScript's `null`, which is distinct from `undefined`.
654
+ @inlinable
631
655
  public static var null: JavaScriptValue {
632
- return sharedNull
656
+ return sharedNullValue
633
657
  }
634
658
 
635
659
  /// This is a lightweight way to create a boolean true value that can be used in contexts
@@ -697,3 +721,10 @@ extension JavaScriptValue {
697
721
  }
698
722
  }
699
723
  }
724
+
725
+ // Shared instances behind `JavaScriptValue.undefined` and `JavaScriptValue.null`; see the comment on
726
+ // those accessors for why these are globals.
727
+ @usableFromInline
728
+ internal let sharedUndefinedValue = JavaScriptValue(nil, facebook.jsi.Value.undefined())
729
+ @usableFromInline
730
+ internal let sharedNullValue = JavaScriptValue(nil, facebook.jsi.Value.null())
@@ -32,40 +32,47 @@ internal func forwardingSwiftErrorsToJS(
32
32
  ) -> facebook.jsi.Value {
33
33
  do {
34
34
  return try body()
35
- } catch let jsError as JavaScriptError {
36
- // Relay the wrapped `jsi::JSError` directly so the thrown value reaches JS as-is, which may be
37
- // an arbitrary value rather than an `Error` instance.
38
- expo.CppError.setCurrent(jsError.toJSError())
39
- } catch let throwable as JavaScriptThrowable {
40
- expo.CppError.setCurrent(JavaScriptError(runtime, from: throwable).toJSError())
41
- } catch let cppError as expo.CppError {
42
- // Re-thrown by `capturingCppErrors` when nested JSI work raised a JS error; relay
43
- // the original so its `jsi::JSError` (with stack, code, custom properties) survives.
44
- expo.CppError.setCurrent(cppError)
45
- } catch let error {
46
- expo.CppError.setCurrent(runtime.pointee, std.string(String(describing: error)))
35
+ } catch {
36
+ storeSwiftError(error, runtime: runtime)
37
+ return .undefined()
47
38
  }
48
- return .undefined()
49
39
  }
50
40
 
51
- /// Void overload of `forwardingSwiftErrorsToJS` for host object setter trampolines, which
52
- /// have no return value to propagate.
41
+ /// Overload for trampolines that write their result into the caller's slot themselves. Returns
42
+ /// whether an error was stored, so the C++ caller reads the thread-local error slot only then and
43
+ /// skips the thread-local access on every successful call.
53
44
  @_transparent
54
45
  internal func forwardingSwiftErrorsToJS(
55
46
  runtime: JavaScriptRuntime,
56
47
  _ body: () throws -> Void
57
- ) {
48
+ ) -> Bool {
58
49
  do {
59
50
  try body()
60
- } catch let jsError as JavaScriptError {
51
+ return false
52
+ } catch {
53
+ storeSwiftError(error, runtime: runtime)
54
+ return true
55
+ }
56
+ }
57
+
58
+ /// Stores an error thrown by a host callback in `expo::CppError`'s thread-local slot for the C++
59
+ /// side to rethrow as a `jsi::JSError`. Kept out of line on purpose: the trampolines above inline
60
+ /// into every host callback, and with the error handling in their bodies the hot path carried the
61
+ /// stack frame and register saves that only these branches need.
62
+ @inline(never)
63
+ internal func storeSwiftError(_ error: any Error, runtime: JavaScriptRuntime) {
64
+ switch error {
65
+ case let jsError as JavaScriptError:
61
66
  // Relay the wrapped `jsi::JSError` directly so the thrown value reaches JS as-is, which may be
62
67
  // an arbitrary value rather than an `Error` instance.
63
68
  expo.CppError.setCurrent(jsError.toJSError())
64
- } catch let throwable as JavaScriptThrowable {
69
+ case let throwable as JavaScriptThrowable:
65
70
  expo.CppError.setCurrent(JavaScriptError(runtime, from: throwable).toJSError())
66
- } catch let cppError as expo.CppError {
71
+ case let cppError as expo.CppError:
72
+ // Re-thrown by `capturingCppErrors` when nested JSI work raised a JS error; relay
73
+ // the original so its `jsi::JSError` (with stack, code, custom properties) survives.
67
74
  expo.CppError.setCurrent(cppError)
68
- } catch let error {
75
+ default:
69
76
  expo.CppError.setCurrent(runtime.pointee, std.string(String(describing: error)))
70
77
  }
71
78
  }
@@ -17,7 +17,10 @@ extension String {
17
17
  internal init(jsiString: borrowing facebook.jsi.String, in runtime: facebook.jsi.IRuntime) {
18
18
  var result = ""
19
19
  withUnsafeMutablePointer(to: &result) { resultPtr in
20
- runtime.getStringData(jsiString, UnsafeMutableRawPointer(resultPtr), appendEngineStringChunk)
20
+ // Goes through the `expo.getStringData` wrapper rather than the runtime method: on RN older
21
+ // than 0.86 (e.g. react-native-macos 0.81) `Runtime::getStringData` is protected, so Swift
22
+ // cannot call it directly.
23
+ expo.getStringData(runtime, jsiString, UnsafeMutableRawPointer(resultPtr), appendEngineStringChunk)
21
24
  }
22
25
  self = result
23
26
  }
@@ -15,7 +15,9 @@ public:
15
15
  // The result travels through an out-parameter instead of a return value: the Swift side can then
16
16
  // write it straight into the caller's slot from inside its guaranteed-reference scope, with no
17
17
  // placeholder value to construct and no extra move of the `jsi::Value`.
18
- using Closure = void(Context context, const facebook::jsi::Value *_Nonnull thisValue, const facebook::jsi::Value *_Nonnull args, size_t count, facebook::jsi::Value *_Nonnull result);
18
+ // Returns whether the Swift side stored an error in `CppError`'s thread-local slot. Reporting that
19
+ // through the return value spares the caller a thread-local read on every successful call.
20
+ using Closure = bool(Context context, const facebook::jsi::Value *_Nonnull thisValue, const facebook::jsi::Value *_Nonnull args, size_t count, facebook::jsi::Value *_Nonnull result);
19
21
 
20
22
  explicit HostFunctionClosure(Context context, Closure closure, Deallocator deallocator) : RetainedSwiftPointer(context, deallocator), _closure(closure) {};
21
23
 
@@ -26,8 +28,8 @@ public:
26
28
  /**
27
29
  Calls the Swift closure with given `this` value and arguments.
28
30
  */
29
- inline void call(const facebook::jsi::Value &thisValue, const facebook::jsi::Value *_Nonnull args, size_t count, facebook::jsi::Value &result) const {
30
- _closure(_context, &thisValue, args, count, &result);
31
+ inline bool call(const facebook::jsi::Value &thisValue, const facebook::jsi::Value *_Nonnull args, size_t count, facebook::jsi::Value &result) const {
32
+ return _closure(_context, &thisValue, args, count, &result);
31
33
  }
32
34
 
33
35
  private:
@@ -24,11 +24,12 @@ public:
24
24
 
25
25
  inline jsi::Value get(jsi::Runtime &runtime, const jsi::PropNameID &name) override {
26
26
  jsi::Value result;
27
- _callbacks.get(name.utf8(runtime).c_str(), result);
28
27
  // If the Swift getter stored a pending error, rethrow its JSError directly
29
28
  // to preserve all properties (message, code, stack, etc.).
30
- if (auto *error = CppError::getCurrent()) {
31
- throw error->release();
29
+ if (_callbacks.get(name.utf8(runtime).c_str(), result)) {
30
+ if (auto *error = CppError::getCurrent()) {
31
+ throw error->release();
32
+ }
32
33
  }
33
34
  return result;
34
35
  }
@@ -38,9 +39,10 @@ public:
38
39
  // `jsi::JSError` directly and the `CppError` check below is never reached.
39
40
  // For writable host objects, a throwing Swift setter routes its error through
40
41
  // `CppError`'s thread-local slot, which we drain and rethrow here.
41
- _callbacks.set(runtime, name.utf8(runtime).c_str(), value);
42
- if (auto *error = CppError::getCurrent()) {
43
- throw error->release();
42
+ if (_callbacks.set(runtime, name.utf8(runtime).c_str(), value)) {
43
+ if (auto *error = CppError::getCurrent()) {
44
+ throw error->release();
45
+ }
44
46
  }
45
47
  }
46
48
 
@@ -18,16 +18,18 @@ public:
18
18
  using PropNameIds = std::vector<facebook::jsi::PropNameID>;
19
19
  // The getter writes its result through an out-parameter, like `HostFunctionClosure`, so the
20
20
  // Swift side can fill the caller's slot from inside its guaranteed-reference scope.
21
- using Getter = void(Context, const char *_Nonnull name, facebook::jsi::Value *_Nonnull result);
22
- using Setter = void(Context, const char *_Nonnull name, void *_Nonnull value);
21
+ // Both return whether the Swift side stored an error in `CppError`'s thread-local slot, so the
22
+ // caller reads that slot only when needed.
23
+ using Getter = bool(Context, const char *_Nonnull name, facebook::jsi::Value *_Nonnull result);
24
+ using Setter = bool(Context, const char *_Nonnull name, void *_Nonnull value);
23
25
  using PropertyNamesGetter = PropNameIds(Context);
24
26
  using Deallocator = void(Context);
25
27
 
26
28
  explicit HostObjectCallbacks(Context context, Getter getter, Setter *_Nullable setter, PropertyNamesGetter propertyNamesGetter, Deallocator deallocator)
27
29
  : _context(context), _getter(getter), _setter(setter), _propertyNamesGetter(propertyNamesGetter), _deallocator(deallocator) {}
28
30
 
29
- inline void get(const char *_Nonnull name, facebook::jsi::Value &result) const {
30
- _getter(_context, name, &result);
31
+ inline bool get(const char *_Nonnull name, facebook::jsi::Value &result) const {
32
+ return _getter(_context, name, &result);
31
33
  }
32
34
 
33
35
  /**
@@ -39,7 +41,7 @@ public:
39
41
  JSI call frame above this on the stack; calling outside that context will surface
40
42
  the throw as an unhandled C++ exception.
41
43
  */
42
- inline void set(facebook::jsi::Runtime &runtime, const char *_Nonnull name, const facebook::jsi::Value &value) const {
44
+ inline bool set(facebook::jsi::Runtime &runtime, const char *_Nonnull name, const facebook::jsi::Value &value) const {
43
45
  if (_setter == nullptr) {
44
46
  throw facebook::jsi::JSError(
45
47
  runtime,
@@ -48,7 +50,7 @@ public:
48
50
  "Pass a `set` closure to `createHostObject` to make this property writable."
49
51
  );
50
52
  }
51
- _setter(_context, name, (void *)(&value));
53
+ return _setter(_context, name, (void *)(&value));
52
54
  }
53
55
 
54
56
  inline PropNameIds getPropertyNames() const {
@@ -1,8 +1,10 @@
1
1
  // Copyright 2022-present 650 Industries. All rights reserved.
2
2
 
3
3
  #pragma once
4
+
4
5
  #ifdef __cplusplus
5
6
 
7
+ #include <new>
6
8
  #include <TargetConditionals.h>
7
9
 
8
10
  // `jsi.h` only forward-declares `jsi::Instrumentation`.
@@ -103,12 +105,12 @@ inline jsi::Function createHostFunction(jsi::IRuntime &runtime, const jsi::PropN
103
105
  auto closurePtr = std::shared_ptr<HostFunctionClosure>(closure);
104
106
  return jsi::Function::createFromHostFunction(runtime, propName, 0, [closurePtr](jsi::Runtime &runtime, const jsi::Value &thisValue, const jsi::Value *_Nonnull args, size_t count) -> jsi::Value {
105
107
  jsi::Value result;
106
- closurePtr->call(thisValue, args, count, result);
107
-
108
108
  // If the Swift closure stored a pending error, rethrow its JSError directly
109
109
  // to preserve all properties (message, code, stack, etc.).
110
- if (auto *error = CppError::getCurrent()) {
111
- throw error->release();
110
+ if (closurePtr->call(thisValue, args, count, result)) {
111
+ if (auto *error = CppError::getCurrent()) {
112
+ throw error->release();
113
+ }
112
114
  }
113
115
  return result;
114
116
  });
@@ -152,6 +154,33 @@ inline void collectGarbage(jsi::IRuntime &runtime, const std::string &cause) {
152
154
  runtime.instrumentation().collectGarbage(cause);
153
155
  }
154
156
 
157
+ // MARK: - Result slots
158
+
159
+ // Construct a host callback's result in place in the engine's result slot. `::new (slot) T(...)` is
160
+ // placement new: it runs the constructor on existing memory and allocates nothing. These `jsi::Value`
161
+ // constructors are inline, so each helper is two stores, whereas a move-assignment into the slot
162
+ // calls `Value::~Value()` and `Value::Value(Value&&)`, both out-of-line in the engine library.
163
+ //
164
+ // Placement new does not destroy the slot's previous contents, so the slot must hold a value that
165
+ // owns nothing; an object or string there would leak its engine handle. Both callers,
166
+ // `createHostFunction` and `HostObject::get`, pass a default-constructed slot. The Swift side picks
167
+ // the helper by kind in `JavaScriptValue.writeJSIValue(to:)` and moves everything else.
168
+ inline void emplaceUndefined(jsi::Value *_Nonnull slot) noexcept {
169
+ ::new (slot) jsi::Value();
170
+ }
171
+
172
+ inline void emplaceNull(jsi::Value *_Nonnull slot) noexcept {
173
+ ::new (slot) jsi::Value(nullptr);
174
+ }
175
+
176
+ inline void emplaceBool(jsi::Value *_Nonnull slot, bool value) noexcept {
177
+ ::new (slot) jsi::Value(value);
178
+ }
179
+
180
+ inline void emplaceNumber(jsi::Value *_Nonnull slot, double value) noexcept {
181
+ ::new (slot) jsi::Value(value);
182
+ }
183
+
155
184
  inline jsi::Value callFunction(jsi::IRuntime &runtime, const jsi::Function &function, const jsi::Value *_Nullable args, size_t count) {
156
185
  return expo::CppError::tryCatch(runtime, [&] {
157
186
  return function.call(runtime, args, count);
@@ -170,6 +199,35 @@ inline jsi::Value callAsConstructor(jsi::IRuntime &runtime, const jsi::Function
170
199
  });
171
200
  }
172
201
 
202
+ // MARK: - String
203
+
204
+ /**
205
+ Invokes `callback` with the engine's internal representation of `string`, chunk by chunk
206
+ (see `jsi::Runtime::getStringData` docs). On RN 0.86+ the method is public on `IRuntime` and is
207
+ called directly. On older versions (e.g. react-native-macos 0.81) it is a protected member of
208
+ `jsi::Runtime`, which Swift cannot call, so this wrapper routes through the public
209
+ `jsi::String::getStringData` template helper instead. That adds one extra indirect call per chunk,
210
+ negligible next to the copy/transcode of the chunk itself.
211
+ The callback's pointer parameters mirror the unannotated `jsi.h` signature, so the same Swift
212
+ function can be passed to either.
213
+ */
214
+ inline void getStringData(
215
+ jsi::IRuntime &runtime,
216
+ const jsi::String &string,
217
+ void *ctx,
218
+ void (*callback)(void *_Nullable ctx, bool ascii, const void *_Nullable data, size_t num)
219
+ ) {
220
+ #if defined(REACT_NATIVE_VERSION_MAJOR) && defined(REACT_NATIVE_VERSION_MINOR) && \
221
+ (REACT_NATIVE_VERSION_MAJOR > 0 || REACT_NATIVE_VERSION_MINOR >= 86)
222
+ runtime.getStringData(string, ctx, callback);
223
+ #else
224
+ auto forward = [ctx, callback](bool ascii, const void *data, size_t num) {
225
+ callback(ctx, ascii, data, num);
226
+ };
227
+ string.getStringData(runtime, forward);
228
+ #endif
229
+ }
230
+
173
231
  // MARK: - ArrayBuffer
174
232
 
175
233
  /**
@@ -0,0 +1,116 @@
1
+ // Copyright 2025-present 650 Industries. All rights reserved.
2
+
3
+ import ExpoModulesJSI
4
+ import Testing
5
+
6
+ // `Task.encode` settles its promise from a detached task, so the runtime here needs a real thread
7
+ // for that settle to land on. A standalone `JavaScriptRuntime()` would run it inline on the
8
+ // cooperative thread, concurrently with whatever the test body is doing to the same runtime. Every
9
+ // call that touches the runtime runs through `runIsolated` for the same reason; `await()` itself is
10
+ // a plain Swift suspension and resumes on the thread that settled the promise.
11
+ @Suite("JavaScriptCodable+Task")
12
+ struct JavaScriptCodableTaskTests {
13
+ @Test
14
+ func `encodes a task as a promise object`() async throws {
15
+ let runtime = await TestRuntimeScheduler().makeRuntime()
16
+ let promise = try await runtime.scheduler.runIsolated {
17
+ let task = Task<Int, any Error> {
18
+ return 42
19
+ }
20
+ let encoded = try Task.encode(task, in: runtime.runtime)
21
+ #expect(encoded.isObject())
22
+ return UncheckedSendable(value: try encoded.getPromise())
23
+ }
24
+ _ = try await promise.value.await()
25
+ }
26
+
27
+ @Test
28
+ func `resolves with the encoded success value`() async throws {
29
+ let runtime = await TestRuntimeScheduler().makeRuntime()
30
+ let promise = try await runtime.scheduler.runIsolated {
31
+ let task = Task<Int, any Error> {
32
+ return 42
33
+ }
34
+ return UncheckedSendable(value: try Task.encode(task, in: runtime.runtime).getPromise())
35
+ }
36
+ let result = try await promise.value.await()
37
+ #expect(result.getInt() == 42)
38
+ }
39
+
40
+ @Test
41
+ func `resolves with a value produced after suspension`() async throws {
42
+ let runtime = await TestRuntimeScheduler().makeRuntime()
43
+ let promise = try await runtime.scheduler.runIsolated {
44
+ let task = Task<String, any Error> {
45
+ try await Task.sleep(nanoseconds: 10_000_000) // 10ms
46
+ return "done"
47
+ }
48
+ return UncheckedSendable(value: try Task.encode(task, in: runtime.runtime).getPromise())
49
+ }
50
+ let result = try await promise.value.await()
51
+ #expect(result.getString() == "done")
52
+ }
53
+
54
+ @Test
55
+ func `rejects when the task throws`() async throws {
56
+ struct TestError: Error {}
57
+ let runtime = await TestRuntimeScheduler().makeRuntime()
58
+ let promise = try await runtime.scheduler.runIsolated {
59
+ let task = Task<Int, any Error> {
60
+ throw TestError()
61
+ }
62
+ return UncheckedSendable(value: try Task.encode(task, in: runtime.runtime).getPromise())
63
+ }
64
+ await #expect(throws: Error.self) {
65
+ try await promise.value.await()
66
+ }
67
+ }
68
+
69
+ @Test
70
+ func `encodes a non-throwing task`() async throws {
71
+ let runtime = await TestRuntimeScheduler().makeRuntime()
72
+ let promise = try await runtime.scheduler.runIsolated {
73
+ let task = Task<Int, Never> {
74
+ return 7
75
+ }
76
+ return UncheckedSendable(value: try Task.encode(task, in: runtime.runtime).getPromise())
77
+ }
78
+ let result = try await promise.value.await()
79
+ #expect(result.getInt() == 7)
80
+ }
81
+
82
+ @Test
83
+ func `encodes a task whose success is itself codable`() async throws {
84
+ let runtime = await TestRuntimeScheduler().makeRuntime()
85
+ let promise = try await runtime.scheduler.runIsolated {
86
+ let task = Task<[String: Int], any Error> {
87
+ return ["answer": 42]
88
+ }
89
+ return UncheckedSendable(value: try Task.encode(task, in: runtime.runtime).getPromise())
90
+ }
91
+ let result = try await promise.value.await()
92
+ #expect(result.getObject().getProperty("answer").getInt() == 42)
93
+ }
94
+
95
+ @Test
96
+ func `encodes a task nested inside a container`() async throws {
97
+ // The motivating case: a promise handed to JS as a *value* rather than a function's own return.
98
+ let runtime = await TestRuntimeScheduler().makeRuntime()
99
+ let promises = try await runtime.scheduler.runIsolated {
100
+ let tasks = [
101
+ Task<Int, any Error> { return 1 },
102
+ Task<Int, any Error> { return 2 },
103
+ ]
104
+ let array = try [Task<Int, any Error>].encode(tasks, in: runtime.runtime).getArray()
105
+ #expect(array.length == 2)
106
+ return [
107
+ UncheckedSendable(value: try array.getValue(at: 0).getPromise()),
108
+ UncheckedSendable(value: try array.getValue(at: 1).getPromise()),
109
+ ]
110
+ }
111
+ let first = try await promises[0].value.await()
112
+ let second = try await promises[1].value.await()
113
+ #expect(first.getInt() == 1)
114
+ #expect(second.getInt() == 2)
115
+ }
116
+ }
@@ -1,6 +1,16 @@
1
1
  import ExpoModulesJSI
2
+ import Foundation
2
3
  import Testing
3
4
 
5
+ /// A `JavaScriptEncodable` whose `encode` always throws, for exercising the encodable `resolve`'s
6
+ /// encode-failure path.
7
+ private struct FailingEncodable: JavaScriptEncodable {
8
+ struct EncodingError: Error {}
9
+ static func encode(_ value: FailingEncodable, in runtime: borrowing JavaScriptRuntime) throws -> JavaScriptValue {
10
+ throw EncodingError()
11
+ }
12
+ }
13
+
4
14
  @Suite
5
15
  @JavaScriptActor
6
16
  struct JavaScriptPromiseTests {
@@ -12,6 +22,32 @@ struct JavaScriptPromiseTests {
12
22
  #expect(promise.isDeferred == true)
13
23
  }
14
24
 
25
+ @Test
26
+ func `deferred promises from separate runtimes settle independently`() async throws {
27
+ let first = JavaScriptRuntime()
28
+ let second = JavaScriptRuntime()
29
+ let firstPromise = try JavaScriptPromise(first)
30
+ let secondPromise = try JavaScriptPromise(second)
31
+ firstPromise.resolve(1.0)
32
+ secondPromise.resolve(2.0)
33
+ #expect(try await firstPromise.await().getDouble() == 1.0)
34
+ #expect(try await secondPromise.await().getDouble() == 2.0)
35
+ }
36
+
37
+ @Test
38
+ func `creating many deferred promises reuses one runtime-level helper`() throws {
39
+ let runtime = JavaScriptRuntime()
40
+ for _ in 0..<1_000 {
41
+ let promise = try JavaScriptPromise(runtime)
42
+ promise.resolve(42.0)
43
+ }
44
+ // The only object the wrapper may leave on `globalThis` is the long-lived-objects anchor; a
45
+ // per-promise helper leaking into the global scope would show up as extra own properties.
46
+ let ownGlobals = try runtime.eval("Object.getOwnPropertyNames(globalThis).length").getInt()
47
+ let freshGlobals = try JavaScriptRuntime().eval("Object.getOwnPropertyNames(globalThis).length").getInt()
48
+ #expect(ownGlobals - freshGlobals <= 1)
49
+ }
50
+
15
51
  @Test
16
52
  func `deferred promise construction throws when Promise constructor is unavailable`() throws {
17
53
  let runtime = JavaScriptRuntime()
@@ -33,6 +69,45 @@ struct JavaScriptPromiseTests {
33
69
  #expect(result.getInt() == 42)
34
70
  }
35
71
 
72
+ @Test
73
+ func `resolve with a both-conforming value keeps its representable type`() async throws {
74
+ // A 64-bit integer conforms to both `JavaScriptRepresentable` and `JavaScriptEncodable`. The
75
+ // encodable overload is disfavored, so it resolves through the representable path and stays a JS
76
+ // `number`, rather than encoding to a `bigint` (`Int64.encode`) or rejecting for exceeding the
77
+ // safe-integer range (`Int.encode`).
78
+ let runtime = JavaScriptRuntime()
79
+ let promise = try JavaScriptPromise(runtime)
80
+ promise.resolve(Int64(42))
81
+ let result = try await promise.await()
82
+ #expect(result.isNumber())
83
+ #expect(result.getInt() == 42)
84
+ }
85
+
86
+ @Test
87
+ func `resolve promise with an encodable-only value`() async throws {
88
+ // `Date` is `JavaScriptEncodable` but not `JavaScriptRepresentable`, so it can only settle the
89
+ // promise through the encodable overload. It encodes to a JS `Date`.
90
+ let runtime = JavaScriptRuntime()
91
+ let promise = try JavaScriptPromise(runtime)
92
+ let date = Date(timeIntervalSince1970: 1000)
93
+ promise.resolve(date)
94
+ let result = try await promise.await()
95
+ let milliseconds = try result.getObject().callFunction("getTime").asDouble()
96
+ #expect(milliseconds == 1_000_000)
97
+ }
98
+
99
+ @Test
100
+ func `resolve rejects the promise when encoding throws`() async throws {
101
+ // The encodable `resolve` encodes on the JavaScript thread; if that throws, the promise must
102
+ // reject with the thrown error rather than fulfill.
103
+ let runtime = JavaScriptRuntime()
104
+ let promise = try JavaScriptPromise(runtime)
105
+ promise.resolve(FailingEncodable())
106
+ await #expect(throws: Error.self) {
107
+ try await promise.await()
108
+ }
109
+ }
110
+
36
111
  @Test
37
112
  func `resolve promise with string`() async throws {
38
113
  let runtime = JavaScriptRuntime()
@@ -585,6 +585,19 @@ struct JavaScriptRuntimeTests {
585
585
  #expect(result.getInt() == 42)
586
586
  }
587
587
 
588
+ @Test
589
+ func `host function results of primitive kinds reach JavaScript unchanged`() throws {
590
+ runtime.global().setProperty("yes", value: runtime.createFunction("yes") { _, _ in .true() }.asValue())
591
+ runtime.global().setProperty("no", value: runtime.createFunction("no") { _, _ in .false() }.asValue())
592
+ runtime.global().setProperty("nothing", value: runtime.createFunction("nothing") { _, _ in .null }.asValue())
593
+ runtime.global().setProperty("half", value: runtime.createFunction("half") { _, _ in .number(0.5) }.asValue())
594
+ let result = try runtime.eval("[yes() === true, no() === false, nothing() === null, half() === 0.5]").getArray()
595
+ #expect(result[0].getBool() == true)
596
+ #expect(result[1].getBool() == true)
597
+ #expect(result[2].getBool() == true)
598
+ #expect(result[3].getBool() == true)
599
+ }
600
+
588
601
  // MARK: - Async functions
589
602
 
590
603
  @Test
@@ -858,6 +871,28 @@ struct JavaScriptRuntimeTests {
858
871
  #expect(runtime.longLivedObjects.count == 1)
859
872
  }
860
873
 
874
+ @Test
875
+ func `non-owning wrapper outliving its runtime does not destroy cached PropNameIDs against freed memory`() throws {
876
+ // A non-owning wrapper's `deinit` returns early, so its stored `propNameIdsRegistry` is
877
+ // destroyed whenever the wrapper deallocates. If the wrapper outlives its runtime (e.g. it is
878
+ // captured by a task abandoned on reload), the cached `jsi::PropNameID` destructors used to run
879
+ // against the freed Hermes runtime, a use-after-free. Now the teardown sweep flushes the
880
+ // registry on the JS thread while the runtime is still valid.
881
+ var wrapper: JavaScriptRuntime? = nil
882
+ do {
883
+ let baseRuntime = JavaScriptRuntime()
884
+ wrapper = baseRuntime.withUnsafePointee { JavaScriptRuntime(unsafePointer: $0) }
885
+ // Creating a promise populates the wrapper's registry with cached "Promise" and "then" IDs.
886
+ _ = try JavaScriptPromise(wrapper!)
887
+ // Leaving the scope destroys the Hermes runtime while the wrapper is still alive; its
888
+ // teardown sweep must flush the cached PropNameIDs before Hermes is destroyed.
889
+ }
890
+ // Deallocating the wrapper here must not touch the freed runtime. Reaching the end of the test
891
+ // without a crash is the assertion.
892
+ wrapper = nil
893
+ _ = wrapper
894
+ }
895
+
861
896
  // MARK: - Garbage collection
862
897
 
863
898
  @Test
@@ -888,6 +923,21 @@ struct JavaScriptRuntimeTests {
888
923
  }
889
924
  }
890
925
 
926
+ /// Tasks captured by `holdSchedulerTask` instead of being executed, emulating a React
927
+ /// `RuntimeScheduler` that is torn down with work still queued (the #47716 reload scenario).
928
+ /// Safe without synchronization: the dispatch always runs synchronously on the test's thread.
929
+ nonisolated(unsafe) private var heldSchedulerTasks: [() -> Void] = []
930
+
931
+ /// A `dispatch` trampoline for `JavaScriptRuntime.init(unsafePointer:scheduler:dispatch:)` that
932
+ /// holds the scheduled tasks instead of running them, so a test controls when (and whether) they
933
+ /// are released. Matches `expo.RuntimeScheduler.ScheduleFn`.
934
+ private let holdSchedulerTask:
935
+ @convention(c) (
936
+ UnsafeMutableRawPointer?, Int32, @escaping @convention(block) () -> Void
937
+ ) -> Void = { _, _, callback in
938
+ heldSchedulerTasks.append(callback)
939
+ }
940
+
891
941
  /// Runs `body` on a freshly spawned synchronous thread and bridges the result back into the
892
942
  /// async test. The thread has a real run loop, which the cross-thread `execute` path pumps.
893
943
  private func onSyncOffThread<R: Sendable>(