feat(sdk): add offline mode to the react-native SDK

This commit is contained in:
Carl-Gerhard Lindesvärd
2026-03-23 10:21:55 +01:00
parent bca07ae0d7
commit a82069c28c

View File

@@ -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 { OpenPanel as OpenPanelBase } from '@openpanel/sdk';
import * as Application from 'expo-application'; import * as Application from 'expo-application';
import Constants from 'expo-constants'; import Constants from 'expo-constants';
@@ -6,9 +10,49 @@ import { AppState, Platform } from 'react-native';
export * from '@openpanel/sdk'; export * from '@openpanel/sdk';
const QUEUE_STORAGE_KEY = '@openpanel/offline_queue';
interface StorageLike {
getItem(key: string): Promise<string | null>;
setItem(key: string, value: string): Promise<void>;
}
interface NetworkStateLike {
isConnected: boolean | null;
}
interface NetworkInfoLike {
addEventListener(callback: (state: NetworkStateLike) => void): () => void;
fetch(): Promise<NetworkStateLike>;
}
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 { export class OpenPanel extends OpenPanelBase {
private lastPath = ''; private lastPath = '';
constructor(public options: OpenPanelOptions) { private readonly storage?: StorageLike;
private isOnline = true;
constructor(public options: ReactNativeOpenPanelOptions) {
super({ super({
...options, ...options,
sdk: 'react-native', sdk: 'react-native',
@@ -16,14 +60,30 @@ export class OpenPanel extends OpenPanelBase {
}); });
this.api.addHeader('User-Agent', Constants.getWebViewUserAgentAsync()); 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) => { AppState.addEventListener('change', (state) => {
if (state === 'active') { if (state === 'active') {
this.setDefaultProperties(); this.setDefaultProperties();
this.flush();
} }
}); });
this.setDefaultProperties(); this.setDefaultProperties();
this.loadPersistedQueue();
} }
private async setDefaultProperties() { 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) { track(name: string, properties?: TrackProperties) {
return super.track(name, { ...properties, __path: this.lastPath }); return super.track(name, { ...properties, __path: this.lastPath });
} }