Skip to content

fix(ios): keep SafeAreaView insets current in native sheets after rotation - #803

Merged
janicduplessis merged 3 commits into
appandflow:mainfrom
hirbod:fix/ios-safeareaview-sheet-insets
Nov 2, 2026
Merged

janicduplessis merged 3 commits into
appandflow:mainfrom
hirbod:fix/ios-safeareaview-sheet-insets

Conversation

@hirbod

@hirbod hirbod commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Summary

A SafeAreaView inside a native sheet (like a react-native-screens formSheet) shows stale insets after a rotation. There are two separate problems behind it.

The sheet doesn't live under the provider's view tree, so the view falls back to being its own provider. Nothing tells it when its own insets change, so it keeps the old ones until React commits again (#781). Overriding safeAreaInsetsDidChange for that case fixes it.

With that in place, the update still goes through the default async state mode and waits for the next event beat. In a busy app you see the old insets for a few frames (#802). react-native-screens has the same problem with its screen state and uses unstable_Immediate for it, so this does the same.

You need both. With only one of them the insets are still wrong.

Closes #781
Closes #802

Test Plan

Repro is at https://github.com/hirbod/rnsac-rotation-repro (Expo SDK 58, RN 0.88.1, react-native-screens 4.30.1, iOS 27 simulator). Open the sheet, rotate to landscape left, then landscape right, then back to portrait.

Left is 5.11.0, middle only has the safeAreaInsetsDidChange change, right has both. Slowed down 4x.

Before and after

I also checked it in the sheets of my own app.

@vercel

vercel Bot commented Nov 2, 2026

Copy link
Copy Markdown

@hirbod is attempting to deploy a commit to the appandflow Team on Vercel.

A member of the Team first needs to authorize it.

@floco-af floco-af left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the detailed repro and the slowed-down comparison, the two-part diagnosis makes sense. Review of the diff (read through; I have not run it on a device, so none of this is native-validated by me):

1. safeAreaInsetsDidChange override (fixes #781) – looks correct. It is gated on _providerView == self, so views under a real SafeAreaProvider still go through the notification path and don't get a duplicate update. updateStateIfNecessaryMeasuringCorners: already bails on a nil window and on unchanged insets, so repeated callbacks are cheap.

2. UpdateMode::unstable_Immediate (fixes #802) – a few concerns:

  • unstable_Immediate was added to EventQueue::UpdateMode in RN 0.82 (it isn't in 0.81 or earlier). The package's peerDependencies is react-native: "*", so this would break the iOS build for anyone on <0.82. It needs a version guard (e.g. a __has_include/RN version macro check, or a bump of the minimum supported RN) – please tell us which you'd prefer; we'd lean towards a guard.
  • updateState is also called from finalizeUpdates: and didMoveToWindow, i.e. during a mounting transaction on the main thread. unstable_Immediate flushes the state update synchronously (flushStateUpdates()), which can commit a new shadow tree re-entrantly from inside mounting. react-native-screens only uses it behind an opt-in flag (_synchronousShadowStateUpdatesEnabled), not unconditionally. It would be safer to use Immediate only for the safeAreaInsetsDidChange / provider-notification path (the rotation case) and keep the default async mode for mount-time updates.
  • The file is shared with the macOS target, so the same RN-version question applies to react-native-macos.

3. Tests / validation – the before/after GIF is a good demonstration of the sheet case. It would be good to also confirm the non-sheet case (normal SafeAreaView under a provider, plain rotation, and a screen with many mounts) doesn't regress, and to note which RN version the fix was tested against (0.88.1 per the report).

Verdict: the approach looks right, but it needs the RN version guard and ideally narrowing the Immediate mode before merge. cc @janicduplessis

EventQueue::UpdateMode came in React Native 0.82, so passing unstable_Immediate broke the
build on 0.81 and older. Detect it at compile time and fall back to the default update.
A SafeAreaView in a native formSheet, with and without a SafeAreaProvider, plus switches for
a busy JS thread and for mounting 200 SafeAreaViews, to check rotation in sheets.
@hirbod

hirbod commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor Author

@floco-af thanks, pushed an update.

RN version. Good catch. EventQueue::UpdateMode doesn't exist before 0.82, so the build broke there. The fix now detects unstable_Immediate at compile time and falls back to the plain updateState call. No version check, so react-native-macos (1000.0 on main) is covered too. Builds on RN 0.81.6 and 0.85.

Immediate during mounting. Kept it. A commit that lands mid-transaction isn't nested, RCTMountingManager mounts it right after. react-native-screens uses Immediate by default too. Happy to narrow it to the rotation path if you prefer.

Tests. Added a Form Sheet example (dev menu) with a busy JS switch and a switch that mounts 200 SafeAreaViews. iPhone 18 Pro simulator, iOS 27, RN 0.85. The border clears the notch from the first frame after every rotation.

To try it in the repro, use the pr-803-latest branch. Its fix.patch matches this PR.

No sheet, under a provider Sheet Sheet, busy JS
No sheet, under a provider Sheet Sheet, busy JS
Sheet with a provider Sheet with a provider, busy JS 200 SafeAreaViews mounting
Sheet with a provider Sheet with a provider, busy JS 200 SafeAreaViews mounting

Also checked on a real device. Not tested on react-native-macos.

@floco-af floco-af left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the update, and for the form sheet example and the extra recordings.

RN compat guard – this resolves my concern. I compiled the SupportsImmediateStateUpdate detection (C++17, std::void_t) against a stub queue with and without UpdateMode::unstable_Immediate: it resolves to true_type and false_type respectively, so older RN (and anything without the enum value) falls back to the plain updateState call. Detecting the symbol rather than checking a version is also the right call for react-native-macos. I haven't built it against RN itself (no Xcode here), so I'm relying on your 0.81.6 / 0.85 builds for that.

Immediate during mounting – your argument that a commit landing mid-transaction is queued by RCTMountingManager and mounted right after is plausible, and matching what react-native-screens does is reasonable. I can't confirm the re-entrancy behaviour on a device, so I'd leave the final call on keeping it for all paths vs. narrowing it to the rotation path to the maintainer. The 200-SafeAreaView mounting case in your example is the right stress test for it.

Validation – the non-sheet, provider, busy-JS and mass-mount cases now cover what I asked for. Still not validated by me on a native build.

From my side the remaining items are addressed; the rest is a maintainer decision. cc @janicduplessis

@janicduplessis janicduplessis left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good, thanks for the fix!

@janicduplessis
janicduplessis merged commit 533c6ac into appandflow:main Nov 2, 2026
3 of 4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

3 participants