From b48aae8e2a72ab8982ff4826bf1cb7e89422f3bc Mon Sep 17 00:00:00 2001 From: Fadi George Date: Mon, 4 May 2026 10:18:02 -0700 Subject: [PATCH 1/3] fix(ios): encode nulls in trackEvent for TurboModule bridge --- ios/RCTOneSignal/RCTOneSignalEventEmitter.mm | 37 +++++++++- src/constants/internal.ts | 14 ++++ src/helpers.test.ts | 57 ++++++++++++++- src/helpers.ts | 27 +++++++ src/index.test.ts | 75 ++++++++++++++++++++ src/index.ts | 19 +++-- 6 files changed, 221 insertions(+), 8 deletions(-) create mode 100644 src/constants/internal.ts diff --git a/ios/RCTOneSignal/RCTOneSignalEventEmitter.mm b/ios/RCTOneSignal/RCTOneSignalEventEmitter.mm index 92ad3010..ea11fc6d 100644 --- a/ios/RCTOneSignal/RCTOneSignalEventEmitter.mm +++ b/ios/RCTOneSignal/RCTOneSignalEventEmitter.mm @@ -6,6 +6,40 @@ #pragma GCC diagnostic push #pragma GCC diagnostic ignored "-Wdeprecated-declarations" +// Sentinel string used by the JS side of `trackEvent` to round-trip `null` +// values across the React Native iOS TurboModule bridge. Must match +// IOS_NULL_SENTINEL in src/constants/internal.ts byte-for-byte. The string +// avoids NUL bytes because RN's convertJSIStringToNSString uses +// stringWithUTF8String: which would truncate at the first NUL. +// See SDK-4386. +static NSString *const kOSNullSentinel = @"__OS_RN_NULL_8b3f72d6c1a04f9e__"; + +// Recursively walks `value`, returning a copy with any string equal to the +// sentinel replaced by `[NSNull null]`. Containers are rebuilt; primitives +// are returned as-is. +static id _Nullable OSDecodeNullSentinels(id _Nullable value) { + if ([value isKindOfClass:[NSDictionary class]]) { + NSDictionary *dict = (NSDictionary *)value; + NSMutableDictionary *out = [NSMutableDictionary dictionaryWithCapacity:dict.count]; + [dict enumerateKeysAndObjectsUsingBlock:^(id key, id obj, BOOL *stop) { + out[key] = OSDecodeNullSentinels(obj) ?: [NSNull null]; + }]; + return out; + } + if ([value isKindOfClass:[NSArray class]]) { + NSArray *arr = (NSArray *)value; + NSMutableArray *out = [NSMutableArray arrayWithCapacity:arr.count]; + for (id item in arr) { + [out addObject:OSDecodeNullSentinels(item) ?: [NSNull null]]; + } + return out; + } + if ([value isKindOfClass:[NSString class]] && [(NSString *)value isEqualToString:kOSNullSentinel]) { + return [NSNull null]; + } + return value; +} + @interface RCTOneSignalEventEmitter () - (void)emitEventWithName:(NSString *)name body:(NSDictionary *)body; @end @@ -600,7 +634,8 @@ - (void)removeUserStateObserver { RCT_EXPORT_METHOD(trackEvent : (NSString *)name properties : (NSDictionary *_Nullable)properties) { - [OneSignal.User trackEventWithName:name properties:properties]; + NSDictionary *decoded = properties == nil ? nil : OSDecodeNullSentinels(properties); + [OneSignal.User trackEventWithName:name properties:decoded]; } - (std::shared_ptr)getTurboModule: diff --git a/src/constants/internal.ts b/src/constants/internal.ts new file mode 100644 index 00000000..26129419 --- /dev/null +++ b/src/constants/internal.ts @@ -0,0 +1,14 @@ +/** + * Sentinel string used to round-trip JS `null` values across the React Native + * iOS TurboModule bridge, which otherwise drops `null` dictionary values + * before they reach native code. The native side swaps this string back to + * `[NSNull null]`. + * + * The string must not contain NUL bytes: RN's `convertJSIStringToNSString` + * uses `[NSString stringWithUTF8String:]`, which terminates at the first NUL + * byte and would silently truncate the sentinel to `@""`. Collision with a + * customer-supplied string is avoided by the random hex suffix. + * + * See SDK-4386. + */ +export const IOS_NULL_SENTINEL = '__OS_RN_NULL_8b3f72d6c1a04f9e__'; diff --git a/src/helpers.test.ts b/src/helpers.test.ts index c4f764e0..0c98b7d5 100644 --- a/src/helpers.test.ts +++ b/src/helpers.test.ts @@ -1,7 +1,13 @@ import type { NativeModule } from 'react-native'; import { beforeEach, describe, expect, test, vi, type MockInstance } from 'vite-plus/test'; -import { isNativeModuleLoaded, isObjectSerializable, isValidCallback } from './helpers'; +import { IOS_NULL_SENTINEL } from './constants/internal'; +import { + encodeNullsForIOS, + isNativeModuleLoaded, + isObjectSerializable, + isValidCallback, +} from './helpers'; describe('helpers', () => { let errorSpy: MockInstance; @@ -103,4 +109,53 @@ describe('helpers', () => { expect(isObjectSerializable(circular)).toBe(false); }); }); + + describe('encodeNullsForIOS', () => { + test('replaces top-level null with the sentinel', () => { + expect(encodeNullsForIOS(null)).toBe(IOS_NULL_SENTINEL); + }); + + test('replaces null values inside an object', () => { + expect(encodeNullsForIOS({ a: 1, b: null })).toEqual({ + a: 1, + b: IOS_NULL_SENTINEL, + }); + }); + + test('replaces null values inside nested objects', () => { + expect(encodeNullsForIOS({ outer: { inner: null, ok: 'x' } })).toEqual({ + outer: { inner: IOS_NULL_SENTINEL, ok: 'x' }, + }); + }); + + test('replaces null values inside arrays', () => { + expect(encodeNullsForIOS([1, null, 'x'])).toEqual([1, IOS_NULL_SENTINEL, 'x']); + }); + + test('replaces null values inside mixed arrays of objects', () => { + expect(encodeNullsForIOS([1, '2', { a: '3' }, null])).toEqual([ + 1, + '2', + { a: '3' }, + IOS_NULL_SENTINEL, + ]); + }); + + test.each([ + { description: 'a number', value: 42 }, + { description: 'a float', value: 3.14 }, + { description: 'a string', value: 'abc' }, + { description: 'a boolean true', value: true }, + { description: 'a boolean false', value: false }, + ])('leaves $description untouched', ({ value }: { description: string; value: unknown }) => { + expect(encodeNullsForIOS(value)).toBe(value); + }); + + test('does not mutate the input object', () => { + const input = { a: 1, b: null, nested: { c: null }, arr: [null] }; + const snapshot = JSON.parse(JSON.stringify(input)); + encodeNullsForIOS(input); + expect(input).toEqual(snapshot); + }); + }); }); diff --git a/src/helpers.ts b/src/helpers.ts index e811357a..a383623b 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -1,5 +1,7 @@ import invariant from 'invariant'; +import { IOS_NULL_SENTINEL } from './constants/internal'; + export function isValidCallback(handler: Function) { invariant(typeof handler === 'function', 'Must provide a valid callback'); } @@ -30,3 +32,28 @@ export function isObjectSerializable(value: unknown): boolean { return false; } } + +/** + * Returns a structurally-identical clone of `value` with every `null` replaced + * by `IOS_NULL_SENTINEL`. Used to round-trip `null` values across the React + * Native iOS TurboModule bridge, which otherwise drops `null` dictionary + * values. See SDK-4386. + */ +export function encodeNullsForIOS(value: Record): Record; +export function encodeNullsForIOS(value: unknown): unknown; +export function encodeNullsForIOS(value: unknown): unknown { + if (value === null) { + return IOS_NULL_SENTINEL; + } + if (Array.isArray(value)) { + return value.map((item) => encodeNullsForIOS(item)); + } + if (typeof value === 'object') { + const out: Record = {}; + for (const [k, v] of Object.entries(value)) { + out[k] = encodeNullsForIOS(v); + } + return out; + } + return value; +} diff --git a/src/index.test.ts b/src/index.test.ts index 047cba65..2516a574 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -14,6 +14,7 @@ import { SUBSCRIPTION_CHANGED, USER_STATE_CHANGED, } from './constants/events'; +import { IOS_NULL_SENTINEL } from './constants/internal'; import EventManager, { type EventListenerMap } from './events/EventManager'; import * as helpers from './helpers'; import { LogLevel, OneSignal, OSNotificationPermission } from './index'; @@ -840,6 +841,80 @@ describe('OneSignal', () => { expect(errorSpy).toHaveBeenCalledWith('Properties must be a JSON-serializable object'); expect(mockRNOneSignal.trackEvent).not.toHaveBeenCalled(); }); + + // See SDK-4388 + describe('iOS null handling', () => { + beforeEach(() => { + mockPlatform.OS = 'ios'; + }); + + test('replaces top-level null with sentinel', () => { + OneSignal.User.trackEvent('event', { someNum: 1, someNull: null }); + expect(mockRNOneSignal.trackEvent).toHaveBeenCalledWith('event', { + someNum: 1, + someNull: IOS_NULL_SENTINEL, + }); + }); + + test('replaces nested-object null with sentinel', () => { + OneSignal.User.trackEvent('event', { + someObject: { abc: '123', nested: { def: '456' }, ghi: null }, + }); + expect(mockRNOneSignal.trackEvent).toHaveBeenCalledWith('event', { + someObject: { abc: '123', nested: { def: '456' }, ghi: IOS_NULL_SENTINEL }, + }); + }); + + test('replaces null inside arrays with sentinel', () => { + OneSignal.User.trackEvent('event', { arr: [1, null, 'x'] }); + expect(mockRNOneSignal.trackEvent).toHaveBeenCalledWith('event', { + arr: [1, IOS_NULL_SENTINEL, 'x'], + }); + }); + + test('replaces null inside mixed arrays of objects with sentinel', () => { + OneSignal.User.trackEvent('event', { + mixed: [1, '2', { abc: '123' }, null], + }); + expect(mockRNOneSignal.trackEvent).toHaveBeenCalledWith('event', { + mixed: [1, '2', { abc: '123' }, IOS_NULL_SENTINEL], + }); + }); + + test('leaves primitives untouched', () => { + const props = { n: 1, f: 3.14, s: 'abc', b: true }; + OneSignal.User.trackEvent('event', props); + expect(mockRNOneSignal.trackEvent).toHaveBeenCalledWith('event', props); + }); + + test('does not mutate the caller-supplied properties object', () => { + const props = { + top: null, + nested: { ghi: null }, + arr: [1, null], + }; + const snapshot = JSON.parse(JSON.stringify(props)); + OneSignal.User.trackEvent('event', props); + expect(props).toEqual(snapshot); + }); + }); + + describe('Android null handling', () => { + beforeEach(() => { + mockPlatform.OS = 'android'; + }); + + test('passes properties through unchanged (no sentinel substitution)', () => { + const props = { + someNum: 123, + someNull: null, + someObject: { ghi: null }, + arr: [1, null], + }; + OneSignal.User.trackEvent('event', props); + expect(mockRNOneSignal.trackEvent).toHaveBeenCalledWith('event', props); + }); + }); }); describe('Notifications', () => { diff --git a/src/index.ts b/src/index.ts index d2057177..1b4770a7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,7 +15,12 @@ import { import type { OSNotificationPermission } from './constants/subscription'; import EventManager from './events/EventManager'; import NotificationWillDisplayEvent from './events/NotificationWillDisplayEvent'; -import { isNativeModuleLoaded, isObjectSerializable, isValidCallback } from './helpers'; +import { + encodeNullsForIOS, + isNativeModuleLoaded, + isObjectSerializable, + isValidCallback, +} from './helpers'; import NativeOneSignal from './NativeOneSignal'; import type { InAppMessage, @@ -538,10 +543,7 @@ export namespace OneSignal { return tags as { [key: string]: string }; } - /** - * Track custom events for the current user. - * Note: Currently, null values will be omitted for Android. - * */ + /** Track custom events for the current user. */ export function trackEvent(name: string, properties: Record = {}) { if (!isNativeModuleLoaded(RNOneSignal)) return; @@ -550,7 +552,12 @@ export namespace OneSignal { return; } - RNOneSignal.trackEvent(name, properties); + // The iOS TurboModule bridge drops dictionary entries whose value is + // `null`. Encode nulls as a sentinel string so the native side can + // restore them as `NSNull`. See SDK-4386. + const payload = Platform.OS === 'ios' ? encodeNullsForIOS(properties) : properties; + + RNOneSignal.trackEvent(name, payload); } } From aba3d8560da7da90d5ad98c46f8ec6e2c1c90469 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Mon, 4 May 2026 10:59:29 -0700 Subject: [PATCH 2/3] test: remove stale SDK-4388 comment --- src/index.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/index.test.ts b/src/index.test.ts index 2516a574..ef6a3a88 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -842,7 +842,6 @@ describe('OneSignal', () => { expect(mockRNOneSignal.trackEvent).not.toHaveBeenCalled(); }); - // See SDK-4388 describe('iOS null handling', () => { beforeEach(() => { mockPlatform.OS = 'ios'; From 57357e64c062f94d7a04be4a1c83ab58f1f89be7 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Mon, 4 May 2026 13:25:32 -0700 Subject: [PATCH 3/3] refactor(ios): drop unreachable NSNull fallback in OSDecodeNullSentinels Co-authored-by: Cursor --- ios/RCTOneSignal/RCTOneSignalEventEmitter.mm | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ios/RCTOneSignal/RCTOneSignalEventEmitter.mm b/ios/RCTOneSignal/RCTOneSignalEventEmitter.mm index ea11fc6d..fa54595f 100644 --- a/ios/RCTOneSignal/RCTOneSignalEventEmitter.mm +++ b/ios/RCTOneSignal/RCTOneSignalEventEmitter.mm @@ -17,12 +17,12 @@ // Recursively walks `value`, returning a copy with any string equal to the // sentinel replaced by `[NSNull null]`. Containers are rebuilt; primitives // are returned as-is. -static id _Nullable OSDecodeNullSentinels(id _Nullable value) { +static id OSDecodeNullSentinels(id value) { if ([value isKindOfClass:[NSDictionary class]]) { NSDictionary *dict = (NSDictionary *)value; NSMutableDictionary *out = [NSMutableDictionary dictionaryWithCapacity:dict.count]; [dict enumerateKeysAndObjectsUsingBlock:^(id key, id obj, BOOL *stop) { - out[key] = OSDecodeNullSentinels(obj) ?: [NSNull null]; + out[key] = OSDecodeNullSentinels(obj); }]; return out; } @@ -30,7 +30,7 @@ static id _Nullable OSDecodeNullSentinels(id _Nullable value) { NSArray *arr = (NSArray *)value; NSMutableArray *out = [NSMutableArray arrayWithCapacity:arr.count]; for (id item in arr) { - [out addObject:OSDecodeNullSentinels(item) ?: [NSNull null]]; + [out addObject:OSDecodeNullSentinels(item)]; } return out; }