GeometryReader/onGloballyPositioned bounds writes have no epsilon — sub-pixel Rect jitter causes idle recomposition loops
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; 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?
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with Layout/GeometryReader.swift, Compose/ComposeExtensions.swift, AdditionalViewModifiers.swift, and Containers/PresentationRoot.swift, then inspect the linked GeometryLoopScene reproduction. Trace each bounds callback through its state write and compare the behavior after layout settles. Done means idle geometry no longer causes repeated recomposition while meaningful geometry changes still propagate.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- kotlin, swift
- Domain
- frontend, mobile-dev
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 68/100