Skip to content

Major API change on JSClosure #113

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Jan 4, 2021
Merged
39 changes: 39 additions & 0 deletions IntegrationTests/TestSuites/Sources/PrimaryTests/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,45 @@ try test("Function Call") {
try expectEqual(func6(true, "OK", 2), .string("OK"))
}

try test("Closure Lifetime") {
do {
let c1 = JSClosure { arguments in
return arguments[0]
}
try expectEqual(c1(arguments: [JSValue.number(1.0)]), .number(1.0))
c1.release()
}

do {
let c1 = JSClosure { arguments in
return arguments[0]
}
c1.release()
// Call a released closure
_ = try expectThrow(try c1.throws())
}

do {
let c1 = JSClosure { _ in
// JSClosure will be deallocated before `release()`
_ = JSClosure { _ in .undefined }
return .undefined
}
_ = try expectThrow(try c1.throws())
c1.release()
}

do {
let c1 = JSOneshotClosure { _ in
return .boolean(true)
}
try expectEqual(c1(), .boolean(true))
// second call will cause `fatalError` that can be caught as a JavaScript exception
_ = try expectThrow(try c1.throws())
// OneshotClosure won't call fatalError even if it's deallocated before `release`
}
}

try test("Host Function Registration") {
// ```js
// global.globalObject1 = {
Expand Down
21 changes: 15 additions & 6 deletions Sources/JavaScriptKit/BasicObjects/JSPromise.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,10 @@ public final class JSPromise<Success, Failure>: ConvertibleToJSValue, Constructi
/** Schedules the `success` closure to be invoked on sucessful completion of `self`.
*/
public func then(success: @escaping () -> ()) {
let closure = JSClosure { _ in success() }
let closure = JSClosure { _ in
success()
return .undefined
}
callbacks.append(closure)
_ = jsObject.then!(closure)
}
Expand All @@ -63,6 +66,7 @@ public final class JSPromise<Success, Failure>: ConvertibleToJSValue, Constructi
public func finally(successOrFailure: @escaping () -> ()) -> Self {
let closure = JSClosure { _ in
successOrFailure()
return .undefined
}
callbacks.append(closure)
return .init(unsafe: jsObject.finally!(closure).object!)
Expand All @@ -78,10 +82,11 @@ extension JSPromise where Success == (), Failure == Never {
a closure that your code should call to resolve this `JSPromise` instance.
*/
public convenience init(resolver: @escaping (@escaping () -> ()) -> ()) {
let closure = JSClosure { arguments -> () in
let closure = JSClosure { arguments in
// The arguments are always coming from the `Promise` constructor, so we should be
// safe to assume their type here
resolver { arguments[0].function!() }
return .undefined
}
self.init(unsafe: JSObject.global.Promise.function!.new(closure))
callbacks.append(closure)
Expand All @@ -93,7 +98,7 @@ extension JSPromise where Failure: ConvertibleToJSValue {
two closure that your code should call to either resolve or reject this `JSPromise` instance.
*/
public convenience init(resolver: @escaping (@escaping (Result<Success, JSError>) -> ()) -> ()) {
let closure = JSClosure { arguments -> () in
let closure = JSClosure { arguments in
// The arguments are always coming from the `Promise` constructor, so we should be
// safe to assume their type here
let resolve = arguments[0].function!
Expand All @@ -107,6 +112,7 @@ extension JSPromise where Failure: ConvertibleToJSValue {
reject(error.jsValue())
}
}
return .undefined
}
self.init(unsafe: JSObject.global.Promise.function!.new(closure))
callbacks.append(closure)
Expand All @@ -118,7 +124,7 @@ extension JSPromise where Success: ConvertibleToJSValue, Failure: JSError {
a closure that your code should call to either resolve or reject this `JSPromise` instance.
*/
public convenience init(resolver: @escaping (@escaping (Result<Success, JSError>) -> ()) -> ()) {
let closure = JSClosure { arguments -> () in
let closure = JSClosure { arguments in
// The arguments are always coming from the `Promise` constructor, so we should be
// safe to assume their type here
let resolve = arguments[0].function!
Expand All @@ -132,6 +138,7 @@ extension JSPromise where Success: ConvertibleToJSValue, Failure: JSError {
reject(error.jsValue())
}
}
return .undefined
}
self.init(unsafe: JSObject.global.Promise.function!.new(closure))
callbacks.append(closure)
Expand All @@ -146,11 +153,12 @@ extension JSPromise where Success: ConstructibleFromJSValue {
file: StaticString = #file,
line: Int = #line
) {
let closure = JSClosure { arguments -> () in
let closure = JSClosure { arguments in
guard let result = Success.construct(from: arguments[0]) else {
fatalError("\(file):\(line): failed to unwrap success value for `then` callback")
}
success(result)
return .undefined
}
callbacks.append(closure)
_ = jsObject.then!(closure)
Expand Down Expand Up @@ -222,11 +230,12 @@ extension JSPromise where Failure: ConstructibleFromJSValue {
file: StaticString = #file,
line: Int = #line
) {
let closure = JSClosure { arguments -> () in
let closure = JSClosure { arguments in
guard let error = Failure.construct(from: arguments[0]) else {
fatalError("\(file):\(line): failed to unwrap error value for `catch` callback")
}
failure(error)
return .undefined
}
callbacks.append(closure)
_ = jsObject.then!(JSValue.undefined, closure)
Expand Down
5 changes: 4 additions & 1 deletion Sources/JavaScriptKit/BasicObjects/JSTimer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,10 @@ public final class JSTimer {
- callback: the closure to be executed after a given `millisecondsDelay` interval.
*/
public init(millisecondsDelay: Double, isRepeating: Bool = false, callback: @escaping () -> ()) {
closure = JSClosure { _ in callback() }
closure = JSClosure { _ in
callback()
return .undefined
}
self.isRepeating = isRepeating
if isRepeating {
value = global.setInterval.function!(closure, millisecondsDelay)
Expand Down
140 changes: 140 additions & 0 deletions Sources/JavaScriptKit/FundamentalObjects/JSClosure.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import _CJavaScriptKit

fileprivate var sharedFunctions: [JavaScriptHostFuncRef: ([JSValue]) -> JSValue] = [:]

/// `JSOneshotClosure` is a JavaScript function that can be called only once.
public class JSOneshotClosure: JSFunction {
private var hostFuncRef: JavaScriptHostFuncRef = 0

public init(_ body: @escaping ([JSValue]) -> JSValue) {
// 1. Fill `id` as zero at first to access `self` to get `ObjectIdentifier`.
super.init(id: 0)
let objectId = ObjectIdentifier(self)
let funcRef = JavaScriptHostFuncRef(bitPattern: Int32(objectId.hashValue))
// 2. Retain the given body in static storage by `funcRef`.
sharedFunctions[funcRef] = body
// 3. Create a new JavaScript function which calls the given Swift function.
var objectRef: JavaScriptObjectRef = 0
_create_function(funcRef, &objectRef)

hostFuncRef = funcRef
id = objectRef
}

public override func callAsFunction(this: JSObject? = nil, arguments: [ConvertibleToJSValue]) -> JSValue {
defer { release() }
return super.callAsFunction(this: this, arguments: arguments)
}

/// Release this function resource.
/// After calling `release`, calling this function from JavaScript will fail.
public func release() {
sharedFunctions[hostFuncRef] = nil
}
}

/// `JSClosure` represents a JavaScript function the body of which is written in Swift.
/// This type can be passed as a callback handler to JavaScript functions.
/// Note that the lifetime of `JSClosure` should be managed by users manually
/// due to GC boundary between Swift and JavaScript.
/// For further discussion, see also [swiftwasm/JavaScriptKit #33](https://github.com/swiftwasm/JavaScriptKit/pull/33)
///
/// e.g.
/// ```swift
/// let eventListenter = JSClosure { _ in
/// ...
/// return JSValue.undefined
/// }
///
/// button.addEventListener!("click", JSValue.function(eventListenter))
/// ...
/// button.removeEventListener!("click", JSValue.function(eventListenter))
/// eventListenter.release()
/// ```
///
public class JSClosure: JSOneshotClosure {

var isReleased: Bool = false

@available(*, deprecated, message: "This initializer will be removed in the next minor version update. Please use `init(_ body: @escaping ([JSValue]) -> JSValue)`")
@_disfavoredOverload
public init(_ body: @escaping ([JSValue]) -> ()) {
super.init({
body($0)
return .undefined
})
}

public override init(_ body: @escaping ([JSValue]) -> JSValue) {
super.init(body)
}

public override func callAsFunction(this: JSObject? = nil, arguments: [ConvertibleToJSValue]) -> JSValue {
try! invokeJSFunction(self, arguments: arguments, this: this)
}

public override func release() {
isReleased = true
super.release()
}

deinit {
guard isReleased else {
fatalError("""
release() must be called on closures manually before deallocating.
This is caused by the lack of support for the `FinalizationRegistry` API in Safari.
""")
}
}
}

// MARK: - `JSClosure` mechanism note
//
// 1. Create thunk function in JavaScript world, that has a reference
// to Swift Closure.
// ┌─────────────────────┬──────────────────────────┐
// │ Swift side │ JavaScript side │
// │ │ │
// │ │ │
// │ │ ┌──[Thunk function]──┐ │
// │ ┌ ─ ─ ─ ─ ─│─ ─│─ ─ ─ ─ ─ ┐ │ │
// │ ↓ │ │ │ │ │
// │ [Swift Closure] │ │ Host Function ID │ │
// │ │ │ │ │
// │ │ └────────────────────┘ │
// └─────────────────────┴──────────────────────────┘
//
// 2. When thunk function is invoked, it calls Swift Closure via
// `_call_host_function` and callback the result through callback func
// ┌─────────────────────┬──────────────────────────┐
// │ Swift side │ JavaScript side │
// │ │ │
// │ │ │
// │ Apply ┌──[Thunk function]──┐ │
// │ ┌ ─ ─ ─ ─ ─│─ ─│─ ─ ─ ─ ─ ┐ │ │
// │ ↓ │ │ │ │ │
// │ [Swift Closure] │ │ Host Function ID │ │
// │ │ │ │ │ │
// │ │ │ └────────────────────┘ │
// │ │ │ ↑ │
// │ │ Apply │ │
// │ └─[Result]─┼───>[Callback func]─┘ │
// │ │ │
// └─────────────────────┴──────────────────────────┘

@_cdecl("_call_host_function_impl")
func _call_host_function_impl(
_ hostFuncRef: JavaScriptHostFuncRef,
_ argv: UnsafePointer<RawJSValue>, _ argc: Int32,
_ callbackFuncRef: JavaScriptObjectRef
) {
guard let hostFunc = sharedFunctions[hostFuncRef] else {
fatalError("The function was already released")
}
let arguments = UnsafeBufferPointer(start: argv, count: Int(argc)).map {
$0.jsValue()
}
let result = hostFunc(arguments)
let callbackFuncRef = JSFunction(id: callbackFuncRef)
_ = callbackFuncRef(result)
}
Loading