Skip to content

JNI: return main-actor and async closures to Java - #28

Open
piercifani wants to merge 2 commits into
jni/23-foundation-java-typesfrom
jni/24-main-actor-async-closure-results
Open

piercifani wants to merge 2 commits into
jni/23-foundation-java-typesfrom
jni/24-main-actor-async-closure-results

Conversation

@piercifani

Copy link
Copy Markdown

Stack 24/24 · base: jni/23-foundation-java-types · previous: #27

Problem

#21 lets Java read a closure back from Swift, such as the getter of a closure property, as a Java lambda over a SwiftClosure handle. Java calls that lambda synchronously and from any thread, so closures that can't be called that way were skipped:

  • @MainActor closures;
  • async closures;
  • throws closures.

These are exactly the callbacks a view model hands its UI: var onTap: @MainActor () -> Void, var onSelect: @MainActor (Item) -> Void, var save: @MainActor (String) async throws -> Void.

Changes

Not included: cancelling the returned future doesn't cancel the task, unlike async functions (#10).

Kotlin in this PR

ScreenCallbacksTest.kt with ScreenCallbacks.swift:

val screen = ScreenModel.init()                 // @MainActor final class
screen.onTap.apply()                            // var onTap: @MainActor () -> Void
screen.onSelect.apply("BTC")                    // var onSelect: @MainActor (String) -> Void
assertEquals("Markets", screen.title.apply())   // var title: @MainActor () -> String
screen.onDismiss?.apply()                       // var onDismiss: (@MainActor () -> Void)?

assertEquals(3L, screen.count.apply("abc").get())   // var count: @Sendable (String) async -> Int64
val failed = screen.save.apply("")                   // var save: @MainActor (String) async throws -> Void
assertIs<ScreenError>(assertFailsWith<ExecutionException> { failed.get() }.cause)

Why Skip needs it

Getters of closure properties were the largest remaining cause of skipped declarations in a production Skip Fuse app (9 Skip modules) run through this stack: 420 of 930, 405 of them in its view-model module. Compose calls these callbacks from the UI thread.

Testing

  • Kotlin sample: ./gradlew :Samples:SwiftJavaExtractJNIKotlinSampleApp:test passes (56 tests, including 2 in ScreenCallbacksTest).
  • JNIClosureResultsTests: the @MainActor and async throws closure thunks. A synchronous throwing closure is still skipped; that test replaces the one asserting @MainActor closures were skipped.
  • swift test passes.

🤖 Generated with Claude Code

piercifani and others added 2 commits November 4, 2026 14:49
A closure Java reads back, such as a closure property's getter, was skipped
when it was `@MainActor`, `async` or `throws`: Java calls it synchronously,
from any thread. View models hand their UI callbacks of exactly these shapes.

- A `@MainActor` closure is wrapped by `_makeMainActorSwiftClosure`, which
  calls it under `MainActor.assumeIsolated`. Java has to call it on the main
  thread, as Compose does with a view model's callbacks; any other thread
  traps, as calling a `@MainActor` function from Java does.
- An `async` closure, main-actor isolated or not and throwing or not, returns
  a `CompletableFuture`, which its Java interface already declares for
  closures Java implements. `_swiftClosureFuture` converts the arguments on
  the calling thread, awaits the closure in a task and completes the future
  with the result, or with the error as a `Throwable`.

A synchronous throwing closure is still skipped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant