Skip to content

Commit 0fffca4

Browse files
Pass a top-level JS null TurboModule arg to ObjC as nil, not NSNull (#58190)
Summary: Changelog: [iOS][Fixed] - Pass a top-level JS `null` TurboModule argument to Objective-C as `nil` instead of `NSNull` when `enableModuleArgumentNSNullConversionIOS` is enabled When `enableModuleArgumentNSNullConversionIOS` is on, `convertJSIValueToObjCObject` maps a JS `null` to `(id)kCFNull`. That is the intended behaviour for nulls *nested* inside arrays and dictionaries, but a `null` in **argument position** must still reach Objective-C as `nil` — `NSNull` is truthy and does not respond to the selectors the receiver expects, so leaking it crashes the callee. The guard that enforced this lived three branches deep in `ObjCTurboModule::setInvocationArg`, reachable only when all of the following held: - `objCArgType == encode(id)`, and - `getArgumentTypeName(...)` returned non-nil, and - `RCTConvert` responded to a selector named after that type. `getArgumentTypeName` resolves the argument type by scanning for `__rct_export__`-prefixed selectors, which the compiler only emits for methods declared with `RCT_EXPORT_METHOD`. Any TurboModule method without that macro — or with an `id`-typed argument, since `[RCTConvert respondsToSelector:selector(id:)]` is `NO` — silently skipped the guard and received `NSNull`. This diff hoists the check to immediately after the conversion, so it applies to every argument regardless of the method's `__rct_export__` metadata, its ObjC type encoding, or whether an `RCTConvert` converter exists. Returning without calling `setArgument:` leaves the `NSInvocation` slot zeroed, i.e. `nil` — identical to what the old guard did. Behaviour is unchanged when the flag is off: the check short-circuits on the flag. Nested `NSNull` inside arrays and dictionaries is untouched, as asserted by the new `testInvokeTurboModuleKeepsNestedNullAsNSNullWhenFlagEnabled` case. (The flag-enabled branch of the pre-existing `testInvokeTurboModuleWithNull` case never executes while the flag defaults to `false`, so it did not cover this.) The pre-existing check inside the `RCTConvert` branch is left in place. It is now effectively unreachable — `objCArg == kCFNull` is the only way `convertedObjCArg` can be `kCFNull`, because every `RCTConvert` converter either returns a non-`kCFNull` input unchanged or builds a new object — but it costs nothing and keeps the diff narrow. Differential Revision: D117960577
1 parent c6b137c commit 0fffca4

2 files changed

Lines changed: 101 additions & 0 deletions

File tree

packages/react-native/ReactCommon/react/nativemodule/core/iostests/RCTTurboModuleTests.mm

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@
1212
#import <hermes/hermes.h>
1313
#import <jsi/decorator.h>
1414
#import <react/featureflags/ReactNativeFeatureFlags.h>
15+
#import <react/featureflags/ReactNativeFeatureFlagsDefaults.h>
1516

17+
#import <array>
1618
#import <memory>
1719
#import <vector>
1820

@@ -22,6 +24,12 @@
2224

2325
@interface RCTTestTurboModule : NSObject <RCTBridgeModule>
2426

27+
// Deliberately not exported with RCT_EXPORT_METHOD: without `__rct_export__` metadata,
28+
// `getArgumentTypeName` returns nil for the arguments.
29+
- (void)testMethodWhichTakesStringWithoutExportMacro:(NSString *)string;
30+
31+
- (void)logEvent:(NSString *)eventName data:(NSDictionary *)data analyticsModule:(nullable NSString *)analyticsModule;
32+
2533
@end
2634

2735
@implementation RCTTestTurboModule
@@ -30,8 +38,24 @@ @implementation RCTTestTurboModule
3038

3139
RCT_EXPORT_METHOD(testMethodWhichTakesObject : (id)object) {}
3240

41+
- (void)testMethodWhichTakesStringWithoutExportMacro:(NSString *)string
42+
{
43+
}
44+
45+
- (void)logEvent:(NSString *)eventName data:(NSDictionary *)data analyticsModule:(nullable NSString *)analyticsModule
46+
{
47+
}
48+
3349
@end
3450

51+
class ReactNativeFeatureFlagsNSNullConversionEnabled : public ReactNativeFeatureFlagsDefaults {
52+
public:
53+
bool enableModuleArgumentNSNullConversionIOS() override
54+
{
55+
return true;
56+
}
57+
};
58+
3559
// Minimal concrete MutableBuffer that owns its bytes, used to observe lifetime.
3660
class TestMutableBuffer : public facebook::jsi::MutableBuffer {
3761
public:
@@ -122,6 +146,8 @@ - (void)tearDown
122146
module_ = nullptr;
123147
instance_ = nil;
124148

149+
ReactNativeFeatureFlags::dangerouslyReset();
150+
125151
[super tearDown];
126152
}
127153

@@ -159,6 +185,74 @@ - (void)testInvokeTurboModuleWithNull
159185
OCMVerify(OCMTimes(1), [instance_ testMethodWhichTakesObject:nil]);
160186
}
161187

188+
- (void)testInvokeUnexportedTurboModuleMethodWithNullPassesNil
189+
{
190+
ReactNativeFeatureFlags::dangerouslyForceOverride(std::make_unique<ReactNativeFeatureFlagsNSNullConversionEnabled>());
191+
192+
auto hermesRuntime = facebook::hermes::makeHermesRuntime();
193+
facebook::jsi::Runtime *rt = hermesRuntime.get();
194+
195+
std::array<facebook::jsi::Value, 1> args = {facebook::jsi::Value::null()};
196+
module_->invokeObjCMethod(
197+
*rt,
198+
VoidKind,
199+
"testMethodWhichTakesStringWithoutExportMacro",
200+
@selector(testMethodWhichTakesStringWithoutExportMacro:),
201+
args.data(),
202+
args.size());
203+
204+
OCMVerify(OCMTimes(1), [instance_ testMethodWhichTakesStringWithoutExportMacro:nil]);
205+
OCMVerify(OCMNever(), [instance_ testMethodWhichTakesStringWithoutExportMacro:(id)kCFNull]);
206+
}
207+
208+
- (void)testInvokeUnexportedTurboModuleMethodWithNullTrailingArgumentPassesNil
209+
{
210+
ReactNativeFeatureFlags::dangerouslyForceOverride(std::make_unique<ReactNativeFeatureFlagsNSNullConversionEnabled>());
211+
212+
auto hermesRuntime = facebook::hermes::makeHermesRuntime();
213+
facebook::jsi::Runtime *rt = hermesRuntime.get();
214+
215+
__block id capturedAnalyticsModule = (id)kCFNull;
216+
OCMStub([instance_ logEvent:OCMOCK_ANY
217+
data:OCMOCK_ANY
218+
analyticsModule:[OCMArg checkWithBlock:^BOOL(id value) {
219+
capturedAnalyticsModule = value;
220+
return YES;
221+
}]]);
222+
223+
std::array<facebook::jsi::Value, 3> args = {
224+
facebook::jsi::String::createFromAscii(*rt, "some_event"),
225+
facebook::jsi::Object(*rt),
226+
facebook::jsi::Value::null()};
227+
args[1].asObject(*rt).setProperty(*rt, "key", "value");
228+
229+
module_->invokeObjCMethod(
230+
*rt, VoidKind, "logEvent", @selector(logEvent:data:analyticsModule:), args.data(), args.size());
231+
232+
OCMVerify(OCMTimes(1), [instance_ logEvent:@"some_event" data:@{@"key" : @"value"} analyticsModule:nil]);
233+
XCTAssertNil(capturedAnalyticsModule);
234+
235+
// `NSNull` is truthy, so this fallback would forward it and throw on -mutableCopy.
236+
NSString *analyticsModule = capturedAnalyticsModule ? capturedAnalyticsModule : @"";
237+
XCTAssertNoThrow([analyticsModule mutableCopy]);
238+
}
239+
240+
// Scrubbing a null in argument position must not scrub nulls nested inside a collection argument.
241+
- (void)testInvokeTurboModuleKeepsNestedNullAsNSNullWhenFlagEnabled
242+
{
243+
ReactNativeFeatureFlags::dangerouslyForceOverride(std::make_unique<ReactNativeFeatureFlagsNSNullConversionEnabled>());
244+
245+
auto hermesRuntime = facebook::hermes::makeHermesRuntime();
246+
facebook::jsi::Runtime *rt = hermesRuntime.get();
247+
248+
std::array<facebook::jsi::Value, 1> args = {facebook::jsi::Object(*rt)};
249+
args[0].asObject(*rt).setProperty(*rt, "foo", facebook::jsi::Value::null());
250+
module_->invokeObjCMethod(
251+
*rt, VoidKind, "testMethodWhichTakesObject", @selector(testMethodWhichTakesObject:), args.data(), args.size());
252+
253+
OCMVerify(OCMTimes(1), [instance_ testMethodWhichTakesObject:@{@"foo" : (id)kCFNull}]);
254+
}
255+
162256
// A native-backed ArrayBuffer is aliased rather than copied, and the RCTArrayBuffer retains
163257
// the backing MutableBuffer, so the alias outlives the JS object.
164258
- (void)testNativeBackedArrayBufferIsAliasedAndKeepsBackingStoreAlive

packages/react-native/ReactCommon/react/nativemodule/core/platform/ios/ReactCommon/RCTTurboModule.mm

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -727,6 +727,13 @@ TraceSection s(
727727
BOOL enableModuleArgumentNSNullConversionIOS = ReactNativeFeatureFlags::enableModuleArgumentNSNullConversionIOS();
728728
id objCArg =
729729
convertJSIValueToObjCObject(runtime, arg, jsInvoker_, enableModuleArgumentNSNullConversionIOS, mustCopyBytes);
730+
731+
// A JS `null` in argument position must reach ObjC as `nil`; only nulls nested inside arrays and
732+
// dictionaries are preserved as `kCFNull`. Skipping `setArgument:` leaves the slot zeroed.
733+
if (enableModuleArgumentNSNullConversionIOS && objCArg == (id)kCFNull) {
734+
return;
735+
}
736+
730737
if (objCArg != nullptr) {
731738
NSString *methodNameNSString = @(methodName);
732739

0 commit comments

Comments
 (0)