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 Compose SideEffect, which runs only after the current recomposition applies. A state write inside an onChange is 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 are Any compared with Kotlin equality, so reference-typed ids relaunch the task on identity change rather than value change; and .task bodies start via DisposableEffect (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
onChange action only mutates app state, run it synchronously at detection during composition, or route it through Snapshot.withMutableSnapshot so 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?
Environment
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
.onChangelowers to aSideEffect:SideEffectruns after composition applies, so a chainA changes → onChange(A) writes B → onChange(B) writes Csettles over three frames instead of one. This also affects OS-driven sources (scenePhase, focus) where app code can't restructure the relay away..task:DisposableEffectstarts theTaska frame after first appear; theidparameter is compared by whateverequalsthe 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'sEquatablecontract ontask(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
.taskdata 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.onChangemodifiers, 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
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..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?