From 935b7de71b374939479d29ca41dd0f68307d9e71 Mon Sep 17 00:00:00 2001 From: Mathieu Acthernoene Date: Tue, 24 Feb 2026 13:31:14 +0100 Subject: [PATCH 1/4] Add ExtraWindowEventListener interface --- .../react/bridge/ExtraWindowEventListener.kt | 29 +++++++++++++++ .../facebook/react/bridge/ReactContext.java | 35 +++++++++++++++++++ .../react/uimanager/ThemedReactContext.kt | 18 ++++++++++ .../react/views/modal/ReactModalHostView.kt | 4 +++ 4 files changed, 86 insertions(+) create mode 100644 packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/ExtraWindowEventListener.kt diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/ExtraWindowEventListener.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/ExtraWindowEventListener.kt new file mode 100644 index 000000000000..e428a81ea90b --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/ExtraWindowEventListener.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.react.bridge + +import android.view.Window + +/** + * Listener for receiving extra window creation and destruction events. + * + * This allows modules to react to new windows being added or removed, such as Dialog windows + * registered by Modal components. Modules like StatusBarModule can implement this interface to + * apply their configuration to all active windows. + * + * Third-party libraries can both implement this listener and emit window events through + * [ReactContext.onExtraWindowCreate] and [ReactContext.onExtraWindowDestroy]. + */ +public interface ExtraWindowEventListener { + + /** Called when a new [Window] is created (e.g. a Dialog window for a Modal). */ + public fun onExtraWindowCreate(window: Window) + + /** Called when a [Window] is destroyed (e.g. on Dialog window dismiss). */ + public fun onExtraWindowDestroy(window: Window) +} diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/ReactContext.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/ReactContext.java index dc9acd738dd7..9d70e5081dc3 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/ReactContext.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/ReactContext.java @@ -15,6 +15,7 @@ import android.content.Intent; import android.os.Bundle; import android.view.LayoutInflater; +import android.view.Window; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.facebook.common.logging.FLog; @@ -48,6 +49,8 @@ public interface RCTDeviceEventEmitter extends JavaScriptModule { new CopyOnWriteArraySet<>(); private final CopyOnWriteArraySet mActivityEventListeners = new CopyOnWriteArraySet<>(); + private final CopyOnWriteArraySet mExtraWindowEventListeners = + new CopyOnWriteArraySet<>(); private final CopyOnWriteArraySet mWindowFocusEventListeners = new CopyOnWriteArraySet<>(); private final ScrollEndedListeners mScrollEndedListeners = new ScrollEndedListeners(); @@ -246,6 +249,14 @@ public void removeActivityEventListener(ActivityEventListener listener) { mActivityEventListeners.remove(listener); } + public void addExtraWindowEventListener(ExtraWindowEventListener listener) { + mExtraWindowEventListeners.add(listener); + } + + public void removeExtraWindowEventListener(ExtraWindowEventListener listener) { + mExtraWindowEventListeners.remove(listener); + } + public void addWindowFocusChangeListener(WindowFocusChangeListener listener) { mWindowFocusEventListeners.add(listener); } @@ -356,6 +367,30 @@ public void onActivityResult( } } + @ThreadConfined(UI) + public void onExtraWindowCreate(Window window) { + UiThreadUtil.assertOnUiThread(); + for (ExtraWindowEventListener listener : mExtraWindowEventListeners) { + try { + listener.onExtraWindowCreate(window); + } catch (RuntimeException e) { + handleException(e); + } + } + } + + @ThreadConfined(UI) + public void onExtraWindowDestroy(Window window) { + UiThreadUtil.assertOnUiThread(); + for (ExtraWindowEventListener listener : mExtraWindowEventListeners) { + try { + listener.onExtraWindowDestroy(window); + } catch (RuntimeException e) { + handleException(e); + } + } + } + @ThreadConfined(UI) public void onWindowFocusChange(boolean hasFocus) { UiThreadUtil.assertOnUiThread(); diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ThemedReactContext.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ThemedReactContext.kt index 58fc5c8527b5..86a90a402b50 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ThemedReactContext.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ThemedReactContext.kt @@ -11,8 +11,10 @@ package com.facebook.react.uimanager import android.app.Activity import android.content.Context +import android.view.Window import com.facebook.react.bridge.Callback import com.facebook.react.bridge.CatalystInstance +import com.facebook.react.bridge.ExtraWindowEventListener import com.facebook.react.bridge.JavaScriptContextHolder import com.facebook.react.bridge.JavaScriptModule import com.facebook.react.bridge.LifecycleEventListener @@ -67,6 +69,22 @@ public class ThemedReactContext( reactApplicationContext.removeLifecycleEventListener(listener) } + override fun addExtraWindowEventListener(listener: ExtraWindowEventListener) { + reactApplicationContext.addExtraWindowEventListener(listener) + } + + override fun removeExtraWindowEventListener(listener: ExtraWindowEventListener) { + reactApplicationContext.removeExtraWindowEventListener(listener) + } + + override fun onExtraWindowCreate(window: Window) { + reactApplicationContext.onExtraWindowCreate(window) + } + + override fun onExtraWindowDestroy(window: Window) { + reactApplicationContext.onExtraWindowDestroy(window) + } + override fun hasCurrentActivity(): Boolean = reactApplicationContext.hasCurrentActivity() override fun getCurrentActivity(): Activity? = reactApplicationContext.getCurrentActivity() diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/modal/ReactModalHostView.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/modal/ReactModalHostView.kt index d634b601db6b..512e5f147dfe 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/modal/ReactModalHostView.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/modal/ReactModalHostView.kt @@ -196,6 +196,9 @@ public class ReactModalHostView(context: ThemedReactContext) : UiThreadUtil.assertOnUiThread() dialog?.let { nonNullDialog -> + nonNullDialog.window?.let { window -> + (context as ThemedReactContext).onExtraWindowDestroy(window) + } if (nonNullDialog.isShowing) { val dialogContext = ContextUtils.findContextOfType(nonNullDialog.context, Activity::class.java) @@ -341,6 +344,7 @@ public class ReactModalHostView(context: ThemedReactContext) : newDialog.show() updateSystemAppearance() window.clearFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE) + (context as ThemedReactContext).onExtraWindowCreate(window) } } From f6451ab6173826f6c90baec2c923e886f4d23675 Mon Sep 17 00:00:00 2001 From: Mathieu Acthernoene Date: Tue, 14 Apr 2026 18:14:49 +0200 Subject: [PATCH 2/4] Add tests --- .../bridge/ExtraWindowEventListenerTest.kt | 122 ++++++++++++++++++ .../react/uimanager/ThemedReactContextTest.kt | 84 ++++++++++++ 2 files changed, 206 insertions(+) create mode 100644 packages/react-native/ReactAndroid/src/test/java/com/facebook/react/bridge/ExtraWindowEventListenerTest.kt create mode 100644 packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/ThemedReactContextTest.kt diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/bridge/ExtraWindowEventListenerTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/bridge/ExtraWindowEventListenerTest.kt new file mode 100644 index 000000000000..128d22f9465f --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/bridge/ExtraWindowEventListenerTest.kt @@ -0,0 +1,122 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.react.bridge + +import android.view.Window +import com.facebook.testutils.shadows.ShadowSoLoader +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(shadows = [ShadowSoLoader::class]) +class ExtraWindowEventListenerTest { + private lateinit var reactContext: ReactApplicationContext + private lateinit var window: Window + + @Before + fun setUp() { + reactContext = ReactTestHelper.createCatalystContextForTest() + window = mock() + } + + @Test + fun testOnExtraWindowCreateNotifiesListener() { + val listener: ExtraWindowEventListener = mock() + + reactContext.addExtraWindowEventListener(listener) + reactContext.onExtraWindowCreate(window) + + verify(listener, times(1)).onExtraWindowCreate(window) + } + + @Test + fun testOnExtraWindowDestroyNotifiesListener() { + val listener: ExtraWindowEventListener = mock() + + reactContext.addExtraWindowEventListener(listener) + reactContext.onExtraWindowDestroy(window) + + verify(listener, times(1)).onExtraWindowDestroy(window) + } + + @Test + fun testMultipleListenersAreNotified() { + val listener1: ExtraWindowEventListener = mock() + val listener2: ExtraWindowEventListener = mock() + + reactContext.addExtraWindowEventListener(listener1) + reactContext.addExtraWindowEventListener(listener2) + reactContext.onExtraWindowCreate(window) + + verify(listener1, times(1)).onExtraWindowCreate(window) + verify(listener2, times(1)).onExtraWindowCreate(window) + } + + @Test + fun testRemovedListenerIsNotNotified() { + val listener: ExtraWindowEventListener = mock() + + reactContext.addExtraWindowEventListener(listener) + reactContext.removeExtraWindowEventListener(listener) + reactContext.onExtraWindowCreate(window) + + verify(listener, never()).onExtraWindowCreate(window) + } + + @Test + fun testOnlyRemovedListenerStopsReceivingEvents() { + val listener1: ExtraWindowEventListener = mock() + val listener2: ExtraWindowEventListener = mock() + + reactContext.addExtraWindowEventListener(listener1) + reactContext.addExtraWindowEventListener(listener2) + reactContext.removeExtraWindowEventListener(listener1) + reactContext.onExtraWindowDestroy(window) + + verify(listener1, never()).onExtraWindowDestroy(window) + verify(listener2, times(1)).onExtraWindowDestroy(window) + } + + @Test + fun testListenerReceivesBothCreateAndDestroyEvents() { + val listener: ExtraWindowEventListener = mock() + + reactContext.addExtraWindowEventListener(listener) + reactContext.onExtraWindowCreate(window) + reactContext.onExtraWindowDestroy(window) + + verify(listener, times(1)).onExtraWindowCreate(window) + verify(listener, times(1)).onExtraWindowDestroy(window) + } + + @Test + fun testNoListenersDoesNotCrash() { + // Should not throw when no listeners are registered + reactContext.onExtraWindowCreate(window) + reactContext.onExtraWindowDestroy(window) + } + + @Test + fun testDuplicateAddIsIdempotent() { + val listener: ExtraWindowEventListener = mock() + + reactContext.addExtraWindowEventListener(listener) + reactContext.addExtraWindowEventListener(listener) + reactContext.onExtraWindowCreate(window) + + // CopyOnWriteArraySet deduplicates, so listener should only be called once + verify(listener, times(1)).onExtraWindowCreate(window) + } +} diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/ThemedReactContextTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/ThemedReactContextTest.kt new file mode 100644 index 000000000000..e0ae508ed417 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/ThemedReactContextTest.kt @@ -0,0 +1,84 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.react.uimanager + +import android.view.Window +import com.facebook.react.bridge.ExtraWindowEventListener +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactTestHelper +import com.facebook.testutils.shadows.ShadowSoLoader +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(shadows = [ShadowSoLoader::class]) +class ThemedReactContextTest { + private lateinit var reactApplicationContext: ReactApplicationContext + private lateinit var themedReactContext: ThemedReactContext + private lateinit var window: Window + + @Before + fun setUp() { + reactApplicationContext = ReactTestHelper.createCatalystContextForTest() + themedReactContext = + ThemedReactContext(reactApplicationContext, RuntimeEnvironment.getApplication(), null, -1) + window = mock() + } + + @Test + fun testAddExtraWindowEventListenerDelegatesToReactApplicationContext() { + val listener: ExtraWindowEventListener = mock() + + themedReactContext.addExtraWindowEventListener(listener) + // Verify the listener was registered on the underlying context by dispatching an event + reactApplicationContext.onExtraWindowCreate(window) + + verify(listener, times(1)).onExtraWindowCreate(window) + } + + @Test + fun testRemoveExtraWindowEventListenerDelegatesToReactApplicationContext() { + val listener: ExtraWindowEventListener = mock() + + themedReactContext.addExtraWindowEventListener(listener) + themedReactContext.removeExtraWindowEventListener(listener) + // After removal via ThemedReactContext, the listener should not be notified + reactApplicationContext.onExtraWindowCreate(window) + + verify(listener, times(0)).onExtraWindowCreate(window) + } + + @Test + fun testOnExtraWindowCreateDelegatesToReactApplicationContext() { + val listener: ExtraWindowEventListener = mock() + + reactApplicationContext.addExtraWindowEventListener(listener) + // Dispatching via ThemedReactContext should reach listeners on the underlying context + themedReactContext.onExtraWindowCreate(window) + + verify(listener, times(1)).onExtraWindowCreate(window) + } + + @Test + fun testOnExtraWindowDestroyDelegatesToReactApplicationContext() { + val listener: ExtraWindowEventListener = mock() + + reactApplicationContext.addExtraWindowEventListener(listener) + // Dispatching via ThemedReactContext should reach listeners on the underlying context + themedReactContext.onExtraWindowDestroy(window) + + verify(listener, times(1)).onExtraWindowDestroy(window) + } +} From 06fac8fc98d232e1abbbb0b80852f065f5a5ee6c Mon Sep 17 00:00:00 2001 From: Mathieu Acthernoene Date: Tue, 14 Apr 2026 18:30:33 +0200 Subject: [PATCH 3/4] Move ExtraWindowEventListener to com.facebook.react.interfaces --- .../src/main/java/com/facebook/react/bridge/ReactContext.java | 1 + .../react/{bridge => interfaces}/ExtraWindowEventListener.kt | 2 +- .../java/com/facebook/react/uimanager/ThemedReactContext.kt | 2 +- .../{bridge => interfaces}/ExtraWindowEventListenerTest.kt | 4 +++- .../com/facebook/react/uimanager/ThemedReactContextTest.kt | 2 +- 5 files changed, 7 insertions(+), 4 deletions(-) rename packages/react-native/ReactAndroid/src/main/java/com/facebook/react/{bridge => interfaces}/ExtraWindowEventListener.kt (96%) rename packages/react-native/ReactAndroid/src/test/java/com/facebook/react/{bridge => interfaces}/ExtraWindowEventListenerTest.kt (96%) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/ReactContext.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/ReactContext.java index 9d70e5081dc3..75123abe9469 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/ReactContext.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/ReactContext.java @@ -26,6 +26,7 @@ import com.facebook.react.bridge.queue.MessageQueueThread; import com.facebook.react.bridge.queue.ReactQueueConfiguration; import com.facebook.react.common.LifecycleState; +import com.facebook.react.interfaces.ExtraWindowEventListener; import com.facebook.react.turbomodule.core.interfaces.CallInvokerHolder; import java.lang.ref.WeakReference; import java.util.Collection; diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/ExtraWindowEventListener.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/interfaces/ExtraWindowEventListener.kt similarity index 96% rename from packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/ExtraWindowEventListener.kt rename to packages/react-native/ReactAndroid/src/main/java/com/facebook/react/interfaces/ExtraWindowEventListener.kt index e428a81ea90b..70cbdcadead8 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/ExtraWindowEventListener.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/interfaces/ExtraWindowEventListener.kt @@ -5,7 +5,7 @@ * LICENSE file in the root directory of this source tree. */ -package com.facebook.react.bridge +package com.facebook.react.interfaces import android.view.Window diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ThemedReactContext.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ThemedReactContext.kt index 86a90a402b50..06cb79b4be32 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ThemedReactContext.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ThemedReactContext.kt @@ -14,7 +14,6 @@ import android.content.Context import android.view.Window import com.facebook.react.bridge.Callback import com.facebook.react.bridge.CatalystInstance -import com.facebook.react.bridge.ExtraWindowEventListener import com.facebook.react.bridge.JavaScriptContextHolder import com.facebook.react.bridge.JavaScriptModule import com.facebook.react.bridge.LifecycleEventListener @@ -24,6 +23,7 @@ import com.facebook.react.bridge.ReactContext import com.facebook.react.bridge.ScrollEndedListeners import com.facebook.react.bridge.UIManager import com.facebook.react.common.annotations.internal.LegacyArchitecture +import com.facebook.react.interfaces.ExtraWindowEventListener import com.facebook.react.turbomodule.core.interfaces.CallInvokerHolder /** diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/bridge/ExtraWindowEventListenerTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/interfaces/ExtraWindowEventListenerTest.kt similarity index 96% rename from packages/react-native/ReactAndroid/src/test/java/com/facebook/react/bridge/ExtraWindowEventListenerTest.kt rename to packages/react-native/ReactAndroid/src/test/java/com/facebook/react/interfaces/ExtraWindowEventListenerTest.kt index 128d22f9465f..24b0a376b69f 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/bridge/ExtraWindowEventListenerTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/interfaces/ExtraWindowEventListenerTest.kt @@ -5,9 +5,11 @@ * LICENSE file in the root directory of this source tree. */ -package com.facebook.react.bridge +package com.facebook.react.interfaces import android.view.Window +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactTestHelper import com.facebook.testutils.shadows.ShadowSoLoader import org.junit.Before import org.junit.Test diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/ThemedReactContextTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/ThemedReactContextTest.kt index e0ae508ed417..b0e05900d1ba 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/ThemedReactContextTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/ThemedReactContextTest.kt @@ -8,9 +8,9 @@ package com.facebook.react.uimanager import android.view.Window -import com.facebook.react.bridge.ExtraWindowEventListener import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReactTestHelper +import com.facebook.react.interfaces.ExtraWindowEventListener import com.facebook.testutils.shadows.ShadowSoLoader import org.junit.Before import org.junit.Test From 3d54d1d53501a42aa584191a3c8e53e07c4ee05b Mon Sep 17 00:00:00 2001 From: Mathieu Acthernoene Date: Wed, 15 Apr 2026 11:35:12 +0200 Subject: [PATCH 4/4] Only call onExtraWindowDestroy if nonNullDialog has been shown --- .../com/facebook/react/views/modal/ReactModalHostView.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/modal/ReactModalHostView.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/modal/ReactModalHostView.kt index 512e5f147dfe..f36a1e3e1386 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/modal/ReactModalHostView.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/modal/ReactModalHostView.kt @@ -196,10 +196,10 @@ public class ReactModalHostView(context: ThemedReactContext) : UiThreadUtil.assertOnUiThread() dialog?.let { nonNullDialog -> - nonNullDialog.window?.let { window -> - (context as ThemedReactContext).onExtraWindowDestroy(window) - } if (nonNullDialog.isShowing) { + nonNullDialog.window?.let { window -> + (context as ThemedReactContext).onExtraWindowDestroy(window) + } val dialogContext = ContextUtils.findContextOfType(nonNullDialog.context, Activity::class.java) if (dialogContext == null || !dialogContext.isFinishing) {