appandflow / appandflow/react-native-safe-area-context
Fix proposal for the inset flicker (#364, #485): SafeAreaProvider emits a transient inset → 0 → inset across separate frames
- Dominant language
- TypeScript
- Stars
- 2.8k
- Forks
- 258
- Avg merge
- 3d 1h
- Merged PRs (30d)
- 2
Description
## Summary
`SafeAreaProvider` can emit a transient `inset → 0 → inset` whose three values land in **separate frames**. Because they aren't in one batch, React commits the intermediate `0` as its own render, producing a one-frame layout flicker/jump. This affects both `` and `useSafeAreaInsets()` (both read from the provider). This is a root-cause write-up + proposed fix for the long-standing #364 (Android flicker) and #485 (iOS `insets.top` → 0 on orientation change), which appear to be the same underlying behavior.
## Environment
- `react-native-safe-area-context` 5.8.0 (also present in earlier 4.x/5.x per the linked issues)
- Android edge-to-edge, transparent status bar, `windowSoftInputMode="adjustResize"` (Pixel 8a, New Architecture / Fabric)
- Per #485, also reproduces on iOS during orientation changes
## Root cause (native)
Both `SafeAreaProvider.kt` and `SafeAreaView.kt` register an `OnPreDrawListener` and recompute the inset **on every draw** via `SafeAreaUtils.getSafeAreaInsets()`:
```kotlin
val windowInsets = getRootWindowInsetsCompat(rootView) ?: return null // rootView.rootWindowInsets
view.getGlobalVisibleRect(visibleRect)
top = max(windowInsets.top - visibleRect.top, 0f)
```
The inset is **not stored**, it's re-derived every draw from `rootView.rootWindowInsets`. On Android edge-to-edge the OS **re-dispatches `WindowInsets`** on focus/config/redraw events and momentarily reports `top = 0` before the real value re-applies. `SafeAreaProvider.maybeUpdateInsets()` only guards `mLastInsets != edgeInsets`, so `46 → 0` passes through, then `0 → 46` passes through again, the transient reaches JS.
## Why it becomes a visible flicker (the key subtlety)
In `SafeAreaContext.tsx`, `onInsetsChange` calls `setInsets`. React 18 auto-batches, so if `46 → 0 → 46` arrived in **one** event batch it would net to `46` and never render `0`. But the emissions land **one frame apart**, measured **~19 ms** on a 60 Hz device, so React commits the 0 as its own render as its own render. This means **same-frame event coalescing** (RNscroll-event `canCoalesce`, floated in #485) does **not** fix it: the transient is **cross-frame**.
## Evidence
Instrumenting `onInsetsChange` (raw event) and `setInsets` (committed):
```
[RAW] top = 46.095 initial
[RAW] top = 0 transient emitted by the library
[RAW] top = 46.095 reverts 19 ms later
→ React committed 46 → 0 → 46 (a one-frame layout jump)
```
## Proposed fix (JS provider, cross-platform)
Coalesce in `onInsetsChange`. A spurious value is always momentarily smaller than the real one, so we defer only decreases by a short window and drop them if the inset **reverts** to the committed value:
```
committed == null → commit immediately (initial mount)
insets === committed → drop pending (reverted → it was the transient)
increase (all edges ≥) → commit immediately (cold-start restore, keyboard close)
decrease → defer; a revert cancels it, a persistent value commits after the window
```
- **Never blanket-suppresses `0`**, a *persistent* `0` still commits (respects the note in #485 that insets can legitimately be 0).
- **No added latency** on increases/initial, only a real *shrink* (rotation, hidden bars) waits one window.
- **One place fixes both platforms** (Android #364 + iOS #485), since both go through the shared JS provider.
This is a mitigation of the *propagation*; the deeper native fix would stop all (validate/debounce before dispatch, or move Android off per-draw`OnPreDrawListener` polling onto a `WindowInsets` listener). Happy to go whichever direction the maintainers prefer, I have a working implementation with on-device validation and can open a PR.
Contributor guide
Assessment
This issue has not been assessed yet.