Skip to content
Open
Show file tree
Hide file tree
Changes from all 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 Capacitor-specific detector
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