Skip to content

Commit 4c9b876

Browse files
Major change on JSClosure
- Add `JSOneshotClosure` which provide an ability to invoke a closure at most once. - Remove `JSClosure.init(_ body: @escaping ([JSValue]) -> Void)` overload, which forces users to write result type in closure. - Add JSClosure lifetime test suites
1 parent 09a2cd3 commit 4c9b876

File tree

6 files changed

+251
-191
lines changed

6 files changed

+251
-191
lines changed

IntegrationTests/TestSuites/Sources/PrimaryTests/main.swift

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,45 @@ try test("Function Call") {
183183
try expectEqual(func6(true, "OK", 2), .string("OK"))
184184
}
185185

186+
try test("Closure Lifetime") {
187+
do {
188+
let c1 = JSClosure { arguments in
189+
return arguments[0]
190+
}
191+
try expectEqual(c1(arguments: [JSValue.number(1.0)]), .number(1.0))
192+
c1.release()
193+
}
194+
195+
do {
196+
let c1 = JSClosure { arguments in
197+
return arguments[0]
198+
}
199+
c1.release()
200+
// Call a released closure
201+
_ = try expectThrow(try c1.throws())
202+
}
203+
204+
do {
205+
let c1 = JSClosure { _ in
206+
// JSClosure will be deallocated before `release()`
207+
_ = JSClosure { _ in .undefined }
208+
return .undefined
209+
}
210+
_ = try expectThrow(try c1.throws())
211+
c1.release()
212+
}
213+
214+
do {
215+
let c1 = JSOneshotClosure { _ in
216+
return .boolean(true)
217+
}
218+
try expectEqual(c1(), .boolean(true))
219+
// second call will cause fatalError that can be catched as a JavaScript exception
220+
_ = try expectThrow(try c1.throws())
221+
// OneshotClosure won't call fatalError even if it's deallocated before `release`
222+
}
223+
}
224+
186225
try test("Host Function Registration") {
187226
// ```js
188227
// global.globalObject1 = {

Sources/JavaScriptKit/BasicObjects/JSPromise.swift

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,10 @@ public final class JSPromise<Success, Failure>: ConvertibleToJSValue, Constructi
5252
/** Schedules the `success` closure to be invoked on sucessful completion of `self`.
5353
*/
5454
public func then(success: @escaping () -> ()) {
55-
let closure = JSClosure { _ in success() }
55+
let closure = JSClosure { _ in
56+
success()
57+
return .undefined
58+
}
5659
callbacks.append(closure)
5760
_ = jsObject.then!(closure)
5861
}
@@ -63,6 +66,7 @@ public final class JSPromise<Success, Failure>: ConvertibleToJSValue, Constructi
6366
public func finally(successOrFailure: @escaping () -> ()) -> Self {
6467
let closure = JSClosure { _ in
6568
successOrFailure()
69+
return .undefined
6670
}
6771
callbacks.append(closure)
6872
return .init(unsafe: jsObject.finally!(closure).object!)
@@ -78,10 +82,11 @@ extension JSPromise where Success == (), Failure == Never {
7882
a closure that your code should call to resolve this `JSPromise` instance.
7983
*/
8084
public convenience init(resolver: @escaping (@escaping () -> ()) -> ()) {
81-
let closure = JSClosure { arguments -> () in
85+
let closure = JSClosure { arguments in
8286
// The arguments are always coming from the `Promise` constructor, so we should be
8387
// safe to assume their type here
8488
resolver { arguments[0].function!() }
89+
return .undefined
8590
}
8691
self.init(unsafe: JSObject.global.Promise.function!.new(closure))
8792
callbacks.append(closure)
@@ -93,7 +98,7 @@ extension JSPromise where Failure: ConvertibleToJSValue {
9398
two closure that your code should call to either resolve or reject this `JSPromise` instance.
9499
*/
95100
public convenience init(resolver: @escaping (@escaping (Result<Success, JSError>) -> ()) -> ()) {
96-
let closure = JSClosure { arguments -> () in
101+
let closure = JSClosure { arguments in
97102
// The arguments are always coming from the `Promise` constructor, so we should be
98103
// safe to assume their type here
99104
let resolve = arguments[0].function!
@@ -107,6 +112,7 @@ extension JSPromise where Failure: ConvertibleToJSValue {
107112
reject(error.jsValue())
108113
}
109114
}
115+
return .undefined
110116
}
111117
self.init(unsafe: JSObject.global.Promise.function!.new(closure))
112118
callbacks.append(closure)
@@ -118,7 +124,7 @@ extension JSPromise where Success: ConvertibleToJSValue, Failure: JSError {
118124
a closure that your code should call to either resolve or reject this `JSPromise` instance.
119125
*/
120126
public convenience init(resolver: @escaping (@escaping (Result<Success, JSError>) -> ()) -> ()) {
121-
let closure = JSClosure { arguments -> () in
127+
let closure = JSClosure { arguments in
122128
// The arguments are always coming from the `Promise` constructor, so we should be
123129
// safe to assume their type here
124130
let resolve = arguments[0].function!
@@ -132,6 +138,7 @@ extension JSPromise where Success: ConvertibleToJSValue, Failure: JSError {
132138
reject(error.jsValue())
133139
}
134140
}
141+
return .undefined
135142
}
136143
self.init(unsafe: JSObject.global.Promise.function!.new(closure))
137144
callbacks.append(closure)
@@ -146,11 +153,12 @@ extension JSPromise where Success: ConstructibleFromJSValue {
146153
file: StaticString = #file,
147154
line: Int = #line
148155
) {
149-
let closure = JSClosure { arguments -> () in
156+
let closure = JSClosure { arguments in
150157
guard let result = Success.construct(from: arguments[0]) else {
151158
fatalError("\(file):\(line): failed to unwrap success value for `then` callback")
152159
}
153160
success(result)
161+
return .undefined
154162
}
155163
callbacks.append(closure)
156164
_ = jsObject.then!(closure)
@@ -222,11 +230,12 @@ extension JSPromise where Failure: ConstructibleFromJSValue {
222230
file: StaticString = #file,
223231
line: Int = #line
224232
) {
225-
let closure = JSClosure { arguments -> () in
233+
let closure = JSClosure { arguments in
226234
guard let error = Failure.construct(from: arguments[0]) else {
227235
fatalError("\(file):\(line): failed to unwrap error value for `catch` callback")
228236
}
229237
failure(error)
238+
return .undefined
230239
}
231240
callbacks.append(closure)
232241
_ = jsObject.then!(JSValue.undefined, closure)

Sources/JavaScriptKit/BasicObjects/JSTimer.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,10 @@ public final class JSTimer {
3434
- callback: the closure to be executed after a given `millisecondsDelay` interval.
3535
*/
3636
public init(millisecondsDelay: Double, isRepeating: Bool = false, callback: @escaping () -> ()) {
37-
closure = JSClosure { _ in callback() }
37+
closure = JSClosure { _ in
38+
callback()
39+
return .undefined
40+
}
3841
self.isRepeating = isRepeating
3942
if isRepeating {
4043
value = global.setInterval.function!(closure, millisecondsDelay)
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import _CJavaScriptKit
2+
3+
fileprivate var sharedFunctions: [JavaScriptHostFuncRef: ([JSValue]) -> JSValue] = [:]
4+
5+
/// `JSOneshotClosure` is a JavaScript function that can be called at once.
6+
public class JSOneshotClosure: JSFunction {
7+
private var hostFuncRef: JavaScriptHostFuncRef = 0
8+
9+
public init(_ body: @escaping ([JSValue]) -> JSValue) {
10+
// 1. Fill `id` as zero at first to access `self` to get `ObjectIdentifier`.
11+
super.init(id: 0)
12+
let objectId = ObjectIdentifier(self)
13+
let funcRef = JavaScriptHostFuncRef(bitPattern: Int32(objectId.hashValue))
14+
// 2. Retain the given body in static storage by `funcRef`.
15+
sharedFunctions[funcRef] = body
16+
// 3. Create a new JavaScript function which calls the given Swift function.
17+
var objectRef: JavaScriptObjectRef = 0
18+
_create_function(funcRef, &objectRef)
19+
20+
hostFuncRef = funcRef
21+
id = objectRef
22+
}
23+
24+
public override func callAsFunction(this: JSObject? = nil, arguments: [ConvertibleToJSValue]) -> JSValue {
25+
defer { release() }
26+
return super.callAsFunction(this: this, arguments: arguments)
27+
}
28+
29+
/// Release this function resource.
30+
/// After calling `release`, calling this function from JavaScript will fail.
31+
public func release() {
32+
sharedFunctions[hostFuncRef] = nil
33+
}
34+
}
35+
36+
/// `JSClosure` represents a JavaScript function the body of which is written in Swift.
37+
/// This type can be passed as a callback handler to JavaScript functions.
38+
/// Note that the lifetime of `JSClosure` should be managed by users manually
39+
/// due to GC boundary between Swift and JavaScript.
40+
/// For further discussion, see also [swiftwasm/JavaScriptKit #33](https://github.com/swiftwasm/JavaScriptKit/pull/33)
41+
///
42+
/// e.g.
43+
/// ```swift
44+
/// let eventListenter = JSClosure { _ in
45+
/// ...
46+
/// return JSValue.undefined
47+
/// }
48+
///
49+
/// button.addEventListener!("click", JSValue.function(eventListenter))
50+
/// ...
51+
/// button.removeEventListener!("click", JSValue.function(eventListenter))
52+
/// eventListenter.release()
53+
/// ```
54+
///
55+
public class JSClosure: JSOneshotClosure {
56+
57+
var isReleased: Bool = false
58+
59+
public override func callAsFunction(this: JSObject? = nil, arguments: [ConvertibleToJSValue]) -> JSValue {
60+
try! invokeJSFunction(self, arguments: arguments, this: this)
61+
}
62+
63+
public override func release() {
64+
isReleased = true
65+
super.release()
66+
}
67+
68+
deinit {
69+
guard isReleased else {
70+
fatalError("""
71+
release() must be called on closures manually before deallocating.
72+
This is caused by the lack of support for the `FinalizationRegistry` API in Safari.
73+
""")
74+
}
75+
}
76+
}
77+
78+
// MARK: - `JSClosure` mechanism note
79+
//
80+
// 1. Create thunk function in JavaScript world, that has a reference
81+
// to Swift Closure.
82+
// ┌─────────────────────┬──────────────────────────┐
83+
// │ Swift side │ JavaScript side │
84+
// │ │ │
85+
// │ │ │
86+
// │ │ ┌──[Thunk function]──┐ │
87+
// │ ┌ ─ ─ ─ ─ ─│─ ─│─ ─ ─ ─ ─ ┐ │ │
88+
// │ ↓ │ │ │ │ │
89+
// │ [Swift Closure] │ │ Host Function ID │ │
90+
// │ │ │ │ │
91+
// │ │ └────────────────────┘ │
92+
// └─────────────────────┴──────────────────────────┘
93+
//
94+
// 2. When thunk function is invoked, it calls Swift Closure via
95+
// `_call_host_function` and callback the result through callback func
96+
// ┌─────────────────────┬──────────────────────────┐
97+
// │ Swift side │ JavaScript side │
98+
// │ │ │
99+
// │ │ │
100+
// │ Apply ┌──[Thunk function]──┐ │
101+
// │ ┌ ─ ─ ─ ─ ─│─ ─│─ ─ ─ ─ ─ ┐ │ │
102+
// │ ↓ │ │ │ │ │
103+
// │ [Swift Closure] │ │ Host Function ID │ │
104+
// │ │ │ │ │ │
105+
// │ │ │ └────────────────────┘ │
106+
// │ │ │ ↑ │
107+
// │ │ Apply │ │
108+
// │ └─[Result]─┼───>[Callback func]─┘ │
109+
// │ │ │
110+
// └─────────────────────┴──────────────────────────┘
111+
112+
@_cdecl("_call_host_function_impl")
113+
func _call_host_function_impl(
114+
_ hostFuncRef: JavaScriptHostFuncRef,
115+
_ argv: UnsafePointer<RawJSValue>, _ argc: Int32,
116+
_ callbackFuncRef: JavaScriptObjectRef
117+
) {
118+
guard let hostFunc = sharedFunctions[hostFuncRef] else {
119+
fatalError("The function was already released")
120+
}
121+
let arguments = UnsafeBufferPointer(start: argv, count: Int(argc)).map {
122+
$0.jsValue()
123+
}
124+
let result = hostFunc(arguments)
125+
let callbackFuncRef = JSFunction(id: callbackFuncRef)
126+
_ = callbackFuncRef(result)
127+
}

0 commit comments

Comments
 (0)