Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/open-pillows-beg.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/offline-transactions': minor
---

Add support for capacitor on offline-transactions
19 changes: 19 additions & 0 deletions packages/offline-transactions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,21 @@ npm install @tanstack/offline-transactions @react-native-community/netinfo

The React Native implementation requires the `@react-native-community/netinfo` peer dependency for network connectivity detection.

### Capacitor

```bash
npm install @tanstack/offline-transactions @capacitor/network @capacitor/preferences
```

The Capacitor implementation requires the `@capacitor/network` and `@capacitor/preferences` peer dependency for network connectivity detection.

## Platform Support

This package provides platform-specific implementations for web and React Native environments:

- **Web**: Uses browser APIs (`window.online/offline` events, `document.visibilitychange`)
- **React Native**: Uses React Native primitives (`@react-native-community/netinfo` for network status, `AppState` for foreground/background detection)
- **Capacitor**: Uses Capacitor primitives (`@capacitor/network` and `@capacitor/preferences`)

## Quick Start

Expand All @@ -50,6 +59,12 @@ import { startOfflineExecutor } from '@tanstack/offline-transactions'
import { startOfflineExecutor } from '@tanstack/offline-transactions/react-native'
```

**Capacitor**

```typescript
import { startOfflineExecutor } from '@tanstack/offline-transactions/capacitor'
```

**Usage (same for both platforms):**

```typescript
Expand Down Expand Up @@ -254,6 +269,10 @@ await tx.commit() // Works offline!
- **Required peer dependency**: `@react-native-community/netinfo` for network connectivity detection
- **Storage**: Uses AsyncStorage or custom storage adapters

## Capacitor
- **Capacitor**: 8.0.0+ (tested with latest versions)
- **Required peer dependency**: `@capacitor/network` and `@capacitor/preferences` for network connectivity detection

## License

MIT
22 changes: 21 additions & 1 deletion packages/offline-transactions/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,16 @@
"default": "./dist/cjs/react-native/index.cjs"
}
},
"./capacitor": {
"import": {
"types": "./dist/esm/capacitor/index.d.ts",
"default": "./dist/esm/capacitor/index.js"
},
"require": {
"types": "./dist/cjs/capacitor/index.d.cts",
"default": "./dist/cjs/capacitor/index.cjs"
}
},
"./package.json": "./package.json"
},
"sideEffects": false,
Expand All @@ -62,18 +72,28 @@
},
"peerDependencies": {
"@react-native-community/netinfo": ">=11.0.0",
"react-native": ">=0.70.0"
"react-native": ">=0.70.0",
"@capacitor/preferences": ">=8.0.0",
"@capacitor/network": ">=8.0.0"
},
"peerDependenciesMeta": {
"@react-native-community/netinfo": {
"optional": true
},
"react-native": {
"optional": true
},
"@capacitor/preferences": {
"optional": true
},
"@capacitor/network": {
"optional": true
}
},
"devDependencies": {
"@react-native-community/netinfo": "11.4.1",
"@capacitor/preferences": "8.0.0",
"@capacitor/network": "8.0.0",
"@types/node": "^25.2.2",
"eslint": "^9.39.2",
"react-native": "0.79.6",
Expand Down
18 changes: 18 additions & 0 deletions packages/offline-transactions/src/capacitor/OfflineExecutor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { OfflineExecutor as BaseOfflineExecutor } from '../OfflineExecutor'
import { CapacitorOnlineDetector } from '../connectivity/CapacitorOnlineDetector'
import { CapacitorStorageAdapter } from '../storage/CapacitorStorageAdapter'
import type { OfflineConfig } from '../types'

export class OfflineExecutor extends BaseOfflineExecutor {
constructor(config: OfflineConfig) {
super({
...config,
storage: config.storage ?? new CapacitorStorageAdapter(),
onlineDetector: config.onlineDetector ?? new CapacitorOnlineDetector(),
})
}
}

export function startOfflineExecutor(config: OfflineConfig): OfflineExecutor {
return new OfflineExecutor(config)
}
45 changes: 45 additions & 0 deletions packages/offline-transactions/src/capacitor/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// Re-export from main entry (types, utilities, etc.)
export {
// Types
type OfflineTransaction,
type OfflineConfig,
type OfflineMode,
type StorageAdapter,
type StorageDiagnostic,
type StorageDiagnosticCode,
type RetryPolicy,
type LeaderElection,
type OnlineDetector,
type CreateOfflineTransactionOptions,
type CreateOfflineActionOptions,
type SerializedError,
type SerializedMutation,
NonRetriableError,
// Storage adapters
IndexedDBAdapter,
LocalStorageAdapter,
// Retry policies
DefaultRetryPolicy,
BackoffCalculator,
// Coordination
WebLocksLeader,
BroadcastChannelLeader,
// Connectivity - export web detector too for flexibility
WebOnlineDetector,
DefaultOnlineDetector,
// API components
OfflineTransactionAPI,
createOfflineAction,
// Outbox management
OutboxManager,
TransactionSerializer,
// Execution engine
KeyScheduler,
TransactionExecutor,
} from '../index'

// Export RN-specific detector
Comment thread
curebasemarco marked this conversation as resolved.
Outdated
export { CapacitorOnlineDetector } from '../connectivity/CapacitorOnlineDetector'

// Export Capacitor-configured executor
export { OfflineExecutor, startOfflineExecutor } from './OfflineExecutor'
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { Network } from '@capacitor/network'
import type { OnlineDetector } from '../types'

interface ListenerHandle {
remove: () => Promise<void>
}

export class CapacitorOnlineDetector implements OnlineDetector {
private listeners: Set<() => void> = new Set()
private networkListenerHandle: ListenerHandle | null = null
private isListening = false
private wasConnected = true

constructor() {
this.startListening()
}

private startListening(): void {
if (this.isListening) {
return
}

this.isListening = true

Network.addListener(`networkStatusChange`, (status) => {
const isConnected = status.connected

if (isConnected && !this.wasConnected) {
this.notifyListeners()
}

this.wasConnected = isConnected
}).then((handle) => {
this.networkListenerHandle = handle
})

if (typeof document !== `undefined`) {
document.addEventListener(`visibilitychange`, this.handleVisibilityChange)
}
}

private handleVisibilityChange = (): void => {
if (document.visibilityState === `visible`) {
this.notifyListeners()
}
}

private stopListening(): void {
if (!this.isListening) {
return
}

this.isListening = false

if (this.networkListenerHandle) {
this.networkListenerHandle.remove()
this.networkListenerHandle = null
}

if (typeof document !== `undefined`) {
document.removeEventListener(
`visibilitychange`,
this.handleVisibilityChange,
)
}
}

private notifyListeners(): void {
for (const listener of this.listeners) {
try {
listener()
} catch (error) {
console.warn(`CapacitorOnlineDetector listener error:`, error)
}
}
}

subscribe(callback: () => void): () => void {
this.listeners.add(callback)

return () => {
this.listeners.delete(callback)

if (this.listeners.size === 0) {
this.stopListening()
}
}
}

notifyOnline(): void {
this.notifyListeners()
}

dispose(): void {
this.stopListening()
this.listeners.clear()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { Preferences } from '@capacitor/preferences'
import { BaseStorageAdapter } from './StorageAdapter'

export class CapacitorStorageAdapter extends BaseStorageAdapter {
private prefix: string

constructor(prefix = `offline-tx:`) {
super()
this.prefix = prefix
}

static async probe(): Promise<{ available: boolean; error?: Error }> {
try {
const testKey = `__offline-tx-probe__`
const testValue = `test`

await Preferences.set({ key: testKey, value: testValue })
const { value: retrieved } = await Preferences.get({ key: testKey })
await Preferences.remove({ key: testKey })

if (retrieved !== testValue) {
return {
available: false,
error: new Error(`Capacitor Preferences read/write verification failed`),
}
}

return { available: true }
} catch (error) {
return {
available: false,
error: error instanceof Error ? error : new Error(String(error)),
}
}
}

private getKey(key: string): string {
return `${this.prefix}${key}`
}

async get(key: string): Promise<string | null> {
try {
const { value } = await Preferences.get({ key: this.getKey(key) })
return value
} catch (error) {
console.warn(`Capacitor Preferences get failed:`, error)
return null
}
}

async set(key: string, value: string): Promise<void> {
await Preferences.set({ key: this.getKey(key), value })
}

async delete(key: string): Promise<void> {
try {
await Preferences.remove({ key: this.getKey(key) })
} catch (error) {
console.warn(`Capacitor Preferences delete failed:`, error)
}
}

async keys(): Promise<Array<string>> {
try {
const { keys } = await Preferences.keys()
return keys
.filter((key) => key.startsWith(this.prefix))
.map((key) => key.slice(this.prefix.length))
} catch (error) {
console.warn(`Capacitor Preferences keys failed:`, error)
return []
}
}

async clear(): Promise<void> {
try {
const prefixedKeys = await this.keys()
await Promise.all(
prefixedKeys.map((key) => Preferences.remove({ key: this.getKey(key) }))
)
} catch (error) {
console.warn(`Capacitor Preferences clear failed:`, error)
}
}
}
Loading