skiptools / skiptools/skip-ui

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

Open
#489 2 comments 0 reactions 0 assignees View on GitHub

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:

  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?

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.