Environment
Summary
Two related design points in SkipUI combine so that state invalidation on Android is per-container where SwiftUI (and idiomatic Compose) is per-view / per-row:
- Observation registers on the enclosing container's single restart scope, so one
@Observable write re-runs the whole container and all its children.
- Lazy containers rebuild their item factories and re-evaluate all visible items on every pass, so even perfectly stable
ForEach ids don't let Compose skip unchanged rows.
Together these mean the cost of any state change scales with everything on screen rather than with what changed. App code can shrink the blast radius (small child views, reads pushed to leaves, lazy containers) but can never reach per-view granularity, because both mechanisms are framework-owned.
Mechanism A — container-scope invalidation
ComposeBuilder content lambdas return a ComposeResult, which deliberately makes them non-restartable:
// Compose/ComposeBuilder.swift:49
// - Note: Returning a result from `content` is important. This prevents Compose from recomposing
// `content` on its own. Instead, a change that would recompose `content` elevates to our void
// `Renderable.Render`.
View.Evaluate (View/View.swift:67) is a value-returning @Composable where each child's body actually executes, and containers call it inline as the first line of their own restartable Render:
// Containers/VStack.swift:53
let renderables = content.Evaluate(context: context, options: 0).filter { !$0.isSwiftUIEmptyView }
So every @Observable read in any child body registers on the container's single restart scope; a mutation bumps the backing state (skip-model MutableStateBacking.swift:46, state[index].value += 1) and invalidates that one coarse scope, re-running the container and re-evaluating every child — including children that never read the mutated value. StateTracking.pushBody/popBody manage the read cursor but do not open a per-view restart group.
Mechanism B — lazy items re-evaluated every pass, no memo boundary
LazyVStack.Render re-runs content.EvaluateLazyItems(level: 0, ...) unconditionally (Containers/LazyVStack.swift:69), and the remembered LazyItemCollector is re-initialized inside the LazyColumn body with freshly allocated factory closures each pass (LazyVStack.swift:128, LazySupport.swift:145 content.removeAll() then reassigns item/indexedItems).
The registered item body is factory(index + range.start, scopedContext).Render(...) (LazyVStack.swift:139), and the ForEach factory internally does a fresh .Evaluate(options: 0) building new Renderables on every run (Containers/ForEach.swift:205–212). A stable key therefore prevents slot destruction/reordering — but never composition skipping: the lambda is a new instance per registration and returns new unstable objects, so Compose can't prove skippability.
List additionally wraps rows in Modifier.animateItem() and runs a recurring time-keyed churn (List.swift:267–270: LaunchedEffect(System.currentTimeMillis()) { delay(300); forceUnanimatedItems.value = false }) that recomposes idle, static lists. The // SKIP INSERT: @Stable comment on List itself (List.swift:102) shows the codebase already fights exactly this instability cascade internally.
Impact
Measured in a Fuse app with a lazy list of ~10 visible rows: any single @Observable write on the screen (a countdown label, a badge) re-runs the container body and full Evaluate+Render for every visible row, every time. On iOS the identical SwiftUI code re-bodies one view. This is the dominant residual cost after all the app-side hygiene we could find (lazy containers, stable ids, precomputed row strings, no geometry feedback loops) — profiling shows recomposition counts per state change equal to the visible subtree size rather than 1.
Suggested direction
- Wrap each composed child in its own void restartable
@Composable so its observable reads attribute to a child scope (or split Evaluate so structural reads land on the container and value reads on per-child scopes).
- Remember lazy item factories across recompositions keyed by content id instead of reallocating per pass, and put a
remember/movableContentOf boundary (keyed by id + content version) around item bodies so stable ids buy actual skipping.
- Skip
animateItem() when the list has no move animations pending, and drive forceUnanimatedItems off data-version changes rather than a wall-clock LaunchedEffect.
Repro
https://github.com/Aecasorg/skip-fuse-perf-repro — scene 1 (UnrelatedStateScene), with a toggle between an eager VStack and a LazyVStack with stable Identifiable ids: the per-second body-evaluation log shows every visible row re-evaluating on each unrelated @Observable tick in both variants.
Offer
We're happy to work on PRs for the item-factory memoization pieces with your guidance — the per-child restart-scope design likely needs your direction first. Would you accept changes along these lines?
Environment
animateItemcorner of this from another angle.Summary
Two related design points in SkipUI combine so that state invalidation on Android is per-container where SwiftUI (and idiomatic Compose) is per-view / per-row:
@Observablewrite re-runs the whole container and all its children.ForEachids don't let Compose skip unchanged rows.Together these mean the cost of any state change scales with everything on screen rather than with what changed. App code can shrink the blast radius (small child views, reads pushed to leaves, lazy containers) but can never reach per-view granularity, because both mechanisms are framework-owned.
Mechanism A — container-scope invalidation
ComposeBuildercontent lambdas return aComposeResult, which deliberately makes them non-restartable:View.Evaluate(View/View.swift:67) is a value-returning@Composablewhere each child'sbodyactually executes, and containers call it inline as the first line of their own restartableRender:So every
@Observableread in any child body registers on the container's single restart scope; a mutation bumps the backing state (skip-model MutableStateBacking.swift:46,state[index].value += 1) and invalidates that one coarse scope, re-running the container and re-evaluating every child — including children that never read the mutated value.StateTracking.pushBody/popBodymanage the read cursor but do not open a per-view restart group.Mechanism B — lazy items re-evaluated every pass, no memo boundary
LazyVStack.Renderre-runscontent.EvaluateLazyItems(level: 0, ...)unconditionally (Containers/LazyVStack.swift:69), and the rememberedLazyItemCollectoris re-initialized inside theLazyColumnbody with freshly allocated factory closures each pass (LazyVStack.swift:128, LazySupport.swift:145content.removeAll()then reassignsitem/indexedItems).The registered item body is
factory(index + range.start, scopedContext).Render(...)(LazyVStack.swift:139), and theForEachfactory internally does a fresh.Evaluate(options: 0)building newRenderables on every run (Containers/ForEach.swift:205–212). A stable key therefore prevents slot destruction/reordering — but never composition skipping: the lambda is a new instance per registration and returns new unstable objects, so Compose can't prove skippability.Listadditionally wraps rows inModifier.animateItem()and runs a recurring time-keyed churn (List.swift:267–270:LaunchedEffect(System.currentTimeMillis()) { delay(300); forceUnanimatedItems.value = false }) that recomposes idle, static lists. The// SKIP INSERT: @Stablecomment onListitself (List.swift:102) shows the codebase already fights exactly this instability cascade internally.Impact
Measured in a Fuse app with a lazy list of ~10 visible rows: any single
@Observablewrite on the screen (a countdown label, a badge) re-runs the container body and fullEvaluate+Renderfor every visible row, every time. On iOS the identical SwiftUI code re-bodies one view. This is the dominant residual cost after all the app-side hygiene we could find (lazy containers, stable ids, precomputed row strings, no geometry feedback loops) — profiling shows recomposition counts per state change equal to the visible subtree size rather than 1.Suggested direction
@Composableso its observable reads attribute to a child scope (or splitEvaluateso structural reads land on the container and value reads on per-child scopes).remember/movableContentOfboundary (keyed by id + content version) around item bodies so stable ids buy actual skipping.animateItem()when the list has no move animations pending, and driveforceUnanimatedItemsoff data-version changes rather than a wall-clockLaunchedEffect.Repro
https://github.com/Aecasorg/skip-fuse-perf-repro — scene 1 (
UnrelatedStateScene), with a toggle between an eagerVStackand aLazyVStackwith stableIdentifiableids: the per-second body-evaluation log shows every visible row re-evaluating on each unrelated@Observabletick in both variants.Offer
We're happy to work on PRs for the item-factory memoization pieces with your guidance — the per-child restart-scope design likely needs your direction first. Would you accept changes along these lines?