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

オープン
#738 コメント 1 件 リアクション 2 件 担当者 0 名 GitHub で見る
主要言語
TypeScript
スター
2.8k
フォーク
258
平均マージ
3日 1時間
マージ済み PR(30日)
2

説明

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

コントリビューションガイド

コントリビューションガイドを開く

評価

この issue はまだ評価されていません。

新しい issue をメールで受け取る

初心者向けの GitHub issue を短くまとめたダイジェスト。