.onChange/.onAppear/.task run post-apply (SideEffect/DisposableEffect): +1 frame per state relay hop; .task(id:) ids compared by Kotlin equality can over-relaunch
Nobody has claimed this yet.
- Dominant language
- Swift
- Stars
- 330
- Forks
- 76
- Avg merge
- 3h 6m
- Merged PRs (30d)
- 1
Description
Environment
- skip 1.9.4, skip-fuse-ui 1.17.2, skip-ui 1.57.0
- Fuse app; Kotlin 2.3.0, compileSdk 35
Summary
Two related timing/semantics gaps vs iOS:
.onChange(and.onAppear) actions are scheduled via ComposeSideEffect, which runs only after the current recomposition applies. A state write inside anonChangeis therefore not visible to the frame that detected the change — it schedules another recomposition, adding one frame (~16ms) of latency per hop in any onChange→state→onChange relay. On iOS these writes settle within the same transaction..task(id:)keys areAnycompared with Kotlin equality, so reference-typed ids relaunch the task on identity change rather than value change; and.taskbodies start viaDisposableEffect(post-apply), so initial task-loaded state lands at least a frame after first compose.
Mechanism
.onChange lowers to a SideEffect:
// View/AdditionalViewModifiers.swift:887–889 (zero-param variant; two-param at :923)
if rememberedValue.value != value {
rememberedValue.value = value
SideEffect { action(value) }
}
// :874 — onAppear likewise: SideEffect { action?() }
SideEffect runs after composition applies, so a chain A changes → onChange(A) writes B → onChange(B) writes C settles over three frames instead of one. This also affects OS-driven sources (scenePhase, focus) where app code can't restructure the relay away.
.task:
// View/AdditionalViewModifiers.swift:1434, 1441
task(priority:) → task(id: 0, priority:, action) // constant key
DisposableEffect(value) { let task = Task(priority) { handler.value() }; onDispose { task.cancel() } }
DisposableEffect starts the Task a frame after first appear; the id parameter is compared by whatever equals the bridged value has — for reference-typed Swift ids this is identity, causing cancel+relaunch when an equal-valued but distinct instance arrives (the opposite of SwiftUI's Equatable contract on task(id:)).
Impact
Multi-hop relays visibly "step": in our app, an alert pipeline (enqueue → stage → promote → present) and a scenePhase→auth→navigation chain settle one frame per hop on Android while being atomic on iOS — sheets flash intermediate states and focus lags. Separately, first-appear .task data pops in a frame late (placeholder flash iOS doesn't show), and a reference-typed .task(id:) caused spurious relaunches mid-scroll until we switched to value-typed ids.
Repro
https://github.com/Aecasorg/skip-fuse-perf-repro — scene 3 (RelayHopScene): a button write relays a → b → c through two .onChange modifiers, logging a millisecond stamp at each hop. On iOS all stamps land in the same transaction; on Android each hop lands ~one frame later.
Suggested direction
- When an
onChangeaction only mutates app state, run it synchronously at detection during composition, or route it throughSnapshot.withMutableSnapshotso dependent state becomes visible in the same frame; coalesce chained writes into one snapshot apply. If full parity is impractical, an explicit opt-in (e.g. an immediate variant) plus documenting the post-apply semantics would already help a lot. - For
.task(id:): require/normalize value equality for the id (Hashable/Equatable bridge comparison), matching SwiftUI's contract; document the post-apply start timing.
Offer
The task(id:) value-equality piece and the documentation are small and we'd happily PR them; the synchronous-onChange path likely needs your guidance on where a same-transaction write is safe within your composition model. Would you accept work along these lines?
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in View/AdditionalViewModifiers.swift at the onChange/onAppear implementation around lines 874–923 and the task implementation around lines 1434–1441; compare the SideEffect and DisposableEffect timing with the reported semantics. Run scene 3, RelayHopScene, from the linked reproduction and verify relay hops, initial task state, and task(id:) relaunch behavior against the intended iOS behavior.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- kotlin, swift
- Domain
- frontend, mobile
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 48/100