From b46be00f3ae4169c6843b6a581943e6367e32e2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 5 Aug 2026 19:22:14 +0200 Subject: [PATCH 1/2] fix(mobile): stop AppsFlyer connector calls before create `resetAppsFlyerState()` called `stopObservingTransactions()` on every iOS reset, including the signed-out cold start where `create()` never ran. Native rejects with "Connector not configured, did you call `create` first?", and the library discards that promise, so it surfaced in Sentry as an unhandled rejection. Memoize the `create()` result and gate both `startObservingTransactions` and `stopObservingTransactions` on it. This also removes the race where the `initSdk` success callback started observing before `create()` settled. --- apps/mobile/src/lib/appsflyer.test.ts | 52 +++++++++++++- apps/mobile/src/lib/appsflyer.ts | 97 +++++++++++++++------------ 2 files changed, 102 insertions(+), 47 deletions(-) diff --git a/apps/mobile/src/lib/appsflyer.test.ts b/apps/mobile/src/lib/appsflyer.test.ts index 2a35cf959b0..4bfca283ffd 100644 --- a/apps/mobile/src/lib/appsflyer.test.ts +++ b/apps/mobile/src/lib/appsflyer.test.ts @@ -74,6 +74,13 @@ async function loadModule() { return module; } +/** Drains the microtasks that gate connector calls on the create() promise. */ +async function flushConnector() { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); +} + describe('initAppsFlyer purchase connector', () => { beforeEach(() => { vi.clearAllMocks(); @@ -101,9 +108,21 @@ describe('initAppsFlyer purchase connector', () => { sandbox: false, storeKitVersion: 'SK2', }); + await flushConnector(); expect(mockedAppsFlyer.startObservingTransactions).toHaveBeenCalledTimes(1); }); + it('does not observe transactions when create fails', async () => { + mockedPlatform.OS = 'ios'; + mockedAppsFlyer.create.mockRejectedValue(new Error('native bridge down')); + + const initAppsFlyer = await loadInit(); + initAppsFlyer(); + + await flushConnector(); + expect(mockedAppsFlyer.startObservingTransactions).not.toHaveBeenCalled(); + }); + it('does not touch the purchase connector on Android', async () => { mockedPlatform.OS = 'android'; const initAppsFlyer = await loadInit(); @@ -131,6 +150,7 @@ describe('initAppsFlyer purchase connector', () => { initAppsFlyer(); initAppsFlyer(); + await flushConnector(); expect(mockedAppsFlyer.create).toHaveBeenCalledTimes(1); expect(mockedAppsFlyer.startObservingTransactions).not.toHaveBeenCalled(); @@ -138,6 +158,7 @@ describe('initAppsFlyer purchase connector', () => { initAppsFlyer(); + await flushConnector(); expect(mockedAppsFlyer.create).toHaveBeenCalledTimes(1); expect(mockedAppsFlyer.startObservingTransactions).toHaveBeenCalledTimes(1); }); @@ -410,19 +431,38 @@ describe('resetAppsFlyerState', () => { expect(mockedAppsFlyer.stop).toHaveBeenCalledWith(true); }); - it('calls stopObservingTransactions on iOS', async () => { + it('calls stopObservingTransactions on iOS after the connector was created', async () => { mockedPlatform.OS = 'ios'; - const { resetAppsFlyerState } = await loadModule(); + const { initAppsFlyer, resetAppsFlyerState } = await loadModule(); + initAppsFlyer(); + await flushConnector(); + resetAppsFlyerState(); + await flushConnector(); expect(mockedAppsFlyer.stopObservingTransactions).toHaveBeenCalledTimes(1); }); + // Regression: a signed-out cold start resets before initAppsFlyer ever runs. + // Native rejects with "Connector not configured, did you call `create` + // first?" and the library drops that promise, so it reached Sentry as an + // unhandled rejection. + it('does not call stopObservingTransactions when the connector was never created', async () => { + mockedPlatform.OS = 'ios'; + const { resetAppsFlyerState } = await loadModule(); + resetAppsFlyerState(); + + await flushConnector(); + expect(mockedAppsFlyer.stopObservingTransactions).not.toHaveBeenCalled(); + }); + it('does not call stopObservingTransactions on Android', async () => { mockedPlatform.OS = 'android'; - const { resetAppsFlyerState } = await loadModule(); + const { initAppsFlyer, resetAppsFlyerState } = await loadModule(); + initAppsFlyer(); resetAppsFlyerState(); + await flushConnector(); expect(mockedAppsFlyer.stopObservingTransactions).not.toHaveBeenCalled(); }); @@ -490,6 +530,7 @@ describe('resetAppsFlyerState', () => { // Fire the stale callback — must not re-arm the SDK. successHolders[0]?.('ok'); + await flushConnector(); expect(mockedAppsFlyer.startObservingTransactions).not.toHaveBeenCalled(); // Track an event — must buffer, not log, because initialized was cleared. @@ -568,6 +609,7 @@ describe('stale initSdk callback', () => { // initialized must still be false, so trackEvent queues instead of logging. module.trackEvent('post-stale-event'); + await flushConnector(); expect(mockedAppsFlyer.logEvent).not.toHaveBeenCalled(); expect(mockedAppsFlyer.startObservingTransactions).not.toHaveBeenCalled(); }); @@ -616,6 +658,7 @@ describe('stale initSdk callback', () => { // Fire the stale callback. successHolders[0]?.('ok'); + await flushConnector(); expect(mockedAppsFlyer.startObservingTransactions).not.toHaveBeenCalled(); // Re-init — generation now matches the controller. @@ -631,6 +674,7 @@ describe('stale initSdk callback', () => { // Fire the new callback. expect(successHolders).toHaveLength(2); successHolders[1]?.('ok'); + await flushConnector(); expect(mockedAppsFlyer.startObservingTransactions).toHaveBeenCalledTimes(1); }); }); @@ -714,6 +758,7 @@ describe('callback token revoke invalidation', () => { // Fire the stale callback. successHolders[0]?.('ok'); + await flushConnector(); expect(mockedAppsFlyer.startObservingTransactions).not.toHaveBeenCalled(); // Re-init — token now matches because resetAppsFlyerState was already called. @@ -729,6 +774,7 @@ describe('callback token revoke invalidation', () => { // Fire the new callback — token matches, must succeed. expect(successHolders).toHaveLength(2); successHolders[1]?.('ok'); + await flushConnector(); expect(mockedAppsFlyer.startObservingTransactions).toHaveBeenCalledTimes(1); }); }); diff --git a/apps/mobile/src/lib/appsflyer.ts b/apps/mobile/src/lib/appsflyer.ts index 72f16aa7f93..fd803ee8b15 100644 --- a/apps/mobile/src/lib/appsflyer.ts +++ b/apps/mobile/src/lib/appsflyer.ts @@ -11,8 +11,11 @@ import { APPSFLYER_APP_ID, APPSFLYER_DEV_KEY } from '@/lib/config'; import { allowsOptional, currentGeneration } from '@/lib/telemetry/controller'; let initialized = false; -/** Blocks re-entry into create() within one JS bundle (before initSdk succeeds). */ -let purchaseConnectorCreateStarted = false; +/** + * Resolves to whether the native purchase connector is configured. Null until + * `create()` is first called. See `ensurePurchaseConnector`. + */ +let connectorReady: Promise | null = null; /** * Invalidation token for in-flight initSdk callbacks. Incremented by * `resetAppsFlyerState()` so a late success after stop/optional revoke @@ -74,21 +77,46 @@ function isConnectorAlreadyConfigured(error: unknown): boolean { } /** - * Settles create() without floating promises. `Promise.resolve` normalizes a - * non-promise return (bare mocks / unpatched install) so await never throws - * TypeError. Known-benign "already configured" is swallowed; anything else - * goes to Sentry via handleError. + * Reports whether the native purchase connector is configured. + * + * Native PCAppsFlyer keeps a process-lifetime static connector. A JS reload + * resets module state while native state survives, so create() then rejects + * with "Connector already configured" — which still means configured. Any + * other failure goes to Sentry and leaves the connector unusable. */ -async function settlePurchaseConnectorCreate( - createResult: void | PromiseLike -): Promise { +async function createPurchaseConnector(): Promise { try { - await Promise.resolve(createResult); + await AppsFlyerPurchaseConnector.create({ + logSubscriptions: true, + logInApps: false, + sandbox: __DEV__, + storeKitVersion: StoreKitVersion.SK2, + }); + return true; } catch (error: unknown) { if (isConnectorAlreadyConfigured(error)) { - return; + return true; } handleError('AppsFlyer purchase connector failed')(error); + return false; + } +} + +/** + * Runs a connector call only once the connector is known to be configured. + * The library discards the promise that start/stopObservingTransactions + * return, so a native "Connector not configured" rejection escapes as an + * unhandled rejection and lands in Sentry. `connectorReady` stays null on + * Android and before the first create(), so both calls are skipped there. + */ +async function whenConnectorReady(action: () => void): Promise { + if (connectorReady === null || !(await connectorReady)) { + return; + } + try { + action(); + } catch { + // Native module missing or threw synchronously. } } @@ -117,22 +145,9 @@ export function initAppsFlyer(): void { // purchase revenue server-side, so revenue is attributed without touching the // purchase flow. iOS-only: Kilo Pass IAP ships on iOS only (subscriptions, // StoreKit 2 via expo-iap). Create it before initSdk and start observing once - // the SDK has started (in the success callback below). - // - // Native PCAppsFlyer keeps a process-lifetime static connector. JS reloads - // reset module state while native state remains, so create() can reject with - // "Connector already configured". Guard sync re-entry within one bundle and - // swallow only that known-benign rejection (any other failure goes to Sentry). - if (Platform.OS === 'ios' && !purchaseConnectorCreateStarted) { - purchaseConnectorCreateStarted = true; - void settlePurchaseConnectorCreate( - AppsFlyerPurchaseConnector.create({ - logSubscriptions: true, - logInApps: false, - sandbox: __DEV__, - storeKitVersion: StoreKitVersion.SK2, - }) - ); + // both the SDK has started and the connector is configured. + if (Platform.OS === 'ios') { + connectorReady ??= createPurchaseConnector(); } // Send the optional-consent signal before the SDK starts so attribution @@ -169,9 +184,13 @@ export function initAppsFlyer(): void { return; } initialized = true; - if (Platform.OS === 'ios') { + void whenConnectorReady(() => { + // Re-check: a reset can land while the create() promise settles. + if (currentGeneration() !== initGeneration || callbackToken !== initToken) { + return; + } AppsFlyerPurchaseConnector.startObservingTransactions(); - } + }); drainPendingEvents(); }, handleError('AppsFlyer init failed') @@ -208,8 +227,8 @@ export function trackEvent(name: string, values?: Record): void * clears the pending-event buffer so stale events from a prior account do * not transmit on a later init. * - * Does NOT reset `purchaseConnectorCreateStarted`: native `PCAppsFlyer` keeps - * a process-lifetime static connector, so re-entering `create()` rejects with + * Does NOT clear `connectorReady`: native `PCAppsFlyer` keeps a + * process-lifetime static connector, so re-entering `create()` rejects with * "Connector already configured". */ export function resetAppsFlyerState(): void { @@ -228,17 +247,7 @@ export function resetAppsFlyerState(): void { // Native stop may throw — JS invalidation has already run. } - if (Platform.OS === 'ios') { - try { - if ( - typeof (AppsFlyerPurchaseConnector as unknown as Record) - .stopObservingTransactions === 'function' - ) { - AppsFlyerPurchaseConnector.stopObservingTransactions(); - } - } catch { - // Native stopObservingTransactions may throw — JS invalidation has - // already run. - } - } + void whenConnectorReady(() => { + AppsFlyerPurchaseConnector.stopObservingTransactions(); + }); } From 27e6851e7a21b7954350ee684b6a443f5b463cae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 5 Aug 2026 19:26:39 +0200 Subject: [PATCH 2/2] Update apps/mobile/src/lib/appsflyer.ts Co-authored-by: kilo-code-bot[bot] <240665456+kilo-code-bot[bot]@users.noreply.github.com> --- apps/mobile/src/lib/appsflyer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile/src/lib/appsflyer.ts b/apps/mobile/src/lib/appsflyer.ts index fd803ee8b15..96af146a3fc 100644 --- a/apps/mobile/src/lib/appsflyer.ts +++ b/apps/mobile/src/lib/appsflyer.ts @@ -13,7 +13,7 @@ import { allowsOptional, currentGeneration } from '@/lib/telemetry/controller'; let initialized = false; /** * Resolves to whether the native purchase connector is configured. Null until - * `create()` is first called. See `ensurePurchaseConnector`. + * `create()` is first called. See `createPurchaseConnector`. */ let connectorReady: Promise | null = null; /**