Skip to content

.onChange/.onAppear/.task run post-apply (SideEffect/DisposableEffect): +1 frame per state relay hop; .task(id:) ids compared by Kotlin equality can over-relaunch #489

Description

@Aecasorg

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:

  1. .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.
  2. .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?

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions