Skip to content

Pass a top-level JS null TurboModule arg to ObjC as nil, not NSNull (#58190) - #58190

Open
christophpurrer wants to merge 1 commit into
react:mainfrom
christophpurrer:export-D117960577
Open

Pass a top-level JS null TurboModule arg to ObjC as nil, not NSNull (#58190)#58190
christophpurrer wants to merge 1 commit into
react:mainfrom
christophpurrer:export-D117960577

Conversation

@christophpurrer

@christophpurrer christophpurrer commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

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 nilNSNull 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

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Aug 29, 2026
@meta-codesync

meta-codesync Bot commented Aug 29, 2026

Copy link
Copy Markdown

@christophpurrer has exported this pull request. If you are a Meta employee, you can view the originating Diff in D117960577.

@meta-codesync meta-codesync Bot changed the title Pass a top-level JS null TurboModule arg to ObjC as nil, not NSNull Pass a top-level JS null TurboModule arg to ObjC as nil, not NSNull (#58190) Aug 29, 2026
christophpurrer added a commit to christophpurrer/react-native-macos that referenced this pull request Aug 29, 2026
…eact#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
christophpurrer added a commit to christophpurrer/react-native-macos that referenced this pull request Aug 29, 2026
…eact#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
…eact#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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. meta-exported p: Facebook Partner: Facebook Partner

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant