From 48206fe0d6d745c8eeb4462dc27cfde235c4ea10 Mon Sep 17 00:00:00 2001 From: "jh.xiong" Date: Sat, 17 Oct 2026 16:51:15 +0800 Subject: [PATCH] [iOS] Clear DevLoadingView when its host is invalidated --- .../React/CoreModules/RCTDevLoadingView.mm | 22 ++- .../RNTesterPods.xcodeproj/project.pbxproj | 4 + .../RCTDevLoadingViewTests.m | 141 ++++++++++++++++++ 3 files changed, 166 insertions(+), 1 deletion(-) create mode 100644 packages/rn-tester/RNTesterUnitTests/RCTDevLoadingViewTests.m diff --git a/packages/react-native/React/CoreModules/RCTDevLoadingView.mm b/packages/react-native/React/CoreModules/RCTDevLoadingView.mm index 94a22481935e..100dc3ce064e 100644 --- a/packages/react-native/React/CoreModules/RCTDevLoadingView.mm +++ b/packages/react-native/React/CoreModules/RCTDevLoadingView.mm @@ -35,6 +35,7 @@ @implementation RCTDevLoadingView { UIButton *_dismissButton; NSDate *_showDate; BOOL _hiding; + BOOL _invalidated; dispatch_block_t _initialMessageBlock; } @@ -59,6 +60,20 @@ - (instancetype)init return self; } +- (void)invalidate +{ + RCTExecuteOnMainQueue(^{ + self->_invalidated = YES; + // The delayed message retains this module, so dealloc cannot cancel it when the host is invalidated. + [self clearInitialMessageDelay]; + self->_window.hidden = YES; + self->_window = nil; + self->_container = nil; + self->_label = nil; + self->_dismissButton = nil; + }); +} + - (void)dealloc { [self clearInitialMessageDelay]; @@ -127,7 +142,7 @@ - (void)showMessage:(NSString *)message } dispatch_async(dispatch_get_main_queue(), ^{ - if (RCTRunningInTestEnvironment()) { + if (self->_invalidated || [self isRunningInTestEnvironment]) { return; } @@ -324,6 +339,11 @@ - (void)showOfflineMessage [self showMessage:message color:color backgroundColor:backgroundColor dismissButton:false]; } +- (BOOL)isRunningInTestEnvironment +{ + return RCTRunningInTestEnvironment(); +} + - (BOOL)isDarkModeEnabled { // We pass nil here to match the behavior of the native module. diff --git a/packages/rn-tester/RNTesterPods.xcodeproj/project.pbxproj b/packages/rn-tester/RNTesterPods.xcodeproj/project.pbxproj index 76c55b4f55e5..725e8a288236 100644 --- a/packages/rn-tester/RNTesterPods.xcodeproj/project.pbxproj +++ b/packages/rn-tester/RNTesterPods.xcodeproj/project.pbxproj @@ -56,6 +56,7 @@ E7DB218C22B41FCD005AC45F /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E7DB218B22B41FCD005AC45F /* XCTest.framework */; }; F0D621C32BBB9E38005960AC /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = F0D621C22BBB9E38005960AC /* PrivacyInfo.xcprivacy */; }; F1A0B1C23D4E5F6071829302 /* RCTTurboModuleArrayBufferTests.mm in Sources */ = {isa = PBXBuildFile; fileRef = F1A0B1C23D4E5F6071829301 /* RCTTurboModuleArrayBufferTests.mm */; }; + D1A60AD11B9247A080734F02 /* RCTDevLoadingViewTests.m in Sources */ = {isa = PBXBuildFile; fileRef = D1A60AD11B9247A080734F01 /* RCTDevLoadingViewTests.m */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -163,6 +164,7 @@ E7DB218B22B41FCD005AC45F /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; path = XCTest.framework; sourceTree = DEVELOPER_DIR; }; F0D621C22BBB9E38005960AC /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; F1A0B1C23D4E5F6071829301 /* RCTTurboModuleArrayBufferTests.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; path = RCTTurboModuleArrayBufferTests.mm; sourceTree = ""; }; + D1A60AD11B9247A080734F01 /* RCTDevLoadingViewTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RCTDevLoadingViewTests.m; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -328,6 +330,7 @@ E7DB20CA22B2BAA5005AC45F /* RCTConvert_NSURLTests.m */, E7DB20CE22B2BAA5005AC45F /* RCTConvert_YGValueTests.m */, 383889D923A7398900D06C3E /* RCTConvert_UIColorTests.m */, + D1A60AD11B9247A080734F01 /* RCTDevLoadingViewTests.m */, E7DB20C822B2BAA5005AC45F /* RCTDevMenuTests.m */, E7DB20AF22B2BAA4005AC45F /* RCTFontTests.m */, E7DB20C722B2BAA5005AC45F /* RCTFormatErrorTests.m */, @@ -770,6 +773,7 @@ E7DB20E722B2BAA6005AC45F /* RCTConvert_NSURLTests.m in Sources */, E7DB20EA22B2BAA6005AC45F /* RCTImageUtilTests.m in Sources */, E7DB20D722B2BAA6005AC45F /* RCTModuleInitTests.m in Sources */, + D1A60AD11B9247A080734F02 /* RCTDevLoadingViewTests.m in Sources */, E7DB20E522B2BAA6005AC45F /* RCTDevMenuTests.m in Sources */, E7DB20DE22B2BAA6005AC45F /* RCTUnicodeDecodeTests.m in Sources */, CD10C7A5290BD4EB0033E1ED /* RCTEventEmitterTests.m in Sources */, diff --git a/packages/rn-tester/RNTesterUnitTests/RCTDevLoadingViewTests.m b/packages/rn-tester/RNTesterUnitTests/RCTDevLoadingViewTests.m new file mode 100644 index 000000000000..73e217893cc8 --- /dev/null +++ b/packages/rn-tester/RNTesterUnitTests/RCTDevLoadingViewTests.m @@ -0,0 +1,141 @@ +/* + * 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. + */ + +#import +#import +#import +#import +#import + +#if RCT_DEV_MENU + +@interface RCTDevLoadingView (Tests) +- (void)invalidate; +- (BOOL)isRunningInTestEnvironment; +@end + +@interface RCTDevLoadingViewForTests : RCTDevLoadingView +@end + +@implementation RCTDevLoadingViewForTests +- (BOOL)isRunningInTestEnvironment +{ + // Exercise the real display path instead of the XCTest early return. + return NO; +} +@end + +@interface RCTDevLoadingViewTests : XCTestCase +@end + +@implementation RCTDevLoadingViewTests { + BOOL _wasEnabled; + NSMutableArray *_loadingViews; +} + +- (void)setUp +{ + [super setUp]; + XCTAssertTrue(RCTIsMainQueue()); + _wasEnabled = RCTDevLoadingViewGetEnabled(); + RCTDevLoadingViewSetEnabled(YES); + _loadingViews = [NSMutableArray new]; +} + +- (void)tearDown +{ + for (RCTDevLoadingView *loadingView in _loadingViews) { + [loadingView invalidate]; + } + [self drainMainQueue]; + _loadingViews = nil; + RCTDevLoadingViewSetEnabled(_wasEnabled); + [super tearDown]; +} + +- (RCTDevLoadingView *)newLoadingView +{ + RCTDevLoadingView *loadingView = [RCTDevLoadingViewForTests new]; + [_loadingViews addObject:loadingView]; + return loadingView; +} + +- (void)drainMainQueue +{ + XCTestExpectation *drained = [self expectationWithDescription:@"Queued loading messages finished"]; + dispatch_async(dispatch_get_main_queue(), ^{ + // updateProgress enqueues another display block from its main-queue callback. + dispatch_async(dispatch_get_main_queue(), ^{ + [drained fulfill]; + }); + }); + [self waitForExpectations:@[ drained ] timeout:2]; +} + +- (void)showMessage:(RCTDevLoadingView *)loadingView +{ + [loadingView showMessage:@"Loading from Metro…" + color:UIColor.whiteColor + backgroundColor:UIColor.blackColor + dismissButton:YES]; +} + +- (void)testInvalidateClearsVisibleWindow +{ + RCTDevLoadingView *loadingView = [self newLoadingView]; + [self showMessage:loadingView]; + [self drainMainQueue]; + + UIWindow *window = [loadingView valueForKey:@"window"]; + XCTAssertNotNil(window); + XCTAssertFalse(window.hidden); + XCTAssertNotNil([loadingView valueForKey:@"dismissButton"]); + + [loadingView invalidate]; + [self drainMainQueue]; + + XCTAssertTrue(window.hidden); + for (NSString *key in @[ @"window", @"container", @"label", @"dismissButton" ]) { + XCTAssertNil([loadingView valueForKey:key], @"%@ should be released during invalidation", key); + } +} + +- (void)testInvalidateCancelsPendingInitialMessage +{ + RCTDevLoadingView *loadingView = [self newLoadingView]; + [loadingView showWithURL:[NSURL URLWithString:@"http://localhost:8081/index.bundle"]]; + dispatch_block_t pendingMessage = [loadingView valueForKey:@"initialMessageBlock"]; + XCTAssertNotNil(pendingMessage); + XCTAssertEqual(dispatch_block_testcancel(pendingMessage), 0L); + + [loadingView invalidate]; + [self drainMainQueue]; + + XCTAssertNil([loadingView valueForKey:@"initialMessageBlock"]); + XCTAssertNotEqual(dispatch_block_testcancel(pendingMessage), 0L); +} + +- (void)testInvalidatePreventsQueuedMessagesFromRecreatingWindow +{ + RCTDevLoadingView *messageView = [self newLoadingView]; + RCTDevLoadingView *progressView = [self newLoadingView]; + RCTLoadingProgress *progress = [RCTLoadingProgress new]; + progress.status = @"Downloading"; + + [self showMessage:messageView]; + [progressView updateProgress:progress]; + [messageView invalidate]; + [progressView invalidate]; + [self drainMainQueue]; + + XCTAssertNil([messageView valueForKey:@"window"]); + XCTAssertNil([progressView valueForKey:@"window"]); +} + +@end + +#endif