What happened?
Optional<Struct> round-trips through the Swift Cxx-interop bridge corrupt enum tag bytes when the wrapping struct is wide and contains multiple distinct string-union enum optional fields. The C++-side JS converter then sees a non-discriminator integer in the enum slot and throws e.g. Cannot convert Tier to JS - invalid value: 83886081.
The garbage value differs per call (observed 83886081 and 2003789939 in separate runs), and the bug surfaces only under optimization (Swift -O / Clang -Os/-O3). iOS Debug, Android (no Cxx-interop in play), and narrower struct shapes all round-trip cleanly.
Surface conditions
| Build |
Result |
| iOS Debug |
✅ passes |
| iOS Release |
❌ tryOptionalEnumStruct(...) equals fails — Cannot convert <Enum> to JS - invalid value: <garbage int> |
| Android |
✅ passes on both TestObjectCpp and TestObjectSwiftKotlin (no Cxx-interop) |
Bisection (three repro tests on TestObject, increasing in width)
| Repro |
Shape |
iOS Release |
tryOptionalStruct(value?: OptionalWrapper) |
2 primitive optionals, no enums |
✅ |
tryOptionalNestedStruct(value?: OptionalNested…) |
3 fields incl. nested struct with 2× same Side enum |
✅ |
tryOptionalEnumStruct(value?: OptionalEnum…) |
14 primitive optionals + 2 distinct enums + nested struct with 2 more enums |
❌ |
Together these bracket the bug: small + few enums = works, wide + multiple distinct string-union enum types under -O = corrupted enum tag. Garbage values being non-deterministic across calls (and different from a stable wrong-pointer value) point at uninitialized padding bytes or an optimizer-revealed layout mismatch in Swift's Optional<Struct> projection across the Cxx-interop bridge — not a stable wrong-pointer or logic bug.
Reproduceable Code
Repro branch (on top of mrousavy/nitro@main): https://github.com/notsuhas/nitro/tree/repro/optional-struct-bridge
It adds three runtime tests to react-native-nitro-test's TestObject. Spec (packages/react-native-nitro-test/src/specs/TestObject.nitro.ts):
export type Side = 'left' | 'right'
export interface OptionalNestedInner { start?: Side; end?: Side }
export interface OptionalNestedWrapper {
count?: number; enabled?: boolean; inner?: OptionalNestedInner
}
export type Stage = 'idle' | 'warmup' | 'active' | 'cool'
export type Tier = 'free' | 'pro' | 'enterprise'
export type Region = 'us' | 'eu' | 'apac' | 'global'
export type Tone = 'cool' | 'warm' | 'neutral'
export interface OptionalEnumInner { stage?: Stage; tone?: Tone }
export interface OptionalEnumWrapper {
count?: number; weight?: number; ttl?: number; jitter?: number;
retries?: number; delayMs?: number; timeoutMs?: number; ratio?: number;
threshold?: number;
enabled?: boolean; active?: boolean; shouldBuffer?: boolean;
shouldRetry?: boolean; verbose?: boolean;
tier?: Tier; region?: Region;
inner?: OptionalEnumInner
}
interface SharedTestObjectProps {
// ...
tryOptionalStruct(value?: OptionalWrapper): OptionalWrapper | undefined
tryOptionalNestedStruct(value?: OptionalNestedWrapper): OptionalNestedWrapper | undefined
tryOptionalEnumStruct(value?: OptionalEnumWrapper): OptionalEnumWrapper | undefined
}
Native impls are identity bounces (return value) on Cpp / Swift / Kotlin. JS-side fixtures + tests in example/src/getTests.ts. Build the example in Release on iOS, open the test runner, run the TestObjectSwiftKotlin suite — tryOptionalEnumStruct(...) equals fails as described.
Relevant log output
❌ Test "tryOptionalEnumStruct(...) equals" failed!
Error: Expected test to not throw any errors, but an error was thrown!
Error: Error: TestObjectSwiftKotlin.tryOptionalEnumStruct(...):
Cannot convert Tier to JS - invalid value: 83886081!
(Tier / Region / Stage / Tone rotate as the offending field across runs; the integer value differs per call.)
Suspected root cause + candidate fix
The Cxx-interop wrapper for Optional<Struct> uses Swift's .value direct-access projection on std::optional<T> for both parameter unwrapping and field reads. Generated example:
// HybridTestObjectSwiftKotlinSpec_cxx.swift
public final func tryOptionalEnumStruct(value: bridge.std__optional_OptionalEnumWrapper_) -> ... {
let __result = try self.__implementation.tryOptionalEnumStruct(value: value.value)
// ^^^^^^^^^^^^
// direct .value projection — reads garbage enum tag bytes under -O
}
The pre-existing TODO at packages/nitrogen/src/syntax/swift/SwiftCxxBridgedType.ts already routes optional booleans/numbers through bridge.has_value_* / bridge.get_* shims to dodge swiftlang/swift#84848, with the comment "…and who knows what else?". This appears to be one of those what else cases.
I have a local candidate fix that extends the same workaround to also cover:
kind === 'struct'
kind === 'enum' && jsType === 'union' (string-union enums)
The bridge has_value_* / get_* shims are already emitted by the C++ bridge generator for both struct and enum optionals, so no bridge-side work is needed.
Verified on-device, iOS Release, after that patch: all 6 tryOptional*Struct cases pass; 244/245 of the existing test suite passes (the remaining 1 is callCallbackThatReturnsPromiseVoid timing out after repeated runs — unrelated, appears to be a pre-existing test-harness state-pollution flake reproducible without my changes).
Happy to share the patch / open a PR if that's the direction you'd want. Flagging here first since it's a codegen change with a non-obvious blast radius and you may want to take a different approach (e.g. tighten the workaround further, change layout assumptions, file a Swift-team bug, etc.).
Device
iPhone, iOS Release build (physical device). Reproducible across runs.
Nitro Modules Version
0.35.6
Nitrogen Version
0.35.6
Can you reproduce this issue in the Nitro Example app here?
Yes, I can reproduce the same issue in the Example app here
Additional information
What happened?
Optional<Struct>round-trips through the Swift Cxx-interop bridge corrupt enum tag bytes when the wrapping struct is wide and contains multiple distinct string-union enum optional fields. The C++-side JS converter then sees a non-discriminator integer in the enum slot and throws e.g.Cannot convert Tier to JS - invalid value: 83886081.The garbage value differs per call (observed
83886081and2003789939in separate runs), and the bug surfaces only under optimization (Swift-O/ Clang-Os/-O3). iOS Debug, Android (no Cxx-interop in play), and narrower struct shapes all round-trip cleanly.Surface conditions
tryOptionalEnumStruct(...) equalsfails —Cannot convert <Enum> to JS - invalid value: <garbage int>TestObjectCppandTestObjectSwiftKotlin(no Cxx-interop)Bisection (three repro tests on
TestObject, increasing in width)tryOptionalStruct(value?: OptionalWrapper)tryOptionalNestedStruct(value?: OptionalNested…)SideenumtryOptionalEnumStruct(value?: OptionalEnum…)Together these bracket the bug:
small + few enums = works,wide + multiple distinct string-union enum types under -O = corrupted enum tag. Garbage values being non-deterministic across calls (and different from a stable wrong-pointer value) point at uninitialized padding bytes or an optimizer-revealed layout mismatch in Swift'sOptional<Struct>projection across the Cxx-interop bridge — not a stable wrong-pointer or logic bug.Reproduceable Code
Repro branch (on top of
mrousavy/nitro@main): https://github.com/notsuhas/nitro/tree/repro/optional-struct-bridgeIt adds three runtime tests to
react-native-nitro-test'sTestObject. Spec (packages/react-native-nitro-test/src/specs/TestObject.nitro.ts):Native impls are identity bounces (
return value) on Cpp / Swift / Kotlin. JS-side fixtures + tests inexample/src/getTests.ts. Build the example in Release on iOS, open the test runner, run theTestObjectSwiftKotlinsuite —tryOptionalEnumStruct(...) equalsfails as described.Relevant log output
(
Tier/Region/Stage/Tonerotate as the offending field across runs; the integer value differs per call.)Suspected root cause + candidate fix
The Cxx-interop wrapper for
Optional<Struct>uses Swift's.valuedirect-access projection onstd::optional<T>for both parameter unwrapping and field reads. Generated example:The pre-existing TODO at
packages/nitrogen/src/syntax/swift/SwiftCxxBridgedType.tsalready routes optional booleans/numbers throughbridge.has_value_*/bridge.get_*shims to dodge swiftlang/swift#84848, with the comment "…and who knows what else?". This appears to be one of those what else cases.I have a local candidate fix that extends the same workaround to also cover:
kind === 'struct'kind === 'enum' && jsType === 'union'(string-union enums)The bridge
has_value_*/get_*shims are already emitted by the C++ bridge generator for both struct and enum optionals, so no bridge-side work is needed.Verified on-device, iOS Release, after that patch: all 6
tryOptional*Structcases pass; 244/245 of the existing test suite passes (the remaining 1 iscallCallbackThatReturnsPromiseVoidtiming out after repeated runs — unrelated, appears to be a pre-existing test-harness state-pollution flake reproducible without my changes).Happy to share the patch / open a PR if that's the direction you'd want. Flagging here first since it's a codegen change with a non-obvious blast radius and you may want to take a different approach (e.g. tighten the workaround further, change layout assumptions, file a Swift-team bug, etc.).
Device
iPhone, iOS Release build (physical device). Reproducible across runs.
Nitro Modules Version
0.35.6
Nitrogen Version
0.35.6
Can you reproduce this issue in the Nitro Example app here?
Yes, I can reproduce the same issue in the Example app here
Additional information