Skip to content

[πŸ›] iOS (New Architecture): Realtime Database set/update/onDisconnect/transaction don't decode __rnfbNull sentinels β€” nested nulls are written as {__rnfbNull:true}Β #9339

Description

@mong-x

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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions