Skip to content

Commit 58fec1f

Browse files
zoontekmeta-codesync[bot]
authored andcommitted
Add ExtraWindowEventListener interface (#55721)
Summary: This PR introduces a `ExtraWindowEventListener` interface that allows native modules to be notified when new windows are created or destroyed (e.g. Modal dialogs). Third-party libraries can implement it, or emit window events through `ReactContext.onExtraWindowCreate` / `ReactContext.onExtraWindowDestroy`. This opens the door for libraries like `expo-navigation-bar` to build a proper `NavigationBar` module that stays in sync across all windows (including modals). ## Related issue - zoontek/react-native-navigation-bar#4 ## Changelog: [ANDROID] [ADDED] - Add `ExtraWindowEventListener` interface to allow native modules to react to window creation / destruction (e.g. Modal dialogs) Pull Request resolved: #55721 Test Plan: Follow #56059 test plan Reviewed By: cortinico Differential Revision: D94871845 Pulled By: alanleedev fbshipit-source-id: b2c00950c3c60dbeb3d53520d95160b301829a5e
1 parent f8fa76f commit 58fec1f

File tree

7 files changed

+308
-0
lines changed

7 files changed

+308
-0
lines changed

packages/react-native/ReactAndroid/api/ReactAndroid.api

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -943,6 +943,7 @@ public abstract class com/facebook/react/bridge/ReactContext : android/content/C
943943
protected field mInteropModuleRegistry Lcom/facebook/react/bridge/interop/InteropModuleRegistry;
944944
public fun <init> (Landroid/content/Context;)V
945945
public fun addActivityEventListener (Lcom/facebook/react/bridge/ActivityEventListener;)V
946+
public fun addExtraWindowEventListener (Lcom/facebook/react/interfaces/ExtraWindowEventListener;)V
946947
public fun addLifecycleEventListener (Lcom/facebook/react/bridge/LifecycleEventListener;)V
947948
public fun addWindowFocusChangeListener (Lcom/facebook/react/bridge/WindowFocusChangeListener;)V
948949
public fun assertOnJSQueueThread ()V
@@ -986,6 +987,8 @@ public abstract class com/facebook/react/bridge/ReactContext : android/content/C
986987
public fun isOnNativeModulesQueueThread ()Z
987988
public fun isOnUiQueueThread ()Z
988989
public fun onActivityResult (Landroid/app/Activity;IILandroid/content/Intent;)V
990+
public fun onExtraWindowCreate (Landroid/view/Window;)V
991+
public fun onExtraWindowDestroy (Landroid/view/Window;)V
989992
public fun onHostDestroy ()V
990993
public fun onHostDestroy (Z)V
991994
public fun onHostPause ()V
@@ -995,6 +998,7 @@ public abstract class com/facebook/react/bridge/ReactContext : android/content/C
995998
public fun onWindowFocusChange (Z)V
996999
public abstract fun registerSegment (ILjava/lang/String;Lcom/facebook/react/bridge/Callback;)V
9971000
public fun removeActivityEventListener (Lcom/facebook/react/bridge/ActivityEventListener;)V
1001+
public fun removeExtraWindowEventListener (Lcom/facebook/react/interfaces/ExtraWindowEventListener;)V
9981002
public fun removeLifecycleEventListener (Lcom/facebook/react/bridge/LifecycleEventListener;)V
9991003
public fun removeWindowFocusChangeListener (Lcom/facebook/react/bridge/WindowFocusChangeListener;)V
10001004
public fun resetPerfStats ()V
@@ -2305,6 +2309,11 @@ public final class com/facebook/react/fabric/mounting/SurfaceMountingManager {
23052309
public final fun updateState (ILcom/facebook/react/uimanager/StateWrapper;)V
23062310
}
23072311

2312+
public abstract interface class com/facebook/react/interfaces/ExtraWindowEventListener {
2313+
public abstract fun onExtraWindowCreate (Landroid/view/Window;)V
2314+
public abstract fun onExtraWindowDestroy (Landroid/view/Window;)V
2315+
}
2316+
23082317
public abstract interface class com/facebook/react/interfaces/TaskInterface {
23092318
public abstract fun getError ()Ljava/lang/Exception;
23102319
public abstract fun getResult ()Ljava/lang/Object;
@@ -4149,6 +4158,7 @@ public final class com/facebook/react/uimanager/ThemedReactContext : com/faceboo
41494158
public fun <init> (Lcom/facebook/react/bridge/ReactApplicationContext;Landroid/content/Context;Ljava/lang/String;)V
41504159
public fun <init> (Lcom/facebook/react/bridge/ReactApplicationContext;Landroid/content/Context;Ljava/lang/String;I)V
41514160
public synthetic fun <init> (Lcom/facebook/react/bridge/ReactApplicationContext;Landroid/content/Context;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
4161+
public fun addExtraWindowEventListener (Lcom/facebook/react/interfaces/ExtraWindowEventListener;)V
41524162
public fun addLifecycleEventListener (Lcom/facebook/react/bridge/LifecycleEventListener;)V
41534163
public fun destroy ()V
41544164
public fun getCatalystInstance ()Lcom/facebook/react/bridge/CatalystInstance;
@@ -4174,7 +4184,10 @@ public final class com/facebook/react/uimanager/ThemedReactContext : com/faceboo
41744184
public fun hasNativeModule (Ljava/lang/Class;)Z
41754185
public fun hasReactInstance ()Z
41764186
public fun isBridgeless ()Z
4187+
public fun onExtraWindowCreate (Landroid/view/Window;)V
4188+
public fun onExtraWindowDestroy (Landroid/view/Window;)V
41774189
public fun registerSegment (ILjava/lang/String;Lcom/facebook/react/bridge/Callback;)V
4190+
public fun removeExtraWindowEventListener (Lcom/facebook/react/interfaces/ExtraWindowEventListener;)V
41784191
public fun removeLifecycleEventListener (Lcom/facebook/react/bridge/LifecycleEventListener;)V
41794192
}
41804193

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/ReactContext.java

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import android.content.Intent;
1616
import android.os.Bundle;
1717
import android.view.LayoutInflater;
18+
import android.view.Window;
1819
import androidx.annotation.NonNull;
1920
import androidx.annotation.Nullable;
2021
import com.facebook.common.logging.FLog;
@@ -25,6 +26,7 @@
2526
import com.facebook.react.bridge.queue.MessageQueueThread;
2627
import com.facebook.react.bridge.queue.ReactQueueConfiguration;
2728
import com.facebook.react.common.LifecycleState;
29+
import com.facebook.react.interfaces.ExtraWindowEventListener;
2830
import com.facebook.react.turbomodule.core.interfaces.CallInvokerHolder;
2931
import java.lang.ref.WeakReference;
3032
import java.util.Collection;
@@ -48,6 +50,8 @@ public interface RCTDeviceEventEmitter extends JavaScriptModule {
4850
new CopyOnWriteArraySet<>();
4951
private final CopyOnWriteArraySet<ActivityEventListener> mActivityEventListeners =
5052
new CopyOnWriteArraySet<>();
53+
private final CopyOnWriteArraySet<ExtraWindowEventListener> mExtraWindowEventListeners =
54+
new CopyOnWriteArraySet<>();
5155
private final CopyOnWriteArraySet<WindowFocusChangeListener> mWindowFocusEventListeners =
5256
new CopyOnWriteArraySet<>();
5357
private final ScrollEndedListeners mScrollEndedListeners = new ScrollEndedListeners();
@@ -246,6 +250,14 @@ public void removeActivityEventListener(ActivityEventListener listener) {
246250
mActivityEventListeners.remove(listener);
247251
}
248252

253+
public void addExtraWindowEventListener(ExtraWindowEventListener listener) {
254+
mExtraWindowEventListeners.add(listener);
255+
}
256+
257+
public void removeExtraWindowEventListener(ExtraWindowEventListener listener) {
258+
mExtraWindowEventListeners.remove(listener);
259+
}
260+
249261
public void addWindowFocusChangeListener(WindowFocusChangeListener listener) {
250262
mWindowFocusEventListeners.add(listener);
251263
}
@@ -356,6 +368,30 @@ public void onActivityResult(
356368
}
357369
}
358370

371+
@ThreadConfined(UI)
372+
public void onExtraWindowCreate(Window window) {
373+
UiThreadUtil.assertOnUiThread();
374+
for (ExtraWindowEventListener listener : mExtraWindowEventListeners) {
375+
try {
376+
listener.onExtraWindowCreate(window);
377+
} catch (RuntimeException e) {
378+
handleException(e);
379+
}
380+
}
381+
}
382+
383+
@ThreadConfined(UI)
384+
public void onExtraWindowDestroy(Window window) {
385+
UiThreadUtil.assertOnUiThread();
386+
for (ExtraWindowEventListener listener : mExtraWindowEventListeners) {
387+
try {
388+
listener.onExtraWindowDestroy(window);
389+
} catch (RuntimeException e) {
390+
handleException(e);
391+
}
392+
}
393+
}
394+
359395
@ThreadConfined(UI)
360396
public void onWindowFocusChange(boolean hasFocus) {
361397
UiThreadUtil.assertOnUiThread();
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/*
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
package com.facebook.react.interfaces
9+
10+
import android.view.Window
11+
12+
/**
13+
* Listener for receiving extra window creation and destruction events.
14+
*
15+
* This allows modules to react to new windows being added or removed, such as Dialog windows
16+
* registered by Modal components. Modules like StatusBarModule can implement this interface to
17+
* apply their configuration to all active windows.
18+
*
19+
* Third-party libraries can both implement this listener and emit window events through
20+
* [ReactContext.onExtraWindowCreate] and [ReactContext.onExtraWindowDestroy].
21+
*/
22+
public interface ExtraWindowEventListener {
23+
24+
/** Called when a new [Window] is created (e.g. a Dialog window for a Modal). */
25+
public fun onExtraWindowCreate(window: Window)
26+
27+
/** Called when a [Window] is destroyed (e.g. on Dialog window dismiss). */
28+
public fun onExtraWindowDestroy(window: Window)
29+
}

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ThemedReactContext.kt

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ package com.facebook.react.uimanager
1111

1212
import android.app.Activity
1313
import android.content.Context
14+
import android.view.Window
1415
import com.facebook.react.bridge.Callback
1516
import com.facebook.react.bridge.CatalystInstance
1617
import com.facebook.react.bridge.JavaScriptContextHolder
@@ -22,6 +23,7 @@ import com.facebook.react.bridge.ReactContext
2223
import com.facebook.react.bridge.ScrollEndedListeners
2324
import com.facebook.react.bridge.UIManager
2425
import com.facebook.react.common.annotations.internal.LegacyArchitecture
26+
import com.facebook.react.interfaces.ExtraWindowEventListener
2527
import com.facebook.react.turbomodule.core.interfaces.CallInvokerHolder
2628

2729
/**
@@ -67,6 +69,22 @@ public class ThemedReactContext(
6769
reactApplicationContext.removeLifecycleEventListener(listener)
6870
}
6971

72+
override fun addExtraWindowEventListener(listener: ExtraWindowEventListener) {
73+
reactApplicationContext.addExtraWindowEventListener(listener)
74+
}
75+
76+
override fun removeExtraWindowEventListener(listener: ExtraWindowEventListener) {
77+
reactApplicationContext.removeExtraWindowEventListener(listener)
78+
}
79+
80+
override fun onExtraWindowCreate(window: Window) {
81+
reactApplicationContext.onExtraWindowCreate(window)
82+
}
83+
84+
override fun onExtraWindowDestroy(window: Window) {
85+
reactApplicationContext.onExtraWindowDestroy(window)
86+
}
87+
7088
override fun hasCurrentActivity(): Boolean = reactApplicationContext.hasCurrentActivity()
7189

7290
override fun getCurrentActivity(): Activity? = reactApplicationContext.getCurrentActivity()

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/modal/ReactModalHostView.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,9 @@ public class ReactModalHostView(context: ThemedReactContext) :
197197

198198
dialog?.let { nonNullDialog ->
199199
if (nonNullDialog.isShowing) {
200+
nonNullDialog.window?.let { window ->
201+
(context as ThemedReactContext).onExtraWindowDestroy(window)
202+
}
200203
val dialogContext =
201204
ContextUtils.findContextOfType(nonNullDialog.context, Activity::class.java)
202205
if (dialogContext == null || !dialogContext.isFinishing) {
@@ -341,6 +344,7 @@ public class ReactModalHostView(context: ThemedReactContext) :
341344
newDialog.show()
342345
updateSystemAppearance()
343346
window.clearFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE)
347+
(context as ThemedReactContext).onExtraWindowCreate(window)
344348
}
345349
}
346350

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
/*
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
package com.facebook.react.interfaces
9+
10+
import android.view.Window
11+
import com.facebook.react.bridge.ReactApplicationContext
12+
import com.facebook.react.bridge.ReactTestHelper
13+
import com.facebook.testutils.shadows.ShadowSoLoader
14+
import org.junit.Before
15+
import org.junit.Test
16+
import org.junit.runner.RunWith
17+
import org.mockito.kotlin.mock
18+
import org.mockito.kotlin.never
19+
import org.mockito.kotlin.times
20+
import org.mockito.kotlin.verify
21+
import org.robolectric.RobolectricTestRunner
22+
import org.robolectric.annotation.Config
23+
24+
@RunWith(RobolectricTestRunner::class)
25+
@Config(shadows = [ShadowSoLoader::class])
26+
class ExtraWindowEventListenerTest {
27+
private lateinit var reactContext: ReactApplicationContext
28+
private lateinit var window: Window
29+
30+
@Before
31+
fun setUp() {
32+
reactContext = ReactTestHelper.createCatalystContextForTest()
33+
window = mock()
34+
}
35+
36+
@Test
37+
fun testOnExtraWindowCreateNotifiesListener() {
38+
val listener: ExtraWindowEventListener = mock()
39+
40+
reactContext.addExtraWindowEventListener(listener)
41+
reactContext.onExtraWindowCreate(window)
42+
43+
verify(listener, times(1)).onExtraWindowCreate(window)
44+
}
45+
46+
@Test
47+
fun testOnExtraWindowDestroyNotifiesListener() {
48+
val listener: ExtraWindowEventListener = mock()
49+
50+
reactContext.addExtraWindowEventListener(listener)
51+
reactContext.onExtraWindowDestroy(window)
52+
53+
verify(listener, times(1)).onExtraWindowDestroy(window)
54+
}
55+
56+
@Test
57+
fun testMultipleListenersAreNotified() {
58+
val listener1: ExtraWindowEventListener = mock()
59+
val listener2: ExtraWindowEventListener = mock()
60+
61+
reactContext.addExtraWindowEventListener(listener1)
62+
reactContext.addExtraWindowEventListener(listener2)
63+
reactContext.onExtraWindowCreate(window)
64+
65+
verify(listener1, times(1)).onExtraWindowCreate(window)
66+
verify(listener2, times(1)).onExtraWindowCreate(window)
67+
}
68+
69+
@Test
70+
fun testRemovedListenerIsNotNotified() {
71+
val listener: ExtraWindowEventListener = mock()
72+
73+
reactContext.addExtraWindowEventListener(listener)
74+
reactContext.removeExtraWindowEventListener(listener)
75+
reactContext.onExtraWindowCreate(window)
76+
77+
verify(listener, never()).onExtraWindowCreate(window)
78+
}
79+
80+
@Test
81+
fun testOnlyRemovedListenerStopsReceivingEvents() {
82+
val listener1: ExtraWindowEventListener = mock()
83+
val listener2: ExtraWindowEventListener = mock()
84+
85+
reactContext.addExtraWindowEventListener(listener1)
86+
reactContext.addExtraWindowEventListener(listener2)
87+
reactContext.removeExtraWindowEventListener(listener1)
88+
reactContext.onExtraWindowDestroy(window)
89+
90+
verify(listener1, never()).onExtraWindowDestroy(window)
91+
verify(listener2, times(1)).onExtraWindowDestroy(window)
92+
}
93+
94+
@Test
95+
fun testListenerReceivesBothCreateAndDestroyEvents() {
96+
val listener: ExtraWindowEventListener = mock()
97+
98+
reactContext.addExtraWindowEventListener(listener)
99+
reactContext.onExtraWindowCreate(window)
100+
reactContext.onExtraWindowDestroy(window)
101+
102+
verify(listener, times(1)).onExtraWindowCreate(window)
103+
verify(listener, times(1)).onExtraWindowDestroy(window)
104+
}
105+
106+
@Test
107+
fun testNoListenersDoesNotCrash() {
108+
// Should not throw when no listeners are registered
109+
reactContext.onExtraWindowCreate(window)
110+
reactContext.onExtraWindowDestroy(window)
111+
}
112+
113+
@Test
114+
fun testDuplicateAddIsIdempotent() {
115+
val listener: ExtraWindowEventListener = mock()
116+
117+
reactContext.addExtraWindowEventListener(listener)
118+
reactContext.addExtraWindowEventListener(listener)
119+
reactContext.onExtraWindowCreate(window)
120+
121+
// CopyOnWriteArraySet deduplicates, so listener should only be called once
122+
verify(listener, times(1)).onExtraWindowCreate(window)
123+
}
124+
}

0 commit comments

Comments
 (0)