expo-modules-jsi 57.0.1 → 57.0.3
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 +19 -0
- package/apple/Sources/ExpoModulesJSI/Coding/JavaScriptCodable+Date.swift +70 -0
- package/apple/Sources/ExpoModulesJSI/Runtime/JavaScriptRef.swift +12 -0
- package/apple/Sources/ExpoModulesJSI/Runtime/JavaScriptRuntime.swift +83 -1
- package/apple/Sources/ExpoModulesJSI/Runtime/LongLivedObjectCollection.swift +46 -0
- package/apple/Sources/ExpoModulesJSI/Runtime/Values/JavaScriptError.swift +15 -0
- package/apple/Sources/ExpoModulesJSI/Runtime/Values/JavaScriptPromise.swift +89 -31
- package/apple/Sources/ExpoModulesJSI-Cxx/include/JSIUtils.h +10 -0
- package/apple/Sources/ExpoModulesJSI-Cxx/include/RuntimeScheduler.h +3 -2
- package/apple/Tests/JavaScriptCodableDateTests.swift +270 -0
- package/apple/Tests/JavaScriptErrorTests.swift +33 -0
- package/apple/Tests/JavaScriptPromiseTests.swift +125 -0
- package/apple/Tests/JavaScriptRuntimeTests.swift +67 -0
- package/apple/Tests/LongLivedObjectCollectionTests.swift +228 -0
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,24 @@
|
|
|
10
10
|
|
|
11
11
|
### 💡 Others
|
|
12
12
|
|
|
13
|
+
## 57.0.3 — 2026-07-15
|
|
14
|
+
|
|
15
|
+
_This version does not introduce any user-facing changes._
|
|
16
|
+
|
|
17
|
+
## 57.0.2 — 2026-07-15
|
|
18
|
+
|
|
19
|
+
### 🎉 New features
|
|
20
|
+
|
|
21
|
+
- [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))
|
|
22
|
+
- [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))
|
|
23
|
+
- [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))
|
|
24
|
+
|
|
25
|
+
### 🐛 Bug fixes
|
|
26
|
+
|
|
27
|
+
- [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))
|
|
28
|
+
- [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))
|
|
29
|
+
- [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))
|
|
30
|
+
|
|
13
31
|
## 57.0.1 — 2026-07-07
|
|
14
32
|
|
|
15
33
|
### 🐛 Bug fixes
|
|
@@ -58,6 +76,7 @@
|
|
|
58
76
|
|
|
59
77
|
### 🐛 Bug fixes
|
|
60
78
|
|
|
79
|
+
- [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
80
|
- [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
81
|
- [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
82
|
- 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`:
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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
|
|
41
|
-
resolveFunction
|
|
42
|
-
rejectFunction
|
|
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
|
-
|
|
90
|
+
let object =
|
|
47
91
|
try runtime
|
|
48
92
|
.global()
|
|
49
93
|
.getPropertyAsFunction(.cached(runtime, "Promise"))
|
|
50
94
|
.callAsConstructor(setup.asValue())
|
|
51
|
-
|
|
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
|
-
|
|
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) { [
|
|
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
|
-
//
|
|
90
|
-
|
|
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) { [
|
|
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
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
|
|
109
|
-
let errorValue =
|
|
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
|
-
//
|
|
116
|
-
|
|
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.
|
|
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
|
|
35
|
-
react::RuntimeScheduler
|
|
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.
|
|
3
|
+
"version": "57.0.3",
|
|
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": "
|
|
44
|
+
"gitHead": "b9503d06b8e130abfca1bc16f671dbefb41ad709",
|
|
45
45
|
"scripts": {
|
|
46
46
|
"build": "apple/scripts/build-xcframework.sh",
|
|
47
47
|
"swift:format": "../../scripts/swift-format.sh",
|