Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
37 changes: 36 additions & 1 deletion ios/RCTOneSignal/RCTOneSignalEventEmitter.mm
Original file line number Diff line number Diff line change
Expand Up @@ -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 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);
}];
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)];
}
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
Expand Down Expand Up @@ -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<facebook::react::TurboModule>)getTurboModule:
Expand Down
14 changes: 14 additions & 0 deletions src/constants/internal.ts
Original file line number Diff line number Diff line change
@@ -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__';
57 changes: 56 additions & 1 deletion src/helpers.test.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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);
});
});
});
27 changes: 27 additions & 0 deletions src/helpers.ts
Original file line number Diff line number Diff line change
@@ -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');
}
Expand Down Expand Up @@ -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<string, unknown>): Record<string, unknown>;
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<string, unknown> = {};
for (const [k, v] of Object.entries(value)) {
out[k] = encodeNullsForIOS(v);
}
return out;
}
return value;
}
74 changes: 74 additions & 0 deletions src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -840,6 +841,79 @@ describe('OneSignal', () => {
expect(errorSpy).toHaveBeenCalledWith('Properties must be a JSON-serializable object');
expect(mockRNOneSignal.trackEvent).not.toHaveBeenCalled();
});

describe('iOS null handling', () => {
Comment thread
claude[bot] marked this conversation as resolved.
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', () => {
Expand Down
19 changes: 13 additions & 6 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, unknown> = {}) {
if (!isNativeModuleLoaded(RNOneSignal)) return;

Expand All @@ -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);
}
}

Expand Down
Loading