Issue
On iOS with the New Architecture (TurboModules), Realtime Database writes that contain a nested null store the null-sentinel object instead of deleting the key.
import { getDatabase, ref, update } from '@react-native-firebase/database';
await update(ref(getDatabase(), 'x'), { a: null, b: 1 });
// iOS: /x/a is stored as { "__rnfbNull": true } instead of being removed
// Android, Web, Admin SDK: /x/a is removed
Behind security rules that validate a (e.g. ".validate": "newData.hasChildren(['foo'])"), the same write fails with permission_denied, because the server sees an object where the app sent a delete. In our app this broke every iOS write that clears a field, such as { isPlaying: false, timer: null }. Writes without nulls worked, so it looked like an intermittent permissions problem.
It affects set (including set(ref, null)), update, setWithPriority, setPriority, onDisconnect().set/setWithPriority/update, and a transaction update function that returns null. We confirmed it with the Realtime Database emulator: a payload containing timer: { __rnfbNull: true } is denied, and the same payload with a real timer: null is allowed.
Root cause
#8774 (the fix for #8144) added null-sentinel encoding for iOS TurboModules:
packages/app/lib/internal/nullSerialization / registry/nativeModule encode nested null as { __rnfbNull: true } whenever isIOS && isTurboModule.
RNFBNullSentinelInterceptor.m decodes it by swizzling the typed RCTCxxConvert JS_*_Spec*Data: converters.
The Database TurboModule spec types these arguments as a plain Object (e.g. NativeRNFBTurboDatabaseReference.ts: set(..., props: Object), update(..., props: Object)). So no typed converter exists, the interceptor never runs, and the native helpers pass the encoded value straight to the Firebase iOS SDK:
RNFBDatabaseReferenceHelper.m: setValue:[props valueForKey:@"value"], updateChildValues:[props valueForKey:@"values"], setValue:andPriority:, setPriority:
RNFBDatabaseOnDisconnectHelper.m: onDisconnectSetValue:, onDisconnectSetValue:andPriority:, onDisconnectUpdateChildValues:
RNFBDatabaseTransactionHelper.m: transactionTryCommit, id newValue = [updates valueForKey:@"value"]
Storage already handles the same situation explicitly (RNFBStorageHelper.m calls [RNFBSharedUtils decodeNullSentinels:]). Database doesn't.
Fix
Decode the values with [RNFBSharedUtils decodeNullSentinels:] before they reach the SDK, the same way Storage does. This is the patch we're shipping against 26.3.2. The same code is unchanged in 26.4.0.
// RNFBDatabaseReferenceHelper.m
+#import "RNFBApp/RNFBSharedUtils.h"
- [firDatabaseReference setValue:[props valueForKey:@"value"]
+ [firDatabaseReference setValue:[RNFBSharedUtils decodeNullSentinels:[props valueForKey:@"value"]]
- [firDatabaseReference updateChildValues:[props valueForKey:@"values"]
+ NSDictionary *decodedValues = [RNFBSharedUtils decodeNullSentinels:[props valueForKey:@"values"]];
+ [firDatabaseReference updateChildValues:decodedValues
- [firDatabaseReference setValue:[props valueForKey:@"value"]
- andPriority:[props valueForKey:@"priority"]
+ [firDatabaseReference setValue:[RNFBSharedUtils decodeNullSentinels:[props valueForKey:@"value"]]
+ andPriority:[RNFBSharedUtils decodeNullSentinels:[props valueForKey:@"priority"]]
- [firDatabaseReference setPriority:[props valueForKey:@"priority"]
+ [firDatabaseReference setPriority:[RNFBSharedUtils decodeNullSentinels:[props valueForKey:@"priority"]]
// RNFBDatabaseOnDisconnectHelper.m
+#import "RNFBApp/RNFBSharedUtils.h"
- [firDatabaseReference onDisconnectSetValue:[props valueForKey:@"value"]
+ [firDatabaseReference onDisconnectSetValue:[RNFBSharedUtils decodeNullSentinels:[props valueForKey:@"value"]]
// (same for onDisconnectSetValue:andPriority: β value and priority)
- [firDatabaseReference onDisconnectUpdateChildValues:[props valueForKey:@"values"]
+ NSDictionary *decodedValues = [RNFBSharedUtils decodeNullSentinels:[props valueForKey:@"values"]];
+ [firDatabaseReference onDisconnectUpdateChildValues:decodedValues
// RNFBDatabaseTransactionHelper.m (transactionTryCommit)
- id newValue = [updates valueForKey:@"value"];
+ id newValue = [RNFBSharedUtils decodeNullSentinels:[updates valueForKey:@"value"]];
Notes:
decodeNullSentinels: returns NSNull for a top-level sentinel, so set(ref, null) deletes again. It leaves other dictionaries unchanged, so ServerValue.TIMESTAMP ({".sv":"timestamp"}) passes through intact.
- Query modifiers (
RNFBDatabaseQuery.m) aren't affected, because null bounds use the explicit valueType: "null" path.
- Android needs nothing. The encoding is iOS-only.
A more general alternative is to decode in the interceptor for every id/NSDictionary TurboModule argument, not only typed spec structs. That would cover any other module whose spec uses Object params. We're happy to open a PR with the targeted fix above, or with tests, whichever you prefer.
Environment
@react-native-firebase/app / database: 26.3.2 (code unchanged in 26.4.0)
- React Native 0.86, New Architecture (TurboModules), Expo SDK 57
- iOS 26 (device, TestFlight release build)
- Android: not affected
Issue
On iOS with the New Architecture (TurboModules), Realtime Database writes that contain a nested
nullstore the null-sentinel object instead of deleting the key.Behind security rules that validate
a(e.g.".validate": "newData.hasChildren(['foo'])"), the same write fails withpermission_denied, because the server sees an object where the app sent a delete. In our app this broke every iOS write that clears a field, such as{ isPlaying: false, timer: null }. Writes without nulls worked, so it looked like an intermittent permissions problem.It affects
set(includingset(ref, null)),update,setWithPriority,setPriority,onDisconnect().set/setWithPriority/update, and a transaction update function that returnsnull. We confirmed it with the Realtime Database emulator: a payload containingtimer: { __rnfbNull: true }is denied, and the same payload with a realtimer: nullis allowed.Root cause
#8774 (the fix for #8144) added null-sentinel encoding for iOS TurboModules:
packages/app/lib/internal/nullSerialization/registry/nativeModuleencode nestednullas{ __rnfbNull: true }wheneverisIOS && isTurboModule.RNFBNullSentinelInterceptor.mdecodes it by swizzling the typedRCTCxxConvert JS_*_Spec*Data:converters.The Database TurboModule spec types these arguments as a plain
Object(e.g.NativeRNFBTurboDatabaseReference.ts:set(..., props: Object),update(..., props: Object)). So no typed converter exists, the interceptor never runs, and the native helpers pass the encoded value straight to the Firebase iOS SDK:RNFBDatabaseReferenceHelper.m:setValue:[props valueForKey:@"value"],updateChildValues:[props valueForKey:@"values"],setValue:andPriority:,setPriority:RNFBDatabaseOnDisconnectHelper.m:onDisconnectSetValue:,onDisconnectSetValue:andPriority:,onDisconnectUpdateChildValues:RNFBDatabaseTransactionHelper.m:transactionTryCommit,id newValue = [updates valueForKey:@"value"]Storage already handles the same situation explicitly (
RNFBStorageHelper.mcalls[RNFBSharedUtils decodeNullSentinels:]). Database doesn't.Fix
Decode the values with
[RNFBSharedUtils decodeNullSentinels:]before they reach the SDK, the same way Storage does. This is the patch we're shipping against 26.3.2. The same code is unchanged in 26.4.0.Notes:
decodeNullSentinels:returnsNSNullfor a top-level sentinel, soset(ref, null)deletes again. It leaves other dictionaries unchanged, soServerValue.TIMESTAMP({".sv":"timestamp"}) passes through intact.RNFBDatabaseQuery.m) aren't affected, because null bounds use the explicitvalueType: "null"path.A more general alternative is to decode in the interceptor for every
id/NSDictionaryTurboModule argument, not only typed spec structs. That would cover any other module whose spec usesObjectparams. We're happy to open a PR with the targeted fix above, or with tests, whichever you prefer.Environment
@react-native-firebase/app/database: 26.3.2 (code unchanged in 26.4.0)