From a61b77777994f7430f9c3dda2a19e41c139bfe72 Mon Sep 17 00:00:00 2001 From: Christoph Purrer Date: Wed, 9 Sep 2026 18:35:25 -0700 Subject: [PATCH] Make `+moduleName` optional so New Arch modules can drop `RCT_EXPORT_MODULE` WIP (#58417) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: Changelog: [iOS][Breaking] - `+moduleName` is now optional on `RCTBridgeModule` instead of required, so a TurboModule resolved via a module provider need not declare it Investigating whether ObjC Turbo Modules still need `RCT_EXPORT_MODULE` when only the New Architecture is supported. The macro does two things, and they have diverged. **Registration is already dead code in a new-arch-only build.** When both `RCT_REMOVE_LEGACY_MODULE_INTEROP` and `RCT_REMOVE_LEGACY_COMPONENT_INTEROP` are set, `RCT_EXPORT_MODULE` already reduces to a bare name stub (`RCTBridgeModule.h:72-80`), and `RCTRegisterModule` / `RCTGetModuleClasses` are compiled out of `RCTBridge.mm` entirely. Every reader of that registry sits behind the same guards. Discovery instead runs through `RCTTurboModuleManagerDelegate`: `RCTTurboModulePluginClassProvider` over `RctTurboModuleProviderSocket` (populated by `react_module_plugin_providers` in BUCK) internally, and generated `RCTModuleProviders.mm` plus the `NSClassFromString` fallback in OSS. Neither path consults `+moduleName`. **But `+moduleName` was still load-bearing**, which is what actually blocked removing the macro. It was `required` on `RCTBridgeModule`, and `RCTBridgeModuleNameForClass` called it unguarded from `RCTTurboModuleManager.mm:778` — the hot path for every ObjC Turbo Module instantiation. Deleting `RCT_EXPORT_MODULE` therefore produced an unrecognized-selector crash, not graceful degradation. This diff makes absence safe, without touching any macro: - `RCTBridgeModule.h` — move `+ (NSString *)moduleName` below `optional`. It was already effectively optional; `RCTLogBox`, `RCTRedBox` and `RCTDevLoadingView` return `nil` today. - `RCTBridge.mm` and `RCTComponentData.mm` — guard the call with `respondsToSelector:`, letting the pre-existing `name.length == 0 -> NSStringFromClass` fallback handle absence. No behavior change for any module that declares `+moduleName`. This only unblocks removal; it does not remove the macro anywhere. **Why this is tagged Breaking.** `RCTBridgeModule` is public API, and the `required` -> `optional` move changes its published contract. Existing *implementors* are unaffected — an implementation that satisfies a required member also satisfies an optional one — but any code that *consumed* the old guarantee, i.e. called `[cls moduleName]` on an arbitrary `id` without a `respondsToSelector:` check, is no longer guaranteed a responder once modules start dropping the macro. Third-party callers doing that need the same guard this diff adds to the two in-tree call sites. **Cxx / ObjC API snapshot: verified unchanged, no regeneration needed.** The `scripts/cxx-api` snapshot format does not encode ObjC `required` / `optional`, and members are sorted alphabetically rather than by declaration order, so neither half of this edit is observable in the `.api` output. Verified empirically rather than assumed — see the test plan. Two caveats worth recording for whoever does the removal: - It is **not** safe to drop the macro everywhere yet. In apps without `react-remove-legacy-module-interop[enabled]` (per-app, rollout incomplete) the `RCTGetModuleClasses()` scan at `RCTTurboModuleManager.mm:761` is live and is the only resolver for a custom-JS-named module absent from the plugin socket; removal there silently yields `nil` for `NativeModules.Foo`. Same for OSS third-party libs where ObjC class name != JS name — both removal flags are opt-in and off by default. Do not drop it from view managers. - Pre-existing latent break, unrelated to this diff: `RCT_EXPORT_MODULE_NO_LOAD` sits at `RCTBridgeModule.h:112-121`, inside the `#else` branch, so `RCT_EXTERN_MODULE` (Swift) is undefined under interop-removal. facebook https://www.internalfb.com/agent-home?session_id=dmh-b3fdfe1a-fc40-4b19-9f59-aff69a033625 Reviewed By: javache Differential Revision: D116325661 --- packages/react-native/React/Base/RCTBridge.mm | 2 +- packages/react-native/React/Base/RCTBridgeModule.h | 7 ++++--- .../RCTLegacyViewManagerInteropComponentView.mm | 5 +++-- packages/react-native/React/Views/RCTComponentData.mm | 2 +- .../core/platform/ios/ReactCommon/RCTTurboModuleManager.mm | 4 ++++ 5 files changed, 13 insertions(+), 7 deletions(-) diff --git a/packages/react-native/React/Base/RCTBridge.mm b/packages/react-native/React/Base/RCTBridge.mm index d7379488d371..6f141963feff 100644 --- a/packages/react-native/React/Base/RCTBridge.mm +++ b/packages/react-native/React/Base/RCTBridge.mm @@ -177,7 +177,7 @@ void RCTRegisterModule(Class moduleClass) cls); #endif - NSString *name = [cls moduleName]; + NSString *name = [cls respondsToSelector:@selector(moduleName)] ? [cls moduleName] : nil; if (name.length == 0) { name = NSStringFromClass(cls); } diff --git a/packages/react-native/React/Base/RCTBridgeModule.h b/packages/react-native/React/Base/RCTBridgeModule.h index 6f2e8115f79f..14c479b25bd0 100644 --- a/packages/react-native/React/Base/RCTBridgeModule.h +++ b/packages/react-native/React/Base/RCTBridgeModule.h @@ -122,11 +122,12 @@ RCT_EXTERN_C_END #endif // defined(RCT_REMOVE_LEGACY_MODULE_INTEROP) && defined(RCT_REMOVE_LEGACY_COMPONENT_INTEROP) -// Implemented by RCT_EXPORT_MODULE -+ (NSString *)moduleName; - @optional +// Implemented by RCT_EXPORT_MODULE. When absent, callers fall back to the ObjC class +// name, so a TurboModule resolved via a provider or plugin socket need not declare it. ++ (NSString *)moduleName; + /** * A reference to the RCTModuleRegistry. Useful for modules that require access * to other NativeModules. To implement this in your module, just add `@synthesize diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/LegacyViewManagerInterop/RCTLegacyViewManagerInteropComponentView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/LegacyViewManagerInterop/RCTLegacyViewManagerInteropComponentView.mm index cc081964b1f3..e0808af76e4f 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/LegacyViewManagerInterop/RCTLegacyViewManagerInteropComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/LegacyViewManagerInterop/RCTLegacyViewManagerInteropComponentView.mm @@ -130,9 +130,10 @@ + (BOOL)isSupported:(NSString *)componentName for (Class moduleClass in registeredModules) { id bridgeModule = (id)moduleClass; - NSString *moduleName = [[bridgeModule moduleName] isEqualToString:@""] + NSString *exportedName = [bridgeModule respondsToSelector:@selector(moduleName)] ? [bridgeModule moduleName] : nil; + NSString *moduleName = exportedName.length == 0 ? [NSStringFromClass(moduleClass) stringByReplacingOccurrencesOfString:@"Manager" withString:@""] - : [bridgeModule moduleName]; + : exportedName; if (supportedLegacyViewComponents[moduleName] == NULL) { supportedLegacyViewComponents[moduleName] = moduleClass; diff --git a/packages/react-native/React/Views/RCTComponentData.mm b/packages/react-native/React/Views/RCTComponentData.mm index 64190118a881..6004e4e7c646 100644 --- a/packages/react-native/React/Views/RCTComponentData.mm +++ b/packages/react-native/React/Views/RCTComponentData.mm @@ -544,7 +544,7 @@ - (void)setProps:(NSDictionary *)props forView:(id // We want to get rid of RCT and RK prefixes, but a lot of JS code still references // view names by prefix. So, while RCTBridgeModuleNameForClass now drops these // prefixes by default, we'll still keep them around here. - NSString *name = [managerClass moduleName]; + NSString *name = [managerClass respondsToSelector:@selector(moduleName)] ? [managerClass moduleName] : nil; if (name.length == 0) { name = NSStringFromClass(managerClass); } diff --git a/packages/react-native/ReactCommon/react/nativemodule/core/platform/ios/ReactCommon/RCTTurboModuleManager.mm b/packages/react-native/ReactCommon/react/nativemodule/core/platform/ios/ReactCommon/RCTTurboModuleManager.mm index c5368ea8842f..10f87ec53f89 100644 --- a/packages/react-native/ReactCommon/react/nativemodule/core/platform/ios/ReactCommon/RCTTurboModuleManager.mm +++ b/packages/react-native/ReactCommon/react/nativemodule/core/platform/ios/ReactCommon/RCTTurboModuleManager.mm @@ -763,6 +763,10 @@ - (Class)_getModuleClassFromName:(const char *)moduleName NSString *objcModuleName = [NSString stringWithUTF8String:moduleName]; NSArray *modules = RCTGetModuleClasses(); for (Class current in modules) { + // A class without +moduleName has no custom JS name, so it can never match here. + if (![current respondsToSelector:@selector(moduleName)]) { + continue; + } NSString *currentModuleName = [current moduleName]; if ([objcModuleName isEqualToString:currentModuleName]) { return current;