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.
- package/CHANGELOG.md +20 -0
- package/apple/Benchmarks/PromiseBenchmarks.swift +43 -0
- package/apple/Sources/ExpoModulesJSI/Coding/JavaScriptCodable+Containers.swift +14 -8
- package/apple/Sources/ExpoModulesJSI/Coding/JavaScriptCodable+Task.swift +35 -0
- package/apple/Sources/ExpoModulesJSI/Contexts/HostCallbackContext.swift +9 -9
- package/apple/Sources/ExpoModulesJSI/Runtime/JavaScriptRuntime.swift +32 -18
- package/apple/Sources/ExpoModulesJSI/Runtime/JavaScriptValuesBuffer.swift +32 -17
- package/apple/Sources/ExpoModulesJSI/Runtime/Values/JavaScriptPromise.swift +96 -20
- package/apple/Sources/ExpoModulesJSI/Runtime/Values/JavaScriptValue.swift +35 -4
- package/apple/Sources/ExpoModulesJSI/Utilities/ErrorHandling.swift +27 -20
- package/apple/Sources/ExpoModulesJSI/Utilities/String+JSI.swift +4 -1
- package/apple/Sources/ExpoModulesJSI-Cxx/include/HostFunctionClosure.h +5 -3
- package/apple/Sources/ExpoModulesJSI-Cxx/include/HostObject.h +8 -6
- package/apple/Sources/ExpoModulesJSI-Cxx/include/HostObjectCallbacks.h +8 -6
- package/apple/Sources/ExpoModulesJSI-Cxx/include/JSIUtils.h +62 -4
- package/apple/Tests/JavaScriptCodableTaskTests.swift +116 -0
- package/apple/Tests/JavaScriptPromiseTests.swift +75 -0
- package/apple/Tests/JavaScriptRuntimeTests.swift +50 -0
- package/apple/Tests/Support/TestRuntimeScheduler.swift +151 -0
- package/package.json +2 -2
- package/spm.config.json +1 -1
|
@@ -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
|
-
|
|
619
|
-
|
|
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
|
|
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
|
|
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
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
///
|
|
52
|
-
///
|
|
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
|
-
|
|
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
|
-
|
|
69
|
+
case let throwable as JavaScriptThrowable:
|
|
65
70
|
expo.CppError.setCurrent(JavaScriptError(runtime, from: throwable).toJSError())
|
|
66
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
31
|
-
|
|
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
|
-
|
|
43
|
-
|
|
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
|
-
|
|
22
|
-
|
|
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
|
|
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
|
|
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 (
|
|
111
|
-
|
|
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>(
|