Skip to content

Commit 5e0fd8e

Browse files
javachemeta-codesync[bot]
authored andcommitted
Add unit tests for RCTInstance eager main-queue module setup (#56919)
Summary: Pull Request resolved: #56919 There was no unit-test coverage for `enableEagerMainQueueModulesOnIOS`, the runtime gate that lets selected native modules initialize on the main thread during React Native init. These characterization tests pin down the behavior end-to-end through the real production path: - With the flag off, `RCTInstanceDelegate.unstableModulesRequiringMainQueueSetup` is never consulted and no eager module is instantiated. - With the flag on, the delegate is consulted exactly once and each returned module name flows through `RCTTurboModuleManager` to a real `-init` on the main queue. - The JS thread that drives `_loadScriptFromSource:` blocks at the `beforeLoad` hook until the eager module setup completes. The bundle-load test runs init on a background queue, gates `FakeEagerModule.init` on a semaphore, and observes the `RCTInstanceDidLoadBundle` notification fired from `afterLoad` — asserting that by the time the gate opens, the module's constructor has fully returned. The tests exercise the real `RCTModuleRegistry` + `RCTTurboModuleManager` path (no subclass shims, no KVC into private ivars). The fixture provides a minimal `FakeEagerModule` conforming to `<RCTBridgeModule, RCTTurboModule>` so it survives TMM's `_shouldCreateObjCModule:` filter when interop is off. Changelog: [Internal] Reviewed By: sammy-SC Differential Revision: D105953816 fbshipit-source-id: 8173ed56225587bfd86de735ead00d4d52685f1d
1 parent 630e376 commit 5e0fd8e

1 file changed

Lines changed: 276 additions & 0 deletions

File tree

Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
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 <XCTest/XCTest.h>
9+
10+
#import <OCMock/OCMock.h>
11+
#import <React/RCTBridgeModule.h>
12+
#import <React/RCTBundleManager.h>
13+
#import <React/RCTJavaScriptLoader.h>
14+
#import <ReactCommon/RCTHermesInstance.h>
15+
#import <ReactCommon/RCTInstance.h>
16+
#import <ReactCommon/RCTTurboModule.h>
17+
#import <ReactCommon/RCTTurboModuleManager.h>
18+
#import <react/featureflags/ReactNativeFeatureFlags.h>
19+
#import <react/featureflags/ReactNativeFeatureFlagsDefaults.h>
20+
21+
using namespace facebook::react;
22+
23+
namespace {
24+
class EagerMainQueueOverride : public ReactNativeFeatureFlagsDefaults {
25+
public:
26+
explicit EagerMainQueueOverride(bool enabled) : enabled_(enabled) {}
27+
bool enableEagerMainQueueModulesOnIOS() override
28+
{
29+
return enabled_;
30+
}
31+
32+
private:
33+
bool enabled_;
34+
};
35+
} // namespace
36+
37+
@interface FakeEagerModule : NSObject <RCTBridgeModule, RCTTurboModule>
38+
@property (class, readonly) NSCountedSet<NSString *> *initCounts;
39+
@property (class, nullable) void (^onInit)(void);
40+
+ (void)reset;
41+
@end
42+
43+
@implementation FakeEagerModule
44+
45+
static NSCountedSet<NSString *> *sInitCounts;
46+
static void (^sOnInit)(void);
47+
48+
+ (NSCountedSet<NSString *> *)initCounts
49+
{
50+
if (sInitCounts == nil) {
51+
sInitCounts = [NSCountedSet new];
52+
}
53+
return sInitCounts;
54+
}
55+
56+
+ (void)setOnInit:(void (^)(void))block
57+
{
58+
sOnInit = [block copy];
59+
}
60+
61+
+ (void (^)(void))onInit
62+
{
63+
return sOnInit;
64+
}
65+
66+
+ (NSString *)moduleName
67+
{
68+
return @"FakeEagerModule";
69+
}
70+
71+
+ (BOOL)requiresMainQueueSetup
72+
{
73+
return YES;
74+
}
75+
76+
+ (void)reset
77+
{
78+
[[FakeEagerModule initCounts] removeAllObjects];
79+
sOnInit = nil;
80+
}
81+
82+
- (instancetype)init
83+
{
84+
if (self = [super init]) {
85+
// Block FIRST, then bump the counter. This way "init has fully completed"
86+
// is observable as count == 1, distinct from "init has been entered".
87+
if (sOnInit) {
88+
sOnInit();
89+
}
90+
@synchronized([FakeEagerModule initCounts]) {
91+
[[FakeEagerModule initCounts] addObject:[[self class] moduleName]];
92+
}
93+
}
94+
return self;
95+
}
96+
97+
- (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:
98+
(const facebook::react::ObjCTurboModule::InitParams &)params
99+
{
100+
return nullptr;
101+
}
102+
103+
@end
104+
105+
@interface RCTInstanceTests : XCTestCase
106+
@end
107+
108+
@implementation RCTInstanceTests {
109+
id<RCTInstanceDelegate> _mockDelegate;
110+
id<RCTTurboModuleManagerDelegate> _mockTMMDelegate;
111+
id _bundleLoadObserver;
112+
}
113+
114+
- (void)setUp
115+
{
116+
[super setUp];
117+
ReactNativeFeatureFlags::dangerouslyReset();
118+
[FakeEagerModule reset];
119+
120+
_mockDelegate = OCMProtocolMock(@protocol(RCTInstanceDelegate));
121+
_mockTMMDelegate = OCMProtocolMock(@protocol(RCTTurboModuleManagerDelegate));
122+
123+
// Match only FakeEagerModule by name; anything else returns nil so unrelated
124+
// lookups (RCTDevMenu, RCTImageLoader, ...) don't accidentally instantiate it.
125+
OCMStub([_mockTMMDelegate getModuleClassFromName:nullptr]).ignoringNonObjectArgs().andDo(^(NSInvocation *invocation) {
126+
const char *requestedName = nullptr;
127+
[invocation getArgument:&requestedName atIndex:2];
128+
Class result =
129+
(requestedName != nullptr && strcmp(requestedName, "FakeEagerModule") == 0) ? [FakeEagerModule class] : Nil;
130+
[invocation setReturnValue:&result];
131+
});
132+
}
133+
134+
- (void)tearDown
135+
{
136+
if (_bundleLoadObserver != nil) {
137+
[[NSNotificationCenter defaultCenter] removeObserver:_bundleLoadObserver];
138+
_bundleLoadObserver = nil;
139+
}
140+
ReactNativeFeatureFlags::dangerouslyReset();
141+
[FakeEagerModule reset];
142+
_mockDelegate = nil;
143+
_mockTMMDelegate = nil;
144+
[super tearDown];
145+
}
146+
147+
- (RCTInstance *)makeInstance
148+
{
149+
RCTBundleManager *bundleManager = [RCTBundleManager new];
150+
// bundleURL asserts the bridgeless getter is non-nil; without it the NSException
151+
// unwinds the JS-thread init callback before reaching _loadJSBundle:.
152+
NSURL * (^urlGetter)(void) = ^{
153+
return [NSURL URLWithString:@"file:///empty.bundle"];
154+
};
155+
[bundleManager setBridgelessBundleURLGetter:urlGetter
156+
andSetter:^(NSURL *_) {
157+
}
158+
andDefaultGetter:urlGetter];
159+
return [[RCTInstance alloc] initWithDelegate:_mockDelegate
160+
jsRuntimeFactory:std::make_shared<RCTHermesInstance>()
161+
bundleManager:bundleManager
162+
turboModuleManagerDelegate:_mockTMMDelegate
163+
moduleRegistry:[RCTModuleRegistry new]
164+
parentInspectorTarget:nullptr
165+
launchOptions:nil];
166+
}
167+
168+
- (void)testFlagOff_doesNotConsultDelegate
169+
{
170+
OCMReject([_mockDelegate unstableModulesRequiringMainQueueSetup]);
171+
172+
RCTInstance *instance = [self makeInstance];
173+
174+
XCTAssertEqual([FakeEagerModule.initCounts countForObject:FakeEagerModule.moduleName], 0u);
175+
176+
[instance invalidate];
177+
}
178+
179+
- (void)testFlagOn_consultsDelegateExactlyOnce
180+
{
181+
ReactNativeFeatureFlags::override(std::make_unique<EagerMainQueueOverride>(true));
182+
OCMStub([_mockDelegate unstableModulesRequiringMainQueueSetup]).andReturn(@[]);
183+
184+
RCTInstance *instance = [self makeInstance];
185+
186+
OCMVerify(OCMTimes(1), [_mockDelegate unstableModulesRequiringMainQueueSetup]);
187+
188+
[instance invalidate];
189+
}
190+
191+
- (void)testFlagOn_instantiatesRequestedModuleOnMainQueue
192+
{
193+
ReactNativeFeatureFlags::override(std::make_unique<EagerMainQueueOverride>(true));
194+
OCMStub([_mockDelegate unstableModulesRequiringMainQueueSetup]).andReturn(@[ FakeEagerModule.moduleName ]);
195+
196+
RCTInstance *instance = [self makeInstance];
197+
198+
XCTAssertEqual(
199+
[FakeEagerModule.initCounts countForObject:FakeEagerModule.moduleName],
200+
1u,
201+
@"FakeEagerModule should have been constructed exactly once during eager setup");
202+
203+
[instance invalidate];
204+
}
205+
206+
- (void)testFlagOn_bundleLoadAwaitsMainQueueModuleSetup
207+
{
208+
ReactNativeFeatureFlags::override(std::make_unique<EagerMainQueueOverride>(true));
209+
OCMStub([_mockDelegate unstableModulesRequiringMainQueueSetup]).andReturn(@[ FakeEagerModule.moduleName ]);
210+
211+
// Hand the instance an empty bundle the moment it asks for one. The JS thread
212+
// will then drive _loadScriptFromSource: which invokes the wait block before
213+
// evaluating the script. RCTInstance posts RCTInstanceDidLoadBundle from the
214+
// `afterLoad` lambda, i.e. after the wait block has returned.
215+
RCTSource *emptySource = OCMClassMock([RCTSource class]);
216+
OCMStub([emptySource url]).andReturn([NSURL URLWithString:@"file:///empty.bundle"]);
217+
OCMStub([emptySource data]).andReturn([NSData data]);
218+
OCMStub([_mockDelegate loadBundleAtURL:[OCMArg any] onProgress:[OCMArg any] onComplete:[OCMArg any]])
219+
.andDo(^(NSInvocation *invocation) {
220+
__unsafe_unretained RCTSourceLoadBlock onComplete;
221+
[invocation getArgument:&onComplete atIndex:4];
222+
onComplete(nil, emptySource);
223+
});
224+
225+
dispatch_semaphore_t initEntered = dispatch_semaphore_create(0);
226+
dispatch_semaphore_t releaseInit = dispatch_semaphore_create(0);
227+
228+
FakeEagerModule.onInit = ^{
229+
dispatch_semaphore_signal(initEntered);
230+
dispatch_semaphore_wait(releaseInit, DISPATCH_TIME_FOREVER);
231+
};
232+
233+
XCTestExpectation *bundleLoaded = [self expectationWithDescription:@"RCTInstanceDidLoadBundle"];
234+
__block NSUInteger initCountAtBundleLoad = 0;
235+
_bundleLoadObserver = [[NSNotificationCenter defaultCenter]
236+
addObserverForName:@"RCTInstanceDidLoadBundle"
237+
object:nil
238+
queue:nil
239+
usingBlock:^(NSNotification *_) {
240+
initCountAtBundleLoad = [FakeEagerModule.initCounts countForObject:FakeEagerModule.moduleName];
241+
[bundleLoaded fulfill];
242+
}];
243+
244+
XCTestExpectation *done = [self expectationWithDescription:@"orchestration complete"];
245+
__block RCTInstance *instance = nil;
246+
247+
// Orchestration runs off-main so the main thread is free to service the
248+
// RCTExecuteOnMainQueue block from _start, where FakeEagerModule.init runs.
249+
dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{
250+
instance = [self makeInstance];
251+
252+
XCTAssertEqual(
253+
dispatch_semaphore_wait(initEntered, dispatch_time(DISPATCH_TIME_NOW, 5 * NSEC_PER_SEC)),
254+
0,
255+
@"FakeEagerModule -init did not run on main queue within timeout");
256+
257+
// Releasing here unblocks main-queue setup, which signals the wait block,
258+
// which lets the JS thread proceed past `beforeLoad` and post the bundle
259+
// notification.
260+
dispatch_semaphore_signal(releaseInit);
261+
262+
[done fulfill];
263+
});
264+
265+
// Invalidate AFTER the notification fires; otherwise `_valid = false` makes
266+
// _loadScriptFromSource: short-circuit and `afterLoad` never runs.
267+
[self waitForExpectations:@[ bundleLoaded, done ] timeout:10.0];
268+
[instance invalidate];
269+
270+
XCTAssertEqual(
271+
initCountAtBundleLoad,
272+
1u,
273+
@"FakeEagerModule -init must have fully returned before the JS bundle finishes loading");
274+
}
275+
276+
@end

0 commit comments

Comments
 (0)