Skip to content

Add JSClosure APIs to support specifying TaskExecutor and TaskPriority #383

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 3 commits into from
Jul 21, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 90 additions & 6 deletions Sources/JavaScriptKit/FundamentalObjects/JSClosure.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ public protocol JSClosureProtocol: JSValueCompatible {
public class JSOneshotClosure: JSObject, JSClosureProtocol {
private var hostFuncRef: JavaScriptHostFuncRef = 0

public init(_ body: @escaping (sending [JSValue]) -> JSValue, file: String = #fileID, line: UInt32 = #line) {
public init(file: String = #fileID, line: UInt32 = #line, _ body: @escaping (sending [JSValue]) -> JSValue) {
// 1. Fill `id` as zero at first to access `self` to get `ObjectIdentifier`.
super.init(id: 0)

Expand All @@ -44,11 +44,40 @@ public class JSOneshotClosure: JSObject, JSClosureProtocol {
}

#if compiler(>=5.5) && (!hasFeature(Embedded) || os(WASI))
/// Creates a new `JSOneshotClosure` that calls the given Swift function asynchronously.
///
/// - Parameters:
/// - priority: The priority of the new unstructured Task created under the hood.
/// - body: The Swift function to call asynchronously.
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
public static func async(
priority: TaskPriority? = nil,
file: String = #fileID,
line: UInt32 = #line,
_ body: sending @escaping (sending [JSValue]) async throws(JSException) -> JSValue
) -> JSOneshotClosure {
JSOneshotClosure(makeAsyncClosure(body))
JSOneshotClosure(file: file, line: line, makeAsyncClosure(priority: priority, body))
}

/// Creates a new `JSOneshotClosure` that calls the given Swift function asynchronously.
///
/// - Parameters:
/// - taskExecutor: The executor preference of the new unstructured Task created under the hood.
/// - priority: The priority of the new unstructured Task created under the hood.
/// - body: The Swift function to call asynchronously.
@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *)
public static func async(
executorPreference taskExecutor: (any TaskExecutor)? = nil,
priority: TaskPriority? = nil,
file: String = #fileID,
line: UInt32 = #line,
_ body: @Sendable @escaping (sending [JSValue]) async throws(JSException) -> JSValue
) -> JSOneshotClosure {
JSOneshotClosure(
file: file,
line: line,
makeAsyncClosure(executorPreference: taskExecutor, priority: priority, body)
)
}
#endif

Expand Down Expand Up @@ -117,7 +146,7 @@ public class JSClosure: JSFunction, JSClosureProtocol {
})
}

public init(_ body: @escaping (sending [JSValue]) -> JSValue, file: String = #fileID, line: UInt32 = #line) {
public init(file: String = #fileID, line: UInt32 = #line, _ body: @escaping (sending [JSValue]) -> JSValue) {
// 1. Fill `id` as zero at first to access `self` to get `ObjectIdentifier`.
super.init(id: 0)

Expand All @@ -137,11 +166,36 @@ public class JSClosure: JSFunction, JSClosureProtocol {
}

#if compiler(>=5.5) && (!hasFeature(Embedded) || os(WASI))
/// Creates a new `JSClosure` that calls the given Swift function asynchronously.
///
/// - Parameters:
/// - priority: The priority of the new unstructured Task created under the hood.
/// - body: The Swift function to call asynchronously.
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
public static func async(
_ body: @Sendable @escaping (sending [JSValue]) async throws(JSException) -> JSValue
priority: TaskPriority? = nil,
file: String = #fileID,
line: UInt32 = #line,
_ body: sending @escaping @isolated(any) (sending [JSValue]) async throws(JSException) -> JSValue
) -> JSClosure {
JSClosure(file: file, line: line, makeAsyncClosure(priority: priority, body))
}

/// Creates a new `JSClosure` that calls the given Swift function asynchronously.
///
/// - Parameters:
/// - taskExecutor: The executor preference of the new unstructured Task created under the hood.
/// - priority: The priority of the new unstructured Task created under the hood.
/// - body: The Swift function to call asynchronously.
@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *)
public static func async(
executorPreference taskExecutor: (any TaskExecutor)? = nil,
priority: TaskPriority? = nil,
file: String = #fileID,
line: UInt32 = #line,
_ body: sending @escaping (sending [JSValue]) async throws(JSException) -> JSValue
) -> JSClosure {
JSClosure(makeAsyncClosure(body))
JSClosure(file: file, line: line, makeAsyncClosure(executorPreference: taskExecutor, priority: priority, body))
}
#endif

Expand All @@ -157,6 +211,36 @@ public class JSClosure: JSFunction, JSClosureProtocol {
#if compiler(>=5.5) && (!hasFeature(Embedded) || os(WASI))
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
private func makeAsyncClosure(
priority: TaskPriority?,
_ body: sending @escaping @isolated(any) (sending [JSValue]) async throws(JSException) -> JSValue
) -> ((sending [JSValue]) -> JSValue) {
{ arguments in
JSPromise { resolver in
// NOTE: The context is fully transferred to the unstructured task
// isolation but the compiler can't prove it yet, so we need to
// use `@unchecked Sendable` to make it compile with the Swift 6 mode.
struct Context: @unchecked Sendable {
let resolver: (JSPromise.Result) -> Void
let arguments: [JSValue]
let body: (sending [JSValue]) async throws(JSException) -> JSValue
}
let context = Context(resolver: resolver, arguments: arguments, body: body)
Task(priority: priority) {
do throws(JSException) {
let result = try await context.body(context.arguments)
context.resolver(.success(result))
} catch {
context.resolver(.failure(error.thrownValue))
}
}
}.jsValue()
}
}

@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *)
private func makeAsyncClosure(
executorPreference taskExecutor: (any TaskExecutor)?,
priority: TaskPriority?,
_ body: sending @escaping (sending [JSValue]) async throws(JSException) -> JSValue
) -> ((sending [JSValue]) -> JSValue) {
{ arguments in
Expand All @@ -170,7 +254,7 @@ private func makeAsyncClosure(
let body: (sending [JSValue]) async throws(JSException) -> JSValue
}
let context = Context(resolver: resolver, arguments: arguments, body: body)
Task {
Task(executorPreference: taskExecutor, priority: priority) {
do throws(JSException) {
let result = try await context.body(context.arguments)
context.resolver(.success(result))
Expand Down
110 changes: 110 additions & 0 deletions Tests/JavaScriptEventLoopTests/JSClosure+AsyncTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import JavaScriptKit
import XCTest

class JSClosureAsyncTests: XCTestCase {
@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *)
final class AnyTaskExecutor: TaskExecutor {
func enqueue(_ job: UnownedJob) {
job.runSynchronously(on: asUnownedTaskExecutor())
}
}

final class UnsafeSendableBox<T>: @unchecked Sendable {
var value: T
init(_ value: T) {
self.value = value
}
}

func testAsyncClosure() async throws {
let closure = JSClosure.async { _ in
return (42.0).jsValue
}.jsValue
let result = try await JSPromise(from: closure.function!())!.value()
XCTAssertEqual(result, 42.0)
}

func testAsyncClosureWithPriority() async throws {
let priority = UnsafeSendableBox<TaskPriority?>(nil)
let closure = JSClosure.async(priority: .high) { _ in
priority.value = Task.currentPriority
return (42.0).jsValue
}.jsValue
let result = try await JSPromise(from: closure.function!())!.value()
XCTAssertEqual(result, 42.0)
XCTAssertEqual(priority.value, .high)
}

@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *)
func testAsyncClosureWithTaskExecutor() async throws {
let executor = AnyTaskExecutor()
let closure = JSClosure.async(executorPreference: executor) { _ in
return (42.0).jsValue
}.jsValue
let result = try await JSPromise(from: closure.function!())!.value()
XCTAssertEqual(result, 42.0)
}

@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *)
func testAsyncClosureWithTaskExecutorPreference() async throws {
let executor = AnyTaskExecutor()
let priority = UnsafeSendableBox<TaskPriority?>(nil)
let closure = JSClosure.async(executorPreference: executor, priority: .high) { _ in
priority.value = Task.currentPriority
return (42.0).jsValue
}.jsValue
let result = try await JSPromise(from: closure.function!())!.value()
XCTAssertEqual(result, 42.0)
XCTAssertEqual(priority.value, .high)
}

// TODO: Enable the following tests once:
// - Make JSObject a final-class
// - Unify JSFunction and JSObject into JSValue
// - Make JS(Oneshot)Closure as a wrapper of JSObject, not a subclass
/*
func testAsyncOneshotClosure() async throws {
let closure = JSOneshotClosure.async { _ in
return (42.0).jsValue
}.jsValue
let result = try await JSPromise(
from: closure.function!()
)!.value()
XCTAssertEqual(result, 42.0)
}

func testAsyncOneshotClosureWithPriority() async throws {
let priority = UnsafeSendableBox<TaskPriority?>(nil)
let closure = JSOneshotClosure.async(priority: .high) { _ in
priority.value = Task.currentPriority
return (42.0).jsValue
}.jsValue
let result = try await JSPromise(from: closure.function!())!.value()
XCTAssertEqual(result, 42.0)
XCTAssertEqual(priority.value, .high)
}

@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *)
func testAsyncOneshotClosureWithTaskExecutor() async throws {
let executor = AnyTaskExecutor()
let closure = JSOneshotClosure.async(executorPreference: executor) { _ in
return (42.0).jsValue
}.jsValue
let result = try await JSPromise(from: closure.function!())!.value()
XCTAssertEqual(result, 42.0)
}

@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *)
func testAsyncOneshotClosureWithTaskExecutorPreference() async throws {
let executor = AnyTaskExecutor()
let priority = UnsafeSendableBox<TaskPriority?>(nil)
let closure = JSOneshotClosure.async(executorPreference: executor, priority: .high) { _ in
priority.value = Task.currentPriority
return (42.0).jsValue
}.jsValue
let result = try await JSPromise(from: closure.function!())!.value()
XCTAssertEqual(result, 42.0)
XCTAssertEqual(priority.value, .high)
}
*/
}
88 changes: 88 additions & 0 deletions Tests/JavaScriptKitTests/JSClosureTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import JavaScriptKit
import XCTest

class JSClosureTests: XCTestCase {
func testClosureLifetime() {
let evalClosure = JSObject.global.globalObject1.eval_closure.function!

do {
let c1 = JSClosure { arguments in
return arguments[0]
}
XCTAssertEqual(evalClosure(c1, JSValue.number(1.0)), .number(1.0))
#if JAVASCRIPTKIT_WITHOUT_WEAKREFS
c1.release()
#endif
}

do {
let array = JSObject.global.Array.function!.new()
let c1 = JSClosure { _ in .number(3) }
_ = array.push!(c1)
XCTAssertEqual(array[0].function!().number, 3.0)
#if JAVASCRIPTKIT_WITHOUT_WEAKREFS
c1.release()
#endif
}

do {
let c1 = JSClosure { _ in .undefined }
XCTAssertEqual(c1(), .undefined)
}

do {
let c1 = JSClosure { _ in .number(4) }
XCTAssertEqual(c1(), .number(4))
}
}

func testHostFunctionRegistration() {
// ```js
// global.globalObject1 = {
// ...
// "prop_6": {
// "call_host_1": function() {
// return global.globalObject1.prop_6.host_func_1()
// }
// }
// }
// ```
let globalObject1 = getJSValue(this: .global, name: "globalObject1")
let globalObject1Ref = try! XCTUnwrap(globalObject1.object)
let prop_6 = getJSValue(this: globalObject1Ref, name: "prop_6")
let prop_6Ref = try! XCTUnwrap(prop_6.object)

var isHostFunc1Called = false
let hostFunc1 = JSClosure { (_) -> JSValue in
isHostFunc1Called = true
return .number(1)
}

setJSValue(this: prop_6Ref, name: "host_func_1", value: .object(hostFunc1))

let call_host_1 = getJSValue(this: prop_6Ref, name: "call_host_1")
let call_host_1Func = try! XCTUnwrap(call_host_1.function)
XCTAssertEqual(call_host_1Func(), .number(1))
XCTAssertEqual(isHostFunc1Called, true)

#if JAVASCRIPTKIT_WITHOUT_WEAKREFS
hostFunc1.release()
#endif

let evalClosure = JSObject.global.globalObject1.eval_closure.function!
let hostFunc2 = JSClosure { (arguments) -> JSValue in
if let input = arguments[0].number {
return .number(input * 2)
} else {
return .string(String(describing: arguments[0]))
}
}

XCTAssertEqual(evalClosure(hostFunc2, 3), .number(6))
XCTAssertTrue(evalClosure(hostFunc2, true).string != nil)

#if JAVASCRIPTKIT_WITHOUT_WEAKREFS
hostFunc2.release()
#endif
}
}
Loading