Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export default [
'**/app.playground.js',
'**/type-test.ts',
'packages/**/modular/dist/**/*',
'packages/**/dist/**/*',
'src/version.js',
'packages/**/node_modules/**/*',
'packages/**/plugin/build/**/*',
Expand Down
7 changes: 4 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,13 @@
"lerna:clean": "lerna clean",
"build:all:clean": "lerna run build:clean",
"build:all:build": "lerna run build",
"test:functions:build": "cd .github/workflows/scripts/functions && yarn build",
"codegen:all": "lerna run codegen",
"lint": "yarn lint:js && yarn lint:android && yarn lint:ios:check",
"lint:js": "eslint packages/* --max-warnings=0",
"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))",
"lint:ios:check": "clang-format --glob=\"packages/*/ios/**/*.{h,cpp,m,mm}\" --style=Google -n -Werror",
"lint:ios:fix": "clang-format -i --glob=\"packages/*/ios/**/*.{h,cpp,m,mm}\" --style=Google",
"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))",
"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",
"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",
"lint:markdown": "prettier --check \"docs/**/*.md\"",
"lint:report": "eslint --output-file=eslint-report.json --format=json . --ext .js,.jsx,.ts,.tsx",
"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",
Expand Down
6 changes: 6 additions & 0 deletions packages/app/ios/RNFBApp.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
2748D8202237018600FC8DC8 /* RNFBMeta.m in Sources */ = {isa = PBXBuildFile; fileRef = 2748D81F2237018600FC8DC8 /* RNFBMeta.m */; };
4D97BAD423042F2700077358 /* RNFBUtilsModule.m in Sources */ = {isa = PBXBuildFile; fileRef = 4D97BAD323042F2700077358 /* RNFBUtilsModule.m */; };
DAA1F28522FCF6AD00F4DEC1 /* RNFBVersion.m in Sources */ = {isa = PBXBuildFile; fileRef = DAA1F28422FCF6AD00F4DEC1 /* RNFBVersion.m */; };
RNFB00001A2025011100000001 /* RNFBNullSentinelInterceptor.m in Sources */ = {isa = PBXBuildFile; fileRef = RNFB00001A2025011100000002 /* RNFBNullSentinelInterceptor.m */; };
/* End PBXBuildFile section */

/* Begin PBXCopyFilesBuildPhase section */
Expand Down Expand Up @@ -53,6 +54,8 @@
4D97BAD323042F2700077358 /* RNFBUtilsModule.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RNFBUtilsModule.m; sourceTree = "<group>"; };
DAA1F28422FCF6AD00F4DEC1 /* RNFBVersion.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RNFBVersion.m; sourceTree = "<group>"; };
DAA1F28622FCF6C200F4DEC1 /* RNFBVersion.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RNFBVersion.h; sourceTree = "<group>"; };
RNFB00001A2025011100000002 /* RNFBNullSentinelInterceptor.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RNFBNullSentinelInterceptor.m; sourceTree = "<group>"; };
RNFB00001A2025011100000003 /* RNFBNullSentinelInterceptor.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RNFBNullSentinelInterceptor.h; sourceTree = "<group>"; };
/* End PBXFileReference section */

/* Begin PBXFrameworksBuildPhase section */
Expand Down Expand Up @@ -97,6 +100,8 @@
DAA1F28622FCF6C200F4DEC1 /* RNFBVersion.h */,
4D97BAD223042F0800077358 /* RNFBUtilsModule.h */,
4D97BAD323042F2700077358 /* RNFBUtilsModule.m */,
RNFB00001A2025011100000003 /* RNFBNullSentinelInterceptor.h */,
RNFB00001A2025011100000002 /* RNFBNullSentinelInterceptor.m */,
);
path = RNFBApp;
sourceTree = "<group>";
Expand Down Expand Up @@ -178,6 +183,7 @@
2744B99121F46140004F8E3F /* RNFBRCTEventEmitter.m in Sources */,
2744B9A421F48A4F004F8E3F /* RCTConvert+FIROptions.m in Sources */,
2748D8152236426300FC8DC8 /* RNFBJSON.m in Sources */,
RNFB00001A2025011100000001 /* RNFBNullSentinelInterceptor.m in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down
47 changes: 47 additions & 0 deletions packages/app/ios/RNFBApp/RNFBNullSentinelInterceptor.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/**
* Copyright (c) 2016-present Invertase Limited & Contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this library except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/

#ifndef RNFBNullSentinelInterceptor_h
#define RNFBNullSentinelInterceptor_h

#import <Foundation/Foundation.h>

/**
* Intercepts TurboModule conversions to automatically decode null sentinels.
*
* iOS TurboModules strip null values from object properties during serialization.
* See: https://github.com/facebook/react-native/issues/52802
* The JavaScript side encodes nulls as sentinel objects,
* and this interceptor automatically converts them back to NSNull before they
* reach module implementation methods.
*
* This class uses method swizzling on RCTCxxConvert to intercept all TurboModule
* data conversion methods (JS_*Module_Spec*Data:), decoding sentinels before the
* data reaches the C++ bridging layer and ultimately your module methods.
*/
@interface RNFBNullSentinelInterceptor : NSObject

/**
* Initializes the null sentinel interceptor.
* This swizzles RCTCxxConvert (TurboModule converter) to automatically decode null sentinels.
* Called automatically when the class is loaded via +load.
*/
+ (void)initialize;

@end

#endif
74 changes: 74 additions & 0 deletions packages/app/ios/RNFBApp/RNFBNullSentinelInterceptor.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/**
* Copyright (c) 2016-present Invertase Limited & Contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this library except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/

#import "RNFBNullSentinelInterceptor.h"
#import <objc/runtime.h>
#import "RNFBSharedUtils.h"

@implementation RNFBNullSentinelInterceptor

+ (void)load {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
[self swizzleRCTConvertMethods];
});
}

+ (void)swizzleRCTConvertMethods {
// For TurboModules: Swizzle RCTCxxConvert to intercept all conversion methods
Class cxxConvertClass = NSClassFromString(@"RCTCxxConvert");
if (cxxConvertClass) {
[self swizzleTurboModuleConversions:cxxConvertClass];
}
}

+ (void)swizzleTurboModuleConversions:(Class)cxxConvertClass {
// Get all methods from RCTCxxConvert
unsigned int methodCount = 0;
Method *methods = class_copyMethodList(object_getClass(cxxConvertClass), &methodCount);

for (unsigned int i = 0; i < methodCount; i++) {
Method method = methods[i];
SEL selector = method_getName(method);
NSString *selectorName = NSStringFromSelector(selector);

// Intercept TurboModule data conversion methods (they follow pattern: JS_*Module_Spec*Data:)
if ([selectorName hasPrefix:@"JS_"] && [selectorName containsString:@"Module_Spec"] &&
([selectorName hasSuffix:@"Data:"] || [selectorName containsString:@"Data:"])) {
// Create a swizzled version using IMP
IMP originalIMP = method_getImplementation(method);
const char *typeEncoding = method_getTypeEncoding(method);

// Replace with our wrapper that decodes nulls
IMP newIMP = imp_implementationWithBlock(^id(id self, id json) {
// Decode null sentinels before passing to original conversion
id decoded = [RNFBSharedUtils decodeNullSentinels:json];

// Call original implementation with decoded data
typedef id (*OriginalFunc)(id, SEL, id);
OriginalFunc originalFunc = (OriginalFunc)originalIMP;
return originalFunc(self, selector, decoded);
});

method_setImplementation(method, newIMP);
}
}

free(methods);
}

@end
2 changes: 2 additions & 0 deletions packages/app/ios/RNFBApp/RNFBSharedUtils.h
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ extern NSString *const DEFAULT_APP_NAME;

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

+ (id)decodeNullSentinels:(id)value;

@end

#endif
127 changes: 127 additions & 0 deletions packages/app/ios/RNFBApp/RNFBSharedUtils.m
Original file line number Diff line number Diff line change
Expand Up @@ -164,4 +164,131 @@ + (BOOL)getConfigBooleanValue:(NSString *)tag key:(NSString *)key defaultValue:(
return enabled;
}

/**
* Decodes null sentinel objects back to NSNull values.
* Uses iterative stack-based traversal to avoid stack overflow on deeply nested structures.
*
* This reverses the encoding done on the JavaScript side where null values in object
* properties are replaced with {__rnfbNull: true} sentinel objects to survive iOS
* TurboModule serialization.
*
* Process:
* 1. Detects sentinel objects: dictionaries with single key "__rnfbNull" set to true
* 2. Replaces sentinels with NSNull in object properties and arrays
* 3. Preserves regular NSNull values that were in arrays (never encoded as sentinels)
* 4. Deep processes all nested objects and arrays using a stack-based iteration
*
* @param value - The value to decode (dictionary, array, or primitive)
* @return The decoded value with sentinels replaced by NSNull
*/
+ (id)decodeNullSentinels:(id)value {
// Non-container values are returned as-is
if (![value isKindOfClass:[NSDictionary class]] && ![value isKindOfClass:[NSArray class]]) {
return value;
}

// Helper to detect the sentinel
BOOL (^isNullSentinel)(NSDictionary *) = ^BOOL(NSDictionary *dict) {
id flag = dict[@"__rnfbNull"];
return (dict.count == 1 && flag != nil && [flag boolValue]);
};

// Root-level sentinel case
if ([value isKindOfClass:[NSDictionary class]] && isNullSentinel((NSDictionary *)value)) {
return [NSNull null];
}

id rootOriginal = value;
id rootMutable = nil;

if ([value isKindOfClass:[NSDictionary class]]) {
NSDictionary *dict = (NSDictionary *)value;
rootMutable = [NSMutableDictionary dictionaryWithCapacity:dict.count];
} else {
NSArray *array = (NSArray *)value;
rootMutable = [NSMutableArray arrayWithCapacity:array.count];
}

// Stack-based iteration to process nested structures without recursion
// Stack frames: { @"original": container, @"mutable": mutableContainer }
NSMutableArray<NSDictionary *> *stack = [NSMutableArray array];
[stack addObject:@{@"original" : rootOriginal, @"mutable" : rootMutable}];

while (stack.count > 0) {
NSDictionary *frame = [stack lastObject];
[stack removeLastObject];

id original = frame[@"original"];
id mutable = frame[@"mutable"];

if ([original isKindOfClass:[NSDictionary class]]) {
NSDictionary *origDict = (NSDictionary *)original;
NSMutableDictionary *mutDict = (NSMutableDictionary *)mutable;

for (id key in origDict) {
id child = origDict[key];

if ([child isKindOfClass:[NSDictionary class]]) {
NSDictionary *childDict = (NSDictionary *)child;

if (isNullSentinel(childDict)) {
// Replace sentinel with NSNull
mutDict[key] = [NSNull null];
} else {
// Process nested dictionary
NSMutableDictionary *childMut =
[NSMutableDictionary dictionaryWithCapacity:childDict.count];
mutDict[key] = childMut;
[stack addObject:@{@"original" : childDict, @"mutable" : childMut}];
}
} else if ([child isKindOfClass:[NSArray class]]) {
// Process nested array
NSArray *childArray = (NSArray *)child;
NSMutableArray *childMut = [NSMutableArray arrayWithCapacity:childArray.count];
mutDict[key] = childMut;
[stack addObject:@{@"original" : childArray, @"mutable" : childMut}];
} else {
// Preserve primitive values
if (child) {
mutDict[key] = child;
} else {
// NSDictionary can't store nil, and original code wouldn't see nil values either.
}
}
}
} else if ([original isKindOfClass:[NSArray class]]) {
NSArray *origArray = (NSArray *)original;
NSMutableArray *mutArray = (NSMutableArray *)mutable;

for (id child in origArray) {
if ([child isKindOfClass:[NSDictionary class]]) {
NSDictionary *childDict = (NSDictionary *)child;

if (isNullSentinel(childDict)) {
// Replace sentinel with NSNull
[mutArray addObject:[NSNull null]];
} else {
// Process nested dictionary
NSMutableDictionary *childMut =
[NSMutableDictionary dictionaryWithCapacity:childDict.count];
[mutArray addObject:childMut];
[stack addObject:@{@"original" : childDict, @"mutable" : childMut}];
}
} else if ([child isKindOfClass:[NSArray class]]) {
// Process nested array
NSArray *childArray = (NSArray *)child;
NSMutableArray *childMut = [NSMutableArray arrayWithCapacity:childArray.count];
[mutArray addObject:childMut];
[stack addObject:@{@"original" : childArray, @"mutable" : childMut}];
} else {
// Preserve primitive values and NSNull (which never became sentinels in arrays)
[mutArray addObject:child ?: [NSNull null]];
}
}
}
}

return rootMutable;
}

@end
Loading
Loading