From 74f75a6570fde91a902f534096f066c161cadaa4 Mon Sep 17 00:00:00 2001 From: Christoph Purrer Date: Sat, 14 Mar 2026 21:26:51 -0700 Subject: [PATCH] Support importing types across Turbo Module specs (#56045) Summary: ## Changelog: [General] [Added] - Add cross-file type import support for React Native Turbo Module codegen Turbo Module JavaScript specs previously required all types (enums, type aliases) to be defined in the same file. This forced developers to duplicate type definitions when sharing them across multiple modules. This diff adds an optional `importedTypes` parameter to `parseString()` that allows the parser to resolve types defined in external files. The approach: - `parseString()` accepts an optional `TypeDeclarationMap` of imported types, which are merged with local types (local definitions take precedence). - `parseFile()` automatically resolves relative `import type` declarations by parsing the source files and extracting their type AST nodes. - A shared `resolveImportedTypes()` utility handles filesystem resolution for both Flow and TypeScript parsers. - `getImportsFromAST()` extracts type-only import declarations from the AST, filtering out value imports to avoid unnecessary filesystem I/O. - The flow-schema OTA safety tool is updated to build imported types from its existing import map infrastructure and pass them to the codegen parser. Code generators require zero changes since they consume the flat schema (enumMap/aliasMap), and imported types produce identical schema entries to file-local types. Differential Revision: D95646987 --- .../modules/NativeImportedEnumTurboModule.js | 28 ++ .../modules/SharedEnumTypes.js | 26 ++ .../GenerateModuleH-test.js.snap | 294 +++++++++++++ .../GenerateModuleObjCpp-test.js.snap | 147 +++++++ .../react-native-codegen/src/CodegenSchema.js | 6 + .../src/generators/modules/GenerateModuleH.js | 45 +- .../__tests__/cross-file-imports-test.js | 416 ++++++++++++++++++ .../parsers/__tests__/parsers-commons-test.js | 8 +- .../src/parsers/flow/parser.js | 35 +- .../src/parsers/parser.js | 7 +- .../src/parsers/parsers-commons.js | 89 +++- .../src/parsers/typescript/parser.js | 34 +- .../react-native-codegen/src/parsers/utils.js | 129 ++++++ .../NativeCxxModuleExample.cpp | 8 +- .../NativeCxxModuleExample.h | 8 +- .../NativeCxxModuleExample.js | 11 +- .../NativeCxxModuleExample/SharedType.js | 25 ++ .../tests/NativeCxxModuleExampleTests.cpp | 12 +- .../NativeCxxModuleExampleExample.js | 6 +- .../simple-scenario-frontend-test.ts | 2 + 20 files changed, 1290 insertions(+), 46 deletions(-) create mode 100644 packages/react-native-codegen/e2e/deep_imports/__test_fixtures__/modules/NativeImportedEnumTurboModule.js create mode 100644 packages/react-native-codegen/e2e/deep_imports/__test_fixtures__/modules/SharedEnumTypes.js create mode 100644 packages/react-native-codegen/src/parsers/__tests__/cross-file-imports-test.js create mode 100644 packages/rn-tester/NativeCxxModuleExample/SharedType.js diff --git a/packages/react-native-codegen/e2e/deep_imports/__test_fixtures__/modules/NativeImportedEnumTurboModule.js b/packages/react-native-codegen/e2e/deep_imports/__test_fixtures__/modules/NativeImportedEnumTurboModule.js new file mode 100644 index 000000000000..73ab3cf53d20 --- /dev/null +++ b/packages/react-native-codegen/e2e/deep_imports/__test_fixtures__/modules/NativeImportedEnumTurboModule.js @@ -0,0 +1,28 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import type { + SharedNumEnum, + SharedStateType, + SharedStatusEnum, +} from './SharedEnumTypes'; +import type {TurboModule} from 'react-native/Libraries/TurboModule/RCTExport'; + +import * as TurboModuleRegistry from 'react-native/Libraries/TurboModule/TurboModuleRegistry'; + +export interface Spec extends TurboModule { + +getStatus: (statusProp: SharedStateType) => SharedStatusEnum; + +getNum: () => SharedNumEnum; + +setStatus: (status: SharedStatusEnum) => void; +} + +export default (TurboModuleRegistry.getEnforcing( + 'NativeImportedEnumTurboModule', +): Spec); diff --git a/packages/react-native-codegen/e2e/deep_imports/__test_fixtures__/modules/SharedEnumTypes.js b/packages/react-native-codegen/e2e/deep_imports/__test_fixtures__/modules/SharedEnumTypes.js new file mode 100644 index 000000000000..fc20c9a5a021 --- /dev/null +++ b/packages/react-native-codegen/e2e/deep_imports/__test_fixtures__/modules/SharedEnumTypes.js @@ -0,0 +1,26 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +export enum SharedStatusEnum { + Active = 'active', + Paused = 'paused', + Off = 'off', +} + +export enum SharedNumEnum { + One = 1, + Two = 2, + Three = 3, +} + +export type SharedStateType = { + status: SharedStatusEnum, + count: number, +}; diff --git a/packages/react-native-codegen/e2e/deep_imports/__tests__/modules/__snapshots__/GenerateModuleH-test.js.snap b/packages/react-native-codegen/e2e/deep_imports/__tests__/modules/__snapshots__/GenerateModuleH-test.js.snap index 12b33f60c4df..70ed4e4251d1 100644 --- a/packages/react-native-codegen/e2e/deep_imports/__tests__/modules/__snapshots__/GenerateModuleH-test.js.snap +++ b/packages/react-native-codegen/e2e/deep_imports/__tests__/modules/__snapshots__/GenerateModuleH-test.js.snap @@ -419,6 +419,153 @@ private: }; +#pragma mark - SharedEnumTypesSharedNumEnum + +enum class SharedEnumTypesSharedNumEnum { One, Two, Three }; + +template <> +struct Bridging { + static SharedEnumTypesSharedNumEnum fromJs(jsi::Runtime &rt, const jsi::Value &rawValue) { + double value = (double)rawValue.asNumber(); + if (value == 1) { + return SharedEnumTypesSharedNumEnum::One; + } else if (value == 2) { + return SharedEnumTypesSharedNumEnum::Two; + } else if (value == 3) { + return SharedEnumTypesSharedNumEnum::Three; + } else { + throw jsi::JSError(rt, \\"No appropriate enum member found for value in SharedEnumTypesSharedNumEnum\\"); + } + } + + static jsi::Value toJs(jsi::Runtime &rt, SharedEnumTypesSharedNumEnum value) { + if (value == SharedEnumTypesSharedNumEnum::One) { + return bridging::toJs(rt, 1); + } else if (value == SharedEnumTypesSharedNumEnum::Two) { + return bridging::toJs(rt, 2); + } else if (value == SharedEnumTypesSharedNumEnum::Three) { + return bridging::toJs(rt, 3); + } else { + throw jsi::JSError(rt, \\"No appropriate enum member found for enum value in SharedEnumTypesSharedNumEnum\\"); + } + } +}; + +#pragma mark - SharedEnumTypesSharedStatusEnum + +enum class SharedEnumTypesSharedStatusEnum { Active, Paused, Off }; + +template <> +struct Bridging { + static SharedEnumTypesSharedStatusEnum fromJs(jsi::Runtime &rt, const jsi::String &rawValue) { + std::string value = rawValue.utf8(rt); + if (value == \\"active\\") { + return SharedEnumTypesSharedStatusEnum::Active; + } else if (value == \\"paused\\") { + return SharedEnumTypesSharedStatusEnum::Paused; + } else if (value == \\"off\\") { + return SharedEnumTypesSharedStatusEnum::Off; + } else { + throw jsi::JSError(rt, \\"No appropriate enum member found for value in SharedEnumTypesSharedStatusEnum\\"); + } + } + + static jsi::String toJs(jsi::Runtime &rt, SharedEnumTypesSharedStatusEnum value) { + if (value == SharedEnumTypesSharedStatusEnum::Active) { + return bridging::toJs(rt, \\"active\\"); + } else if (value == SharedEnumTypesSharedStatusEnum::Paused) { + return bridging::toJs(rt, \\"paused\\"); + } else if (value == SharedEnumTypesSharedStatusEnum::Off) { + return bridging::toJs(rt, \\"off\\"); + } else { + throw jsi::JSError(rt, \\"No appropriate enum member found for enum value in SharedEnumTypesSharedStatusEnum\\"); + } + } +}; +#pragma mark - SharedEnumTypesSharedStateType + +template +struct SharedEnumTypesSharedStateType { + P0 status{}; + P1 count; + bool operator==(const SharedEnumTypesSharedStateType &other) const { + return status == other.status && count == other.count; + } +}; + +template +struct SharedEnumTypesSharedStateTypeBridging { + static T types; + + static T fromJs( + jsi::Runtime &rt, + const jsi::Object &value, + const std::shared_ptr &jsInvoker) { + T result{ + bridging::fromJs(rt, value.getProperty(rt, \\"status\\"), jsInvoker), + bridging::fromJs(rt, value.getProperty(rt, \\"count\\"), jsInvoker)}; + return result; + } + +#ifdef DEBUG + static jsi::String statusToJs(jsi::Runtime &rt, decltype(types.status) value) { + return bridging::toJs(rt, value); + } + static double countToJs(jsi::Runtime &rt, decltype(types.count) value) { + return bridging::toJs(rt, value); + } +#endif + + static jsi::Object toJs( + jsi::Runtime &rt, + const T &value, + const std::shared_ptr &jsInvoker) { + auto result = facebook::jsi::Object(rt); + result.setProperty(rt, \\"status\\", bridging::toJs(rt, value.status, jsInvoker)); + result.setProperty(rt, \\"count\\", bridging::toJs(rt, value.count, jsInvoker)); + return result; + } +}; + + +template +class JSI_EXPORT NativeImportedEnumTurboModuleCxxSpec : public TurboModule { +public: + static constexpr std::string_view kModuleName = \\"NativeImportedEnumTurboModule\\"; + +protected: + NativeImportedEnumTurboModuleCxxSpec(std::shared_ptr jsInvoker) : TurboModule(std::string{NativeImportedEnumTurboModuleCxxSpec::kModuleName}, jsInvoker) { + methodMap_[\\"getStatus\\"] = MethodMetadata {.argCount = 1, .invoker = __getStatus}; + methodMap_[\\"getNum\\"] = MethodMetadata {.argCount = 0, .invoker = __getNum}; + methodMap_[\\"setStatus\\"] = MethodMetadata {.argCount = 1, .invoker = __setStatus}; + } + +private: + static jsi::Value __getStatus(jsi::Runtime &rt, TurboModule &turboModule, const jsi::Value* args, size_t count) { + static_assert( + bridging::getParameterCount(&T::getStatus) == 2, + \\"Expected getStatus(...) to have 2 parameters\\"); + return bridging::callFromJs(rt, &T::getStatus, static_cast(&turboModule)->jsInvoker_, static_cast(&turboModule), + count <= 0 ? throw jsi::JSError(rt, \\"Expected argument in position 0 to be passed\\") : args[0].asObject(rt)); + } + + static jsi::Value __getNum(jsi::Runtime &rt, TurboModule &turboModule, const jsi::Value* /*args*/, size_t /*count*/) { + static_assert( + bridging::getParameterCount(&T::getNum) == 1, + \\"Expected getNum(...) to have 1 parameters\\"); + return bridging::callFromJs(rt, &T::getNum, static_cast(&turboModule)->jsInvoker_, static_cast(&turboModule)); + } + + static jsi::Value __setStatus(jsi::Runtime &rt, TurboModule &turboModule, const jsi::Value* args, size_t count) { + static_assert( + bridging::getParameterCount(&T::setStatus) == 2, + \\"Expected setStatus(...) to have 2 parameters\\"); + bridging::callFromJs(rt, &T::setStatus, static_cast(&turboModule)->jsInvoker_, static_cast(&turboModule), + count <= 0 ? throw jsi::JSError(rt, \\"Expected argument in position 0 to be passed\\") : args[0].asString(rt));return jsi::Value::undefined(); + } +}; + + template class JSI_EXPORT NativeNullableTurboModuleCxxSpec : public TurboModule { public: @@ -2016,6 +2163,153 @@ private: }; +#pragma mark - SharedEnumTypesSharedNumEnum + +enum class SharedEnumTypesSharedNumEnum { One, Two, Three }; + +template <> +struct Bridging { + static SharedEnumTypesSharedNumEnum fromJs(jsi::Runtime &rt, const jsi::Value &rawValue) { + double value = (double)rawValue.asNumber(); + if (value == 1) { + return SharedEnumTypesSharedNumEnum::One; + } else if (value == 2) { + return SharedEnumTypesSharedNumEnum::Two; + } else if (value == 3) { + return SharedEnumTypesSharedNumEnum::Three; + } else { + throw jsi::JSError(rt, \\"No appropriate enum member found for value in SharedEnumTypesSharedNumEnum\\"); + } + } + + static jsi::Value toJs(jsi::Runtime &rt, SharedEnumTypesSharedNumEnum value) { + if (value == SharedEnumTypesSharedNumEnum::One) { + return bridging::toJs(rt, 1); + } else if (value == SharedEnumTypesSharedNumEnum::Two) { + return bridging::toJs(rt, 2); + } else if (value == SharedEnumTypesSharedNumEnum::Three) { + return bridging::toJs(rt, 3); + } else { + throw jsi::JSError(rt, \\"No appropriate enum member found for enum value in SharedEnumTypesSharedNumEnum\\"); + } + } +}; + +#pragma mark - SharedEnumTypesSharedStatusEnum + +enum class SharedEnumTypesSharedStatusEnum { Active, Paused, Off }; + +template <> +struct Bridging { + static SharedEnumTypesSharedStatusEnum fromJs(jsi::Runtime &rt, const jsi::String &rawValue) { + std::string value = rawValue.utf8(rt); + if (value == \\"active\\") { + return SharedEnumTypesSharedStatusEnum::Active; + } else if (value == \\"paused\\") { + return SharedEnumTypesSharedStatusEnum::Paused; + } else if (value == \\"off\\") { + return SharedEnumTypesSharedStatusEnum::Off; + } else { + throw jsi::JSError(rt, \\"No appropriate enum member found for value in SharedEnumTypesSharedStatusEnum\\"); + } + } + + static jsi::String toJs(jsi::Runtime &rt, SharedEnumTypesSharedStatusEnum value) { + if (value == SharedEnumTypesSharedStatusEnum::Active) { + return bridging::toJs(rt, \\"active\\"); + } else if (value == SharedEnumTypesSharedStatusEnum::Paused) { + return bridging::toJs(rt, \\"paused\\"); + } else if (value == SharedEnumTypesSharedStatusEnum::Off) { + return bridging::toJs(rt, \\"off\\"); + } else { + throw jsi::JSError(rt, \\"No appropriate enum member found for enum value in SharedEnumTypesSharedStatusEnum\\"); + } + } +}; +#pragma mark - SharedEnumTypesSharedStateType + +template +struct SharedEnumTypesSharedStateType { + P0 status{}; + P1 count; + bool operator==(const SharedEnumTypesSharedStateType &other) const { + return status == other.status && count == other.count; + } +}; + +template +struct SharedEnumTypesSharedStateTypeBridging { + static T types; + + static T fromJs( + jsi::Runtime &rt, + const jsi::Object &value, + const std::shared_ptr &jsInvoker) { + T result{ + bridging::fromJs(rt, value.getProperty(rt, \\"status\\"), jsInvoker), + bridging::fromJs(rt, value.getProperty(rt, \\"count\\"), jsInvoker)}; + return result; + } + +#ifdef DEBUG + static jsi::String statusToJs(jsi::Runtime &rt, decltype(types.status) value) { + return bridging::toJs(rt, value); + } + static double countToJs(jsi::Runtime &rt, decltype(types.count) value) { + return bridging::toJs(rt, value); + } +#endif + + static jsi::Object toJs( + jsi::Runtime &rt, + const T &value, + const std::shared_ptr &jsInvoker) { + auto result = facebook::jsi::Object(rt); + result.setProperty(rt, \\"status\\", bridging::toJs(rt, value.status, jsInvoker)); + result.setProperty(rt, \\"count\\", bridging::toJs(rt, value.count, jsInvoker)); + return result; + } +}; + + +template +class JSI_EXPORT NativeImportedEnumTurboModuleCxxSpec : public TurboModule { +public: + static constexpr std::string_view kModuleName = \\"NativeImportedEnumTurboModule\\"; + +protected: + NativeImportedEnumTurboModuleCxxSpec(std::shared_ptr jsInvoker) : TurboModule(std::string{NativeImportedEnumTurboModuleCxxSpec::kModuleName}, jsInvoker) { + methodMap_[\\"getStatus\\"] = MethodMetadata {.argCount = 1, .invoker = __getStatus}; + methodMap_[\\"getNum\\"] = MethodMetadata {.argCount = 0, .invoker = __getNum}; + methodMap_[\\"setStatus\\"] = MethodMetadata {.argCount = 1, .invoker = __setStatus}; + } + +private: + static jsi::Value __getStatus(jsi::Runtime &rt, TurboModule &turboModule, const jsi::Value* args, size_t count) { + static_assert( + bridging::getParameterCount(&T::getStatus) == 2, + \\"Expected getStatus(...) to have 2 parameters\\"); + return bridging::callFromJs(rt, &T::getStatus, static_cast(&turboModule)->jsInvoker_, static_cast(&turboModule), + count <= 0 ? throw jsi::JSError(rt, \\"Expected argument in position 0 to be passed\\") : args[0].asObject(rt)); + } + + static jsi::Value __getNum(jsi::Runtime &rt, TurboModule &turboModule, const jsi::Value* /*args*/, size_t /*count*/) { + static_assert( + bridging::getParameterCount(&T::getNum) == 1, + \\"Expected getNum(...) to have 1 parameters\\"); + return bridging::callFromJs(rt, &T::getNum, static_cast(&turboModule)->jsInvoker_, static_cast(&turboModule)); + } + + static jsi::Value __setStatus(jsi::Runtime &rt, TurboModule &turboModule, const jsi::Value* args, size_t count) { + static_assert( + bridging::getParameterCount(&T::setStatus) == 2, + \\"Expected setStatus(...) to have 2 parameters\\"); + bridging::callFromJs(rt, &T::setStatus, static_cast(&turboModule)->jsInvoker_, static_cast(&turboModule), + count <= 0 ? throw jsi::JSError(rt, \\"Expected argument in position 0 to be passed\\") : args[0].asString(rt));return jsi::Value::undefined(); + } +}; + + template class JSI_EXPORT NativeNullableTurboModuleCxxSpec : public TurboModule { public: diff --git a/packages/react-native-codegen/e2e/deep_imports/__tests__/modules/__snapshots__/GenerateModuleObjCpp-test.js.snap b/packages/react-native-codegen/e2e/deep_imports/__tests__/modules/__snapshots__/GenerateModuleObjCpp-test.js.snap index 9bf2025a3a4f..d11e7a9f9724 100644 --- a/packages/react-native-codegen/e2e/deep_imports/__tests__/modules/__snapshots__/GenerateModuleObjCpp-test.js.snap +++ b/packages/react-native-codegen/e2e/deep_imports/__tests__/modules/__snapshots__/GenerateModuleObjCpp-test.js.snap @@ -179,6 +179,48 @@ namespace facebook::react { NativeEnumTurboModuleSpecJSI(const ObjCTurboModule::InitParams ¶ms); }; } // namespace facebook::react +namespace JS { + namespace NativeImportedEnumTurboModule { + struct SharedStateType { + NSString *status() const; + double count() const; + + SharedStateType(NSDictionary *const v) : _v(v) {} + private: + NSDictionary *_v; + }; + } +} + +@interface RCTCxxConvert (NativeImportedEnumTurboModule_SharedStateType) ++ (RCTManagedPointer *)JS_NativeImportedEnumTurboModule_SharedStateType:(id)json; +@end +@protocol NativeImportedEnumTurboModuleSpec + +- (NSString *)getStatus:(JS::NativeImportedEnumTurboModule::SharedStateType &)statusProp; +- (NSNumber *)getNum; +- (void)setStatus:(NSString *)status; + +@end + +@interface NativeImportedEnumTurboModuleSpecBase : NSObject { +@protected +facebook::react::EventEmitterCallback _eventEmitterCallback; +} +- (void)setEventEmitterCallback:(EventEmitterCallbackWrapper *)eventEmitterCallbackWrapper; + + +@end + +namespace facebook::react { + /** + * ObjC++ class for module 'NativeImportedEnumTurboModule' + */ + class JSI_EXPORT NativeImportedEnumTurboModuleSpecJSI : public ObjCTurboModule { + public: + NativeImportedEnumTurboModuleSpecJSI(const ObjCTurboModule::InitParams ¶ms); + }; +} // namespace facebook::react @protocol NativeNullableTurboModuleSpec @@ -1262,6 +1304,16 @@ inline NSString *JS::NativeEnumTurboModule::StateTypeWithEnums::lowerCase() cons id const p = _v[@\\"lowerCase\\"]; return RCTBridgingToString(p); } +inline NSString *JS::NativeImportedEnumTurboModule::SharedStateType::status() const +{ + id const p = _v[@\\"status\\"]; + return RCTBridgingToString(p); +} +inline double JS::NativeImportedEnumTurboModule::SharedStateType::count() const +{ + id const p = _v[@\\"count\\"]; + return RCTBridgingToDouble(p); +} inline bool JS::NativeObjectTurboModule::SpecDifficultObjectAE::D() const @@ -1733,6 +1785,48 @@ namespace facebook::react { NativeEnumTurboModuleSpecJSI(const ObjCTurboModule::InitParams ¶ms); }; } // namespace facebook::react +namespace JS { + namespace NativeImportedEnumTurboModule { + struct SharedStateType { + NSString *status() const; + double count() const; + + SharedStateType(NSDictionary *const v) : _v(v) {} + private: + NSDictionary *_v; + }; + } +} + +@interface RCTCxxConvert (NativeImportedEnumTurboModule_SharedStateType) ++ (RCTManagedPointer *)JS_NativeImportedEnumTurboModule_SharedStateType:(id)json; +@end +@protocol NativeImportedEnumTurboModuleSpec + +- (NSString *)getStatus:(JS::NativeImportedEnumTurboModule::SharedStateType &)statusProp; +- (NSNumber *)getNum; +- (void)setStatus:(NSString *)status; + +@end + +@interface NativeImportedEnumTurboModuleSpecBase : NSObject { +@protected +facebook::react::EventEmitterCallback _eventEmitterCallback; +} +- (void)setEventEmitterCallback:(EventEmitterCallbackWrapper *)eventEmitterCallbackWrapper; + + +@end + +namespace facebook::react { + /** + * ObjC++ class for module 'NativeImportedEnumTurboModule' + */ + class JSI_EXPORT NativeImportedEnumTurboModuleSpecJSI : public ObjCTurboModule { + public: + NativeImportedEnumTurboModuleSpecJSI(const ObjCTurboModule::InitParams ¶ms); + }; +} // namespace facebook::react @protocol NativeNullableTurboModuleSpec @@ -2816,6 +2910,16 @@ inline NSString *JS::NativeEnumTurboModule::StateTypeWithEnums::lowerCase() cons id const p = _v[@\\"lowerCase\\"]; return RCTBridgingToString(p); } +inline NSString *JS::NativeImportedEnumTurboModule::SharedStateType::status() const +{ + id const p = _v[@\\"status\\"]; + return RCTBridgingToString(p); +} +inline double JS::NativeImportedEnumTurboModule::SharedStateType::count() const +{ + id const p = _v[@\\"count\\"]; + return RCTBridgingToDouble(p); +} inline bool JS::NativeObjectTurboModule::SpecDifficultObjectAE::D() const @@ -3294,6 +3398,49 @@ namespace facebook::react { } } // namespace facebook::react +@implementation NativeImportedEnumTurboModuleSpecBase + + +- (void)setEventEmitterCallback:(EventEmitterCallbackWrapper *)eventEmitterCallbackWrapper +{ + _eventEmitterCallback = std::move(eventEmitterCallbackWrapper->_eventEmitterCallback); +} +@end + +@implementation RCTCxxConvert (NativeImportedEnumTurboModule_SharedStateType) ++ (RCTManagedPointer *)JS_NativeImportedEnumTurboModule_SharedStateType:(id)json +{ + return facebook::react::managedPointer(json); +} +@end +namespace facebook::react { + + static facebook::jsi::Value __hostFunction_NativeImportedEnumTurboModuleSpecJSI_getStatus(facebook::jsi::Runtime& rt, TurboModule &turboModule, const facebook::jsi::Value* args, size_t count) { + return static_cast(turboModule).invokeObjCMethod(rt, StringKind, \\"getStatus\\", @selector(getStatus:), args, count); + } + + static facebook::jsi::Value __hostFunction_NativeImportedEnumTurboModuleSpecJSI_getNum(facebook::jsi::Runtime& rt, TurboModule &turboModule, const facebook::jsi::Value* args, size_t count) { + return static_cast(turboModule).invokeObjCMethod(rt, NumberKind, \\"getNum\\", @selector(getNum), args, count); + } + + static facebook::jsi::Value __hostFunction_NativeImportedEnumTurboModuleSpecJSI_setStatus(facebook::jsi::Runtime& rt, TurboModule &turboModule, const facebook::jsi::Value* args, size_t count) { + return static_cast(turboModule).invokeObjCMethod(rt, VoidKind, \\"setStatus\\", @selector(setStatus:), args, count); + } + + NativeImportedEnumTurboModuleSpecJSI::NativeImportedEnumTurboModuleSpecJSI(const ObjCTurboModule::InitParams ¶ms) + : ObjCTurboModule(params) { + + methodMap_[\\"getStatus\\"] = MethodMetadata {1, __hostFunction_NativeImportedEnumTurboModuleSpecJSI_getStatus}; + setMethodArgConversionSelector(@\\"getStatus\\", 0, @\\"JS_NativeImportedEnumTurboModule_SharedStateType:\\"); + + methodMap_[\\"getNum\\"] = MethodMetadata {0, __hostFunction_NativeImportedEnumTurboModuleSpecJSI_getNum}; + + + methodMap_[\\"setStatus\\"] = MethodMetadata {1, __hostFunction_NativeImportedEnumTurboModuleSpecJSI_setStatus}; + + } +} // namespace facebook::react + @implementation NativeNullableTurboModuleSpecBase diff --git a/packages/react-native-codegen/src/CodegenSchema.js b/packages/react-native-codegen/src/CodegenSchema.js index 90043816eeb5..939c64b7f55f 100644 --- a/packages/react-native-codegen/src/CodegenSchema.js +++ b/packages/react-native-codegen/src/CodegenSchema.js @@ -285,6 +285,12 @@ export type NativeModuleSchema = Readonly<{ // TODO: It's clearer to define `restrictedToPlatforms` instead, but // `excludedPlatforms` is used here to be consistent with ComponentSchema. excludedPlatforms?: ReadonlyArray, + // Maps imported type names to their source module haste name. + // Used by code generators to prefix these types with the source module + // name instead of the consuming module name, enabling shared types + // across multiple TurboModule specs. + importedAliasNames?: Readonly<{[aliasName: string]: string}>, + importedEnumNames?: Readonly<{[enumName: string]: string}>, }>; type NativeModuleSpec = Readonly<{ diff --git a/packages/react-native-codegen/src/generators/modules/GenerateModuleH.js b/packages/react-native-codegen/src/generators/modules/GenerateModuleH.js index dddf253913ac..bc21303beca8 100644 --- a/packages/react-native-codegen/src/generators/modules/GenerateModuleH.js +++ b/packages/react-native-codegen/src/generators/modules/GenerateModuleH.js @@ -319,6 +319,8 @@ function createStructsString( aliasMap: NativeModuleAliasMap, resolveAlias: AliasResolver, enumMap: NativeModuleEnumMap, + importedAliasNames?: $FlowFixMe, + emittedSharedStructs?: Set, ): string { const getCppType = ( parentObjectAlias: string, @@ -340,7 +342,19 @@ function createStructsString( if (value.properties.length === 0) { return ''; } - const structName = `${hasteModuleName}${alias}`; + const sourceModule: string | void = + importedAliasNames != null ? importedAliasNames[alias] : undefined; + // Skip shared types that have already been emitted by another module + if (sourceModule != null && emittedSharedStructs != null) { + if (emittedSharedStructs.has(alias)) { + return ''; + } + emittedSharedStructs.add(alias); + } + const structName = + sourceModule != null + ? `${sourceModule}${alias}` + : `${hasteModuleName}${alias}`; const templateParameter = value.properties.filter( v => !isDirectRecursiveMember(alias, v.typeAnnotation) && @@ -549,11 +563,22 @@ function createEnums( hasteModuleName: string, enumMap: NativeModuleEnumMap, resolveAlias: AliasResolver, + importedEnumNames?: $FlowFixMe, + emittedSharedEnums?: Set, ): string { return Object.entries(enumMap) .map(([enumName, enumNode]) => { + const sourceModule: string | void = + importedEnumNames != null ? importedEnumNames[enumName] : undefined; + // Skip shared enums that have already been emitted by another module + if (sourceModule != null && emittedSharedEnums != null) { + if (emittedSharedEnums.has(enumName)) { + return ''; + } + emittedSharedEnums.add(enumName); + } return generateEnum( - hasteModuleName, + sourceModule != null ? sourceModule : hasteModuleName, enumName, enumNode.members, enumNode.memberType, @@ -687,6 +712,10 @@ module.exports = { ): FilesOutput { const nativeModules = getModules(schema); + // Track emitted shared types across modules to avoid duplicates + const emittedSharedStructs: Set = new Set(); + const emittedSharedEnums: Set = new Set(); + const modules = Object.keys(nativeModules).flatMap(hasteModuleName => { const nativeModule = nativeModules[hasteModuleName]; const { @@ -695,6 +724,8 @@ module.exports = { spec: {methods}, spec, moduleName, + importedAliasNames, + importedEnumNames, } = nativeModule; const resolveAlias = createAliasResolver(aliasMap); const structs = createStructsString( @@ -702,8 +733,16 @@ module.exports = { aliasMap, resolveAlias, enumMap, + importedAliasNames, + emittedSharedStructs, + ); + const enums = createEnums( + hasteModuleName, + enumMap, + resolveAlias, + importedEnumNames, + emittedSharedEnums, ); - const enums = createEnums(hasteModuleName, enumMap, resolveAlias); return [ ModuleSpecClassDeclarationTemplate({ hasteModuleName, diff --git a/packages/react-native-codegen/src/parsers/__tests__/cross-file-imports-test.js b/packages/react-native-codegen/src/parsers/__tests__/cross-file-imports-test.js new file mode 100644 index 000000000000..806ef8c2e02f --- /dev/null +++ b/packages/react-native-codegen/src/parsers/__tests__/cross-file-imports-test.js @@ -0,0 +1,416 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +'use strict'; + +const {FlowParser} = require('../flow/parser'); +const {TypeScriptParser} = require('../typescript/parser'); + +const SHARED_ENUM_TYPES_FLOW = ` +/** + * @flow strict-local + * @format + */ + +export enum SharedStatusEnum { + Active = 'active', + Paused = 'paused', + Off = 'off', +} + +export enum SharedNumEnum { + One = 1, + Two = 2, + Three = 3, +} + +export type SharedStateType = { + status: SharedStatusEnum, + count: number, +}; +`; + +const SPEC_WITH_IMPORTS_FLOW = ` +/** + * @flow strict-local + * @format + */ + +import type {TurboModule} from 'react-native/Libraries/TurboModule/RCTExport'; +import type {SharedStatusEnum, SharedNumEnum, SharedStateType} from './SharedEnumTypes'; +import * as TurboModuleRegistry from 'react-native/Libraries/TurboModule/TurboModuleRegistry'; + +export interface Spec extends TurboModule { + +getStatus: (statusProp: SharedStateType) => SharedStatusEnum; + +getNum: () => SharedNumEnum; + +setStatus: (status: SharedStatusEnum) => void; +} + +export default (TurboModuleRegistry.getEnforcing( + 'NativeImportedEnumTurboModule', +): Spec); +`; + +const SHARED_ENUM_TYPES_TS = ` +export enum SharedStatusEnum { + Active = 'active', + Paused = 'paused', + Off = 'off', +} + +export enum SharedNumEnum { + One = 1, + Two = 2, + Three = 3, +} + +export type SharedStateType = { + status: SharedStatusEnum; + count: number; +}; +`; + +const SPEC_WITH_IMPORTS_TS = ` +import type {TurboModule} from 'react-native/Libraries/TurboModule/RCTExport'; +import type {SharedStatusEnum, SharedNumEnum, SharedStateType} from './SharedEnumTypes'; +import * as TurboModuleRegistry from 'react-native/Libraries/TurboModule/TurboModuleRegistry'; + +export interface Spec extends TurboModule { + getStatus(statusProp: SharedStateType): SharedStatusEnum; + getNum(): SharedNumEnum; + setStatus(status: SharedStatusEnum): void; +} + +export default TurboModuleRegistry.getEnforcing( + 'NativeImportedEnumTurboModule', +); +`; + +function getNativeModule(schema: $FlowFixMe, moduleName: string): $FlowFixMe { + const module = schema.modules[moduleName]; + if (module == null) { + throw new Error(`Module ${moduleName} not found in schema`); + } + return module; +} + +describe('Cross-file type imports', () => { + describe('FlowParser', () => { + const parser = new FlowParser(); + + it('should parse a spec with imported enums via parseString with importedTypes', () => { + // First, parse the shared types file to get its TypeDeclarationMap + const sharedAst = parser.getAst(SHARED_ENUM_TYPES_FLOW); + const sharedTypes = parser.getTypes(sharedAst); + + // Verify we got the shared types + expect(sharedTypes.SharedStatusEnum).toBeDefined(); + expect(sharedTypes.SharedNumEnum).toBeDefined(); + expect(sharedTypes.SharedStateType).toBeDefined(); + + // Now parse the spec file, passing the shared types as importedTypes + const importedTypeSourceMap = { + SharedStatusEnum: 'SharedEnumTypes', + SharedNumEnum: 'SharedEnumTypes', + SharedStateType: 'SharedEnumTypes', + }; + const schema = parser.parseString( + SPEC_WITH_IMPORTS_FLOW, + 'NativeImportedEnumTurboModule.js', + sharedTypes, + importedTypeSourceMap, + ); + + // Verify the schema was generated correctly + const module = getNativeModule(schema, 'NativeImportedEnumTurboModule'); + expect(module.type).toBe('NativeModule'); + + // Verify enum map contains the imported enums + expect(module.enumMap).toBeDefined(); + expect(module.enumMap.SharedStatusEnum).toBeDefined(); + expect(module.enumMap.SharedNumEnum).toBeDefined(); + + // Verify enum member types + expect(module.enumMap.SharedStatusEnum.type).toBe( + 'EnumDeclarationWithMembers', + ); + expect(module.enumMap.SharedStatusEnum.memberType).toBe( + 'StringTypeAnnotation', + ); + expect(module.enumMap.SharedNumEnum.memberType).toBe( + 'NumberTypeAnnotation', + ); + + // Verify methods exist + expect(module.spec.methods.length).toBe(3); + + // Verify imported type tracking + expect(module.importedEnumNames).toEqual( + expect.objectContaining({ + SharedNumEnum: 'SharedEnumTypes', + SharedStatusEnum: 'SharedEnumTypes', + }), + ); + }); + + it('should not break when importedTypes is undefined', () => { + // Existing behavior: parse a self-contained spec + const selfContainedSpec = ` +/** + * @flow strict-local + * @format + */ + +import type {TurboModule} from 'react-native/Libraries/TurboModule/RCTExport'; +import * as TurboModuleRegistry from 'react-native/Libraries/TurboModule/TurboModuleRegistry'; + +export enum LocalEnum { + A = 'a', + B = 'b', +} + +export interface Spec extends TurboModule { + +getEnum: () => LocalEnum; +} + +export default (TurboModuleRegistry.getEnforcing( + 'NativeSelfContainedModule', +): Spec); +`; + const schema = parser.parseString( + selfContainedSpec, + 'NativeSelfContainedModule.js', + ); + + const module = getNativeModule(schema, 'NativeSelfContainedModule'); + expect(module.enumMap.LocalEnum).toBeDefined(); + }); + + it('should give local types precedence over imported types', () => { + // Define a shared type + const sharedAst = parser.getAst(SHARED_ENUM_TYPES_FLOW); + const sharedTypes = parser.getTypes(sharedAst); + + // Spec that defines a local type with the same name as an imported one + const specWithLocalOverride = ` +/** + * @flow strict-local + * @format + */ + +import type {TurboModule} from 'react-native/Libraries/TurboModule/RCTExport'; +import * as TurboModuleRegistry from 'react-native/Libraries/TurboModule/TurboModuleRegistry'; + +export enum SharedStatusEnum { + LocalA = 'local_a', + LocalB = 'local_b', +} + +export interface Spec extends TurboModule { + +getStatus: () => SharedStatusEnum; +} + +export default (TurboModuleRegistry.getEnforcing( + 'NativeLocalOverrideModule', +): Spec); +`; + const schema = parser.parseString( + specWithLocalOverride, + 'NativeLocalOverrideModule.js', + sharedTypes, + ); + + const module = getNativeModule(schema, 'NativeLocalOverrideModule'); + expect(module.enumMap.SharedStatusEnum).toBeDefined(); + + // Local definition should win - it has LocalA and LocalB members + const members = module.enumMap.SharedStatusEnum.members; + expect(members).toEqual( + expect.arrayContaining([ + expect.objectContaining({name: 'LocalA'}), + expect.objectContaining({name: 'LocalB'}), + ]), + ); + + // SharedStatusEnum is defined locally, so should NOT be in importedEnumNames + expect(module.importedEnumNames ?? []).not.toContain('SharedStatusEnum'); + }); + }); + + describe('TypeScriptParser', () => { + const parser = new TypeScriptParser(); + + it('should parse a spec with imported enums via parseString with importedTypes', () => { + // First, parse the shared types file to get its TypeDeclarationMap + const sharedAst = parser.getAst(SHARED_ENUM_TYPES_TS); + const sharedTypes = parser.getTypes(sharedAst); + + // Verify we got the shared types + expect(sharedTypes.SharedStatusEnum).toBeDefined(); + expect(sharedTypes.SharedNumEnum).toBeDefined(); + expect(sharedTypes.SharedStateType).toBeDefined(); + + // Now parse the spec file, passing the shared types as importedTypes + const importedTypeSourceMap = { + SharedStatusEnum: 'SharedEnumTypes', + SharedNumEnum: 'SharedEnumTypes', + SharedStateType: 'SharedEnumTypes', + }; + const schema = parser.parseString( + SPEC_WITH_IMPORTS_TS, + 'NativeImportedEnumTurboModule.ts', + sharedTypes, + importedTypeSourceMap, + ); + + // Verify the schema was generated correctly + const module = getNativeModule(schema, 'NativeImportedEnumTurboModule'); + expect(module.type).toBe('NativeModule'); + + // Verify enum map contains the imported enums + expect(module.enumMap).toBeDefined(); + expect(module.enumMap.SharedStatusEnum).toBeDefined(); + expect(module.enumMap.SharedNumEnum).toBeDefined(); + + // Verify enum member types + expect(module.enumMap.SharedStatusEnum.type).toBe( + 'EnumDeclarationWithMembers', + ); + expect(module.enumMap.SharedStatusEnum.memberType).toBe( + 'StringTypeAnnotation', + ); + expect(module.enumMap.SharedNumEnum.memberType).toBe( + 'NumberTypeAnnotation', + ); + + // Verify methods exist + expect(module.spec.methods.length).toBe(3); + + // Verify imported type tracking + expect(module.importedEnumNames).toEqual( + expect.objectContaining({ + SharedNumEnum: 'SharedEnumTypes', + SharedStatusEnum: 'SharedEnumTypes', + }), + ); + }); + + it('should give local types precedence over imported types', () => { + // Define a shared type + const sharedAst = parser.getAst(SHARED_ENUM_TYPES_TS); + const sharedTypes = parser.getTypes(sharedAst); + + // Spec that defines a local type with the same name as an imported one + const specWithLocalOverride = ` +import type {TurboModule} from 'react-native/Libraries/TurboModule/RCTExport'; +import * as TurboModuleRegistry from 'react-native/Libraries/TurboModule/TurboModuleRegistry'; + +export enum SharedStatusEnum { + LocalA = 'local_a', + LocalB = 'local_b', +} + +export interface Spec extends TurboModule { + getStatus(): SharedStatusEnum; +} + +export default TurboModuleRegistry.getEnforcing( + 'NativeLocalOverrideModule', +); +`; + const schema = parser.parseString( + specWithLocalOverride, + 'NativeLocalOverrideModule.ts', + sharedTypes, + ); + + const module = getNativeModule(schema, 'NativeLocalOverrideModule'); + expect(module.enumMap.SharedStatusEnum).toBeDefined(); + + // Local definition should win - it has LocalA and LocalB members + const members = module.enumMap.SharedStatusEnum.members; + expect(members).toEqual( + expect.arrayContaining([ + expect.objectContaining({name: 'LocalA'}), + expect.objectContaining({name: 'LocalB'}), + ]), + ); + + // SharedStatusEnum is defined locally, so should NOT be in importedEnumNames + expect(module.importedEnumNames ?? []).not.toContain('SharedStatusEnum'); + }); + }); + + describe('getImportsFromAST', () => { + const {getImportsFromAST} = require('../utils'); + const flowParser = new FlowParser(); + const tsParser = new TypeScriptParser(); + + it('should extract type-only named imports from Flow import declarations', () => { + const ast = flowParser.getAst(` + import type {Foo, Bar} from './MyTypes'; + import type {Baz} from 'some-module'; + `); + + const imports = getImportsFromAST(ast); + expect(imports).toEqual({ + Foo: './MyTypes', + Bar: './MyTypes', + Baz: 'some-module', + }); + }); + + it('should skip value-only imports', () => { + const ast = flowParser.getAst(` + import {someFunction} from './utils'; + import type {Foo} from './MyTypes'; + `); + + const imports = getImportsFromAST(ast); + expect(imports).toEqual({ + Foo: './MyTypes', + }); + }); + + it('should ignore namespace imports', () => { + const ast = flowParser.getAst(` + import * as React from 'react'; + import type {Foo} from './MyTypes'; + `); + + const imports = getImportsFromAST(ast); + expect(imports).toEqual({ + Foo: './MyTypes', + }); + }); + + it('should return empty object for files with no imports', () => { + const ast = flowParser.getAst(` + const x = 1; + `); + + const imports = getImportsFromAST(ast); + expect(imports).toEqual({}); + }); + + it('should work with TypeScript ASTs', () => { + const ast = tsParser.getAst(` + import type {Foo, Bar} from './MyTypes'; + import {someValue} from './values'; + `); + + const imports = getImportsFromAST(ast); + expect(imports).toEqual({ + Foo: './MyTypes', + Bar: './MyTypes', + }); + }); + }); +}); diff --git a/packages/react-native-codegen/src/parsers/__tests__/parsers-commons-test.js b/packages/react-native-codegen/src/parsers/__tests__/parsers-commons-test.js index 013cfce5b9ae..5d28da9e057f 100644 --- a/packages/react-native-codegen/src/parsers/__tests__/parsers-commons-test.js +++ b/packages/react-native-codegen/src/parsers/__tests__/parsers-commons-test.js @@ -409,7 +409,7 @@ describe('buildSchemaFromConfigType', () => { (_ast, _parser) => componentSchemaMock, ); const buildModuleSchemaMock = jest.fn( - (_0, _1, _2, _3, _4) => moduleSchemaMock, + (_0, _1, _2, _3, _4, _5, _6) => moduleSchemaMock, ); const buildSchemaFromConfigTypeHelper = ( @@ -422,8 +422,8 @@ describe('buildSchemaFromConfigType', () => { astMock, wrapComponentSchemaMock, buildComponentSchemaMock, - /* $FlowFixMe[incompatible-type] Natural Inference rollout. See - * https://fburl.com/workplace/6291gfvu */ + // $FlowFixMe[incompatible-type] + // $FlowFixMe[invalid-tuple-arity] buildModuleSchemaMock, parser, flowTranslateTypeAnnotation, @@ -509,6 +509,8 @@ describe('buildSchemaFromConfigType', () => { expect.any(Function), parser, flowTranslateTypeAnnotation, + undefined, + undefined, ); expect(buildComponentSchemaMock).not.toHaveBeenCalled(); diff --git a/packages/react-native-codegen/src/parsers/flow/parser.js b/packages/react-native-codegen/src/parsers/flow/parser.js index ac2c351c9ad3..91dba932cba2 100644 --- a/packages/react-native-codegen/src/parsers/flow/parser.js +++ b/packages/react-native-codegen/src/parsers/flow/parser.js @@ -48,6 +48,11 @@ const { } = require('../parsers-commons'); const {Visitor} = require('../parsers-primitives'); const {wrapComponentSchema} = require('../schema.js'); +const { + buildImportedTypeSourceMap, + getImportsFromAST, + resolveImportedTypes, +} = require('../utils'); const {buildComponentSchema} = require('./components'); const { flattenProperties, @@ -123,11 +128,33 @@ class FlowParser implements Parser { parseFile(filename: string): SchemaType { const contents = fs.readFileSync(filename, 'utf8'); - - return this.parseString(contents, filename); + const ast = this.getAst(contents, filename); + const imports = getImportsFromAST(ast); + const importedTypes = resolveImportedTypes( + imports, + filename, + ['.js', '.js.flow', '.jsx'], + (c, f) => this.getAst(c, f), + a => this.getTypes(a), + ); + // Build source map: typeName → source haste module name + const importedTypeSourceMap: {[string]: string} | void = importedTypes + ? buildImportedTypeSourceMap(imports) + : undefined; + return this.parseString( + contents, + filename, + importedTypes, + importedTypeSourceMap, + ); } - parseString(contents: string, filename: ?string): SchemaType { + parseString( + contents: string, + filename: ?string, + importedTypes?: TypeDeclarationMap, + importedTypeSourceMap?: {[string]: string}, + ): SchemaType { return buildSchema( contents, filename, @@ -137,6 +164,8 @@ class FlowParser implements Parser { Visitor, this, flowTranslateTypeAnnotation, + importedTypes, + importedTypeSourceMap, ); } diff --git a/packages/react-native-codegen/src/parsers/parser.js b/packages/react-native-codegen/src/parsers/parser.js index a7e10d8ea9b3..5d10e5226f96 100644 --- a/packages/react-native-codegen/src/parsers/parser.js +++ b/packages/react-native-codegen/src/parsers/parser.js @@ -157,9 +157,14 @@ export interface Parser { * Given the content of a file, it returns a Schema. * @parameter contents: the content of the file. * @parameter filename: the name of the file. + * @parameter importedTypes: optional map of type declarations imported from other files. * @returns: the Schema of the file. */ - parseString(contents: string, filename: ?string): SchemaType; + parseString( + contents: string, + filename: ?string, + importedTypes?: TypeDeclarationMap, + ): SchemaType; /** * Given the name of a file, it returns a Schema. * @parameter filename: the name of the file. diff --git a/packages/react-native-codegen/src/parsers/parsers-commons.js b/packages/react-native-codegen/src/parsers/parsers-commons.js index 20f5ec396855..bd6d685ecd96 100644 --- a/packages/react-native-codegen/src/parsers/parsers-commons.js +++ b/packages/react-native-codegen/src/parsers/parsers-commons.js @@ -181,8 +181,14 @@ function getObjectTypeAnnotations( tryParse: ParserErrorCapturer, translateTypeAnnotation: $FlowFixMe, parser: Parser, -): {...NativeModuleAliasMap} { + importedTypeNames?: $FlowFixMe, + importedTypeSourceMap?: {[string]: string}, +): { + aliasMap: {...NativeModuleAliasMap}, + importedAliasNames: {[string]: string}, +} { const aliasMap: {...NativeModuleAliasMap} = {}; + const importedAliasNames: {[string]: string} = {}; Object.entries(types).forEach(([key, value]) => { const isTypeAlias = value.type === 'TypeAlias' || value.type === 'TSTypeAliasDeclaration'; @@ -229,8 +235,15 @@ function getObjectTypeAnnotations( type: 'ObjectTypeAnnotation', properties: typeProperties, }; + if (importedTypeNames != null && importedTypeNames.has(key)) { + const sourceModule = + importedTypeSourceMap != null ? importedTypeSourceMap[key] : undefined; + if (sourceModule != null) { + importedAliasNames[key] = sourceModule; + } + } }); - return aliasMap; + return {aliasMap, importedAliasNames}; } function parseObjectProperty( @@ -575,9 +588,13 @@ function buildSchemaFromConfigType( tryParse: ParserErrorCapturer, parser: Parser, translateTypeAnnotation: $FlowFixMe, + importedTypes?: TypeDeclarationMap, + importedTypeSourceMap?: {[string]: string}, ) => NativeModuleSchema, parser: Parser, translateTypeAnnotation: $FlowFixMe, + importedTypes?: TypeDeclarationMap, + importedTypeSourceMap?: {[string]: string}, ): SchemaType { switch (configType) { case 'component': { @@ -598,6 +615,8 @@ function buildSchemaFromConfigType( tryParse, parser, translateTypeAnnotation, + importedTypes, + importedTypeSourceMap, ), ); @@ -639,12 +658,16 @@ function buildSchema( tryParse: ParserErrorCapturer, parser: Parser, translateTypeAnnotation: $FlowFixMe, + importedTypes?: TypeDeclarationMap, + importedTypeSourceMap?: {[string]: string}, ) => NativeModuleSchema, Visitor: ({isComponent: boolean, isModule: boolean}) => { [type: string]: (node: $FlowFixMe) => void, }, parser: Parser, translateTypeAnnotation: $FlowFixMe, + importedTypes?: TypeDeclarationMap, + importedTypeSourceMap?: {[string]: string}, ): SchemaType { // Early return for non-Spec JavaScript files if ( @@ -666,6 +689,8 @@ function buildSchema( buildModuleSchema, parser, translateTypeAnnotation, + importedTypes, + importedTypeSourceMap, ); } @@ -760,9 +785,24 @@ const buildModuleSchema = ( tryParse: ParserErrorCapturer, parser: Parser, translateTypeAnnotation: $FlowFixMe, + importedTypes?: TypeDeclarationMap, + importedTypeSourceMap?: {[string]: string}, ): NativeModuleSchema => { const language = parser.language(); - const types = parser.getTypes(ast); + const localTypes = parser.getTypes(ast); + const types = importedTypes ? {...importedTypes, ...localTypes} : localTypes; + + // Track which type names are imported (present in importedTypes but not + // overridden by a local definition). Used to populate importedAliasNames + // and importedEnumNames in the schema so generators can skip prefixing. + const importedTypeNames: Set = new Set(); + if (importedTypes) { + for (const name of Object.keys(importedTypes)) { + if (!(name in localTypes)) { + importedTypeNames.add(name); + } + } + } const moduleSpecs = (Object.values(types): ReadonlyArray<$FlowFixMe>).filter( t => parser.isModuleInterface(t), ); @@ -796,15 +836,23 @@ const buildModuleSchema = ( moduleName, ); - const aliasMap: {...NativeModuleAliasMap} = cxxOnly + const { + aliasMap, + importedAliasNames, + }: { + aliasMap: {...NativeModuleAliasMap}, + importedAliasNames: {[string]: string}, + } = cxxOnly ? getObjectTypeAnnotations( hasteModuleName, types, tryParse, translateTypeAnnotation, parser, + importedTypeNames.size > 0 ? importedTypeNames : undefined, + importedTypeSourceMap, ) - : {}; + : {aliasMap: {}, importedAliasNames: {}}; const properties: ReadonlyArray<$FlowFixMe> = language === 'Flow' ? moduleSpec.body.properties : moduleSpec.body.body; @@ -896,6 +944,35 @@ const buildModuleSchema = ( }, ); + // Determine which enum names are imported and their source modules + const importedEnumNames: {[string]: string} = {}; + for (const name of Object.keys(nativeModuleSchema.enumMap)) { + if ( + importedTypeNames.has(name) && + importedTypeSourceMap != null && + importedTypeSourceMap[name] != null + ) { + importedEnumNames[name] = importedTypeSourceMap[name]; + } + } + + // For non-cxxOnly modules, aliasMap is populated via side effects during + // property parsing. Scan it for imported types too. + if (!cxxOnly) { + for (const name of Object.keys(nativeModuleSchema.aliasMap)) { + if ( + importedTypeNames.has(name) && + importedTypeSourceMap != null && + importedTypeSourceMap[name] != null + ) { + importedAliasNames[name] = importedTypeSourceMap[name]; + } + } + } + + const hasImportedAliases = Object.keys(importedAliasNames).length > 0; + const hasImportedEnums = Object.keys(importedEnumNames).length > 0; + return { type: 'NativeModule', aliasMap: getSortedObject(nativeModuleSchema.aliasMap), @@ -906,6 +983,8 @@ const buildModuleSchema = ( }, moduleName, excludedPlatforms: nativeModuleSchema.excludedPlatforms, + importedAliasNames: hasImportedAliases ? importedAliasNames : undefined, + importedEnumNames: hasImportedEnums ? importedEnumNames : undefined, }; }; diff --git a/packages/react-native-codegen/src/parsers/typescript/parser.js b/packages/react-native-codegen/src/parsers/typescript/parser.js index b3f4d603b4d3..1080edc1e4ff 100644 --- a/packages/react-native-codegen/src/parsers/typescript/parser.js +++ b/packages/react-native-codegen/src/parsers/typescript/parser.js @@ -49,6 +49,11 @@ const { } = require('../parsers-commons.js'); const {Visitor} = require('../parsers-primitives'); const {wrapComponentSchema} = require('../schema.js'); +const { + buildImportedTypeSourceMap, + getImportsFromAST, + resolveImportedTypes, +} = require('../utils'); const {buildComponentSchema} = require('./components'); const { flattenProperties, @@ -125,11 +130,32 @@ class TypeScriptParser implements Parser { parseFile(filename: string): SchemaType { const contents = fs.readFileSync(filename, 'utf8'); - - return this.parseString(contents, filename); + const ast = this.getAst(contents, filename); + const imports = getImportsFromAST(ast); + const importedTypes = resolveImportedTypes( + imports, + filename, + ['.ts', '.tsx', '.d.ts'], + (c, f) => this.getAst(c, f), + a => this.getTypes(a), + ); + const importedTypeSourceMap: {[string]: string} | void = importedTypes + ? buildImportedTypeSourceMap(imports) + : undefined; + return this.parseString( + contents, + filename, + importedTypes, + importedTypeSourceMap, + ); } - parseString(contents: string, filename: ?string): SchemaType { + parseString( + contents: string, + filename: ?string, + importedTypes?: TypeDeclarationMap, + importedTypeSourceMap?: {[string]: string}, + ): SchemaType { return buildSchema( contents, filename, @@ -139,6 +165,8 @@ class TypeScriptParser implements Parser { Visitor, this, typeScriptTranslateTypeAnnotation, + importedTypes, + importedTypeSourceMap, ); } diff --git a/packages/react-native-codegen/src/parsers/utils.js b/packages/react-native-codegen/src/parsers/utils.js index 851450b0b181..3743cb1ac1e4 100644 --- a/packages/react-native-codegen/src/parsers/utils.js +++ b/packages/react-native-codegen/src/parsers/utils.js @@ -11,6 +11,7 @@ 'use strict'; const {ParserError} = require('./errors'); +const fs = require('fs'); const path = require('path'); export type TypeDeclarationMap = {[declarationName: string]: $FlowFixMe}; @@ -214,6 +215,131 @@ function getSortedObject(unsortedObject: {[key: string]: T}): { }, {}); } +export type ImportMap = {[importedName: string]: string}; + +/** + * Extracts a map of imported type names to their source module names + * from ImportDeclaration nodes in the AST. Only captures type imports + * (import type {...} or inline type specifiers) to avoid unnecessary + * filesystem I/O when resolving imported types. + * + * For example: + * import type {EnumInt2} from 'MyTypes'; + * produces: + * {EnumInt2: 'MyTypes'} + */ +function getImportsFromAST(ast: $FlowFixMe): ImportMap { + const imports: ImportMap = {}; + for (const node of ast.body) { + if (node.type !== 'ImportDeclaration') { + continue; + } + // Skip pure value imports (importKind === 'value' with no type specifiers) + const isTypeImport = + node.importKind === 'type' || node.importKind === 'typeof'; + const source = node.source.value; + for (const specifier of node.specifiers) { + if (specifier.type === 'ImportSpecifier') { + // Include if the whole declaration is a type import, + // or if this specific specifier is a type import (TS inline type imports) + if (isTypeImport || specifier.importKind === 'type') { + const importedName = specifier.imported?.name ?? specifier.local.name; + imports[importedName] = source; + } + } + } + } + return imports; +} + +/** + * Resolves imported types from other files by reading the source files + * and extracting their type declarations. Only resolves relative path imports; + * framework imports (non-relative paths) are skipped. + * + * When a type is found in a source file, all types from that file are included + * to handle transitive type dependencies (e.g., importing ProjectDetails which + * references Package, both defined in the same source file). + * + * Note: This only follows one level of file imports. Transitive file imports + * (A imports from B which imports from C) are not followed. + */ +function resolveImportedTypes( + imports: ImportMap, + currentFilename: string, + extensions: Array, + getAst: (contents: string, filename?: ?string) => $FlowFixMe, + getTypes: (ast: $FlowFixMe) => TypeDeclarationMap, +): TypeDeclarationMap | void { + let importedTypes: TypeDeclarationMap = {}; + const currentDir = path.dirname(currentFilename); + let found = false; + + for (const typeName of Object.keys(imports)) { + const sourceModule = imports[typeName]; + + // Skip framework imports (react-native, react, etc.) + if (!sourceModule.startsWith('.') && !sourceModule.startsWith('/')) { + continue; + } + + // Resolve relative path + let resolvedPath = null; + const basePath = path.resolve(currentDir, sourceModule); + + for (const ext of extensions) { + const candidate = basePath + ext; + if (fs.existsSync(candidate)) { + resolvedPath = candidate; + break; + } + } + + // Try without extension (file may already have it) + if (resolvedPath == null && fs.existsSync(basePath)) { + resolvedPath = basePath; + } + + if (resolvedPath == null) { + continue; + } + + try { + const sourceContents = fs.readFileSync(resolvedPath, 'utf8'); + const sourceAst = getAst(sourceContents, resolvedPath); + const sourceTypes = getTypes(sourceAst); + if (sourceTypes[typeName] != null) { + // Include all types from the source file, not just the directly + // imported one. This handles transitive type dependencies within + // the same file (e.g., importing ProjectDetails which references + // Package, both defined in the same source file). + importedTypes = {...importedTypes, ...sourceTypes}; + found = true; + } + } catch { + // Skip files that fail to parse + continue; + } + } + + return found ? importedTypes : undefined; +} + +/** + * Builds a map from imported type names to their source haste module names. + * Uses extractNativeModuleName on the source path to derive the haste name. + */ +function buildImportedTypeSourceMap(imports: ImportMap): {[string]: string} { + const sourceMap: {[string]: string} = {}; + for (const typeName of Object.keys(imports)) { + const sourcePath = imports[typeName]; + // Extract the haste module name from the source path + const hasteName = extractNativeModuleName(sourcePath); + sourceMap[typeName] = hasteName; + } + return sourceMap; +} + module.exports = { getConfigType, extractNativeModuleName, @@ -222,4 +348,7 @@ module.exports = { visit, isModuleRegistryCall, getSortedObject, + getImportsFromAST, + resolveImportedTypes, + buildImportedTypeSourceMap, }; diff --git a/packages/rn-tester/NativeCxxModuleExample/NativeCxxModuleExample.cpp b/packages/rn-tester/NativeCxxModuleExample/NativeCxxModuleExample.cpp index 492c29a33b66..a55a699ac174 100644 --- a/packages/rn-tester/NativeCxxModuleExample/NativeCxxModuleExample.cpp +++ b/packages/rn-tester/NativeCxxModuleExample/NativeCxxModuleExample.cpp @@ -95,15 +95,15 @@ GraphNode NativeCxxModuleExample::getGraphNode( return arg; } -NativeCxxModuleExampleEnumInt NativeCxxModuleExample::getNumEnum( +SharedTypeEnumInt NativeCxxModuleExample::getNumEnum( jsi::Runtime& /*rt*/, - NativeCxxModuleExampleEnumInt arg) { + SharedTypeEnumInt arg) { return arg; } NativeCxxModuleExampleEnumStr NativeCxxModuleExample::getStrEnum( jsi::Runtime& /*rt*/, - NativeCxxModuleExampleEnumNone /*arg*/) { + SharedTypeEnumNone /*arg*/) { return NativeCxxModuleExampleEnumStr::SB; } @@ -191,7 +191,7 @@ void NativeCxxModuleExample::voidFunc(jsi::Runtime& /*rt*/) { ObjectStruct{1, "two", std::nullopt}, ObjectStruct{3, "four", std::nullopt}, ObjectStruct{5, "six", std::nullopt}}); - emitOnEvent(NativeCxxModuleExampleEnumNone::NA); + emitOnEvent(SharedTypeEnumNone::NA); } AsyncPromise<> NativeCxxModuleExample::voidPromise(jsi::Runtime& rt) { diff --git a/packages/rn-tester/NativeCxxModuleExample/NativeCxxModuleExample.h b/packages/rn-tester/NativeCxxModuleExample/NativeCxxModuleExample.h index 9c2ce01677b1..ce1b73154899 100644 --- a/packages/rn-tester/NativeCxxModuleExample/NativeCxxModuleExample.h +++ b/packages/rn-tester/NativeCxxModuleExample/NativeCxxModuleExample.h @@ -23,10 +23,10 @@ namespace facebook::react { #pragma mark - Structs -using ConstantsStruct = NativeCxxModuleExampleConstantsStruct; +using ConstantsStruct = SharedTypeConstantsStruct; template <> -struct Bridging : NativeCxxModuleExampleConstantsStructBridging {}; +struct Bridging : SharedTypeConstantsStructBridging {}; using ObjectStruct = NativeCxxModuleExampleObjectStruct>; @@ -138,9 +138,9 @@ class NativeCxxModuleExample : public NativeCxxModuleExampleCxxSpec> getMap( jsi::Runtime &rt, diff --git a/packages/rn-tester/NativeCxxModuleExample/NativeCxxModuleExample.js b/packages/rn-tester/NativeCxxModuleExample/NativeCxxModuleExample.js index ad01c7549045..6bb8c66f4ad5 100644 --- a/packages/rn-tester/NativeCxxModuleExample/NativeCxxModuleExample.js +++ b/packages/rn-tester/NativeCxxModuleExample/NativeCxxModuleExample.js @@ -8,20 +8,11 @@ * @format */ +import type {ConstantsStruct, EnumInt, EnumNone} from './SharedType'; import type {CodegenTypes, TurboModule} from 'react-native'; import {TurboModuleRegistry} from 'react-native'; -export enum EnumInt { - IA = 23, - IB = 42, -} - -export enum EnumNone { - NA, - NB, -} - export enum EnumStr { SA = 's---a', SB = 's---b', diff --git a/packages/rn-tester/NativeCxxModuleExample/SharedType.js b/packages/rn-tester/NativeCxxModuleExample/SharedType.js new file mode 100644 index 000000000000..154e6bb56a83 --- /dev/null +++ b/packages/rn-tester/NativeCxxModuleExample/SharedType.js @@ -0,0 +1,25 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +export enum EnumInt { + IA = 23, + IB = 42, +} + +export enum EnumNone { + NA, + NB, +} + +export type ConstantsStruct = { + const1: boolean, + const2: number, + const3: string, +}; diff --git a/packages/rn-tester/NativeCxxModuleExample/tests/NativeCxxModuleExampleTests.cpp b/packages/rn-tester/NativeCxxModuleExample/tests/NativeCxxModuleExampleTests.cpp index 1de45d805f87..bf42ded02324 100644 --- a/packages/rn-tester/NativeCxxModuleExample/tests/NativeCxxModuleExampleTests.cpp +++ b/packages/rn-tester/NativeCxxModuleExample/tests/NativeCxxModuleExampleTests.cpp @@ -86,19 +86,19 @@ TEST_F(NativeCxxModuleExampleTests, GetGraphNodeReturnsCorrectValues) { TEST_F(NativeCxxModuleExampleTests, GetNumEnumReturnsCorrectValues) { EXPECT_EQ( - module_->getNumEnum(*runtime_, NativeCxxModuleExampleEnumInt::IA), - NativeCxxModuleExampleEnumInt::IA); + module_->getNumEnum(*runtime_, SharedTypeEnumInt::IA), + SharedTypeEnumInt::IA); EXPECT_EQ( - module_->getNumEnum(*runtime_, NativeCxxModuleExampleEnumInt::IB), - NativeCxxModuleExampleEnumInt::IB); + module_->getNumEnum(*runtime_, SharedTypeEnumInt::IB), + SharedTypeEnumInt::IB); } TEST_F(NativeCxxModuleExampleTests, GetStrEnumReturnsCorrectValues) { EXPECT_EQ( - module_->getStrEnum(*runtime_, NativeCxxModuleExampleEnumNone::NA), + module_->getStrEnum(*runtime_, SharedTypeEnumNone::NA), NativeCxxModuleExampleEnumStr::SB); EXPECT_EQ( - module_->getStrEnum(*runtime_, NativeCxxModuleExampleEnumNone::NB), + module_->getStrEnum(*runtime_, SharedTypeEnumNone::NB), NativeCxxModuleExampleEnumStr::SB); } diff --git a/packages/rn-tester/js/examples/TurboModule/NativeCxxModuleExampleExample.js b/packages/rn-tester/js/examples/TurboModule/NativeCxxModuleExampleExample.js index dcee38ad6560..64a2053e8b7a 100644 --- a/packages/rn-tester/js/examples/TurboModule/NativeCxxModuleExampleExample.js +++ b/packages/rn-tester/js/examples/TurboModule/NativeCxxModuleExampleExample.js @@ -10,10 +10,8 @@ import type {EventSubscription, RootTag} from 'react-native'; -import NativeCxxModuleExample, { - EnumInt, - EnumNone, -} from '../../../NativeCxxModuleExample/NativeCxxModuleExample'; +import NativeCxxModuleExample from '../../../NativeCxxModuleExample/NativeCxxModuleExample'; +import {EnumInt, EnumNone} from '../../../NativeCxxModuleExample/SharedType'; import RNTesterText from '../../components/RNTesterText'; import styles from './TurboModuleExampleCommon'; import * as React from 'react'; diff --git a/private/react-native-codegen-typescript-test/src/__tests__/simple-scenario-frontend-test.ts b/private/react-native-codegen-typescript-test/src/__tests__/simple-scenario-frontend-test.ts index ebfcbb62b5f1..d4218f672f9c 100644 --- a/private/react-native-codegen-typescript-test/src/__tests__/simple-scenario-frontend-test.ts +++ b/private/react-native-codegen-typescript-test/src/__tests__/simple-scenario-frontend-test.ts @@ -27,6 +27,8 @@ export default TurboModuleRegistry.getEnforcing('SampleTurboModule'); aliasMap: {}, enumMap: {}, excludedPlatforms: undefined, + importedAliasNames: undefined, + importedEnumNames: undefined, moduleName: 'SampleTurboModule', spec: { eventEmitters: [],