Re-attach key window observer in RCTDeviceInfo - #57989
Conversation
RCTDeviceInfo captures the key window once in -init and observes its frame from there. When the host app creates its UIWindow after the module is built, that capture is nil, [nil addObserver:] does nothing, and the KVO is never installed. Look the key window up again in -initialize and on every frame-change pass so the observer ends up attached to the window that is actually on screen.
RCTKeyWindow() can return nil (backgrounded app, scene transition). Dropping the observer in that case would leave the module without one until another trigger happens to re-attach it.
|
Hi there, thanks for the fix. In which version of React Native are you observing the issue? We are touching this part of the codebase for iOS 27, so this information could help us. |
cipolleschi
left a comment
There was a problem hiding this comment.
Seems good to me. I asked for another pair of eye to look into it.
Right now on RN 0.84, will soon test on 0.86 if needed. |
artus9033
left a comment
There was a problem hiding this comment.
Thank you for the contribution @HugoGresse ! In my view the fix is valid, but there is one major and one minor issue. I will approve after we address those.
|
|
||
| - (void)_interfaceFrameDidChange | ||
| { | ||
| [self _observeKeyWindowIfNeeded]; |
There was a problem hiding this comment.
[minor] This call is redundant - the line below we call invalidateCachedConstants, which calls into _observeKeyWindowIfNeeded. During intensive interactions such as dragging, this will deteriorate performance.
| [self invalidateCachedConstants]; | ||
| NSDictionary *nextInterfaceDimensions = _constants[@"Dimensions"]; | ||
|
|
||
| RCTModuleRegistry *moduleRegistry = _moduleRegistry; | ||
| RCTExecuteOnMainQueue(^{ | ||
| // Report the event across the bridge. | ||
| #pragma clang diagnostic push | ||
| #pragma clang diagnostic ignored "-Wdeprecated-declarations" | ||
| [[moduleRegistry moduleForName:"EventDispatcher"] sendDeviceEventWithName:@"didUpdateDimensions" | ||
| body:nextInterfaceDimensions]; | ||
| #pragma clang diagnostic pop | ||
| }); |
There was a problem hiding this comment.
[major] There's one thread-safety issue this change introduces: _observeKeyWindowIfNeeded can now run off the main thread.
RCTAccessibilityManager has no methodQueue override, so the JS-exported setAccessibilityContentSizeMultipliers runs on the background module queue. It synchronously posts RCTAccessibilityManagerDidUpdateMultiplierNotification (RCTAccessibilityManager.mm:234), and NSNotificationCenter delivers on the posting thread. So RCTDeviceInfo.didReceiveNewContentSizeMultiplier → invalidateCachedConstants → _observeKeyWindowIfNeeded all execute on that background queue.
Before this PR it was not an issue, as this path only did off-main reads (RCTKeyWindow() etc.), but now it also mutates KVO state (removeObserver:/addObserver: on a UIWindow) concurrently with main-thread use of the same window. In debug this is caught by RCTAssertMainQueue() in invalidateCachedConstants (uncaught NSInternalInconsistencyException → crash), but in release the asserts are compiled out and the off-main mutation proceeds silently.
I'd see the fix as wrapping the invalidateCachedConstants call in didReceiveNewContentSizeMultiplier inside an RCTExecuteOnMainQueue block:
- (void)didReceiveNewContentSizeMultiplier
{
// This notification can arrive off the main thread (RCTAccessibilityManager has no methodQueue,
// so a JS setAccessibilityContentSizeMultipliers call posts it synchronously on the module
// queue). invalidateCachedConstants now moves the key-window KVO registration, which must
// happen on main.
__weak __typeof(self) weakSelf = self;
RCTExecuteOnMainQueue(^{
__typeof(self) strongSelf = weakSelf;
if (!strongSelf) {
return;
}
[strongSelf invalidateCachedConstants];
NSDictionary *nextInterfaceDimensions = strongSelf->_constants[@"Dimensions"];
// Report the event across the bridge.
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
[[strongSelf->_moduleRegistry moduleForName:"EventDispatcher"] sendDeviceEventWithName:@"didUpdateDimensions"
body:nextInterfaceDimensions];
#pragma clang diagnostic pop
});
}Simple reproduction:
const NativeAccessibilityManager =
require('react-native/Libraries/Components/AccessibilityInfo/NativeAccessibilityManager').default;
NativeAccessibilityManager?.setAccessibilityContentSizeMultipliers({large: 1.0, extraLarge: 1.12});| // This notification can arrive off the main thread (RCTAccessibilityManager has no methodQueue, | |
| // so a JS setAccessibilityContentSizeMultipliers call posts it synchronously on the module | |
| // queue), while the possible key-window KVO registration must happen on main. | |
| __weak __typeof(self) weakSelf = self; | |
| RCTExecuteOnMainQueue(^{ | |
| __typeof(self) strongSelf = weakSelf; | |
| if (!strongSelf) { | |
| return; | |
| } | |
| [strongSelf invalidateCachedConstants]; | |
| NSDictionary *nextInterfaceDimensions = strongSelf->_constants[@"Dimensions"]; | |
| // Report the event across the bridge. | |
| #pragma clang diagnostic push | |
| #pragma clang diagnostic ignored "-Wdeprecated-declarations" | |
| [[strongSelf->_moduleRegistry moduleForName:"EventDispatcher"] sendDeviceEventWithName:@"didUpdateDimensions" | |
| body:nextInterfaceDimensions]; | |
| #pragma clang diagnostic pop | |
| }); |
Summary:
RCTDeviceInfopublishes dimension updates to JS from four triggers: a KVO on the key window'sframe,UIDeviceOrientationDidChangeNotification,UIApplicationDidBecomeActiveNotificationandRCTUserInterfaceStyleDidChangeNotification.The window it observes is captured once, in
-init:When the host app creates its
UIWindowafter this module is built — which is what the current template'sUIApplicationDelegatedoes, assigningself.windowinsideapplication:didFinishLaunchingWithOptions:—RCTKeyWindow()returnsnil,[nil addObserver:…]silently does nothing, and nothing re-attaches the observer later. The frame KVO then never fires for the lifetime of the app.On a device this is masked by
UIDeviceOrientationDidChangeNotification. It is not masked when there is no orientation sensor: running an iPad app on an Apple Silicon Mac ("Designed for iPad"), rotating through View → Landscape leavesDimensions/useWindowDimensionsreporting the previous size, and the window server displays that stale canvas rotated. Focusing another app and coming back fixes it, because that firesUIApplicationDidBecomeActiveNotification.This is the symptom of #36118, which was fixed by #37649 (61861d2) with an observer on
RCTRootViewFrameDidChangeNotification, posted byRCTRootView. That symbol no longer exists in the repository — there is no observer, no constant, and nothing posting it — so apps on the New Architecture have no equivalent trigger and the original behaviour is back. #47262 and #54105 report the same class of problem for iPad Split View and Stage Manager and may share this root cause.This change looks the key window up again in
-initializeand at the top of-_interfaceFrameDidChange, instead of trusting the-initcapture. It is a no-op when the window is unchanged, and the previous observer is removed before a new one is attached, so-_cleanupObserversstays correct. No other change is needed:RCTExportedDimensionsalready readsRCTKeyWindow().boundslive.An alternative would be to reintroduce a root-view-level notification for the new renderer; happy to go that way instead if maintainers prefer it.
Changelog:
[iOS] [Fixed] - Re-attach the key window observer in RCTDeviceInfo so Dimensions update without requiring app activation
Test Plan:
Verified against an app built for iPad and run on an Apple Silicon Mac, whose
AppDelegatecreates its ownUIWindowinapplication(_:didFinishLaunchingWithOptions:), New Architecture enabled:Before:
useWindowDimensions()keeps the previous size and the UI is drawn rotated; it only corrects after switching to another app and back.After: the dimensions update on rotation and the UI re-lays-out immediately.
Also verified as an equivalent patch on 0.86.2 in a production app (
xcodebuildDebug/iphonesimulator succeeds, app launches and renders normally, iPhone rotation still behaves as before).