Skip to content

Commit c2af8ff

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 c2af8ff

2 files changed

Lines changed: 117 additions & 0 deletions

File tree

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

Lines changed: 107 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,15 @@
2224

2325
@interface RCTTestTurboModule : NSObject <RCTBridgeModule>
2426

27+
// Deliberately not exported with RCT_EXPORT_METHOD: TurboModules dispatch through the codegen'd
28+
// `...SpecJSI`, so many of them carry no `__rct_export__` metadata and `getArgumentTypeName` returns
29+
// nil for their arguments.
30+
- (void)testMethodWhichTakesStringWithoutExportMacro:(NSString *)string;
31+
32+
// Mirrors -[RCTAnalytics logEvent:data:analyticsModule:] in its post-codemod, unexported form: a
33+
// trailing nullable object argument that JS always passes as null. See T286617647.
34+
- (void)logEvent:(NSString *)eventName data:(NSDictionary *)data analyticsModule:(nullable NSString *)analyticsModule;
35+
2536
@end
2637

2738
@implementation RCTTestTurboModule
@@ -30,8 +41,24 @@ @implementation RCTTestTurboModule
3041

3142
RCT_EXPORT_METHOD(testMethodWhichTakesObject : (id)object) {}
3243

44+
- (void)testMethodWhichTakesStringWithoutExportMacro:(NSString *)string
45+
{
46+
}
47+
48+
- (void)logEvent:(NSString *)eventName data:(NSDictionary *)data analyticsModule:(nullable NSString *)analyticsModule
49+
{
50+
}
51+
3352
@end
3453

54+
class ReactNativeFeatureFlagsNSNullConversionEnabled : public ReactNativeFeatureFlagsDefaults {
55+
public:
56+
bool enableModuleArgumentNSNullConversionIOS() override
57+
{
58+
return true;
59+
}
60+
};
61+
3562
// Minimal concrete MutableBuffer that owns its bytes, used to observe lifetime.
3663
class TestMutableBuffer : public facebook::jsi::MutableBuffer {
3764
public:
@@ -122,6 +149,8 @@ - (void)tearDown
122149
module_ = nullptr;
123150
instance_ = nil;
124151

152+
ReactNativeFeatureFlags::dangerouslyReset();
153+
125154
[super tearDown];
126155
}
127156

@@ -159,6 +188,84 @@ - (void)testInvokeTurboModuleWithNull
159188
OCMVerify(OCMTimes(1), [instance_ testMethodWhichTakesObject:nil]);
160189
}
161190

191+
// A JS `null` argument must arrive as `nil` even when the method carries no `__rct_export__`
192+
// metadata, so that nullability checks in the receiver behave. `NSNull` is truthy and does not
193+
// respond to most NSString/NSDictionary selectors, so leaking it crashes the callee.
194+
- (void)testInvokeUnexportedTurboModuleMethodWithNullPassesNil
195+
{
196+
ReactNativeFeatureFlags::dangerouslyForceOverride(std::make_unique<ReactNativeFeatureFlagsNSNullConversionEnabled>());
197+
198+
auto hermesRuntime = facebook::hermes::makeHermesRuntime();
199+
facebook::jsi::Runtime *rt = hermesRuntime.get();
200+
201+
std::array<facebook::jsi::Value, 1> args = {facebook::jsi::Value::null()};
202+
module_->invokeObjCMethod(
203+
*rt,
204+
VoidKind,
205+
"testMethodWhichTakesStringWithoutExportMacro",
206+
@selector(testMethodWhichTakesStringWithoutExportMacro:),
207+
args.data(),
208+
args.size());
209+
210+
OCMVerify(OCMTimes(1), [instance_ testMethodWhichTakesStringWithoutExportMacro:nil]);
211+
OCMVerify(OCMNever(), [instance_ testMethodWhichTakesStringWithoutExportMacro:(id)kCFNull]);
212+
}
213+
214+
// The shape that actually crashed in T286617647: JS calls `Analytics.logEvent(name, data, null)`, so
215+
// the null lands on a trailing nullable object argument of an unexported method. `NSNull` is truthy,
216+
// so -[RCTAnalytics logEvent:data:analyticsModule:]'s `analyticsModule ? analyticsModule : @""`
217+
// fallback forwarded it to FBAnalyticsMergeStructuredLogEventMetadata, which sent it -mutableCopy.
218+
- (void)testInvokeUnexportedTurboModuleMethodWithNullTrailingArgumentPassesNil
219+
{
220+
ReactNativeFeatureFlags::dangerouslyForceOverride(std::make_unique<ReactNativeFeatureFlagsNSNullConversionEnabled>());
221+
222+
auto hermesRuntime = facebook::hermes::makeHermesRuntime();
223+
facebook::jsi::Runtime *rt = hermesRuntime.get();
224+
225+
__block id capturedAnalyticsModule = (id)kCFNull;
226+
OCMStub([instance_ logEvent:OCMOCK_ANY
227+
data:OCMOCK_ANY
228+
analyticsModule:[OCMArg checkWithBlock:^BOOL(id value) {
229+
capturedAnalyticsModule = value;
230+
return YES;
231+
}]]);
232+
233+
std::array<facebook::jsi::Value, 3> args = {
234+
facebook::jsi::String::createFromAscii(*rt, "some_event"),
235+
facebook::jsi::Object(*rt),
236+
facebook::jsi::Value::null()};
237+
args[1].asObject(*rt).setProperty(*rt, "key", "value");
238+
239+
module_->invokeObjCMethod(
240+
*rt, VoidKind, "logEvent", @selector(logEvent:data:analyticsModule:), args.data(), args.size());
241+
242+
OCMVerify(OCMTimes(1), [instance_ logEvent:@"some_event" data:@{@"key" : @"value"} analyticsModule:nil]);
243+
XCTAssertNil(capturedAnalyticsModule);
244+
245+
// The operation that threw once NSNull reached the module.
246+
NSString *analyticsModule = capturedAnalyticsModule ? capturedAnalyticsModule : @"";
247+
XCTAssertNoThrow([analyticsModule mutableCopy]);
248+
}
249+
250+
// The counterpart to the two cases above: scrubbing a null in argument position must not scrub nulls
251+
// *nested* inside a collection argument, which is the behaviour the flag exists to introduce. The
252+
// flag-enabled branch of testInvokeTurboModuleWithNull never runs while the flag defaults to false,
253+
// so this is what actually pins the nested behaviour the fix has to leave alone.
254+
- (void)testInvokeTurboModuleKeepsNestedNullAsNSNullWhenFlagEnabled
255+
{
256+
ReactNativeFeatureFlags::dangerouslyForceOverride(std::make_unique<ReactNativeFeatureFlagsNSNullConversionEnabled>());
257+
258+
auto hermesRuntime = facebook::hermes::makeHermesRuntime();
259+
facebook::jsi::Runtime *rt = hermesRuntime.get();
260+
261+
std::array<facebook::jsi::Value, 1> args = {facebook::jsi::Object(*rt)};
262+
args[0].asObject(*rt).setProperty(*rt, "foo", facebook::jsi::Value::null());
263+
module_->invokeObjCMethod(
264+
*rt, VoidKind, "testMethodWhichTakesObject", @selector(testMethodWhichTakesObject:), args.data(), args.size());
265+
266+
OCMVerify(OCMTimes(1), [instance_ testMethodWhichTakesObject:@{@"foo" : (id)kCFNull}]);
267+
}
268+
162269
// A native-backed ArrayBuffer is aliased rather than copied, and the RCTArrayBuffer retains
163270
// the backing MutableBuffer, so the alias outlives the JS object.
164271
- (void)testNativeBackedArrayBufferIsAliasedAndKeepsBackingStoreAlive

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

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

0 commit comments

Comments
 (0)