Skip to content

Cancel a bridged .task(id:) when its id changes or the view leaves - #540

Open
Ceylo wants to merge 1 commit into
skiptools:mainfrom
Ceylo:fix/bridged-task-cancellation
Open

Ceylo wants to merge 1 commit into
skiptools:mainfrom
Ceylo:fix/bridged-task-cancellation

Conversation

@Ceylo

@Ceylo Ceylo commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

What

In a Fuse app, a .task / .task(id:) is never cancelled when its id changes or its view leaves: the old Swift task runs to completion with Task.isCancelled == false. This PR cancels it in both cases, as on iOS.

Since #420, task(id:bridgedAction:) runs in a SkipLib Task and waits on invokeOnCancellation. But SkipLib's Task.cancel() never cancels the coroutine; it only runs handlers registered with withTaskCancellationHandler. So the action's onCancel was never called.

The fix calls the action, then waits for its completion under withTaskCancellationHandler. Registering after the action has installed onCancel also covers a cancel that lands while the action is still starting: SkipLib then calls the handler at once. Either way it runs once. The task also now calls the action it was created with, rather than reading it through rememberUpdatedState when its coroutine starts.

Pull-to-refresh is not affected: List and ScrollView launch RefreshAction in a Compose scope, whose cancellation is real. A test pins that down.

Before / after

Three new Robolectric tests fail on main and pass here. Each also checks that onCancel runs once.

test fails on main with
testBridgedTaskCancelledWhenIdChanges The task for the previous id was not cancelled
testBridgedTaskCancelledWhenViewLeaves The task of the removed view was not cancelled
testBridgedTaskCancelledBeforeItsActionInstallsOnCancel The task cancelled before its action installed onCancel was not cancelled

Showcase in Fuse, with temporary log lines in two playgrounds:

before after
Timer: open, then Back kept ticking, never ended ended 1.1 s after Back
Sensors: Start, then Stop 240 more events in 11 s, never ended ended within 3.8 s, 3 of 3 times

Side effects

Verified

  • swift test: 120 Swift tests, 57 skipped, 0 failures. JUnit: 164 tests, 159 passed, 5 skipped.
  • Mutation runs: with main's file each new test fails as above; with the handler registered before the action, only the third fails.
  • Showcase built through Gradle against this branch, run in Fuse on an API 37 emulator before and after. Lite doesn't use this path. Not run on iOS: the change is Kotlin-only.

Skip Pull Request Checklist:

  • REQUIRED: I have signed the Contributor Agreement
  • REQUIRED: I have tested my change locally with swift test
  • OPTIONAL: I have tested my change on an iOS simulator or device. Not run: the change is Kotlin-only.
  • OPTIONAL: I have tested my change on an Android emulator or device
  • REQUIRED: I have checked whether this change requires a corresponding update in the Skip Fuse UI repository (link related PR if applicable). No API change, so no paired PR.
  • OPTIONAL: I have added an example of any UI changes to the Showcase sample app. No visible change.

  • AI was used to generate or assist with generating this PR. Please specify below how you used AI to help you, and what steps you have taken to manually verify the changes.

Written with Claude Code: the fix, the tests and this description. Every claim above comes from a run: swift test, the mutation runs, and the Showcase in Fuse on an emulator before and after.

SkipLib's Kotlin `Task.cancel()` only flags the task and runs the handlers
registered with `withTaskCancellationHandler`; it never cancels the
coroutine. Since skiptools#420 moved the bridged `task(id:)` from `LaunchedEffect`
into a `Task`, the `invokeOnCancellation` it waits on never fires, so a
superseded or disposed task ran to completion.

Run the action, then wait for its completion under
`withTaskCancellationHandler`. Registering after the action has installed
its `onCancel` also covers a cancel that lands while the action runs:
SkipLib then calls the handler at once. Either way it runs once.

The task now calls the action it was created with. It used to read it
through `rememberUpdatedState` only once its coroutine started, so a task
that started after its id had changed again could run the next id's action.

`List` and `ScrollView` launch a bridged `RefreshAction` in a Compose
coroutine scope, whose cancellation is a real coroutine cancellation, so
pull-to-refresh is not affected. A test pins that down.
@cla-bot cla-bot Bot added the cla-signed label Oct 2, 2026
@Ceylo
Ceylo marked this pull request as ready for review October 2, 2026 17:30
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