From 6e21a19360fd68bb67b19ea716ab7494e1fc7853 Mon Sep 17 00:00:00 2001 From: Christopher Houdlette Date: Wed, 15 Apr 2026 11:24:03 -0600 Subject: [PATCH 1/4] feat: expose getAnonymousId() as public API Add getAnonymousId(): Promise to AnalyticsInterface, delegating to the native MetaRouter SDK identity layer via a new MetaRouterIdentity bridge module on both iOS and Android. - Add MetaRouterIdentityModule (Android) and MetaRouterIdentity (iOS) native bridge modules with getAnonymousId method - Add NativeIdentity.ts JS wrapper with fault-tolerant bridge access - Wire getAnonymousId through MetaRouterAnalyticsClient, init binding, and proxy (async-queued pre-bind, immediate post-bind) - Resolve null for missing/unavailable native module (no rejection) --- .../reactnative/MetaRouterIdentityModule.kt | 23 +++++++ .../reactnative/MetaRouterPackage.kt | 3 +- ios/MetaRouterIdentity.m | 62 +++++++++++++++++++ jest.setup.js | 3 + .../MetaRouterAnalyticsClient.test.ts | 47 ++++++++++++++ src/analytics/MetaRouterAnalyticsClient.ts | 9 +++ src/analytics/NativeIdentity.test.ts | 44 +++++++++++++ src/analytics/NativeIdentity.ts | 30 +++++++++ src/analytics/init.test.ts | 1 + src/analytics/init.ts | 13 ++-- src/analytics/proxy/proxyClient.test.ts | 26 ++++++++ src/analytics/proxy/proxyClient.ts | 53 +++++++++------- src/analytics/types.ts | 1 + 13 files changed, 287 insertions(+), 28 deletions(-) create mode 100644 android/src/main/java/com/metarouter/reactnative/MetaRouterIdentityModule.kt create mode 100644 ios/MetaRouterIdentity.m create mode 100644 src/analytics/NativeIdentity.test.ts create mode 100644 src/analytics/NativeIdentity.ts diff --git a/android/src/main/java/com/metarouter/reactnative/MetaRouterIdentityModule.kt b/android/src/main/java/com/metarouter/reactnative/MetaRouterIdentityModule.kt new file mode 100644 index 0000000..73a4614 --- /dev/null +++ b/android/src/main/java/com/metarouter/reactnative/MetaRouterIdentityModule.kt @@ -0,0 +1,23 @@ +package com.metarouter.reactnative + +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactContextBaseJavaModule +import com.facebook.react.bridge.ReactMethod +import com.facebook.react.bridge.Promise + +class MetaRouterIdentityModule( + reactContext: ReactApplicationContext +) : ReactContextBaseJavaModule(reactContext) { + + override fun getName(): String = "MetaRouterIdentity" + + @ReactMethod + fun getAnonymousId(promise: Promise) { + try { + val anonymousId = com.metarouter.analytics.MetaRouter.Analytics.client()?.getAnonymousId() + promise.resolve(anonymousId) + } catch (e: Exception) { + promise.resolve(null) + } + } +} diff --git a/android/src/main/java/com/metarouter/reactnative/MetaRouterPackage.kt b/android/src/main/java/com/metarouter/reactnative/MetaRouterPackage.kt index 13acd27..720831f 100644 --- a/android/src/main/java/com/metarouter/reactnative/MetaRouterPackage.kt +++ b/android/src/main/java/com/metarouter/reactnative/MetaRouterPackage.kt @@ -11,7 +11,8 @@ class MetaRouterPackage : ReactPackage { ): List { return listOf( MetaRouterQueueStorageModule(reactContext), - MetaRouterNetworkMonitorModule(reactContext) + MetaRouterNetworkMonitorModule(reactContext), + MetaRouterIdentityModule(reactContext) ) } diff --git a/ios/MetaRouterIdentity.m b/ios/MetaRouterIdentity.m new file mode 100644 index 0000000..f318de4 --- /dev/null +++ b/ios/MetaRouterIdentity.m @@ -0,0 +1,62 @@ +#import + +@interface MetaRouterIdentity : NSObject +@end + +@implementation MetaRouterIdentity + +RCT_EXPORT_MODULE() + ++ (BOOL)requiresMainQueueSetup { + return NO; +} + +RCT_EXPORT_METHOD(getAnonymousId:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) +{ + // Delegate to the native MetaRouter iOS SDK's identity layer. + // If the native SDK is not initialized or unavailable, resolve nil. + @try { + Class metaRouterClass = NSClassFromString(@"MetaRouter.Analytics"); + if (!metaRouterClass) { + resolve([NSNull null]); + return; + } + + // The native iOS SDK exposes getAnonymousId() on the AnalyticsInterface + // returned by MetaRouter.Analytics. Because the iOS SDK is actor-based and + // async, the bridge resolves nil when the client is not yet available. + SEL clientSel = NSSelectorFromString(@"client"); + if (![metaRouterClass respondsToSelector:clientSel]) { + resolve([NSNull null]); + return; + } + +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Warc-performSelector-leaks" + id client = [metaRouterClass performSelector:clientSel]; +#pragma clang diagnostic pop + + if (!client) { + resolve([NSNull null]); + return; + } + + SEL anonIdSel = NSSelectorFromString(@"getAnonymousId"); + if (![client respondsToSelector:anonIdSel]) { + resolve([NSNull null]); + return; + } + +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Warc-performSelector-leaks" + id anonymousId = [client performSelector:anonIdSel]; +#pragma clang diagnostic pop + + resolve(anonymousId ?: [NSNull null]); + } @catch (NSException *exception) { + resolve([NSNull null]); + } +} + +@end diff --git a/jest.setup.js b/jest.setup.js index 0e49fc6..cc90a84 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -17,6 +17,9 @@ jest.mock('react-native', () => ({ writeSnapshot: jest.fn(() => Promise.resolve()), deleteSnapshot: jest.fn(() => Promise.resolve()), }, + MetaRouterIdentity: { + getAnonymousId: jest.fn(() => Promise.resolve(null)), + }, }, NativeEventEmitter: jest.fn().mockImplementation(() => ({ addListener: jest.fn(), diff --git a/src/analytics/MetaRouterAnalyticsClient.test.ts b/src/analytics/MetaRouterAnalyticsClient.test.ts index 472434d..f076c0b 100644 --- a/src/analytics/MetaRouterAnalyticsClient.test.ts +++ b/src/analytics/MetaRouterAnalyticsClient.test.ts @@ -595,6 +595,53 @@ describe('MetaRouterAnalyticsClient', () => { expect(client.queue[0].context.device.advertisingId).toBeUndefined(); }); + describe('getAnonymousId', () => { + it('delegates to the native identity module', async () => { + const { NativeModules } = require('react-native'); + NativeModules.MetaRouterIdentity = { + getAnonymousId: jest.fn(() => Promise.resolve('native-anon-456')), + }; + + const client = new MetaRouterAnalyticsClient(opts); + await client.init(); + + const result = await client.getAnonymousId(); + expect(result).toBe('native-anon-456'); + expect( + NativeModules.MetaRouterIdentity.getAnonymousId + ).toHaveBeenCalled(); + }); + + it('returns null when native module returns null', async () => { + const { NativeModules } = require('react-native'); + NativeModules.MetaRouterIdentity = { + getAnonymousId: jest.fn(() => Promise.resolve(null)), + }; + + const client = new MetaRouterAnalyticsClient(opts); + await client.init(); + + const result = await client.getAnonymousId(); + expect(result).toBeNull(); + }); + + it('does not depend on the JS IdentityManager value', async () => { + const { NativeModules } = require('react-native'); + NativeModules.MetaRouterIdentity = { + getAnonymousId: jest.fn(() => Promise.resolve('native-id')), + }; + + const client = new MetaRouterAnalyticsClient(opts); + await client.init(); + + // The JS IdentityManager has 'anon-123' from the mock, but + // getAnonymousId() should return the native value + const result = await client.getAnonymousId(); + expect(result).toBe('native-id'); + expect(result).not.toBe('anon-123'); + }); + }); + describe('network awareness', () => { it('getDebugInfo includes networkStatus', async () => { const monitor = new StubNetworkMonitor('connected'); diff --git a/src/analytics/MetaRouterAnalyticsClient.ts b/src/analytics/MetaRouterAnalyticsClient.ts index 066451d..269263a 100644 --- a/src/analytics/MetaRouterAnalyticsClient.ts +++ b/src/analytics/MetaRouterAnalyticsClient.ts @@ -10,6 +10,7 @@ import { removeIdentityField, ADVERTISING_ID_KEY, } from './utils/identityStorage'; +import { getAnonymousId as getNativeAnonymousId } from './NativeIdentity'; import CircuitBreaker from './utils/circuitBreaker'; import Dispatcher from './dispatcher'; import { PersistentEventQueue } from './persistence/PersistentEventQueue'; @@ -469,6 +470,14 @@ export class MetaRouterAnalyticsClient { log('Debug logging enabled'); } + /** + * Returns the current anonymous ID from the native identity layer. + * Resolves null when the native SDK is unavailable or has no anonymous ID. + */ + async getAnonymousId(): Promise { + return getNativeAnonymousId(); + } + /** * Get current state for debugging */ diff --git a/src/analytics/NativeIdentity.test.ts b/src/analytics/NativeIdentity.test.ts new file mode 100644 index 0000000..63c22f5 --- /dev/null +++ b/src/analytics/NativeIdentity.test.ts @@ -0,0 +1,44 @@ +describe('NativeIdentity', () => { + beforeEach(() => { + jest.resetModules(); + jest.clearAllMocks(); + }); + + function getNativeModules() { + return require('react-native').NativeModules; + } + + it('resolves a string when the native module returns a string', async () => { + getNativeModules().MetaRouterIdentity = { + getAnonymousId: jest.fn(() => Promise.resolve('anon-abc-123')), + }; + const { getAnonymousId } = require('./NativeIdentity'); + const result = await getAnonymousId(); + expect(result).toBe('anon-abc-123'); + }); + + it('resolves null when the native module returns null', async () => { + getNativeModules().MetaRouterIdentity = { + getAnonymousId: jest.fn(() => Promise.resolve(null)), + }; + const { getAnonymousId } = require('./NativeIdentity'); + const result = await getAnonymousId(); + expect(result).toBeNull(); + }); + + it('resolves null when the native module is missing', async () => { + getNativeModules().MetaRouterIdentity = undefined; + const { getAnonymousId } = require('./NativeIdentity'); + const result = await getAnonymousId(); + expect(result).toBeNull(); + }); + + it('resolves null when the native module rejects unexpectedly', async () => { + getNativeModules().MetaRouterIdentity = { + getAnonymousId: jest.fn(() => Promise.reject(new Error('native crash'))), + }; + const { getAnonymousId } = require('./NativeIdentity'); + const result = await getAnonymousId(); + expect(result).toBeNull(); + }); +}); diff --git a/src/analytics/NativeIdentity.ts b/src/analytics/NativeIdentity.ts new file mode 100644 index 0000000..e7b30d2 --- /dev/null +++ b/src/analytics/NativeIdentity.ts @@ -0,0 +1,30 @@ +import { NativeModules } from 'react-native'; +import { warn } from './utils/logger'; + +interface NativeIdentityModule { + getAnonymousId(): Promise; +} + +function getModule(): NativeIdentityModule | null { + const mod = NativeModules.MetaRouterIdentity as + | NativeIdentityModule + | undefined; + if (!mod) { + warn( + 'MetaRouterIdentity native module is not available. getAnonymousId() will return null.' + ); + return null; + } + return mod; +} + +export async function getAnonymousId(): Promise { + const mod = getModule(); + if (!mod) return null; + try { + return await mod.getAnonymousId(); + } catch (err) { + warn('Failed to get anonymous ID from native module:', err); + return null; + } +} diff --git a/src/analytics/init.test.ts b/src/analytics/init.test.ts index 1df6ba2..3ecdc90 100644 --- a/src/analytics/init.test.ts +++ b/src/analytics/init.test.ts @@ -51,6 +51,7 @@ describe('createAnalyticsClient', () => { expect(typeof client.reset).toBe('function'); expect(typeof client.enableDebugLogging).toBe('function'); expect(typeof client.getDebugInfo).toBe('function'); + expect(typeof client.getAnonymousId).toBe('function'); }); it('returns the same proxy (rebound under the hood)', async () => { diff --git a/src/analytics/init.ts b/src/analytics/init.ts index 27239b4..0edeb56 100644 --- a/src/analytics/init.ts +++ b/src/analytics/init.ts @@ -1,6 +1,6 @@ -import { MetaRouterAnalyticsClient } from "./MetaRouterAnalyticsClient"; -import { proxyClient, setRealClient } from "./proxy/proxyClient"; -import type { InitOptions, AnalyticsInterface } from "./types"; +import { MetaRouterAnalyticsClient } from './MetaRouterAnalyticsClient'; +import { proxyClient, setRealClient } from './proxy/proxyClient'; +import type { InitOptions, AnalyticsInterface } from './types'; // Only one initialization in flight let initPromise: Promise | null = null; @@ -10,7 +10,8 @@ export function createAnalyticsClient( options: InitOptions ): AnalyticsInterface { // Check if options have changed - if so, force reset first - const optionsChanged = currentOptions && + const optionsChanged = + currentOptions && JSON.stringify(currentOptions) !== JSON.stringify(options); if (optionsChanged && initPromise) { @@ -31,11 +32,13 @@ export function createAnalyticsClient( screen: (name, props) => instance.screen(name, props), page: (name, props) => instance.page(name, props), alias: (newUserId) => instance.alias(newUserId), - setAdvertisingId: (advertisingId) => instance.setAdvertisingId(advertisingId), + setAdvertisingId: (advertisingId) => + instance.setAdvertisingId(advertisingId), clearAdvertisingId: () => instance.clearAdvertisingId(), setTracing: (enabled) => instance.setTracing(enabled), enableDebugLogging: () => instance.enableDebugLogging(), getDebugInfo: () => instance.getDebugInfo(), + getAnonymousId: () => instance.getAnonymousId(), flush: () => instance.flush(), reset: async () => { await instance.reset(); diff --git a/src/analytics/proxy/proxyClient.test.ts b/src/analytics/proxy/proxyClient.test.ts index 17cc018..dc7ae4f 100644 --- a/src/analytics/proxy/proxyClient.test.ts +++ b/src/analytics/proxy/proxyClient.test.ts @@ -20,6 +20,7 @@ describe('proxyClient', () => { reset: jest.fn().mockResolvedValue(undefined), enableDebugLogging: jest.fn(), getDebugInfo: jest.fn().mockResolvedValue({ ok: true }), + getAnonymousId: jest.fn().mockResolvedValue('anon-mock-id'), }; const { setRealClient } = require('./proxyClient'); setRealClient(null, { dropPending: true }); // clear state between tests @@ -204,6 +205,29 @@ describe('proxyClient', () => { expect(mockClient.track).toHaveBeenCalledWith('after', undefined); }); + it('getAnonymousId queues pre-bind and resolves after real client is bound', async () => { + const { proxyClient, setRealClient } = require('./proxyClient'); + + const preBindPromise = proxyClient.getAnonymousId(); + + mockClient.getAnonymousId.mockResolvedValue('native-anon-789'); + setRealClient(mockClient); + + const result = await preBindPromise; + expect(result).toBe('native-anon-789'); + expect(mockClient.getAnonymousId).toHaveBeenCalled(); + }); + + it('getAnonymousId forwards immediately post-bind', async () => { + const { proxyClient, setRealClient } = require('./proxyClient'); + mockClient.getAnonymousId.mockResolvedValue('post-bind-id'); + setRealClient(mockClient); + + const result = await proxyClient.getAnonymousId(); + expect(result).toBe('post-bind-id'); + expect(mockClient.getAnonymousId).toHaveBeenCalled(); + }); + it('allows new flush after unbind resets singleflight', async () => { const { proxyClient, setRealClient } = require('./proxyClient'); setRealClient(mockClient); @@ -237,6 +261,7 @@ describe('proxyClient', () => { await proxyClient.flush(); await proxyClient.reset(); await proxyClient.getDebugInfo(); + await proxyClient.getAnonymousId(); expect(mockClient.track).toHaveBeenCalledWith('e', { p: 1 }); expect(mockClient.identify).toHaveBeenCalledWith('u', { plan: 'pro' }); @@ -248,5 +273,6 @@ describe('proxyClient', () => { expect(mockClient.flush).toHaveBeenCalled(); expect(mockClient.reset).toHaveBeenCalled(); expect(mockClient.getDebugInfo).toHaveBeenCalled(); + expect(mockClient.getAnonymousId).toHaveBeenCalled(); }); }); diff --git a/src/analytics/proxy/proxyClient.ts b/src/analytics/proxy/proxyClient.ts index 9aab166..bde0b88 100644 --- a/src/analytics/proxy/proxyClient.ts +++ b/src/analytics/proxy/proxyClient.ts @@ -1,4 +1,4 @@ -import type { AnalyticsInterface } from "../types"; +import type { AnalyticsInterface } from '../types'; type PendingItem = { fn: () => void | Promise; @@ -11,10 +11,16 @@ const MAX_PENDING_CALLS = 20; let realClient: AnalyticsInterface | null = null; // Async-returning methods -type AsyncMethod = "flush" | "getDebugInfo" | "setAdvertisingId" | "clearAdvertisingId"; +type AsyncMethod = + | 'flush' + | 'getDebugInfo' + | 'getAnonymousId' + | 'setAdvertisingId' + | 'clearAdvertisingId'; const ASYNC_METHODS: Record = { flush: true, getDebugInfo: true, + getAnonymousId: true, setAdvertisingId: true, clearAdvertisingId: true, }; @@ -53,7 +59,7 @@ function handleMethodCall( ): ReturnType { // Real client bound if (realClient) { - if (methodName === "flush") { + if (methodName === 'flush') { if (flushInFlight) return flushInFlight as ReturnType; const p = Promise.resolve((realClient.flush as any)(...args)).then( @@ -81,7 +87,7 @@ function handleMethodCall( } // Special-cases while unbound: - if (methodName === "reset") { + if (methodName === 'reset') { // Nothing to reset pre-bind; resolve immediately return Promise.resolve() as ReturnType; } @@ -95,11 +101,11 @@ function handleMethodCall( try { if (!realClient) { return reject( - new Error("Proxy detached before real client was bound") + new Error('Proxy detached before real client was bound') ); } - if (methodName === "flush") { + if (methodName === 'flush') { if (!flushInFlight) { const p = Promise.resolve( (realClient.flush as any)(...args) @@ -132,20 +138,23 @@ function handleMethodCall( } export const proxyClient: AnalyticsInterface = { - track: (event, props) => handleMethodCall("track", event, props), - identify: (userId, traits) => handleMethodCall("identify", userId, traits), - group: (groupId, traits) => handleMethodCall("group", groupId, traits), - screen: (name, props) => handleMethodCall("screen", name, props), - page: (name, props) => handleMethodCall("page", name, props), - alias: (newUserId) => handleMethodCall("alias", newUserId), - setAdvertisingId: (advertisingId) => handleMethodCall("setAdvertisingId", advertisingId) as Promise, - clearAdvertisingId: () => handleMethodCall("clearAdvertisingId") as Promise, - setTracing: (enabled) => handleMethodCall("setTracing", enabled), - - flush: () => handleMethodCall("flush"), - reset: () => handleMethodCall("reset"), - enableDebugLogging: () => handleMethodCall("enableDebugLogging"), - getDebugInfo: () => handleMethodCall("getDebugInfo"), + track: (event, props) => handleMethodCall('track', event, props), + identify: (userId, traits) => handleMethodCall('identify', userId, traits), + group: (groupId, traits) => handleMethodCall('group', groupId, traits), + screen: (name, props) => handleMethodCall('screen', name, props), + page: (name, props) => handleMethodCall('page', name, props), + alias: (newUserId) => handleMethodCall('alias', newUserId), + setAdvertisingId: (advertisingId) => + handleMethodCall('setAdvertisingId', advertisingId) as Promise, + clearAdvertisingId: () => + handleMethodCall('clearAdvertisingId') as Promise, + setTracing: (enabled) => handleMethodCall('setTracing', enabled), + + flush: () => handleMethodCall('flush'), + reset: () => handleMethodCall('reset'), + enableDebugLogging: () => handleMethodCall('enableDebugLogging'), + getDebugInfo: () => handleMethodCall('getDebugInfo'), + getAnonymousId: () => handleMethodCall('getAnonymousId'), }; /** @@ -187,13 +196,13 @@ export function setRealClient( try { Promise.resolve(fn()).catch(() => {}); } catch (err) { - console.warn("[MetaRouter] replay error:", err); + console.warn('[MetaRouter] replay error:', err); } } } else { if (opts?.dropPending) { for (const it of pendingCalls) - it.reject?.(new Error("[MetaRouter] Proxy dropped before bind")); + it.reject?.(new Error('[MetaRouter] Proxy dropped before bind')); pendingCalls.length = 0; } flushInFlight = null; // reset coalescer diff --git a/src/analytics/types.ts b/src/analytics/types.ts index dd49392..6ae877c 100644 --- a/src/analytics/types.ts +++ b/src/analytics/types.ts @@ -51,6 +51,7 @@ export interface AnalyticsInterface { reset: () => Promise; enableDebugLogging: () => void; getDebugInfo: () => Promise>; + getAnonymousId: () => Promise; } export interface EventContext { From d506d8a5be3b130977c087e67c8d8ae9c9c4880c Mon Sep 17 00:00:00 2001 From: Christopher Houdlette Date: Wed, 15 Apr 2026 12:49:53 -0600 Subject: [PATCH 2/4] fix: changing to async method with string --- .../reactnative/MetaRouterIdentityModule.kt | 23 ------- .../reactnative/MetaRouterPackage.kt | 3 +- ios/MetaRouterIdentity.m | 62 ------------------- jest.setup.js | 3 - .../MetaRouterAnalyticsClient.test.ts | 41 +++--------- src/analytics/MetaRouterAnalyticsClient.ts | 10 +-- src/analytics/NativeIdentity.test.ts | 44 ------------- src/analytics/NativeIdentity.ts | 30 --------- src/analytics/proxy/proxyClient.test.ts | 4 +- src/analytics/types.ts | 2 +- 10 files changed, 17 insertions(+), 205 deletions(-) delete mode 100644 android/src/main/java/com/metarouter/reactnative/MetaRouterIdentityModule.kt delete mode 100644 ios/MetaRouterIdentity.m delete mode 100644 src/analytics/NativeIdentity.test.ts delete mode 100644 src/analytics/NativeIdentity.ts diff --git a/android/src/main/java/com/metarouter/reactnative/MetaRouterIdentityModule.kt b/android/src/main/java/com/metarouter/reactnative/MetaRouterIdentityModule.kt deleted file mode 100644 index 73a4614..0000000 --- a/android/src/main/java/com/metarouter/reactnative/MetaRouterIdentityModule.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.metarouter.reactnative - -import com.facebook.react.bridge.ReactApplicationContext -import com.facebook.react.bridge.ReactContextBaseJavaModule -import com.facebook.react.bridge.ReactMethod -import com.facebook.react.bridge.Promise - -class MetaRouterIdentityModule( - reactContext: ReactApplicationContext -) : ReactContextBaseJavaModule(reactContext) { - - override fun getName(): String = "MetaRouterIdentity" - - @ReactMethod - fun getAnonymousId(promise: Promise) { - try { - val anonymousId = com.metarouter.analytics.MetaRouter.Analytics.client()?.getAnonymousId() - promise.resolve(anonymousId) - } catch (e: Exception) { - promise.resolve(null) - } - } -} diff --git a/android/src/main/java/com/metarouter/reactnative/MetaRouterPackage.kt b/android/src/main/java/com/metarouter/reactnative/MetaRouterPackage.kt index 720831f..13acd27 100644 --- a/android/src/main/java/com/metarouter/reactnative/MetaRouterPackage.kt +++ b/android/src/main/java/com/metarouter/reactnative/MetaRouterPackage.kt @@ -11,8 +11,7 @@ class MetaRouterPackage : ReactPackage { ): List { return listOf( MetaRouterQueueStorageModule(reactContext), - MetaRouterNetworkMonitorModule(reactContext), - MetaRouterIdentityModule(reactContext) + MetaRouterNetworkMonitorModule(reactContext) ) } diff --git a/ios/MetaRouterIdentity.m b/ios/MetaRouterIdentity.m deleted file mode 100644 index f318de4..0000000 --- a/ios/MetaRouterIdentity.m +++ /dev/null @@ -1,62 +0,0 @@ -#import - -@interface MetaRouterIdentity : NSObject -@end - -@implementation MetaRouterIdentity - -RCT_EXPORT_MODULE() - -+ (BOOL)requiresMainQueueSetup { - return NO; -} - -RCT_EXPORT_METHOD(getAnonymousId:(RCTPromiseResolveBlock)resolve - rejecter:(RCTPromiseRejectBlock)reject) -{ - // Delegate to the native MetaRouter iOS SDK's identity layer. - // If the native SDK is not initialized or unavailable, resolve nil. - @try { - Class metaRouterClass = NSClassFromString(@"MetaRouter.Analytics"); - if (!metaRouterClass) { - resolve([NSNull null]); - return; - } - - // The native iOS SDK exposes getAnonymousId() on the AnalyticsInterface - // returned by MetaRouter.Analytics. Because the iOS SDK is actor-based and - // async, the bridge resolves nil when the client is not yet available. - SEL clientSel = NSSelectorFromString(@"client"); - if (![metaRouterClass respondsToSelector:clientSel]) { - resolve([NSNull null]); - return; - } - -#pragma clang diagnostic push -#pragma clang diagnostic ignored "-Warc-performSelector-leaks" - id client = [metaRouterClass performSelector:clientSel]; -#pragma clang diagnostic pop - - if (!client) { - resolve([NSNull null]); - return; - } - - SEL anonIdSel = NSSelectorFromString(@"getAnonymousId"); - if (![client respondsToSelector:anonIdSel]) { - resolve([NSNull null]); - return; - } - -#pragma clang diagnostic push -#pragma clang diagnostic ignored "-Warc-performSelector-leaks" - id anonymousId = [client performSelector:anonIdSel]; -#pragma clang diagnostic pop - - resolve(anonymousId ?: [NSNull null]); - } @catch (NSException *exception) { - resolve([NSNull null]); - } -} - -@end diff --git a/jest.setup.js b/jest.setup.js index cc90a84..0e49fc6 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -17,9 +17,6 @@ jest.mock('react-native', () => ({ writeSnapshot: jest.fn(() => Promise.resolve()), deleteSnapshot: jest.fn(() => Promise.resolve()), }, - MetaRouterIdentity: { - getAnonymousId: jest.fn(() => Promise.resolve(null)), - }, }, NativeEventEmitter: jest.fn().mockImplementation(() => ({ addListener: jest.fn(), diff --git a/src/analytics/MetaRouterAnalyticsClient.test.ts b/src/analytics/MetaRouterAnalyticsClient.test.ts index f076c0b..b3823b9 100644 --- a/src/analytics/MetaRouterAnalyticsClient.test.ts +++ b/src/analytics/MetaRouterAnalyticsClient.test.ts @@ -596,49 +596,24 @@ describe('MetaRouterAnalyticsClient', () => { }); describe('getAnonymousId', () => { - it('delegates to the native identity module', async () => { - const { NativeModules } = require('react-native'); - NativeModules.MetaRouterIdentity = { - getAnonymousId: jest.fn(() => Promise.resolve('native-anon-456')), - }; - + it('returns the JS IdentityManager anonymous ID', async () => { const client = new MetaRouterAnalyticsClient(opts); await client.init(); const result = await client.getAnonymousId(); - expect(result).toBe('native-anon-456'); - expect( - NativeModules.MetaRouterIdentity.getAnonymousId - ).toHaveBeenCalled(); + expect(result).toBe('anon-123'); }); - it('returns null when native module returns null', async () => { - const { NativeModules } = require('react-native'); - NativeModules.MetaRouterIdentity = { - getAnonymousId: jest.fn(() => Promise.resolve(null)), - }; - + it('is async and returns a string (never null)', async () => { const client = new MetaRouterAnalyticsClient(opts); await client.init(); - const result = await client.getAnonymousId(); - expect(result).toBeNull(); - }); - - it('does not depend on the JS IdentityManager value', async () => { - const { NativeModules } = require('react-native'); - NativeModules.MetaRouterIdentity = { - getAnonymousId: jest.fn(() => Promise.resolve('native-id')), - }; + const promise = client.getAnonymousId(); + expect(promise).toBeInstanceOf(Promise); - const client = new MetaRouterAnalyticsClient(opts); - await client.init(); - - // The JS IdentityManager has 'anon-123' from the mock, but - // getAnonymousId() should return the native value - const result = await client.getAnonymousId(); - expect(result).toBe('native-id'); - expect(result).not.toBe('anon-123'); + const result = await promise; + expect(typeof result).toBe('string'); + expect(result).toBeTruthy(); }); }); diff --git a/src/analytics/MetaRouterAnalyticsClient.ts b/src/analytics/MetaRouterAnalyticsClient.ts index 269263a..4b726aa 100644 --- a/src/analytics/MetaRouterAnalyticsClient.ts +++ b/src/analytics/MetaRouterAnalyticsClient.ts @@ -10,7 +10,6 @@ import { removeIdentityField, ADVERTISING_ID_KEY, } from './utils/identityStorage'; -import { getAnonymousId as getNativeAnonymousId } from './NativeIdentity'; import CircuitBreaker from './utils/circuitBreaker'; import Dispatcher from './dispatcher'; import { PersistentEventQueue } from './persistence/PersistentEventQueue'; @@ -471,11 +470,12 @@ export class MetaRouterAnalyticsClient { } /** - * Returns the current anonymous ID from the native identity layer. - * Resolves null when the native SDK is unavailable or has no anonymous ID. + * Returns the current anonymous ID managed by the JS identity layer. + * Guaranteed non-null after init() — the IdentityManager always generates + * or loads an anonymous ID before the client reaches the 'ready' state. */ - async getAnonymousId(): Promise { - return getNativeAnonymousId(); + async getAnonymousId(): Promise { + return this.identityManager.getAnonymousId()!; } /** diff --git a/src/analytics/NativeIdentity.test.ts b/src/analytics/NativeIdentity.test.ts deleted file mode 100644 index 63c22f5..0000000 --- a/src/analytics/NativeIdentity.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -describe('NativeIdentity', () => { - beforeEach(() => { - jest.resetModules(); - jest.clearAllMocks(); - }); - - function getNativeModules() { - return require('react-native').NativeModules; - } - - it('resolves a string when the native module returns a string', async () => { - getNativeModules().MetaRouterIdentity = { - getAnonymousId: jest.fn(() => Promise.resolve('anon-abc-123')), - }; - const { getAnonymousId } = require('./NativeIdentity'); - const result = await getAnonymousId(); - expect(result).toBe('anon-abc-123'); - }); - - it('resolves null when the native module returns null', async () => { - getNativeModules().MetaRouterIdentity = { - getAnonymousId: jest.fn(() => Promise.resolve(null)), - }; - const { getAnonymousId } = require('./NativeIdentity'); - const result = await getAnonymousId(); - expect(result).toBeNull(); - }); - - it('resolves null when the native module is missing', async () => { - getNativeModules().MetaRouterIdentity = undefined; - const { getAnonymousId } = require('./NativeIdentity'); - const result = await getAnonymousId(); - expect(result).toBeNull(); - }); - - it('resolves null when the native module rejects unexpectedly', async () => { - getNativeModules().MetaRouterIdentity = { - getAnonymousId: jest.fn(() => Promise.reject(new Error('native crash'))), - }; - const { getAnonymousId } = require('./NativeIdentity'); - const result = await getAnonymousId(); - expect(result).toBeNull(); - }); -}); diff --git a/src/analytics/NativeIdentity.ts b/src/analytics/NativeIdentity.ts deleted file mode 100644 index e7b30d2..0000000 --- a/src/analytics/NativeIdentity.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { NativeModules } from 'react-native'; -import { warn } from './utils/logger'; - -interface NativeIdentityModule { - getAnonymousId(): Promise; -} - -function getModule(): NativeIdentityModule | null { - const mod = NativeModules.MetaRouterIdentity as - | NativeIdentityModule - | undefined; - if (!mod) { - warn( - 'MetaRouterIdentity native module is not available. getAnonymousId() will return null.' - ); - return null; - } - return mod; -} - -export async function getAnonymousId(): Promise { - const mod = getModule(); - if (!mod) return null; - try { - return await mod.getAnonymousId(); - } catch (err) { - warn('Failed to get anonymous ID from native module:', err); - return null; - } -} diff --git a/src/analytics/proxy/proxyClient.test.ts b/src/analytics/proxy/proxyClient.test.ts index dc7ae4f..983e67d 100644 --- a/src/analytics/proxy/proxyClient.test.ts +++ b/src/analytics/proxy/proxyClient.test.ts @@ -210,11 +210,11 @@ describe('proxyClient', () => { const preBindPromise = proxyClient.getAnonymousId(); - mockClient.getAnonymousId.mockResolvedValue('native-anon-789'); + mockClient.getAnonymousId.mockResolvedValue('anon-789'); setRealClient(mockClient); const result = await preBindPromise; - expect(result).toBe('native-anon-789'); + expect(result).toBe('anon-789'); expect(mockClient.getAnonymousId).toHaveBeenCalled(); }); diff --git a/src/analytics/types.ts b/src/analytics/types.ts index 6ae877c..87f7687 100644 --- a/src/analytics/types.ts +++ b/src/analytics/types.ts @@ -51,7 +51,7 @@ export interface AnalyticsInterface { reset: () => Promise; enableDebugLogging: () => void; getDebugInfo: () => Promise>; - getAnonymousId: () => Promise; + getAnonymousId: () => Promise; } export interface EventContext { From 9a8f8073b056b17ca5961184860778c79ebef338 Mon Sep 17 00:00:00 2001 From: Christopher Houdlette Date: Wed, 15 Apr 2026 12:52:49 -0600 Subject: [PATCH 3/4] fix: updating README --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index 9e4c39c..d09da30 100644 --- a/README.md +++ b/README.md @@ -214,6 +214,7 @@ The analytics client provides the following methods: - `alias(newUserId: string)`: Connect anonymous users to known user IDs. See [Using the alias() Method](#using-the-alias-method) for details - `setAdvertisingId(advertisingId: string)`: Set the advertising identifier (IDFA on iOS, GAID on Android) for ad tracking. See [Advertising ID](#advertising-id-idfagaid) section for usage and compliance requirements - `clearAdvertisingId()`: Clear the advertising identifier from storage and context. Useful for GDPR/CCPA compliance when users opt out of ad tracking +- `getAnonymousId(): Promise`: Returns the current anonymous ID. Async, never returns null — guaranteed to resolve a string after `init()` - `setTracing(enabled: boolean)`: Enable or disable tracing headers on API requests. When enabled, includes a `Trace: true` header for debugging request flows - `flush()`: Flush events immediately - `reset()`: Reset analytics state and clear all stored data (includes clearing advertising ID) @@ -393,6 +394,13 @@ The `anonymousId` is a unique identifier automatically generated for each device - Remains stable across app sessions until `reset()` is called - Cleared on `reset()` and a **new** UUID is generated on next `init()` +**Accessing the anonymous ID:** + +```js +const anonymousId = await analytics.getAnonymousId(); +console.log(anonymousId); // e.g. "a1b2c3d4-e5f6-7890-abcd-ef1234567890" +``` + **Use case:** Track user behavior before they log in or create an account, then connect pre-login and post-login activity using the `alias()` method. From 632ffe4dd898e97659861529e610edab2bac2f02 Mon Sep 17 00:00:00 2001 From: Christopher Houdlette Date: Thu, 16 Apr 2026 18:57:00 -0600 Subject: [PATCH 4/4] fix: string existence for anon id --- .../MetaRouterAnalyticsClient.test.ts | 27 +++++++++++++++++++ src/analytics/MetaRouterAnalyticsClient.ts | 15 ++++++++--- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/src/analytics/MetaRouterAnalyticsClient.test.ts b/src/analytics/MetaRouterAnalyticsClient.test.ts index b3823b9..509a12e 100644 --- a/src/analytics/MetaRouterAnalyticsClient.test.ts +++ b/src/analytics/MetaRouterAnalyticsClient.test.ts @@ -615,6 +615,33 @@ describe('MetaRouterAnalyticsClient', () => { expect(typeof result).toBe('string'); expect(result).toBeTruthy(); }); + + it('awaits init() when called while initialization is in-flight', async () => { + const client = new MetaRouterAnalyticsClient(opts); + const initPromise = client.init(); + const idPromise = client.getAnonymousId(); + + await initPromise; + const result = await idPromise; + expect(result).toBe('anon-123'); + }); + + it('throws when called before init() has ever been called', async () => { + const client = new MetaRouterAnalyticsClient(opts); + await expect(client.getAnonymousId()).rejects.toThrow( + /no anonymous ID available/ + ); + }); + + it('throws after reset() clears identity', async () => { + const client = new MetaRouterAnalyticsClient(opts); + await client.init(); + await client.reset(); + + await expect(client.getAnonymousId()).rejects.toThrow( + /no anonymous ID available/ + ); + }); }); describe('network awareness', () => { diff --git a/src/analytics/MetaRouterAnalyticsClient.ts b/src/analytics/MetaRouterAnalyticsClient.ts index 4b726aa..b7aae12 100644 --- a/src/analytics/MetaRouterAnalyticsClient.ts +++ b/src/analytics/MetaRouterAnalyticsClient.ts @@ -471,11 +471,20 @@ export class MetaRouterAnalyticsClient { /** * Returns the current anonymous ID managed by the JS identity layer. - * Guaranteed non-null after init() — the IdentityManager always generates - * or loads an anonymous ID before the client reaches the 'ready' state. + * Awaits init() if it is still in-flight. Throws only if the client was + * never initialized or a reset() races with this call. */ async getAnonymousId(): Promise { - return this.identityManager.getAnonymousId()!; + if (this.initPromise) { + await this.initPromise; + } + const id = this.identityManager.getAnonymousId(); + if (!id) { + throw new Error( + 'getAnonymousId: no anonymous ID available (client was reset or never initialized)' + ); + } + return id; } /**