Environment
- skip 1.9.4, skip-fuse-ui 1.17.2, skip-ui 1.57.0
- Fuse app; Kotlin 2.3.0, compileSdk 35; reproduces on emulator and devices
Summary
GeometryReader (and the shared onGloballyPositionedInRoot/InWindow helpers, and onGeometryChange) write raw float Rect bounds into Compose state on every global-position callback, with no tolerance/epsilon. When the measured content can jitter at sub-pixel scale — or sizes itself against the proxy — this closes a write→recompose→remeasure→write loop that recomposes continuously while the screen is fully idle.
Mechanism
GeometryReader.Render remembers a Rect? and gates its content on reading it back:
// Layout/GeometryReader.swift:26–33
@Composable override func Render(context: ComposeContext) {
let rememberedGlobalFramePx = remember { mutableStateOf<Rect?>(nil) }
Box(modifier: context.modifier.fillSize().onGloballyPositionedInRoot {
rememberedGlobalFramePx.value = $0
}) {
if let globalFramePx = rememberedGlobalFramePx.value { ... content(proxy)... }
The only filter on the write is != Rect.Zero — no comparison against the previous value beyond mutableStateOf's bit-exact structural equality:
// Compose/ComposeExtensions.swift:130–137
@Composable func onGloballyPositionedInRoot(perform: (Rect) -> Void) -> Modifier {
return self.onGloballyPositioned {
let bounds = $0.boundsInRoot()
if bounds != Rect.Zero { perform(bounds) }
}
}
Any sub-pixel float variation between layout passes (remeasure, scroll settle, animations, or content whose size depends on the proxy value) produces a not-bit-identical Rect → state write → recomposition → new layout pass → new Rect. The sibling onGeometryChangeErased guards its action with an exact != (AdditionalViewModifiers.swift:1002–1022) but still updates its internal state unconditionally — marginally better, same missing epsilon. PresentationRoot uses the same unguarded write for presentation bounds (Containers/PresentationRoot.swift:56–57).
Repro
We hit this in our production Fuse app: a GeometryReader wrapping an empty-state layout centered against the reported height recomposed in a tight loop at idle — CPU measurably above zero with no interaction, animations stuttering — and scoping the GeometryReader down to the smallest subtree removed the trigger (which limits blast radius but not the underlying unguarded write). iOS with identical code is fully quiescent.
A standalone packaging of the same pattern with per-second body-evaluation counters is scene 2 (GeometryLoopScene) of https://github.com/Aecasorg/skip-fuse-perf-repro (expected: counters climb at idle on Android, stop after layout settles on iOS).
Suggested fix
In onGloballyPositionedInRoot/InWindow: remember the last-delivered Rect and skip the callback when all four edges are within ~0.5px (one device pixel), or pixel-snap (round to nearest px) before comparing/writing. Apply the same guard to GeometryReader's remembered state and onGeometryChangeErased's internal write. This matches iOS behavior, where geometry callbacks are coalesced and don't feed back at sub-pixel granularity.
Offer
This looks like a small, self-contained change (one helper + two call sites). We'd be glad to submit a PR with the epsilon guard plus the repro — is this direction acceptable?
Environment
Summary
GeometryReader(and the sharedonGloballyPositionedInRoot/InWindowhelpers, andonGeometryChange) write raw floatRectbounds into Compose state on every global-position callback, with no tolerance/epsilon. When the measured content can jitter at sub-pixel scale — or sizes itself against the proxy — this closes a write→recompose→remeasure→write loop that recomposes continuously while the screen is fully idle.Mechanism
GeometryReader.Renderremembers aRect?and gates its content on reading it back:The only filter on the write is
!= Rect.Zero— no comparison against the previous value beyondmutableStateOf's bit-exact structural equality:Any sub-pixel float variation between layout passes (remeasure, scroll settle, animations, or content whose size depends on the proxy value) produces a not-bit-identical
Rect→ state write → recomposition → new layout pass → newRect. The siblingonGeometryChangeErasedguards its action with an exact!=(AdditionalViewModifiers.swift:1002–1022) but still updates its internal state unconditionally — marginally better, same missing epsilon.PresentationRootuses the same unguarded write for presentation bounds (Containers/PresentationRoot.swift:56–57).Repro
We hit this in our production Fuse app: a
GeometryReaderwrapping an empty-state layout centered against the reported height recomposed in a tight loop at idle — CPU measurably above zero with no interaction, animations stuttering — and scoping theGeometryReaderdown to the smallest subtree removed the trigger (which limits blast radius but not the underlying unguarded write). iOS with identical code is fully quiescent.A standalone packaging of the same pattern with per-second body-evaluation counters is scene 2 (
GeometryLoopScene) of https://github.com/Aecasorg/skip-fuse-perf-repro (expected: counters climb at idle on Android, stop after layout settles on iOS).Suggested fix
In
onGloballyPositionedInRoot/InWindow: remember the last-deliveredRectand skip the callback when all four edges are within ~0.5px (one device pixel), or pixel-snap (round to nearest px) before comparing/writing. Apply the same guard toGeometryReader's remembered state andonGeometryChangeErased's internal write. This matches iOS behavior, where geometry callbacks are coalesced and don't feed back at sub-pixel granularity.Offer
This looks like a small, self-contained change (one helper + two call sites). We'd be glad to submit a PR with the epsilon guard plus the repro — is this direction acceptable?