From a82069c28ca5320d48c6fedb6049da22a79985a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carl-Gerhard=20Lindesva=CC=88rd?= Date: Mon, 23 Mar 2026 10:21:55 +0100 Subject: [PATCH] feat(sdk): add offline mode to the react-native SDK --- packages/sdks/react-native/index.ts | 117 +++++++++++++++++++++++++++- 1 file changed, 115 insertions(+), 2 deletions(-) diff --git a/packages/sdks/react-native/index.ts b/packages/sdks/react-native/index.ts index 30a61619..9044c692 100644 --- a/packages/sdks/react-native/index.ts +++ b/packages/sdks/react-native/index.ts @@ -1,4 +1,8 @@ -import type { OpenPanelOptions, TrackProperties } from '@openpanel/sdk'; +import type { + OpenPanelOptions, + TrackHandlerPayload, + TrackProperties, +} from '@openpanel/sdk'; import { OpenPanel as OpenPanelBase } from '@openpanel/sdk'; import * as Application from 'expo-application'; import Constants from 'expo-constants'; @@ -6,9 +10,49 @@ import { AppState, Platform } from 'react-native'; export * from '@openpanel/sdk'; +const QUEUE_STORAGE_KEY = '@openpanel/offline_queue'; + +interface StorageLike { + getItem(key: string): Promise; + setItem(key: string, value: string): Promise; +} + +interface NetworkStateLike { + isConnected: boolean | null; +} + +interface NetworkInfoLike { + addEventListener(callback: (state: NetworkStateLike) => void): () => void; + fetch(): Promise; +} + +export interface ReactNativeOpenPanelOptions extends OpenPanelOptions { + /** + * Provide an AsyncStorage-compatible adapter to persist the event queue + * across app restarts (enables full offline support). + * + * @example + * import AsyncStorage from '@react-native-async-storage/async-storage'; + * new OpenPanel({ clientId: '...', storage: AsyncStorage }); + */ + storage?: StorageLike; + /** + * Provide a NetInfo-compatible adapter to detect connectivity changes and + * automatically flush the queue when the device comes back online. + * + * @example + * import NetInfo from '@react-native-community/netinfo'; + * new OpenPanel({ clientId: '...', networkInfo: NetInfo }); + */ + networkInfo?: NetworkInfoLike; +} + export class OpenPanel extends OpenPanelBase { private lastPath = ''; - constructor(public options: OpenPanelOptions) { + private readonly storage?: StorageLike; + private isOnline = true; + + constructor(public options: ReactNativeOpenPanelOptions) { super({ ...options, sdk: 'react-native', @@ -16,14 +60,30 @@ export class OpenPanel extends OpenPanelBase { }); this.api.addHeader('User-Agent', Constants.getWebViewUserAgentAsync()); + this.storage = options.storage; + + if (options.networkInfo) { + options.networkInfo.fetch().then(({ isConnected }) => { + this.isOnline = isConnected ?? true; + }); + options.networkInfo.addEventListener(({ isConnected }) => { + const wasOffline = !this.isOnline; + this.isOnline = isConnected ?? true; + if (wasOffline && this.isOnline) { + this.flush(); + } + }); + } AppState.addEventListener('change', (state) => { if (state === 'active') { this.setDefaultProperties(); + this.flush(); } }); this.setDefaultProperties(); + this.loadPersistedQueue(); } private async setDefaultProperties() { @@ -37,6 +97,59 @@ export class OpenPanel extends OpenPanelBase { }); } + private async loadPersistedQueue() { + if (!this.storage) { + return; + } + try { + const stored = await this.storage.getItem(QUEUE_STORAGE_KEY); + if (stored) { + const items = JSON.parse(stored); + if (Array.isArray(items) && items.length > 0) { + this.queue = [...items, ...this.queue]; + this.flush(); + } + } + } catch { + this.log('Failed to load persisted queue'); + } + } + + private persistQueue() { + if (!this.storage) { + return; + } + this.storage + .setItem(QUEUE_STORAGE_KEY, JSON.stringify(this.queue)) + .catch(() => { + this.log('Failed to persist queue'); + }); + } + + addQueue(payload: TrackHandlerPayload) { + super.addQueue(payload); + this.persistQueue(); + } + + async send(payload: TrackHandlerPayload) { + if (this.options.filter && !this.options.filter(payload)) { + return null; + } + if (!this.isOnline) { + this.addQueue(payload); + return null; + } + return await super.send(payload); + } + + flush() { + if (!this.isOnline) { + return; + } + super.flush(); + this.persistQueue(); + } + track(name: string, properties?: TrackProperties) { return super.track(name, { ...properties, __path: this.lastPath }); }