Skip to content

Re-attach key window observer in RCTDeviceInfo - #57989

Open
HugoGresse wants to merge 2 commits into
react:mainfrom
HugoGresse:fix-ios-device-info-key-window-observer
Open

Re-attach key window observer in RCTDeviceInfo#57989
HugoGresse wants to merge 2 commits into
react:mainfrom
HugoGresse:fix-ios-device-info-key-window-observer

Conversation

@HugoGresse

Copy link
Copy Markdown

Summary:

RCTDeviceInfo publishes dimension updates to JS from four triggers: a KVO on the key window's frame, UIDeviceOrientationDidChangeNotification, UIApplicationDidBecomeActiveNotification and RCTUserInterfaceStyleDidChangeNotification.

The window it observes is captured once, in -init:

- (instancetype)init
{
  if (self = [super init]) {
    _applicationWindow = RCTKeyWindow();
    [_applicationWindow addObserver:self forKeyPath:kFrameKeyPath options:NSKeyValueObservingOptionNew context:nil];
  }
  return self;
}

When the host app creates its UIWindow after this module is built — which is what the current template's UIApplicationDelegate does, assigning self.window inside application:didFinishLaunchingWithOptions:RCTKeyWindow() returns nil, [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 leaves Dimensions/useWindowDimensions reporting the previous size, and the window server displays that stale canvas rotated. Focusing another app and coming back fixes it, because that fires UIApplicationDidBecomeActiveNotification.

This is the symptom of #36118, which was fixed by #37649 (61861d2) with an observer on RCTRootViewFrameDidChangeNotification, posted by RCTRootView. 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 -initialize and at the top of -_interfaceFrameDidChange, instead of trusting the -init capture. It is a no-op when the window is unchanged, and the previous observer is removed before a new one is attached, so -_cleanupObservers stays correct. No other change is needed: RCTExportedDimensions already reads RCTKeyWindow().bounds live.

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 AppDelegate creates its own UIWindow in application(_:didFinishLaunchingWithOptions:), New Architecture enabled:

  1. Launch the app on the Mac.
  2. Rotate with View → Landscape in the menu bar.

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 (xcodebuild Debug/iphonesimulator succeeds, app launches and renders normally, iPhone rotation still behaves as before).

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.
@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Aug 18, 2026
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.
@facebook-github-tools facebook-github-tools Bot added the Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team. label Aug 18, 2026
@cipolleschi

Copy link
Copy Markdown
Contributor

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

Seems good to me. I asked for another pair of eye to look into it.

@HugoGresse

Copy link
Copy Markdown
Author

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.

Right now on RN 0.84, will soon test on 0.86 if needed.
Thank you for the quick review!

@artus9033 artus9033 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.

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];

@artus9033 artus9033 Aug 18, 2026

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.

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

Comment on lines 280 to 291
[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
});

@artus9033 artus9033 Aug 18, 2026

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.

[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.didReceiveNewContentSizeMultiplierinvalidateCachedConstants_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});
Suggested change
// 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
});

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants