diff --git a/android/build.gradle b/android/build.gradle index a567f20..08cc95a 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -88,6 +88,9 @@ dependencies { // transact deps end implementation "financial.atomic:transact:$transactVersion" + // Decode the JS data-request response into the SDK's @Serializable TransactDataResponse. + // (kotlinx-serialization-json is already a transitive runtime dep of the SDK.) + implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3" } if (isNewArchitectureEnabled()) { diff --git a/android/src/main/java/com/atomicfi/transactreactnative/TransactReactNativeModule.kt b/android/src/main/java/com/atomicfi/transactreactnative/TransactReactNativeModule.kt index ea60166..b56cc57 100644 --- a/android/src/main/java/com/atomicfi/transactreactnative/TransactReactNativeModule.kt +++ b/android/src/main/java/com/atomicfi/transactreactnative/TransactReactNativeModule.kt @@ -8,12 +8,19 @@ import com.facebook.react.modules.core.DeviceEventManagerModule import financial.atomic.transact.Config import financial.atomic.transact.Transact import financial.atomic.transact.receiver.TransactBroadcastReceiver -import org.json.JSONObject import java.lang.Exception +import java.util.concurrent.ConcurrentHashMap +import kotlinx.serialization.json.Json +import org.json.JSONObject class TransactReactNativeModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) { + // jsInstanceId -> receiver. Used only for instance-targeted commands (resolveDataRequest); + // event routing is handled by each receiver's own envelope. Entries are dropped on cleanup. + private val receivers = ConcurrentHashMap() + private val json = Json { ignoreUnknownKeys = true } + override fun getName(): String { return NAME } @@ -47,20 +54,19 @@ class TransactReactNativeModule(reactContext: ReactApplicationContext) : } } - private fun handleCallbackEvent( - eventName: String, - data: JSONObject, - fieldName: String, + // Every event is wrapped in a { instanceId, data } envelope so the JS layer can route it to the + // task that owns `instanceId` (SDK-658). `data` is null for argument-less events (onLaunch). + private fun emitEnvelope( emitter: DeviceEventManagerModule.RCTDeviceEventEmitter, - promise: Promise + eventName: String, + instanceId: String, + data: JSONObject?, ) { - val value = data.optString(fieldName) - val result = Arguments.createMap().apply { - putString(fieldName, value) + val envelope = JSONObject().apply { + put("instanceId", instanceId) + put("data", data ?: JSONObject.NULL) } - - emitter.emit(eventName, data.toString()) - promise.resolve(result) + emitter.emit(eventName, envelope.toString()) } private fun buildConfigToken(config: ReadableMap, wrapperVersion: String): String { @@ -75,13 +81,18 @@ class TransactReactNativeModule(reactContext: ReactApplicationContext) : @ReactMethod fun presentTransact( + instanceId: String, config: ReadableMap, environment: ReadableMap, wrapperVersion: String, setDebug: Boolean, promise: Promise, ) { - val context = reactApplicationContext.currentActivity as Context + val context = reactApplicationContext.currentActivity as? Context + if (context == null) { + promise.reject("no_activity", "No current Activity to present Transact from") + return + } val emitter = reactApplicationContext.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) val environmentURL = parseEnvironment(environment) val token = buildConfigToken(config, wrapperVersion) @@ -89,36 +100,40 @@ class TransactReactNativeModule(reactContext: ReactApplicationContext) : UiThreadUtil.runOnUiThread { try { - Transact.present(context, sdkConfig, object : TransactBroadcastReceiver() { + // Each launch gets its own receiver capturing this call's JS instanceId. The native SDK + // binds its own per-instance id to the receiver and routes broadcasts so this receiver + // only ever sees its own session's events (SDK-659). We re-tag each emit with the JS + // instanceId so the JS registry can route to this task's handlers. + val receiver = object : TransactBroadcastReceiver() { override fun onClose(data: JSONObject) { - handleCallbackEvent("onClose", data, "reason", emitter, promise) + emitEnvelope(emitter, "onClose", instanceId, data) } override fun onFinish(data: JSONObject) { - handleCallbackEvent("onFinish", data, "taskId", emitter, promise) + emitEnvelope(emitter, "onFinish", instanceId, data) } override fun onLaunch() { - emitter.emit("onLaunch", null) + emitEnvelope(emitter, "onLaunch", instanceId, null) } override fun onInteraction(data: JSONObject) { - emitter.emit("onInteraction", data.toString()) + emitEnvelope(emitter, "onInteraction", instanceId, data) } override fun onDataRequest(data: JSONObject) { - emitter.emit("onDataRequest", data.toString()) + emitEnvelope(emitter, "onDataRequest", instanceId, data) } override fun onAuthStatusUpdate(data: JSONObject) { - emitter.emit("onAuthStatusUpdate", data.toString()) + emitEnvelope(emitter, "onAuthStatusUpdate", instanceId, data) } override fun onTaskStatusUpdate(data: JSONObject) { if (!data.has("failReason")) { data.put("failReason", JSONObject.NULL) } - emitter.emit("onTaskStatusUpdate", data.toString()) + emitEnvelope(emitter, "onTaskStatusUpdate", instanceId, data) } override fun onDebugLog( @@ -127,15 +142,48 @@ class TransactReactNativeModule(reactContext: ReactApplicationContext) : message: String, data: JSONObject ) { + // Debug log is process-global (no per-task instanceId), like iOS. emitter.emit("onDebugLog", data.toString()) } - }) + + override fun onCleanup() { + // Terminal (carries no data): the JS registry tears down this task here. Drop our + // command-targeting ref. + emitEnvelope(emitter, "onCleanup", instanceId, null) + receivers.remove(instanceId) + } + } + + receivers[instanceId] = receiver + Transact.present(context, sdkConfig, receiver) + // Resolve as a launch ack. Lifecycle callbacks are delivered via the enveloped events above. + promise.resolve(null) } catch (e: Exception) { + receivers.remove(instanceId) promise.reject(e) } } } + // Sends a data-request response back to the originating task. The JS layer calls this with the + // wrapper instanceId; we resolve the SDK's per-instance id from the bound receiver and target it + // so concurrent flows don't each receive the response. + @ReactMethod + fun resolveDataRequest(instanceId: String, response: ReadableMap?) { + if (response == null) { + return + } + val sdkInstanceId = receivers[instanceId]?.instanceId ?: return + try { + val responseJson = JSONObject(response.toHashMap()).toString() + val dataResponse = + json.decodeFromString(Config.TransactDataResponse.serializer(), responseJson) + Transact.sendData(reactApplicationContext, sdkInstanceId, dataResponse) + } catch (e: Exception) { + // Malformed/empty response — nothing to deliver. + } + } + companion object { const val NAME = "TransactReactNative" } diff --git a/ios/TransactReactNative.m b/ios/TransactReactNative.m index 9e74b3c..fd29812 100644 --- a/ios/TransactReactNative.m +++ b/ios/TransactReactNative.m @@ -4,15 +4,16 @@ @interface RCT_EXTERN_MODULE(TransactReactNative, RCTEventEmitter) -RCT_EXTERN_METHOD(presentTransact:(NSDictionary *)config - environment:(NSDictionary *)environment - presentationStyle:(nullable NSString *)presentationStyle - setDebug:(nullable NSNumber *)setDebug - wrapperVersion:(NSString *)wrapperVersion - withResolver:(RCTPromiseResolveBlock)resolve - withRejecter:(RCTPromiseRejectBlock)reject) +RCT_EXTERN_METHOD(presentTransact:(NSString *)instanceId + config:(NSDictionary *)config + environment:(NSDictionary *)environment + presentationStyle:(nullable NSString *)presentationStyle + setDebug:(nullable NSNumber *)setDebug + wrapperVersion:(NSString *)wrapperVersion + withResolver:(RCTPromiseResolveBlock)resolve + withRejecter:(RCTPromiseRejectBlock)reject) -RCT_EXTERN_METHOD(resolveDataRequest:(id)data) +RCT_EXTERN_METHOD(resolveDataRequest:(NSString *)instanceId data:(id)data) RCT_EXTERN_METHOD(hideTransact:(RCTPromiseResolveBlock)resolve withRejecter:(RCTPromiseRejectBlock)reject) diff --git a/ios/TransactReactNative.swift b/ios/TransactReactNative.swift index a7391e5..554bd7e 100644 --- a/ios/TransactReactNative.swift +++ b/ios/TransactReactNative.swift @@ -4,8 +4,9 @@ import UIKit @objc(TransactReactNative) class TransactReactNative: RCTEventEmitter { - // Data request handler that will be called when the response arrives - private var dataResponseHandler: ((Any) -> Void)? = nil + // Data-request continuations keyed by instanceId, so concurrent/overlapping requests across + // tasks don't collide (the previous single handler was overwritten — SDK-658). + private var dataResponseHandlers: [String: (Any) -> Void] = [:] private func parseEnvironment(_ environmentData: [String: Any]) -> AtomicTransact.TransactEnvironment { guard let environment = environmentData["environment"] as? String else { @@ -37,8 +38,8 @@ class TransactReactNative: RCTEventEmitter { } } - @objc(presentTransact:environment:presentationStyle:setDebug:wrapperVersion:withResolver:withRejecter:) - func presentTransact(config: [String: Any], environment: [String: Any], presentationStyle: String?, setDebug: NSNumber?, wrapperVersion: String, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) -> Void { + @objc(presentTransact:config:environment:presentationStyle:setDebug:wrapperVersion:withResolver:withRejecter:) + func presentTransact(instanceId: String, config: [String: Any], environment: [String: Any], presentationStyle: String?, setDebug: NSNumber?, wrapperVersion: String, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) -> Void { let debugEnabled = setDebug?.boolValue ?? false Task { @MainActor in @@ -65,13 +66,13 @@ class TransactReactNative: RCTEventEmitter { Atomic.presentTransact( from: source, config: config, environment: parsedEnvironment, presentationStyle: parsedPresentationStyle, onInteraction: { interaction in - self.sendEvent(withName: "onInteraction", body: ["name": interaction.name, "value": interaction.value]) + self.sendEvent(withName: "onInteraction", body: ["instanceId": instanceId, "data": ["name": interaction.name, "value": interaction.value]]) }, onDataRequest: { request async -> TransactDataResponse? in // Create a task to handle the async request to React Native return await withCheckedContinuation { continuation in // Store the completion handler - self.dataResponseHandler = { responseData in + self.dataResponseHandlers[instanceId] = { responseData in if let responseDict = responseData as? [String: Any] { // The SDK expects the response data to be passed directly // Let the SDK handle the parsing internally @@ -92,29 +93,44 @@ class TransactReactNative: RCTEventEmitter { } // Send event with request data to React Native - self.sendEvent(withName: "onDataRequest", body: request.data) + self.sendEvent(withName: "onDataRequest", body: ["instanceId": instanceId, "data": request.data]) } }, onAuthStatusUpdate: { status in - self.sendEvent(withName: "onAuthStatusUpdate", body: status.serialize()) + self.sendEvent(withName: "onAuthStatusUpdate", body: ["instanceId": instanceId, "data": status.serialize()]) }, onTaskStatusUpdate: { status in - self.sendEvent(withName: "onTaskStatusUpdate", body: status.serialize()) + self.sendEvent(withName: "onTaskStatusUpdate", body: ["instanceId": instanceId, "data": status.serialize()]) }, onLaunch: { - self.sendEvent(withName: "onLaunch", body: []) + self.sendEvent(withName: "onLaunch", body: ["instanceId": instanceId, "data": NSNull()]) }, onCompletion: { result in switch result { case .finished(let response): + self.sendEvent(withName: "onFinish", body: ["instanceId": instanceId, "data": response.data]) resolve(["finished": response.data]) case .closed(let response): + self.sendEvent(withName: "onClose", body: ["instanceId": instanceId, "data": response.data]) resolve(["closed": response.data]) case .error: resolve(["error": "Unknown error"]) default: resolve(["error": "Unknown error"]) } + }, + onError: { error in + // In-flow SDK error channel (distinct from onCompletion's terminal load errors). + // Routed per-task like every other event; not terminal on its own. + if case let .transactError(data) = error { + self.sendEvent(withName: "onError", body: ["instanceId": instanceId, "data": data]) + } else { + self.sendEvent(withName: "onError", body: ["instanceId": instanceId, "data": NSNull()]) + } + }, + onCleanup: { + self.sendEvent(withName: "onCleanup", body: ["instanceId": instanceId, "data": NSNull()]) + self.dataResponseHandlers[instanceId] = nil } ) } @@ -124,35 +140,27 @@ class TransactReactNative: RCTEventEmitter { } } - // Method to receive response from React Native - @objc(resolveDataRequest:) - func resolveDataRequest(data: Any) -> Void { - // Call the stored response handler with the data from React Native - if let handler = dataResponseHandler { + // Receives a data-request response from React Native, routed to the originating task by id. + @objc(resolveDataRequest:data:) + func resolveDataRequest(instanceId: String, data: Any) -> Void { + if let handler = dataResponseHandlers[instanceId] { handler(data) - - // Clear the handler - dataResponseHandler = nil + dataResponseHandlers[instanceId] = nil } } @objc(hideTransact:withRejecter:) func hideTransact(resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) -> Void { DispatchQueue.main.async { - do { - Atomic.hideTransact() - } catch { - reject("dismiss_error", "Error dismissing Transact", error) - } + // Atomic.hideTransact() is non-throwing and process-global (hides every presented + // session). Resolve so the JS promise settles instead of hanging forever. + Atomic.hideTransact() + resolve(nil) } } - + @objc override func supportedEvents() -> [String] { - return ["onInteraction", "onDataRequest", "onLaunch", "onCompletion", "onAuthStatusUpdate", "onTaskStatusUpdate", "onDebugLog"] - } - - @objc override static func requiresMainQueueSetup() -> Bool { - return true + return ["onInteraction", "onDataRequest", "onLaunch", "onFinish", "onClose", "onCleanup", "onError", "onAuthStatusUpdate", "onTaskStatusUpdate", "onDebugLog"] } } diff --git a/src/__tests__/transactRegistry.test.ts b/src/__tests__/transactRegistry.test.ts new file mode 100644 index 0000000..d25341b --- /dev/null +++ b/src/__tests__/transactRegistry.test.ts @@ -0,0 +1,221 @@ +import { + _resetForTests, + addTransaction, + createInstanceId, + dispatchEvent, + getActiveCount, + handleNativeEvent, + hasTransaction, + parseEnvelope, + removeTransaction, +} from '../transactRegistry'; + +beforeEach(() => { + _resetForTests(); +}); + +describe('createInstanceId', () => { + it('returns distinct ids in a tight loop (counter is load-bearing)', () => { + const ids = new Set(); + for (let i = 0; i < 1000; i++) { + ids.add(createInstanceId()); + } + expect(ids.size).toBe(1000); + }); +}); + +describe('dispatchEvent routing isolation', () => { + it('routes events only to the matching task (core SDK-658 regression)', () => { + const a = jest.fn(); + const b = jest.fn(); + addTransaction('A', { onTaskStatusUpdate: a }); + addTransaction('B', { onTaskStatusUpdate: b }); + + dispatchEvent('onTaskStatusUpdate', 'A', { taskId: 'a1' }); + dispatchEvent('onTaskStatusUpdate', 'B', { taskId: 'b1' }); + dispatchEvent('onTaskStatusUpdate', 'A', { taskId: 'a2' }); + + expect(a).toHaveBeenCalledTimes(2); + expect(a).toHaveBeenNthCalledWith(1, { taskId: 'a1' }); + expect(a).toHaveBeenNthCalledWith(2, { taskId: 'a2' }); + expect(b).toHaveBeenCalledTimes(1); + expect(b).toHaveBeenCalledWith({ taskId: 'b1' }); + }); + + it('forwards interaction and auth payloads to the right handler', () => { + const onInteraction = jest.fn(); + const onAuthStatusUpdate = jest.fn(); + addTransaction('A', { onInteraction, onAuthStatusUpdate }); + + dispatchEvent('onInteraction', 'A', { name: 'x' }); + dispatchEvent('onAuthStatusUpdate', 'A', { status: 'pending' }); + + expect(onInteraction).toHaveBeenCalledWith({ name: 'x' }); + expect(onAuthStatusUpdate).toHaveBeenCalledWith({ status: 'pending' }); + }); + + it('routes onError (iOS-only in-flow error) to the owning task and is not terminal', () => { + const onError = jest.fn(); + addTransaction('A', { onError }); + + dispatchEvent('onError', 'A', { code: 'sdk-error' }); + + expect(onError).toHaveBeenCalledWith({ code: 'sdk-error' }); + // onError alone does not evict — teardown still waits for onCleanup. + expect(hasTransaction('A')).toBe(true); + }); + + it('calls onLaunch with no args', () => { + const onLaunch = jest.fn(); + addTransaction('A', { onLaunch }); + dispatchEvent('onLaunch', 'A', null); + expect(onLaunch).toHaveBeenCalledTimes(1); + expect(onLaunch).toHaveBeenCalledWith(); + }); + + it('is a safe no-op for an unknown instanceId', () => { + expect(() => + dispatchEvent('onTaskStatusUpdate', 'missing', { taskId: 'x' }) + ).not.toThrow(); + }); +}); + +describe('teardown semantics (onCleanup only)', () => { + it('keeps the entry alive through onClose/onFinish and only evicts on onCleanup', () => { + const onClose = jest.fn(); + const onFinish = jest.fn(); + const onTaskStatusUpdate = jest.fn(); + const onCleanup = jest.fn(); + addTransaction('A', { onClose, onFinish, onTaskStatusUpdate, onCleanup }); + + dispatchEvent('onClose', 'A', { reason: 'done' }); + dispatchEvent('onFinish', 'A', { taskId: 'a1' }); + + // Still registered — background work may keep streaming events after close/finish. + expect(hasTransaction('A')).toBe(true); + dispatchEvent('onTaskStatusUpdate', 'A', { + taskId: 'a1', + status: 'processing', + }); + expect(onTaskStatusUpdate).toHaveBeenCalledTimes(1); + + // cleanup-application is the terminal signal. It carries no data — onCleanup takes no args. + dispatchEvent('onCleanup', 'A', null); + expect(onCleanup).toHaveBeenCalledTimes(1); + expect(onCleanup).toHaveBeenCalledWith(); + expect(hasTransaction('A')).toBe(false); + + // Late events after cleanup are dropped. + dispatchEvent('onTaskStatusUpdate', 'A', { + taskId: 'a1', + status: 'completed', + }); + expect(onTaskStatusUpdate).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledTimes(1); + expect(onFinish).toHaveBeenCalledTimes(1); + }); + + it('removeTransaction (safety net for terminal error/dismiss) evicts the entry', () => { + addTransaction('A', { onFinish: jest.fn() }); + expect(getActiveCount()).toBe(1); + removeTransaction('A'); + expect(getActiveCount()).toBe(0); + }); +}); + +describe('onDataRequest round-trip', () => { + it('resolves with the handler result for the originating task', async () => { + const resolve = jest.fn(); + addTransaction('A', { + onDataRequest: async (req: any) => ({ echoed: req.id }), + }); + + await dispatchEvent('onDataRequest', 'A', { id: 'req-a' }, resolve); + + expect(resolve).toHaveBeenCalledTimes(1); + expect(resolve).toHaveBeenCalledWith('A', { echoed: 'req-a' }); + }); + + it('routes concurrent data requests to their own resolvers', async () => { + const resolve = jest.fn(); + addTransaction('A', { onDataRequest: async () => 'respA' }); + addTransaction('B', { onDataRequest: async () => 'respB' }); + + await Promise.all([ + dispatchEvent('onDataRequest', 'A', {}, resolve), + dispatchEvent('onDataRequest', 'B', {}, resolve), + ]); + + expect(resolve).toHaveBeenCalledWith('A', 'respA'); + expect(resolve).toHaveBeenCalledWith('B', 'respB'); + }); + + it('resolves null when there is no handler', async () => { + const resolve = jest.fn(); + addTransaction('A', {}); + await dispatchEvent('onDataRequest', 'A', {}, resolve); + expect(resolve).toHaveBeenCalledWith('A', null); + }); + + it('resolves null when the handler throws', async () => { + const resolve = jest.fn(); + const spy = jest.spyOn(console, 'error').mockImplementation(() => {}); + addTransaction('A', { + onDataRequest: async () => { + throw new Error('boom'); + }, + }); + + await dispatchEvent('onDataRequest', 'A', {}, resolve); + + expect(resolve).toHaveBeenCalledWith('A', null); + spy.mockRestore(); + }); +}); + +describe('parseEnvelope', () => { + it('parses a stringified (Android) envelope', () => { + const raw = JSON.stringify({ instanceId: 'A', data: { taskId: 'a1' } }); + expect(parseEnvelope(raw)).toEqual({ + instanceId: 'A', + data: { taskId: 'a1' }, + }); + }); + + it('passes through an object (iOS) envelope', () => { + const raw = { instanceId: 'A', data: { name: 'x' } }; + expect(parseEnvelope(raw)).toEqual({ + instanceId: 'A', + data: { name: 'x' }, + }); + }); + + it('tolerates a null payload', () => { + expect(parseEnvelope(null)).toEqual({}); + }); +}); + +describe('handleNativeEvent', () => { + it('parses + dispatches a stringified envelope to the right task', () => { + const onTaskStatusUpdate = jest.fn(); + addTransaction('A', { onTaskStatusUpdate }); + + handleNativeEvent( + 'onTaskStatusUpdate', + JSON.stringify({ instanceId: 'A', data: { taskId: 'a1' } }) + ); + + expect(onTaskStatusUpdate).toHaveBeenCalledWith({ taskId: 'a1' }); + }); + + it('dispatches onLaunch from an envelope with a null data field', () => { + const onLaunch = jest.fn(); + addTransaction('A', { onLaunch }); + handleNativeEvent('onLaunch', { instanceId: 'A', data: null }); + expect(onLaunch).toHaveBeenCalledTimes(1); + }); + + it('is a no-op when the envelope has no instanceId', () => { + expect(() => handleNativeEvent('onLaunch', {})).not.toThrow(); + }); +}); diff --git a/src/android.tsx b/src/android.tsx index cc4239b..bd026fa 100644 --- a/src/android.tsx +++ b/src/android.tsx @@ -1,63 +1,86 @@ import { DeviceEventEmitter } from 'react-native'; import * as CONSTANTS from './constants'; +import { + handleNativeEvent, + removeTransaction, + type TransactEventName, +} from './transactRegistry'; -function _eventHandler(request: string, func: Function) { - return func(JSON.parse(request)); -} +// Per-task events carry a { instanceId, data } envelope and route through the registry. +// Android delivers each event as a JSON-string envelope. +const ROUTED_EVENTS: TransactEventName[] = [ + 'onInteraction', + 'onDataRequest', + 'onAuthStatusUpdate', + 'onTaskStatusUpdate', + 'onLaunch', + 'onFinish', + 'onClose', + 'onCleanup', +]; -function _addEventListener(event: string, func: Function | undefined) { - DeviceEventEmitter.removeAllListeners(event); - if (func) { - DeviceEventEmitter.addListener(event, (request) => - _eventHandler(request, func) - ); +let listenersInstalled = false; + +// Install one persistent listener per event exactly once. We never call +// removeAllListeners — the previous per-call teardown is what overrode earlier tasks' +// callbacks (SDK-658). Routing by instanceId keeps each task's callbacks isolated. +function installListeners(TransactReactNative: any) { + if (listenersInstalled) { + return; } + listenersInstalled = true; + + // Debug log is process-global (no per-task instanceId) — a plain passthrough. + DeviceEventEmitter.addListener('onDebugLog', (raw) => { + try { + const log = typeof raw === 'string' ? JSON.parse(raw) : raw; + console.debug('[TransactNative]', log?.message); + } catch { + console.debug('[TransactNative]', raw); + } + }); + + const resolveDataRequest = (instanceId: string, response: unknown) => { + TransactReactNative.resolveDataRequest(instanceId, response); + }; + + ROUTED_EVENTS.forEach((event) => { + DeviceEventEmitter.addListener(event, (raw) => + handleNativeEvent(event, raw, resolveDataRequest) + ); + }); } export const AtomicAndroid = { transact({ TransactReactNative, + instanceId, config, environment, wrapperVersion, - onInteraction, - onLaunch, - onTaskStatusUpdate, - onAuthStatusUpdate, - onFinish, - onDataRequest, - onClose, setDebug, }: { TransactReactNative: any; + instanceId: string; config: any; environment?: CONSTANTS.TransactEnvironment; wrapperVersion: string; - onInteraction?: Function; - onLaunch?: Function; - onTaskStatusUpdate?: Function; - onAuthStatusUpdate?: Function; - onDataRequest?: Function; - onFinish?: Function; - onClose?: Function; setDebug?: boolean; }): void { - _addEventListener('onDebugLog', (log: any) => { - console.debug('[TransactNative]', log.message); - }); - _addEventListener('onClose', onClose); - _addEventListener('onFinish', onFinish); - _addEventListener('onLaunch', onLaunch); - _addEventListener('onInteraction', onInteraction); - _addEventListener('onDataRequest', onDataRequest); - _addEventListener('onTaskStatusUpdate', onTaskStatusUpdate); - _addEventListener('onAuthStatusUpdate', onAuthStatusUpdate); - + installListeners(TransactReactNative); TransactReactNative.presentTransact( + instanceId, config, environment, wrapperVersion, setDebug ?? false - ); + ).catch(() => { + // Launch failed (no foreground Activity, or a native setup throw): the native module + // already drops its receivers-map entry on these paths, so reclaim the JS registry entry + // too — no onFinish/onClose/onCleanup will ever fire for a launch that never started. + // Mirrors the iOS safety net (ios.tsx). Without this the handlers leak for the process + // lifetime and the rejection goes unhandled. + removeTransaction(instanceId); + }); }, }; diff --git a/src/index.tsx b/src/index.tsx index 7a5c2e4..0280e07 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -4,6 +4,11 @@ import { AtomicAndroid } from './android'; import * as CONSTANTS from './constants'; import type { PresentationStyleIOS, AppType, StepType } from './constants'; import pkg from '../package.json'; +import { + addTransaction, + createInstanceId, + removeTransaction, +} from './transactRegistry'; const wrapperVersion: string = pkg.version; @@ -96,6 +101,16 @@ export type { } from './constants'; export type { DeeplinkOptions }; +export interface TransactTask { + /** Wrapper-generated id for this launch; every event for this task carries it. */ + instanceId: string; + /** + * Stop receiving this task's callbacks on the JS side. Does not close the native UI + * (iOS dismiss is process-global); use it to detach a task you no longer care about. + */ + remove(): void; +} + export const Atomic = { transact({ config, @@ -107,6 +122,8 @@ export const Atomic = { onClose, onAuthStatusUpdate, onTaskStatusUpdate, + onCleanup, + onError, presentationStyleIOS, setDebug, }: { @@ -119,9 +136,12 @@ export const Atomic = { onLaunch?: Function; onFinish?: Function; onClose?: Function; + onCleanup?: Function; + /** In-flow SDK error. iOS only — Android surfaces no equivalent callback. */ + onError?: Function; presentationStyleIOS?: PresentationStyleIOS; setDebug?: boolean; - }): void { + }): TransactTask { config.language = config.language || 'en'; config.theme = config.theme || {}; config.theme.dark = @@ -129,18 +149,27 @@ export const Atomic = { ? config.theme.dark : Appearance.getColorScheme() === 'dark'; + // One id per launch. Register the handlers BEFORE the native call so a fast-emitting + // native side can't deliver an event before the registry entry exists. + const instanceId = createInstanceId(); + addTransaction(instanceId, { + onInteraction, + onDataRequest, + onAuthStatusUpdate, + onTaskStatusUpdate, + onLaunch, + onFinish, + onClose, + onCleanup, + onError, + }); + const args = { TransactReactNative, + instanceId, config, environment: environment || CONSTANTS.Environment.production, wrapperVersion, - onInteraction, - onLaunch, - onFinish, - onDataRequest, - onClose, - onAuthStatusUpdate, - onTaskStatusUpdate, presentationStyleIOS, setDebug, }; @@ -153,8 +182,11 @@ export const Atomic = { AtomicAndroid.transact(args); break; default: + removeTransaction(instanceId); throw new Error(`Unsupported OS: ${Platform.OS}`); } + + return { instanceId, remove: () => removeTransaction(instanceId) }; }, hideTransact() { switch (Platform.OS) { diff --git a/src/ios.tsx b/src/ios.tsx index b7b1278..9f02db6 100644 --- a/src/ios.tsx +++ b/src/ios.tsx @@ -1,101 +1,91 @@ import { NativeEventEmitter } from 'react-native'; import * as CONSTANTS from './constants'; +import { + handleNativeEvent, + removeTransaction, + type TransactEventName, +} from './transactRegistry'; + +// Per-task events carry a { instanceId, data } envelope and route through the registry. +const ROUTED_EVENTS: TransactEventName[] = [ + 'onInteraction', + 'onDataRequest', + 'onAuthStatusUpdate', + 'onTaskStatusUpdate', + 'onLaunch', + 'onFinish', + 'onClose', + 'onCleanup', + // iOS-only: the SDK's dedicated in-flow error channel. Android emits no equivalent. + 'onError', +]; + +let listenersInstalled = false; + +// Install one persistent listener per event exactly once. We never call +// removeAllListeners — the previous per-call teardown is what overrode earlier tasks' +// callbacks (SDK-658). Routing by instanceId keeps each task's callbacks isolated. +function installListeners(TransactReactNative: any) { + if (listenersInstalled) { + return; + } + listenersInstalled = true; + + const emitter = new NativeEventEmitter(TransactReactNative); + + // Debug log is process-global (no instanceId) — keep it as a plain passthrough. + emitter.addListener('onDebugLog', (log: any) => { + console.debug('[TransactNative]', log?.message); + }); + + const resolveDataRequest = (instanceId: string, response: unknown) => { + TransactReactNative.resolveDataRequest(instanceId, response); + }; + + ROUTED_EVENTS.forEach((event) => { + emitter.addListener(event, (payload: any) => + handleNativeEvent(event, payload, resolveDataRequest) + ); + }); +} export const AtomicIOS = { transact({ TransactReactNative, + instanceId, config, environment, wrapperVersion, - onInteraction, - onLaunch, - onFinish, - onDataRequest, - onClose, - onAuthStatusUpdate, - onTaskStatusUpdate, presentationStyleIOS, setDebug, }: { TransactReactNative: any; + instanceId: string; config: any; environment?: CONSTANTS.TransactEnvironment; wrapperVersion: string; - onInteraction?: Function; - onDataRequest?: Function; - onLaunch?: Function; - onFinish?: Function; - onClose?: Function; - onAuthStatusUpdate?: Function; - onTaskStatusUpdate?: Function; presentationStyleIOS?: CONSTANTS.PresentationStyleIOS; setDebug?: boolean; }): void { - const TransactReactNativeEvents = new NativeEventEmitter( - TransactReactNative - ); - - const setListener = (event: string, handler?: Function) => { - TransactReactNativeEvents.removeAllListeners(event); - if (handler) { - TransactReactNativeEvents.addListener(event, (payload) => - handler(payload) - ); - } - }; - - setListener('onDebugLog', (log: any) => { - console.debug('[TransactNative]', log.message); - }); - - setListener( - 'onInteraction', - onInteraction && ((interaction: any) => onInteraction(interaction)) - ); - - setListener( - 'onDataRequest', - onDataRequest && - (async (request: any) => { - try { - const response = await onDataRequest(request); - TransactReactNative.resolveDataRequest(response); - return response; - } catch (error) { - console.error('## Error in onDataRequest:', error); - TransactReactNative.resolveDataRequest(null); - return null; - } - }) - ); - - setListener('onLaunch', onLaunch && (() => onLaunch())); - - setListener( - 'onAuthStatusUpdate', - onAuthStatusUpdate && - ((authStatus: any) => onAuthStatusUpdate(authStatus)) - ); - - setListener( - 'onTaskStatusUpdate', - onTaskStatusUpdate && - ((taskStatus: any) => onTaskStatusUpdate(taskStatus)) - ); + installListeners(TransactReactNative); TransactReactNative.presentTransact( + instanceId, config, environment, presentationStyleIOS, setDebug, wrapperVersion - ).then((event: any) => { - if (event.finished && onFinish) { - onFinish(event.finished); - } else if (event.closed && onClose) { - onClose(event.closed); - } - }); + ) + .then((event: any) => { + // onFinish/onClose arrive as enveloped events and are routed by the registry. + // The completion promise is only a safety net: a terminal error/dismiss may never + // emit `cleanup-application`, so reclaim the registry entry here to avoid a leak. + if (event && event.error) { + removeTransaction(instanceId); + } + }) + .catch(() => removeTransaction(instanceId)); }, hideTransact(TransactReactNative: any): Promise { return TransactReactNative.hideTransact(); diff --git a/src/transactRegistry.ts b/src/transactRegistry.ts new file mode 100644 index 0000000..5da4eb4 --- /dev/null +++ b/src/transactRegistry.ts @@ -0,0 +1,192 @@ +// Per-task callback registry for SDK-658 (multi-task / asynchronous callbacks). +// +// Each `Atomic.transact()` call gets a wrapper-generated `instanceId`. The native +// modules echo that id on every event inside an envelope `{ instanceId, data }`, and +// this module routes each event to the handlers registered for that id. That lets +// multiple tasks (especially background `action` flows) run concurrently or +// back-to-back without one launch overriding another's listeners. +// +// Teardown of a task's entry happens ONLY on `onCleanup` (the native +// `cleanup-application` signal). `onFinish`/`onClose` are NOT terminal: for action / +// Uplink flows the SDK keeps a background worker running after the visible UI closes +// and continues emitting `onTaskStatusUpdate`s until cleanup. A terminal error/dismiss +// is handled by the platform layer calling `removeTransaction` directly (safety net). +// +// This module intentionally imports nothing from `react-native` so it can be unit +// tested under the project's bare jest config (mirrors `src/utils.ts`). + +export type TransactEventName = + | 'onInteraction' + | 'onDataRequest' + | 'onAuthStatusUpdate' + | 'onTaskStatusUpdate' + | 'onLaunch' + | 'onFinish' + | 'onClose' + | 'onCleanup' + // In-flow SDK error. iOS-only today: the iOS SDK exposes a dedicated `onError` + // channel (`.transactError`), Android surfaces no equivalent to the host receiver. + | 'onError'; + +export interface TransactHandlers { + onInteraction?: Function; + onDataRequest?: Function; + onAuthStatusUpdate?: Function; + onTaskStatusUpdate?: Function; + onLaunch?: Function; + onFinish?: Function; + onClose?: Function; + onCleanup?: Function; + onError?: Function; +} + +export interface TransactEnvelope { + instanceId?: string; + data?: unknown; +} + +// Sends a data-request response back to the originating task on the native side. +export type ResolveDataRequest = ( + instanceId: string, + response: unknown +) => void; + +const transactions = new Map(); +let counter = 0; + +// Monotonic, process-unique id. The counter (not the timestamp) is what guarantees +// uniqueness — multiple ids created within the same millisecond must still differ. +export function createInstanceId(): string { + counter += 1; + return `rn-transact-${counter}-${Date.now()}`; +} + +export function addTransaction( + instanceId: string, + handlers: TransactHandlers +): void { + transactions.set(instanceId, handlers); +} + +export function removeTransaction(instanceId: string): void { + transactions.delete(instanceId); +} + +export function hasTransaction(instanceId: string): boolean { + return transactions.has(instanceId); +} + +// Number of live transactions — used by tests to assert teardown. +export function getActiveCount(): number { + return transactions.size; +} + +// Test-only reset so suites start from a clean registry. +export function _resetForTests(): void { + transactions.clear(); + counter = 0; +} + +// Normalizes a native event payload into `{ instanceId, data }`. Android emits JSON +// strings (DeviceEventEmitter); iOS emits objects (NativeEventEmitter). Tolerates a +// null/empty payload (returns an envelope with no instanceId, which dispatches to a +// no-op). +export function parseEnvelope(raw: unknown): TransactEnvelope { + const env = typeof raw === 'string' ? JSON.parse(raw) : raw; + if (env == null || typeof env !== 'object') { + return {}; + } + return env as TransactEnvelope; +} + +async function dispatchDataRequest( + instanceId: string, + data: unknown, + handler: Function | undefined, + resolveDataRequest?: ResolveDataRequest +): Promise { + // A data request is request/response: the handler's result must be sent back to the + // task that asked. With no handler (or on failure) we still resolve so the native + // side isn't left waiting (iOS suspends a continuation until this returns). + if (!handler) { + resolveDataRequest?.(instanceId, null); + return; + } + try { + const response = await handler(data); + resolveDataRequest?.(instanceId, response); + } catch (error) { + console.error('## Error in onDataRequest:', error); + resolveDataRequest?.(instanceId, null); + } +} + +// Routes a single native event to the handlers registered for `instanceId`. Returns a +// promise only for `onDataRequest` (so callers/tests can await the response round-trip); +// all other events are dispatched synchronously and return `undefined`. An unknown +// `instanceId` (e.g. a late event after cleanup) is a safe no-op. +export function dispatchEvent( + eventName: TransactEventName, + instanceId: string, + data: unknown, + resolveDataRequest?: ResolveDataRequest +): void | Promise { + const handlers = transactions.get(instanceId); + + switch (eventName) { + case 'onInteraction': + handlers?.onInteraction?.(data); + return; + case 'onAuthStatusUpdate': + handlers?.onAuthStatusUpdate?.(data); + return; + case 'onTaskStatusUpdate': + handlers?.onTaskStatusUpdate?.(data); + return; + case 'onLaunch': + handlers?.onLaunch?.(); + return; + case 'onFinish': + // Not terminal — keep routing this task's events until `onCleanup`. + handlers?.onFinish?.(data); + return; + case 'onClose': + // Not terminal — see `onFinish`. + handlers?.onClose?.(data); + return; + case 'onError': + // In-flow SDK error (iOS-only today). Not terminal on its own — teardown still + // waits for `onCleanup` (or the platform safety net). + handlers?.onError?.(data); + return; + case 'onCleanup': + // The session is fully torn down (native `cleanup-application`). This event carries no + // data, so the consumer callback is invoked with no args (like `onLaunch`). Primary teardown. + handlers?.onCleanup?.(); + removeTransaction(instanceId); + return; + case 'onDataRequest': + return dispatchDataRequest( + instanceId, + data, + handlers?.onDataRequest, + resolveDataRequest + ); + default: + return; + } +} + +// Convenience for the platform layer: parse the native envelope and dispatch. The +// platform files install ONE persistent listener per event name that calls this. +export function handleNativeEvent( + eventName: TransactEventName, + raw: unknown, + resolveDataRequest?: ResolveDataRequest +): void | Promise { + const { instanceId, data } = parseEnvelope(raw); + if (!instanceId) { + return; + } + return dispatchEvent(eventName, instanceId, data, resolveDataRequest); +}