Skip to content

fix(nitrogen): keep | undefined on array elements and Record values - #1478

Open
giaBaoJS wants to merge 1 commit into
mrousavy:mainfrom
giaBaoJS:fix/nitrogen-optional-array-record-elements
Open

fix(nitrogen): keep | undefined on array elements and Record values#1478
giaBaoJS wants to merge 1 commit into
mrousavy:mainfrom
giaBaoJS:fix/nitrogen-optional-array-record-elements

Conversation

@giaBaoJS

Copy link
Copy Markdown

Fixes #1202

Builds on the repro from #1201 by @chrispader — thanks for pinning it down. That PR added the spec + red CI; this one adds the fix and distills the repro to a single method on an existing spec, per CONTRIBUTING.

Root cause

createType(...) threads optionality through every nested type position — except two.

position how isOptional is computed file
parameter hasQuestionToken() || isOptional() || type.isNullable() Parameter.ts:28
tuple element t.isNullable() createType.ts:277
struct property prop.isOptional() || propType.isNullable() getInterfaceProperties.ts:35
function return / promise resolution .getUnionTypes().some(t => t.isUndefined()) createType.ts:283, :299
array element hardcoded false createType.ts:272
Record<K, V> value hardcoded false createType.ts:311

With isOptional = false, an element type like string | undefined reaches the union branch (createType.ts:337), which filters undefined out at :363 with the comment "already treated as isOptional". For these two positions it never was, so the | undefined is silently dropped.

The fix

Pass isNullable() for both, so a nested element is built exactly the same way a parameter, tuple element or struct property of the same type already is. Two lines.

I deliberately used isNullable() rather than the narrower .some(t => t.isUndefined()), so that T[] and a bare T agree. Nitrogen already generates std::optional<std::variant<nitro::NullType, std::string>> for a parameter of type string | null (Parameter.ts:28); with isUndefined only, the array element form would have disagreed with the scalar form for that type. Both predicates produce identical output for the type in #1202.

Second hunk: Kotlin JNI array bridge

The fix makes std::vector<std::optional<T>> reachable for the first time, which exposes a real gap. KotlinCxxBridgedType.dereferenceToJObject(...) switches on the type's own kind:

switch (this.type.kind) {
  case 'array-buffer': case 'function': case 'hybrid-object-base':
  case 'map': case 'promise':
    return `${parameterName}.get()`
  default:
    return `*${parameterName}`
}

An optional never matches those five, so it always fell to *ref — even when the wrapped type needs .get(). This is a compile error, not a runtime issue. Verified by reverting just this hunk and running the Android build:

JHybridTestObjectSwiftKotlinSpec.cpp:746:34: error: no viable conversion from
'Repr' (aka 'facebook::jni::HybridClass<margelo::nitro::JArrayBuffer>::JavaPart')
to 'facebook::jni::detail::JTypeFor<...>::_javaobject *'

dereferenceToJObject(...) now forwards to the wrapping type for optionals. std::optional<std::string> was already fine either way — fbjni's operator* on a null local_ref is well defined (ReprStorage::set always placement-news a Repr holding a possibly-null jobject, References-inl.h:66-80), so it lands as a Java null in the array.

Test

One method on the existing SharedTestObjectProps, reusing existing types — no new structs, enums or spec files:

bounceOptionals(
  strings: (string | undefined)[],
  arrayBuffers: (ArrayBuffer | undefined)[],
  map: Record<string, string | undefined>
): (string | undefined)[]

Each argument is load-bearing: strings pins the array-element fix (and the return exercises the Kotlin→C++ direction), map pins the Record value fix, arrayBuffers pins the dereferenceToJObject hunk — without it the Android build passes and the JNI gap ships unnoticed.

This is a compile-time test (the bug is codegen), so it lives in the specs and is covered by build-ios.yml / build-android.yml. getTests.ts is untouched.

Generated output — before / after

C++ (HybridTestObjectSwiftKotlinSpec.hpp)

-virtual std::vector<std::string> bounceOptionals(const std::vector<std::string>& strings, const std::vector<std::shared_ptr<ArrayBuffer>>& arrayBuffers, const std::unordered_map<std::string, std::string>& map) = 0;
+virtual std::vector<std::optional<std::string>> bounceOptionals(const std::vector<std::optional<std::string>>& strings, const std::vector<std::optional<std::shared_ptr<ArrayBuffer>>>& arrayBuffers, const std::unordered_map<std::string, std::optional<std::string>>& map) = 0;

Swift (HybridTestObjectSwiftKotlinSpec.swift)

-func bounceOptionals(strings: [String], arrayBuffers: [ArrayBuffer], map: Dictionary<String, String>) throws -> [String]
+func bounceOptionals(strings: [String?], arrayBuffers: [ArrayBuffer?], map: Dictionary<String, String?>) throws -> [String?]

Kotlin (HybridTestObjectSwiftKotlinSpec.kt)

-abstract fun bounceOptionals(strings: Array<String>, arrayBuffers: Array<ArrayBuffer>, map: Map<String, String>): Array<String>
+abstract fun bounceOptionals(strings: Array<String?>, arrayBuffers: Array<ArrayBuffer?>, map: Map<String, String?>): Array<String?>

Kotlin JNI bridge (JHybridTestObjectSwiftKotlinSpec.cpp), the arrayBuffers loop:

-auto __elementJni = JArrayBuffer::wrap(__element);
+auto __elementJni = __element.has_value() ? JArrayBuffer::wrap(__element.value()) : nullptr;
 __array->setElement(__i, __elementJni.get());

Verification

Everything below was actually run on this branch (macOS, Xcode 26.2, NDK 27.1, JDK 17):

  • No collateral damage. With the new spec method removed but both nitrogen changes applied, bun specs in react-native-nitro-test-external and react-native-nitro-test regenerates a tree byte-identical to main (git diff --exit-code clean over both nitrogen/generated trees). No existing spec changes shape.
  • Counterfactual. Reverting createType.ts and regenerating puts the buggy std::vector<std::string> / std::unordered_map<std::string, std::string> signature back; restoring it returns the optional form. Reverting only KotlinCxxBridgedType.ts breaks the Android build with the error quoted above.
  • Regeneration check (what run-nitrogen.yml does): bun installbun run buildbun specs in both test packages → git diff --exit-code — clean.
  • iOS build: xcodebuild -workspace NitroExample.xcworkspace -scheme NitroExample -sdk iphonesimulator** BUILD SUCCEEDED **. This compiles the generated Swift↔C++ bridge, including std::vector<std::optional<std::string>> and std::optional<std::shared_ptr<ArrayBuffer>> round-trips.
  • Android build: ./gradlew :react-native-nitro-test:assembleDebug -PreactNativeArchitectures=arm64-v8aBUILD SUCCESSFUL. This compiles the JNI bridge above.
  • bun typecheck, bun lint, bun lint-cpp, bun lint-swift, bun lint-kotlin all pass.

Not verified: I did not run the Harness runtime tests on a device/emulator, and the Android build was arm64-v8a only. There is no runtime assertion in this PR — the bug is a codegen shape mismatch, which the native builds pin.

`createType(...)` threads optionality through every nested position
except two: the array element type and the `Record<K, V>` value type,
which both hardcoded `isOptional = false`.

With `isOptional = false`, an element type such as `string | undefined`
falls into the union branch, which filters `undefined` out on the
assumption that it "is already handled as isOptional" — but for these
two positions it never was. The result is that `(string | undefined)[]`
silently generated `std::vector<std::string>` instead of
`std::vector<std::optional<std::string>>`.

Pass `isNullable()` for both, so a nested element is created exactly the
same way a parameter, tuple element or struct property of the same type
already is.

This makes `std::vector<std::optional<T>>` reachable for the first time,
which exposed a gap in the Kotlin JNI array bridge:
`KotlinCxxBridgedType.dereferenceToJObject(...)` switches on the type's
own kind, so an `optional` always took the `*ref` branch. For the five
kinds that require `.get()` (array-buffer, function, hybrid-object-base,
map, promise) an optional element generated `*__elementJni`, which does
not compile. `dereferenceToJObject(...)` now forwards to the wrapping
type for optionals.

Fixes mrousavy#1202
@vercel

vercel Bot commented Aug 13, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
nitro-docs Skipped Skipped Aug 13, 2026 9:16am

Request Review

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug: undefined not represented in union type within array type in C++

1 participant