Skip to content

Guard geometry state writes against sub-pixel bounds jitter to prevent idle recomposition loops - #499

Open
Aecasorg wants to merge 2 commits into
skiptools:mainfrom
Aecasorg:geometry-epsilon-guard
Open

Guard geometry state writes against sub-pixel bounds jitter to prevent idle recomposition loops#499
Aecasorg wants to merge 2 commits into
skiptools:mainfrom
Aecasorg:geometry-epsilon-guard

Conversation

@Aecasorg

@Aecasorg Aecasorg commented Jul 29, 2026

Copy link
Copy Markdown

Fixes #488.

Problem

GeometryReader, onGeometryChange, and PresentationRoot write raw float Rect bounds from global-position callbacks into remembered state that gates their composed content. Bounds can differ from the previous layout pass by sub-pixel amounts (float rounding, scroll settle, in-progress animations, or content that sizes itself against the reported geometry), and mutableStateOf's structural equality only deduplicates bit-identical rects — so a sub-pixel delta closes a write → recompose → remeasure → write loop that recomposes continuously while the screen is idle. We hit this as a sustained idle-recomposition loop in our production Fuse app (skip-ui 1.57-era): a GeometryReader-centered empty state recomposed continuously at rest, and scoping the GeometryReader to a smaller subtree removed the trigger — which limits blast radius but leaves the unguarded write in place. Full mechanism citations in #488.

Change

Adds Rect.isApproximatelyEqual(to:) (all four edges within boundsEpsilonPx = 0.5f — half a device pixel, below anything visually meaningful) and guards the three gating state writes with it:

  • GeometryReader.RenderrememberedGlobalFramePx
  • onGeometryChangeErasedglobalFramePx
  • PresentationRootpresentationBounds

Deliberately not changed: the shared onGloballyPositionedInRoot/InWindow helpers still deliver every callback. Deduplicating there would silently change semantics for callers whose derived values depend on more than the bounds — e.g. the safe-area edge probing in ComposeLayouts.swift recomputes edges from bounds plus the current safe area, and must not be suppressed when the safe area changes while bounds don't. Instead the helpers' doc comment now warns that callers gating content on remembered bounds should guard their writes.

The comparison intentionally avoids abs() — the transpiled skip.lib form is not comparable against a Kotlin Float (caught by the Android Kotlin compile).

Verification

Verified with CI-built APKs of a standalone counter-instrumented app (https://github.com/Aecasorg/skip-fuse-perf-repro), on an API 35 emulator, comparing stock skip-ui 1.57.0, stock 1.59.1, and 1.59.1 + this patch:

  • Regression-free: four GeometryReader shapes (plain fillSize, inside ScrollView, refreshable ScrollView, proxy-derived text) plus unrelated-state and onChange control scenes behave identically with and without the patch — same body-evaluation counts, same settle points.
  • Honest disclosure: the pure idle loop did not reproduce in those minimal synthetic shapes at either skip-ui version on the emulator — counters settle at 26–28 evaluations and stay flat. The production loop we observed evidently needs conditions the minimal scenes don't capture (device density, concurrent animation, richer layout). We're framing this change as a cheap, verified-safe guard against a demonstrated-in-production hazard rather than claiming a synthetic before/after.

Happy to adjust the epsilon, naming, or approach — including pushing the guard into the shared helpers instead if you'd rather own the call-site audit.

@cla-bot

cla-bot Bot commented Jul 29, 2026

Copy link
Copy Markdown

Thank you for your pull request and welcome to our community. We could not parse the GitHub identity of the following contributors: Henrik Gustavii.
This is most likely caused by a git client misconfiguration; please make sure to:

  1. check if your git client is configured with an email to sign commits git config --list | grep email
  2. If not, set it up using git config --global user.email email@example.com
  3. Make sure that the git commit email is configured in your GitHub account settings, see https://github.com/settings/emails

…t idle recomposition loops

Global-position callbacks can deliver bounds differing from the previous
layout pass by sub-pixel amounts. GeometryReader, onGeometryChangeErased,
and PresentationRoot write those raw Rects into remembered state that
gates their composed content, closing a write -> recompose -> remeasure ->
write loop that recomposes continuously at idle (skiptools#488).

Adds Rect.isApproximatelyEqual(to:) (edges within 0.5px) and guards the
three gating state writes. The shared onGloballyPositionedInRoot/InWindow
helpers still deliver every callback so derived-value consumers (e.g.
safe-area edge probing) keep their semantics; their docs now carry a
warning for bounds-gating callers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Aecasorg
Aecasorg force-pushed the geometry-epsilon-guard branch from 976ad32 to d8d5f6a Compare July 30, 2026 08:10
@cla-bot

cla-bot Bot commented Jul 30, 2026

Copy link
Copy Markdown

Thank you for your pull request and welcome to the Skip community. We require contributors to sign our contributor license agreement (CLA), and we don't seem to have the user(s) @Aecasorg on file. In order for us to review and merge your code, for each noted user please add your GitHub username to Skip's .clabot file

@Aecasorg

Copy link
Copy Markdown
Author

recheck

…les to a form Kotlin cannot compare against Float

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant