Skip to content

Commit ad09a9b

Browse files
feat(functions): turbo modules implementation (#8603)
Co-authored-by: Mike Hardy <[email protected]>
1 parent 2a30c0b commit ad09a9b

File tree

63 files changed

+3068
-831
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

63 files changed

+3068
-831
lines changed

eslint.config.mjs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export default [
2828
'**/app.playground.js',
2929
'**/type-test.ts',
3030
'packages/**/modular/dist/**/*',
31+
'packages/**/dist/**/*',
3132
'src/version.js',
3233
'packages/**/node_modules/**/*',
3334
'packages/**/plugin/build/**/*',
@@ -40,6 +41,7 @@ export default [
4041
'**/type-test.ts',
4142
'packages/**/modular/dist/**/*',
4243
'packages/ai/__tests__/test-utils',
44+
'packages/vertexai/__tests__/test-utils',
4345
'packages/vertexai/dist',
4446
'packages/ai/dist',
4547
],

package.json

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,13 @@
99
"lerna:clean": "lerna clean",
1010
"build:all:clean": "lerna run build:clean",
1111
"build:all:build": "lerna run build",
12+
"test:functions:build": "cd .github/workflows/scripts/functions && yarn build",
13+
"codegen:all": "lerna run codegen",
1214
"lint": "yarn lint:js && yarn lint:android && yarn lint:ios:check",
1315
"lint:js": "eslint packages/* --max-warnings=0",
14-
"lint:android": "(google-java-format --set-exit-if-changed --replace --glob=\"packages/*/android/src/**/*.java\" || (echo \"\n\nandroid formatting error - please re-run\n\n\" && exit 1)) && (git diff --exit-code packages/*/android/src || (echo \"\n\nandroid files changed from linting, please examine and commit result\n\n\" && exit 1))",
15-
"lint:ios:check": "clang-format --glob=\"packages/*/ios/**/*.{h,cpp,m,mm}\" --style=Google -n -Werror",
16-
"lint:ios:fix": "clang-format -i --glob=\"packages/*/ios/**/*.{h,cpp,m,mm}\" --style=Google",
16+
"lint:android": "(find packages/*/android/src -name '*.java' -not -path '*/generated/*' -print0 | xargs -0 google-java-format --set-exit-if-changed --replace || (echo \"\n\nandroid formatting error - please re-run\n\n\" && exit 1)) && (git diff --exit-code packages/*/android/src || (echo \"\n\nandroid files changed from linting, please examine and commit result\n\n\" && exit 1))",
17+
"lint:ios:check": "find packages/*/ios -type f \\( -name '*.h' -o -name '*.cpp' -o -name '*.m' -o -name '*.mm' \\) -not -path '*/generated/*' -print0 | xargs -0 clang-format --style=Google -n -Werror",
18+
"lint:ios:fix": "find packages/*/ios -type f \\( -name '*.h' -o -name '*.cpp' -o -name '*.m' -o -name '*.mm' \\) -not -path '*/generated/*' -print0 | xargs -0 clang-format -i --style=Google",
1719
"lint:markdown": "prettier --check \"docs/**/*.md\"",
1820
"lint:report": "eslint --output-file=eslint-report.json --format=json . --ext .js,.jsx,.ts,.tsx",
1921
"lint:spellcheck": "spellchecker --quiet --files=\"docs/**/*.md\" --dictionaries=\"./.spellcheck.dict.txt\" --reports=\"spelling.json\" --plugins spell indefinite-article repeated-words syntax-mentions syntax-urls frontmatter",
@@ -72,6 +74,7 @@
7274
"@firebase/rules-unit-testing": "^5.0.0",
7375
"@inquirer/prompts": "^7.10.1",
7476
"@octokit/core": "^7.0.6",
77+
"@react-native-community/cli": "latest",
7578
"@tsconfig/node-lts": "^22.0.4",
7679
"@types/react": "~19.0.14",
7780
"@types/react-native": "^0.73.0",

packages/app/android/src/main/java/io/invertase/firebase/common/UniversalFirebaseModule.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ public class UniversalFirebaseModule {
2929
private final Context context;
3030
private final String serviceName;
3131

32-
protected UniversalFirebaseModule(Context context, String serviceName) {
32+
public UniversalFirebaseModule(Context context, String serviceName) {
3333
this.context = context;
3434
this.serviceName = serviceName;
3535
this.executorService = new TaskExecutorService(getName());
@@ -43,7 +43,7 @@ public Context getApplicationContext() {
4343
return getContext().getApplicationContext();
4444
}
4545

46-
protected ExecutorService getExecutor() {
46+
public ExecutorService getExecutor() {
4747
return executorService.getExecutor();
4848
}
4949

packages/app/ios/RNFBApp.xcodeproj/project.pbxproj

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
2748D8202237018600FC8DC8 /* RNFBMeta.m in Sources */ = {isa = PBXBuildFile; fileRef = 2748D81F2237018600FC8DC8 /* RNFBMeta.m */; };
1818
4D97BAD423042F2700077358 /* RNFBUtilsModule.m in Sources */ = {isa = PBXBuildFile; fileRef = 4D97BAD323042F2700077358 /* RNFBUtilsModule.m */; };
1919
DAA1F28522FCF6AD00F4DEC1 /* RNFBVersion.m in Sources */ = {isa = PBXBuildFile; fileRef = DAA1F28422FCF6AD00F4DEC1 /* RNFBVersion.m */; };
20+
RNFB00001A2025011100000001 /* RNFBNullSentinelInterceptor.m in Sources */ = {isa = PBXBuildFile; fileRef = RNFB00001A2025011100000002 /* RNFBNullSentinelInterceptor.m */; };
2021
/* End PBXBuildFile section */
2122

2223
/* Begin PBXCopyFilesBuildPhase section */
@@ -53,6 +54,8 @@
5354
4D97BAD323042F2700077358 /* RNFBUtilsModule.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RNFBUtilsModule.m; sourceTree = "<group>"; };
5455
DAA1F28422FCF6AD00F4DEC1 /* RNFBVersion.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RNFBVersion.m; sourceTree = "<group>"; };
5556
DAA1F28622FCF6C200F4DEC1 /* RNFBVersion.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RNFBVersion.h; sourceTree = "<group>"; };
57+
RNFB00001A2025011100000002 /* RNFBNullSentinelInterceptor.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RNFBNullSentinelInterceptor.m; sourceTree = "<group>"; };
58+
RNFB00001A2025011100000003 /* RNFBNullSentinelInterceptor.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RNFBNullSentinelInterceptor.h; sourceTree = "<group>"; };
5659
/* End PBXFileReference section */
5760

5861
/* Begin PBXFrameworksBuildPhase section */
@@ -97,6 +100,8 @@
97100
DAA1F28622FCF6C200F4DEC1 /* RNFBVersion.h */,
98101
4D97BAD223042F0800077358 /* RNFBUtilsModule.h */,
99102
4D97BAD323042F2700077358 /* RNFBUtilsModule.m */,
103+
RNFB00001A2025011100000003 /* RNFBNullSentinelInterceptor.h */,
104+
RNFB00001A2025011100000002 /* RNFBNullSentinelInterceptor.m */,
100105
);
101106
path = RNFBApp;
102107
sourceTree = "<group>";
@@ -178,6 +183,7 @@
178183
2744B99121F46140004F8E3F /* RNFBRCTEventEmitter.m in Sources */,
179184
2744B9A421F48A4F004F8E3F /* RCTConvert+FIROptions.m in Sources */,
180185
2748D8152236426300FC8DC8 /* RNFBJSON.m in Sources */,
186+
RNFB00001A2025011100000001 /* RNFBNullSentinelInterceptor.m in Sources */,
181187
);
182188
runOnlyForDeploymentPostprocessing = 0;
183189
};
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/**
2+
* Copyright (c) 2016-present Invertase Limited & Contributors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this library except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*
16+
*/
17+
18+
#ifndef RNFBNullSentinelInterceptor_h
19+
#define RNFBNullSentinelInterceptor_h
20+
21+
#import <Foundation/Foundation.h>
22+
23+
/**
24+
* Intercepts TurboModule conversions to automatically decode null sentinels.
25+
*
26+
* iOS TurboModules strip null values from object properties during serialization.
27+
* See: https://github.com/facebook/react-native/issues/52802
28+
* The JavaScript side encodes nulls as sentinel objects,
29+
* and this interceptor automatically converts them back to NSNull before they
30+
* reach module implementation methods.
31+
*
32+
* This class uses method swizzling on RCTCxxConvert to intercept all TurboModule
33+
* data conversion methods (JS_*Module_Spec*Data:), decoding sentinels before the
34+
* data reaches the C++ bridging layer and ultimately your module methods.
35+
*/
36+
@interface RNFBNullSentinelInterceptor : NSObject
37+
38+
/**
39+
* Initializes the null sentinel interceptor.
40+
* This swizzles RCTCxxConvert (TurboModule converter) to automatically decode null sentinels.
41+
* Called automatically when the class is loaded via +load.
42+
*/
43+
+ (void)initialize;
44+
45+
@end
46+
47+
#endif
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/**
2+
* Copyright (c) 2016-present Invertase Limited & Contributors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this library except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*
16+
*/
17+
18+
#import "RNFBNullSentinelInterceptor.h"
19+
#import <objc/runtime.h>
20+
#import "RNFBSharedUtils.h"
21+
22+
@implementation RNFBNullSentinelInterceptor
23+
24+
+ (void)load {
25+
static dispatch_once_t onceToken;
26+
dispatch_once(&onceToken, ^{
27+
[self swizzleRCTConvertMethods];
28+
});
29+
}
30+
31+
+ (void)swizzleRCTConvertMethods {
32+
// For TurboModules: Swizzle RCTCxxConvert to intercept all conversion methods
33+
Class cxxConvertClass = NSClassFromString(@"RCTCxxConvert");
34+
if (cxxConvertClass) {
35+
[self swizzleTurboModuleConversions:cxxConvertClass];
36+
}
37+
}
38+
39+
+ (void)swizzleTurboModuleConversions:(Class)cxxConvertClass {
40+
// Get all methods from RCTCxxConvert
41+
unsigned int methodCount = 0;
42+
Method *methods = class_copyMethodList(object_getClass(cxxConvertClass), &methodCount);
43+
44+
for (unsigned int i = 0; i < methodCount; i++) {
45+
Method method = methods[i];
46+
SEL selector = method_getName(method);
47+
NSString *selectorName = NSStringFromSelector(selector);
48+
49+
// Intercept TurboModule data conversion methods (they follow pattern:
50+
// JS_NativeRNFBTurbo*_Spec*)
51+
if ([selectorName hasPrefix:@"JS_NativeRNFBTurbo"] && [selectorName containsString:@"_Spec"]) {
52+
// Create a swizzled version using IMP
53+
IMP originalIMP = method_getImplementation(method);
54+
const char *typeEncoding = method_getTypeEncoding(method);
55+
56+
// Replace with our wrapper that decodes nulls
57+
IMP newIMP = imp_implementationWithBlock(^id(id self, id json) {
58+
// Decode null sentinels before passing to original conversion
59+
id decoded = [RNFBSharedUtils decodeNullSentinels:json];
60+
61+
// Call original implementation with decoded data
62+
typedef id (*OriginalFunc)(id, SEL, id);
63+
OriginalFunc originalFunc = (OriginalFunc)originalIMP;
64+
return originalFunc(self, selector, decoded);
65+
});
66+
67+
method_setImplementation(method, newIMP);
68+
}
69+
}
70+
71+
free(methods);
72+
}
73+
74+
@end

packages/app/ios/RNFBApp/RNFBSharedUtils.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ extern NSString *const DEFAULT_APP_NAME;
6060

6161
+ (BOOL)getConfigBooleanValue:(NSString *)tag key:(NSString *)key defaultValue:(BOOL)defaultValue;
6262

63+
+ (id)decodeNullSentinels:(id)value;
64+
6365
@end
6466

6567
#endif

packages/app/ios/RNFBApp/RNFBSharedUtils.m

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,4 +164,122 @@ + (BOOL)getConfigBooleanValue:(NSString *)tag key:(NSString *)key defaultValue:(
164164
return enabled;
165165
}
166166

167+
/**
168+
* Decodes null sentinel objects back to NSNull values.
169+
* Uses iterative stack-based traversal to avoid stack overflow on deeply nested structures.
170+
*
171+
* This reverses the encoding done on the JavaScript side where null values in object
172+
* properties are replaced with {__rnfbNull: true} sentinel objects to survive iOS
173+
* TurboModule serialization.
174+
*
175+
* Process:
176+
* 1. Detects sentinel objects: dictionaries with single key "__rnfbNull" set to true
177+
* 2. Replaces sentinels with NSNull in object properties and arrays
178+
* 3. Preserves regular NSNull values that were in arrays (never encoded as sentinels)
179+
* 4. Deep processes all nested objects and arrays using a stack-based iteration
180+
*
181+
* @param value - The value to decode (dictionary, array, or primitive)
182+
* @return The decoded value with sentinels replaced by NSNull
183+
*/
184+
+ (id)decodeNullSentinels:(id)value {
185+
// Non-container values are returned as-is
186+
if (![value isKindOfClass:[NSDictionary class]] && ![value isKindOfClass:[NSArray class]]) {
187+
return value;
188+
}
189+
190+
// Helper to detect the sentinel
191+
BOOL (^isNullSentinel)(NSDictionary *) = ^BOOL(NSDictionary *dict) {
192+
id flag = dict[@"__rnfbNull"];
193+
return (dict.count == 1 && flag != nil && [flag boolValue]);
194+
};
195+
196+
// Helper to process a child element and add it to the parent container
197+
void (^processChild)(id, id, id, BOOL, NSMutableArray *) =
198+
^void(id child, id parentMutable, id keyOrNil, BOOL isParentDict, NSMutableArray *stack) {
199+
id processedValue = nil;
200+
201+
if ([child isKindOfClass:[NSDictionary class]]) {
202+
NSDictionary *childDict = (NSDictionary *)child;
203+
204+
if (isNullSentinel(childDict)) {
205+
// Replace sentinel with NSNull
206+
processedValue = [NSNull null];
207+
} else {
208+
// Process nested dictionary
209+
NSMutableDictionary *childMut =
210+
[NSMutableDictionary dictionaryWithCapacity:childDict.count];
211+
processedValue = childMut;
212+
[stack addObject:@{@"original" : childDict, @"mutable" : childMut}];
213+
}
214+
} else if ([child isKindOfClass:[NSArray class]]) {
215+
// Process nested array
216+
NSArray *childArray = (NSArray *)child;
217+
NSMutableArray *childMut = [NSMutableArray arrayWithCapacity:childArray.count];
218+
processedValue = childMut;
219+
[stack addObject:@{@"original" : childArray, @"mutable" : childMut}];
220+
} else {
221+
// Preserve primitive values
222+
processedValue = child ?: [NSNull null];
223+
}
224+
225+
// Add to parent container based on type
226+
if (isParentDict) {
227+
NSMutableDictionary *mutDict = (NSMutableDictionary *)parentMutable;
228+
if (processedValue) {
229+
mutDict[keyOrNil] = processedValue;
230+
}
231+
// NSDictionary can't store nil, and original code wouldn't see nil values either.
232+
} else {
233+
NSMutableArray *mutArray = (NSMutableArray *)parentMutable;
234+
[mutArray addObject:processedValue];
235+
}
236+
};
237+
238+
// Root-level sentinel case
239+
if ([value isKindOfClass:[NSDictionary class]] && isNullSentinel((NSDictionary *)value)) {
240+
return [NSNull null];
241+
}
242+
243+
id rootOriginal = value;
244+
id rootMutable = nil;
245+
246+
if ([value isKindOfClass:[NSDictionary class]]) {
247+
NSDictionary *dict = (NSDictionary *)value;
248+
rootMutable = [NSMutableDictionary dictionaryWithCapacity:dict.count];
249+
} else {
250+
NSArray *array = (NSArray *)value;
251+
rootMutable = [NSMutableArray arrayWithCapacity:array.count];
252+
}
253+
254+
// Stack-based iteration to process nested structures without recursion
255+
// Stack frames: { @"original": container, @"mutable": mutableContainer }
256+
NSMutableArray<NSDictionary *> *stack = [NSMutableArray array];
257+
[stack addObject:@{@"original" : rootOriginal, @"mutable" : rootMutable}];
258+
259+
while (stack.count > 0) {
260+
NSDictionary *frame = [stack lastObject];
261+
[stack removeLastObject];
262+
263+
id original = frame[@"original"];
264+
id mutable = frame[@"mutable"];
265+
266+
if ([original isKindOfClass:[NSDictionary class]]) {
267+
NSDictionary *origDict = (NSDictionary *)original;
268+
269+
for (id key in origDict) {
270+
id child = origDict[key];
271+
processChild(child, mutable, key, YES, stack);
272+
}
273+
} else if ([original isKindOfClass:[NSArray class]]) {
274+
NSArray *origArray = (NSArray *)original;
275+
276+
for (id child in origArray) {
277+
processChild(child, mutable, nil, NO, stack);
278+
}
279+
}
280+
}
281+
282+
return rootMutable;
283+
}
284+
167285
@end

packages/app/lib/internal/nativeModuleAndroidIos.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
/* eslint-disable no-console */
22
import { NativeModules } from 'react-native';
3+
import { TurboModuleRegistry } from 'react-native';
34

45
/**
56
* This is used by Android and iOS to get a native module.
@@ -8,7 +9,7 @@ import { NativeModules } from 'react-native';
89
* @param moduleName
910
*/
1011
export function getReactNativeModule(moduleName) {
11-
const nativeModule = NativeModules[moduleName];
12+
const nativeModule = NativeModules[moduleName] || TurboModuleRegistry.get(moduleName);
1213
if (!globalThis.RNFBDebug) {
1314
return nativeModule;
1415
}

0 commit comments

Comments
 (0)