skiptools / skiptools/skip-ui

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

Open
#488 1 comment 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; 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

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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.