Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

BridgeJS: Support non-ConvertibleToJSValue async exported return types#758

Merged
kateinoigakukun merged 1 commit into
swiftwasm:mainswiftwasm/JavaScriptKit:mainfrom
PassiveLogic:kr/async-stack-typesPassiveLogic/JavaScriptKit:kr/async-stack-typesCopy head branch name to clipboard
Jun 9, 2026
Merged

BridgeJS: Support non-ConvertibleToJSValue async exported return types#758
kateinoigakukun merged 1 commit into
swiftwasm:mainswiftwasm/JavaScriptKit:mainfrom
PassiveLogic:kr/async-stack-typesPassiveLogic/JavaScriptKit:kr/async-stack-typesCopy head branch name to clipboard

Conversation

@krodak

@krodak krodak commented Jun 8, 2026

Copy link
Copy Markdown
Member

Overview

Addresses #753. Async exported functions previously required a return type conforming to ConvertibleToJSValue. The generated thunk wrapped the body in JSPromise.async { ... } and lowered the result with .jsValue, so types that have no .jsValue representation (@JS structs, raw-value and case enums, and their Optional/Array/Dictionary compositions) could not be returned from an async @JS func:

@JS struct Point { var x: Int; var y: Int }
@JS func getPoint() async -> Point { ... }   // did not compile: Point has no `.jsValue`

This PR settles every async exported return through a single new mechanism that lowers the value via the regular imported-parameter ABI, so all bridged types and their nested compositions can be returned.

Approach

1. Create the Promise eagerly, settle it from a Task.
An exported async function must hand a value back to JavaScript synchronously (the Wasm export returns an i32 object id), while the work itself is asynchronous. The thunk therefore creates a JS Promise immediately via a new _bjs_makePromise intrinsic, returns its retained object id, and resolves or rejects it later from a Task:

// generated thunk (struct return)
public func _bjs_getPoint() -> Int32 {
    return _bjs_makePromise(resolve: Promise_resolve_5PointV, reject: Promise_reject) {
        return await getPoint()
    }
}

The resolve/reject settlers are stashed on the Promise under a Symbol (rather than plain string fields) so they cannot clash with anything else on the object.

2. Settle through generated @JSFunction thunks rather than ad-hoc lowering.
_bjs_makePromise lives in the JavaScriptKit library and cannot name the per-module, per-type lowering that the generator produces. So the generator emits a Promise_resolve_<mangled> thunk per distinct return type (deduplicated by mangled name) and a single shared Promise_reject, and injects them into _bjs_makePromise. Each resolve thunk lowers its value through the standard imported-parameter ABI:

@JSFunction func Promise_resolve_5PointV(_ promise: JSObject, _ value: Point) throws(JSException)
@JSFunction func Promise_reject(_ promise: JSObject, _ value: JSValue) throws(JSException)

The benefit of reusing the imported-parameter ABI is that every existing per-type lowering is inherited for free: struct fields pushed on the stack, enum tags, optional isSome discriminators, arrays, and dictionaries all round-trip with no new marshaling code. reject uses the full JSValue ABI so any thrown error is forwarded faithfully.

3. One path for all async returns.
Rather than branch between JSPromise.async + .jsValue for ConvertibleToJSValue returns and _bjs_makePromise for the rest, every async return now settles through _bjs_makePromise. This removes the dual codegen path and the type predicate that used to select between them. Void is the one special case: a Void value can't cross the bridge as a parameter, so its resolve thunk takes only the promise (via a small _bjs_makePromise overload) and settles with undefined.

4. Drain stack parameters synchronously before the deferred Task.
Complex parameters travel via a single shared, mutable bridge stack. Because the async body runs later on a Task, any stack-using parameter must be lifted into a local in the thunk before the Task is scheduled, otherwise an interleaved bridge call would corrupt the shared stack. The thunk hoists these lifts in reverse (LIFO) order to preserve the stack discipline:

public func _bjs_combine() -> Int32 {
    let _tmp_b = AsyncPoint.bridgeJSLiftParameter()
    let _tmp_a = AsyncPoint.bridgeJSLiftParameter()
    return _bjs_makePromise(resolve: ..., reject: ...) {
        return await combine(_: _tmp_a, _: _tmp_b)
    }
}

5. Annotate the throwing async closure explicitly.
A throwing async body needs an explicit () async throws(JSException) -> T in closure type; without it Swift infers throws(any Error) instead of throws(JSException). Non-throwing bodies infer correctly and are left unannotated.

6. Diagnose, rather than miscompile, the unsupported cases.
The few types that cannot be lowered through the imported-parameter ABI (associated-value enums, protocols, namespace enums, including those nested in Optional/Array/Dictionary) are rejected with a clear BridgeJS diagnostic instead of producing uncompilable Swift.

Tests

End-to-end runtime round-trips plus codegen snapshots cover the matrix: @JS struct, case enum, and raw-value enum, each as a bare value, Optional, Array, and Dictionary; an Int64-backed enum (base and optional, exercising the i64 resolve-helper signature); Void and ConvertibleToJSValue returns; the throwing variant, the reject path (asserted via assert.rejects), multi-parameter stack hoisting, a rich nested struct, an async class method, and concurrent calls via Promise.all (the regression guard for shared-stack corruption). Unsupported types are covered by the diagnostic path.

@krodak krodak self-assigned this Jun 9, 2026
@krodak krodak force-pushed the kr/async-stack-types branch 2 times, most recently from bb0ed80 to 5d17f95 Compare June 9, 2026 10:03
@krodak krodak requested a review from kateinoigakukun June 9, 2026 10:39
@krodak

krodak commented Jun 9, 2026

Copy link
Copy Markdown
Member Author

@kateinoigakukun let me know if approach aligns with your intention - if not just let me know which parts and I'll update accordingly 🙏🏻

@kateinoigakukun kateinoigakukun left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall, looks good to me! Thanks!

Comment thread Plugins/BridgeJS/Sources/BridgeJSSkeleton/BridgeJSSkeleton.swift Outdated
Comment thread Plugins/BridgeJS/Sources/BridgeJSLink/BridgeJSLink.swift Outdated
@krodak krodak force-pushed the kr/async-stack-types branch from 5d17f95 to fef5580 Compare June 9, 2026 14:58
Comment thread Plugins/BridgeJS/Sources/BridgeJSCore/ExportSwift.swift Outdated
Comment thread Plugins/BridgeJS/Sources/BridgeJSCore/ExportSwift.swift Outdated
@krodak krodak force-pushed the kr/async-stack-types branch from fef5580 to 54469d7 Compare June 9, 2026 15:30
@krodak krodak force-pushed the kr/async-stack-types branch from 54469d7 to 18eaf93 Compare June 9, 2026 15:37
@kateinoigakukun kateinoigakukun merged commit 388160c into swiftwasm:main Jun 9, 2026
14 of 26 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

Morty Proxy This is a proxified and sanitized view of the page, visit original site.