Skip to content

feat: add WindowManagerMacOS for multi-window support - #3052

Open
realZachi wants to merge 1 commit into
microsoft:mainfrom
realZachi:feat/macos-multi-window
Open

feat: add WindowManagerMacOS for multi-window support#3052
realZachi wants to merge 1 commit into
microsoft:mainfrom
realZachi:feat/macos-multi-window

Conversation

@realZachi

Copy link
Copy Markdown

Summary:

react-native-macos currently has no way to show React content in more than one window. RCTAppDelegate creates exactly one NSWindow in loadReactNativeWindow:, and there is no JavaScript API to create additional ones. Multi-window support has been asked for repeatedly (#604, #617), and the scope wiki notes the fork has no window APIs today.

This adds WindowManagerMacOS, a macOS-only module for opening and managing additional windows from JavaScript. Each window hosts its own React root — identified by an app key registered with AppRegistry.registerComponent — while all windows share a single bridge and JavaScript runtime.

AppRegistry.registerComponent('Settings', () => SettingsScreen);

await WindowManagerMacOS.open({
  moduleName: 'Settings',
  title: 'Settings',
  width: 480,
  height: 360,
});

API: open, close, focus, setTitle, isOpen, getWindows, plus windowDidOpen / windowDidClose / windowDidFocus / windowDidBlur / windowDidResize events via RCTEventEmitter.

Implementation notes

  • Root views come from the app delegate's RCTRootViewFactory. That factory already guards bridge and host creation internally (createBridgeIfNeeded: returns early when a bridge exists), so it can safely be called more than once — the capability was already there, just unused. Going through it means the same code path works for the old architecture, Fabric and bridgeless without the module having to branch on architecture. When no factory is available it falls back to -[RCTRootView initWithBridge:moduleName:initialProperties:], so the bridge is still shared. -initWithBundleURL: is deliberately never used, since that would create a second bridge.
  • No new pod dependency. RCTRootViewFactory lives in React-RCTAppDelegate, which depends on React-Core; importing it from React/CoreModules would be circular. The module instead redeclares the two members it needs as local protocols and relies on selector dispatch, guarded by respondsToSelector:.
  • open() is idempotent per key. Opening an already-open key focuses the existing window and resolves with its info instead of creating a duplicate — the behaviour a "open settings" menu item wants.
  • window.releasedWhenClosed = NO, since the window is retained by the module's registry.
  • RCTKeyWindow() fallback. On macOS it returned [NSApp keyWindow] unconditionally, which is nil whenever no window holds key focus — RCTDeviceInfo then reports a zero-sized window through Dimensions (useWindowDimensions() and Dimensions.get("window") both return { width: 0, height: 0 } when window does not have keyboard focus [cause and solution provided] #2296). It now falls back to the main window and then to any visible, focusable window. This is more likely to be hit with several windows open, but it is an existing bug independent of this feature.

Known limitation

Dimensions / useWindowDimensions still report the app's key window rather than the window a given root is rendered into. Making those per-surface is a larger change to RCTDeviceInfo and is not attempted here.

This change is macOS-only (#if TARGET_OS_OSX), so there is no corresponding facebook/react-native PR — the API has no meaning on iOS, where the module is not registered and WindowManagerMacOS.isSupported is false.

Test Plan:

Automated — run against this branch:

  • yarn flow-checkFound 0 errors
  • yarn lint → exit 0, no findings
  • yarn jest packages/react-native/Libraries → 115 suites / 1330 tests / 207 snapshots passed
  • yarn changeset status --since=origin/main → exit 0, react-native-macos at patch
  • yarn test-typescript → the only two errors are Duplicate identifier 'FocusEvent' in Libraries/Types/CoreEventTypes.d.ts, which reproduce identically on a clean main and are unrelated to this change

Buildpackages/rn-tester, scheme RNTester-macOS, Debug: ** BUILD SUCCEEDED **, with RCTWindowManager.mm compiled into the target.

Runtime — verified in a separate macOS app on the old architecture, driving the public API directly:

step result
isSupported true
open({moduleName, title, width: 460, height: 520}) resolves 460x520, window appears
windowDidOpen listener fires with the window's key
getWindows() 1
open() again with the same key same key returned, getWindows() still 1 (no duplicate)
close(key) true, getWindows()0

State declared in a plain module-scope store is observed and mutated from both windows, confirming the shared JavaScript runtime.

Manual — a new RNTester example under the macOS category ("WindowManagerMacOS") exercises opening two windows, focusing, retitling, closing, the idempotent re-open, a live list of open windows driven by the lifecycle events, and a counter shared across windows.

react-native-macos has so far had no way to show React content in more
than one window: RCTAppDelegate creates a single NSWindow in
loadReactNativeWindow: and there is no JavaScript API to create more.

Add a WindowManagerMacOS module that opens additional NSWindows from
JavaScript. Each window hosts its own React root, identified by an app
key registered with AppRegistry.registerComponent, while all windows
share a single bridge and JavaScript runtime.

    AppRegistry.registerComponent('Settings', () => SettingsScreen);

    await WindowManagerMacOS.open({
      moduleName: 'Settings',
      title: 'Settings',
      width: 480,
      height: 360,
    });

Root views are created through the app delegate's RCTRootViewFactory,
which already guards bridge and host creation internally and can safely
be called more than once, so the same code path works for the old
architecture, Fabric and bridgeless alike. When no factory is available
the module falls back to -[RCTRootView initWithBridge:moduleName:
initialProperties:] so the bridge is still shared.

open() is idempotent per key: opening an already-open key focuses the
existing window instead of creating a duplicate. Windows can be closed,
focused, retitled and enumerated, and lifecycle events are delivered to
JavaScript via RCTEventEmitter.

Also make RCTKeyWindow() fall back to the main window and then to any
visible, focusable window on macOS. It previously returned
[NSApp keyWindow] unconditionally, which is nil whenever no window holds
key focus, causing RCTDeviceInfo to report a zero-sized window through
Dimensions. That is more likely to happen with several windows open.

Adds an RNTester example under the macOS category demonstrating opening,
focusing, retitling and closing secondary windows, and a shared counter
that shows state being observed across windows.
@realZachi
realZachi requested a review from a team as a code owner July 27, 2026 15:23
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
There may be pipelines that require an authorized user to comment /azp run to run.

@realZachi

Copy link
Copy Markdown
Author

@microsoft-github-policy-service agree

@realZachi please read the following Contributor License Agreement(CLA). If you agree with the CLA, please reply with the following information.

@microsoft-github-policy-service agree [company="{your company}"]

Options:

  • (default - no company specified) I have sole ownership of intellectual property rights to my Submissions and I am not making Submissions in the course of work for my employer.
@microsoft-github-policy-service agree
  • (when company given) I am making Submissions in the course of work for my employer (or my employer has intellectual property rights in my Submissions by contract or applicable law). I have permission from my employer to make Submissions and enter into this Agreement on behalf of my employer. By signing below, the defined term “You” includes me and my employer.
@microsoft-github-policy-service agree company="Microsoft"

Contributor License Agreement

@microsoft-github-policy-service agree

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant