Skip to content

GeometryReader/onGloballyPositioned bounds writes have no epsilon — sub-pixel Rect jitter causes idle recomposition loops #488

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; 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?

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