Skip to content

Commit 6200203

Browse files
fairyswordmeta-codesync[bot]
authored andcommitted
Clear DevLoadingView when its host is invalidated (#59165)
Summary: In an iOS app that creates a React Native host for an individual screen, closing that screen and releasing its host while the bundle is still loading can leave the “Loading from Metro…” banner visible over the native screen. If the host is released before the initial banner delay expires, the banner can appear after the screen has already closed. `RCTDevLoadingView` retains `_initialMessageBlock`, whose callback captures the module. Normally, bundle progress or completion clears this block. When the host is released before the first bundle progress update, the module can remain retained by this cycle. Cleanup in `dealloc` cannot break the cycle, and the module currently has no `invalidate` implementation to clear its pending work and window during host teardown. Cleanup also needs to reject queued display work. An initial-message or download-progress callback can enqueue a display block before invalidation, and that block can run after invalidation has cleared the window. The invalidation check runs inside the queued block so it cannot recreate the banner after teardown. This change: - Implements `invalidate` to cancel the pending initial message and hide and release the loading window and its views on the main queue. - Records that the module has been invalidated and checks that state inside the queued display block, preventing queued messages from recreating the window after teardown. - Adds three XCTest regression tests for pending-message cancellation, visible-window cleanup, and queued display work after invalidation. ## Changelog: [IOS] [FIXED] - Clear the development loading banner when its React Native host is invalidated, including pending messages that could appear after teardown. Pull Request resolved: #59165 Test Plan: Reproduced on main baseline `27e77216ef2557ccbf31a01067a44dc1dd2ec846` and verified with the fix applied, using RNTester in Debug, Xcode 27.0, and an iPhone 18 Pro simulator running iOS 27.0. The [reproduction branch](https://github.com/fairysword/react-native/tree/repro/devloadingview-invalidation) contains the native RNTester controller, a six-second Metro delay proxy, and the Run configuration. [Setup and run instructions](https://github.com/fairysword/react-native/blob/9c5b7edd806ec332fe0f25772b13b4357fb3184b/packages/rn-tester/devloading-invalidation/README.md) are included with the demo. The controller creates and releases a real `RCTReactNativeFactory` / `RCTHost`; it does not call DevLoadingView's display, hide, or invalidation methods. Native controls sit above the loading window so the close button remains tappable while loading. ### Reproduction 1. Follow the demo README to install dependencies, start Metro and the delay proxy, and run RNTester in Xcode. 2. Tap **Open RN page** and wait for **Loading from Metro…**. 3. Before the six-second delay expires, tap **Close RN page and release Host**. Wait at least ten seconds after closing. 4. Apply the fix, rebuild and restart the app, and repeat the same steps. 5. On the patched build, reopen the RN page and let the bundle finish loading. **Before the fix:** the RN view is removed and the weak reference to its host is nil, but the loading banner remains over the native screen. **With the fix:** the banner clears during invalidation and does not reappear. A new host still displays its loading banner normally and clears it when RNTester finishes loading. ### Additional lifecycle checks | Scenario | Before the fix | With the fix | | --- | --- | --- | | Release the host while the banner is visible | Banner remains at the 2-, 8-, and 11-second checkpoints | No banner at those checkpoints | | Release the host before the initial 200 ms display delay | Banner appears after the host is released | Banner stays absent | | Allow the bundle to finish loading | — | RNTester loads and the banner clears | | Close during loading, reopen with a new host, then close | — | Old banner clears; the new host loads normally and leaves no banner after closing | ### Queued display regression Compared the same cleanup implementation with and without the `_invalidated` check, using repeated real host creation and release in the simulator. The controller did not call DevLoadingView methods directly or retain the module. Each variant ran 60 cycles per timing scenario: | Scenario | Cleanup without the invalidation check | Cleanup with the invalidation check | | --- | --- | --- | | Close 150–250 ms after opening, around the initial display delay | 11 queued display blocks recreated the window after cleanup; those modules then deallocated shortly afterward | 11 late display blocks were rejected; no window was recreated | | Close 40–1,000 ms after opening while real Metro multipart responses were throttled to 32 KiB every 20 ms | 2 queued display blocks recreated the window after cleanup; “Downloading 0%…” was still visible at post-close checks with the RN page closed and its host's weak reference nil | The late display block was rejected; no window was recreated and no post-close banner was observed | For both download races, the module deallocated about 3.9 seconds after recreating the window. This confirms that relying on eventual `dealloc` does not ensure the banner disappears when its host is released. Both baseline and patched Debug simulator builds succeeded. The build command used from `packages/rn-tester` was: ```bash xcodebuild \ -workspace RNTesterPods.xcworkspace \ -scheme RNTester \ -configuration Debug \ -sdk iphonesimulator \ -destination 'generic/platform=iOS Simulator' \ -derivedDataPath build/devloading-invalidation/DerivedData \ -jobs 6 \ ARCHS=arm64 ONLY_ACTIVE_ARCH=YES CODE_SIGNING_ALLOWED=NO \ build ``` ### Automated regression tests `RCTDevLoadingViewTests` is included in the `RNTesterUnitTests` target and covers: - Cancelling and releasing the pending initial message during invalidation. - Hiding an existing loading window and releasing its window, container, label, and dismiss-button references. - Preventing queued `showMessage` and `updateProgress` callbacks from creating a window after invalidation. A test subclass overrides a private wrapper around `RCTRunningInTestEnvironment()` so the tests exercise the real display path instead of returning early under XCTest. The production implementation still uses the same environment check. The targeted run used the repository's default AddressSanitizer and UndefinedBehaviorSanitizer test configuration: **3 tests passed, 0 failed, 0 skipped**. For negative controls, the pending-message cancellation, window cleanup, and invalidation guard were each removed independently. Each variant failed its corresponding test while the other two tests passed. After restoring the complete fix, all three tests passed again. Run the regression tests from `packages/rn-tester`, selecting an available iOS simulator: ```bash xcodebuild test \ -workspace RNTesterPods.xcworkspace \ -scheme RNTester \ -configuration Debug \ -sdk iphonesimulator \ -destination 'platform=iOS Simulator,name=iPhone 18 Pro,OS=27.0' \ -derivedDataPath build/devloading-invalidation/DerivedData \ -only-testing:RNTesterUnitTests/RCTDevLoadingViewTests \ -parallel-testing-enabled NO \ -jobs 6 \ ARCHS=arm64 ONLY_ACTIVE_ARCH=YES CODE_SIGNING_ALLOWED=NO ``` Reviewed By: cipolleschi Differential Revision: D126289730 Pulled By: vzaidman fbshipit-source-id: 23d35b10e09c5a95da95a0524c975aec8410dc36
1 parent 7104c5e commit 6200203

3 files changed

Lines changed: 166 additions & 1 deletion

File tree

‎packages/react-native/React/CoreModules/RCTDevLoadingView.mm‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ @implementation RCTDevLoadingView {
3535
UIButton *_dismissButton;
3636
NSDate *_showDate;
3737
BOOL _hiding;
38+
BOOL _invalidated;
3839
dispatch_block_t _initialMessageBlock;
3940
}
4041

@@ -59,6 +60,20 @@ - (instancetype)init
5960
return self;
6061
}
6162

63+
- (void)invalidate
64+
{
65+
RCTExecuteOnMainQueue(^{
66+
self->_invalidated = YES;
67+
// The delayed message retains this module, so dealloc cannot cancel it when the host is invalidated.
68+
[self clearInitialMessageDelay];
69+
self->_window.hidden = YES;
70+
self->_window = nil;
71+
self->_container = nil;
72+
self->_label = nil;
73+
self->_dismissButton = nil;
74+
});
75+
}
76+
6277
- (void)dealloc
6378
{
6479
[self clearInitialMessageDelay];
@@ -127,7 +142,7 @@ - (void)showMessage:(NSString *)message
127142
}
128143

129144
dispatch_async(dispatch_get_main_queue(), ^{
130-
if (RCTRunningInTestEnvironment()) {
145+
if (self->_invalidated || [self isRunningInTestEnvironment]) {
131146
return;
132147
}
133148

@@ -324,6 +339,11 @@ - (void)showOfflineMessage
324339
[self showMessage:message color:color backgroundColor:backgroundColor dismissButton:false];
325340
}
326341

342+
- (BOOL)isRunningInTestEnvironment
343+
{
344+
return RCTRunningInTestEnvironment();
345+
}
346+
327347
- (BOOL)isDarkModeEnabled
328348
{
329349
// We pass nil here to match the behavior of the native module.

‎packages/rn-tester/RNTesterPods.xcodeproj/project.pbxproj‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
E7DB218C22B41FCD005AC45F /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E7DB218B22B41FCD005AC45F /* XCTest.framework */; };
5757
F0D621C32BBB9E38005960AC /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = F0D621C22BBB9E38005960AC /* PrivacyInfo.xcprivacy */; };
5858
F1A0B1C23D4E5F6071829302 /* RCTTurboModuleArrayBufferTests.mm in Sources */ = {isa = PBXBuildFile; fileRef = F1A0B1C23D4E5F6071829301 /* RCTTurboModuleArrayBufferTests.mm */; };
59+
D1A60AD11B9247A080734F02 /* RCTDevLoadingViewTests.m in Sources */ = {isa = PBXBuildFile; fileRef = D1A60AD11B9247A080734F01 /* RCTDevLoadingViewTests.m */; };
5960
/* End PBXBuildFile section */
6061

6162
/* Begin PBXContainerItemProxy section */
@@ -163,6 +164,7 @@
163164
E7DB218B22B41FCD005AC45F /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; path = XCTest.framework; sourceTree = DEVELOPER_DIR; };
164165
F0D621C22BBB9E38005960AC /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
165166
F1A0B1C23D4E5F6071829301 /* RCTTurboModuleArrayBufferTests.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; path = RCTTurboModuleArrayBufferTests.mm; sourceTree = "<group>"; };
167+
D1A60AD11B9247A080734F01 /* RCTDevLoadingViewTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RCTDevLoadingViewTests.m; sourceTree = "<group>"; };
166168
/* End PBXFileReference section */
167169

168170
/* Begin PBXFrameworksBuildPhase section */
@@ -328,6 +330,7 @@
328330
E7DB20CA22B2BAA5005AC45F /* RCTConvert_NSURLTests.m */,
329331
E7DB20CE22B2BAA5005AC45F /* RCTConvert_YGValueTests.m */,
330332
383889D923A7398900D06C3E /* RCTConvert_UIColorTests.m */,
333+
D1A60AD11B9247A080734F01 /* RCTDevLoadingViewTests.m */,
331334
E7DB20C822B2BAA5005AC45F /* RCTDevMenuTests.m */,
332335
E7DB20AF22B2BAA4005AC45F /* RCTFontTests.m */,
333336
E7DB20C722B2BAA5005AC45F /* RCTFormatErrorTests.m */,
@@ -770,6 +773,7 @@
770773
E7DB20E722B2BAA6005AC45F /* RCTConvert_NSURLTests.m in Sources */,
771774
E7DB20EA22B2BAA6005AC45F /* RCTImageUtilTests.m in Sources */,
772775
E7DB20D722B2BAA6005AC45F /* RCTModuleInitTests.m in Sources */,
776+
D1A60AD11B9247A080734F02 /* RCTDevLoadingViewTests.m in Sources */,
773777
E7DB20E522B2BAA6005AC45F /* RCTDevMenuTests.m in Sources */,
774778
E7DB20DE22B2BAA6005AC45F /* RCTUnicodeDecodeTests.m in Sources */,
775779
CD10C7A5290BD4EB0033E1ED /* RCTEventEmitterTests.m in Sources */,
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
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+
#import <React/RCTDevLoadingView.h>
9+
#import <React/RCTDevLoadingViewSetEnabled.h>
10+
#import <React/RCTJavaScriptLoader.h>
11+
#import <React/RCTUtils.h>
12+
#import <XCTest/XCTest.h>
13+
14+
#if RCT_DEV_MENU
15+
16+
@interface RCTDevLoadingView (Tests)
17+
- (void)invalidate;
18+
- (BOOL)isRunningInTestEnvironment;
19+
@end
20+
21+
@interface RCTDevLoadingViewForTests : RCTDevLoadingView
22+
@end
23+
24+
@implementation RCTDevLoadingViewForTests
25+
- (BOOL)isRunningInTestEnvironment
26+
{
27+
// Exercise the real display path instead of the XCTest early return.
28+
return NO;
29+
}
30+
@end
31+
32+
@interface RCTDevLoadingViewTests : XCTestCase
33+
@end
34+
35+
@implementation RCTDevLoadingViewTests {
36+
BOOL _wasEnabled;
37+
NSMutableArray<RCTDevLoadingView *> *_loadingViews;
38+
}
39+
40+
- (void)setUp
41+
{
42+
[super setUp];
43+
XCTAssertTrue(RCTIsMainQueue());
44+
_wasEnabled = RCTDevLoadingViewGetEnabled();
45+
RCTDevLoadingViewSetEnabled(YES);
46+
_loadingViews = [NSMutableArray new];
47+
}
48+
49+
- (void)tearDown
50+
{
51+
for (RCTDevLoadingView *loadingView in _loadingViews) {
52+
[loadingView invalidate];
53+
}
54+
[self drainMainQueue];
55+
_loadingViews = nil;
56+
RCTDevLoadingViewSetEnabled(_wasEnabled);
57+
[super tearDown];
58+
}
59+
60+
- (RCTDevLoadingView *)newLoadingView
61+
{
62+
RCTDevLoadingView *loadingView = [RCTDevLoadingViewForTests new];
63+
[_loadingViews addObject:loadingView];
64+
return loadingView;
65+
}
66+
67+
- (void)drainMainQueue
68+
{
69+
XCTestExpectation *drained = [self expectationWithDescription:@"Queued loading messages finished"];
70+
dispatch_async(dispatch_get_main_queue(), ^{
71+
// updateProgress enqueues another display block from its main-queue callback.
72+
dispatch_async(dispatch_get_main_queue(), ^{
73+
[drained fulfill];
74+
});
75+
});
76+
[self waitForExpectations:@[ drained ] timeout:2];
77+
}
78+
79+
- (void)showMessage:(RCTDevLoadingView *)loadingView
80+
{
81+
[loadingView showMessage:@"Loading from Metro…"
82+
color:UIColor.whiteColor
83+
backgroundColor:UIColor.blackColor
84+
dismissButton:YES];
85+
}
86+
87+
- (void)testInvalidateClearsVisibleWindow
88+
{
89+
RCTDevLoadingView *loadingView = [self newLoadingView];
90+
[self showMessage:loadingView];
91+
[self drainMainQueue];
92+
93+
UIWindow *window = [loadingView valueForKey:@"window"];
94+
XCTAssertNotNil(window);
95+
XCTAssertFalse(window.hidden);
96+
XCTAssertNotNil([loadingView valueForKey:@"dismissButton"]);
97+
98+
[loadingView invalidate];
99+
[self drainMainQueue];
100+
101+
XCTAssertTrue(window.hidden);
102+
for (NSString *key in @[ @"window", @"container", @"label", @"dismissButton" ]) {
103+
XCTAssertNil([loadingView valueForKey:key], @"%@ should be released during invalidation", key);
104+
}
105+
}
106+
107+
- (void)testInvalidateCancelsPendingInitialMessage
108+
{
109+
RCTDevLoadingView *loadingView = [self newLoadingView];
110+
[loadingView showWithURL:[NSURL URLWithString:@"http://localhost:8081/index.bundle"]];
111+
dispatch_block_t pendingMessage = [loadingView valueForKey:@"initialMessageBlock"];
112+
XCTAssertNotNil(pendingMessage);
113+
XCTAssertEqual(dispatch_block_testcancel(pendingMessage), 0L);
114+
115+
[loadingView invalidate];
116+
[self drainMainQueue];
117+
118+
XCTAssertNil([loadingView valueForKey:@"initialMessageBlock"]);
119+
XCTAssertNotEqual(dispatch_block_testcancel(pendingMessage), 0L);
120+
}
121+
122+
- (void)testInvalidatePreventsQueuedMessagesFromRecreatingWindow
123+
{
124+
RCTDevLoadingView *messageView = [self newLoadingView];
125+
RCTDevLoadingView *progressView = [self newLoadingView];
126+
RCTLoadingProgress *progress = [RCTLoadingProgress new];
127+
progress.status = @"Downloading";
128+
129+
[self showMessage:messageView];
130+
[progressView updateProgress:progress];
131+
[messageView invalidate];
132+
[progressView invalidate];
133+
[self drainMainQueue];
134+
135+
XCTAssertNil([messageView valueForKey:@"window"]);
136+
XCTAssertNil([progressView valueForKey:@"window"]);
137+
}
138+
139+
@end
140+
141+
#endif

0 commit comments

Comments
 (0)