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/spicy-foxes-grab.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/offline-transactions': patch
---

Introduce specialized OnlineDetector for React Native
73 changes: 72 additions & 1 deletion packages/offline-transactions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,31 @@ Offline-first transaction capabilities for TanStack DB that provides durable per

## Installation

### Web

```bash
npm install @tanstack/offline-transactions
```

### React Native / Expo

```bash
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.

## 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)

## Quick Start

### Web

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

Expand Down Expand Up @@ -55,6 +74,49 @@ offlineTx.mutate(() => {
await offlineTx.commit()
```

### React Native / Expo

```typescript
import { startOfflineExecutor } from '@tanstack/offline-transactions/react-native'
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@claude no need to repeat all the same code as we already show for the web example. Just show that the import is different.


// Setup offline executor (same API as web)
const offline = startOfflineExecutor({
collections: { todos: todoCollection },
mutationFns: {
syncTodos: async ({ transaction, idempotencyKey }) => {
await api.saveBatch(transaction.mutations, { idempotencyKey })
},
},
onLeadershipChange: (isLeader) => {
if (!isLeader) {
console.warn('Running in online-only mode (another tab is the leader)')
}
},
})

// API usage is identical to web
const offlineTx = offline.createOfflineTransaction({
mutationFnName: 'syncTodos',
autoCommit: false,
})

offlineTx.mutate(() => {
todoCollection.insert({
id: crypto.randomUUID(),
text: 'Buy milk',
completed: false,
})
})

await offlineTx.commit()
```

The only difference between web and React Native usage is the import path:
- **Web**: `import { ... } from '@tanstack/offline-transactions'`
- **React Native**: `import { ... } from '@tanstack/offline-transactions/react-native'`

All other APIs remain the same across platforms.

## Core Concepts

### Outbox-First Persistence
Expand Down Expand Up @@ -207,13 +269,22 @@ tx.mutate(() => todoCollection.insert({ id: '1', text: 'Buy milk' }))
await tx.commit() // Works offline!
```

## Browser Support
## Platform Support

### Web Browsers

- **IndexedDB**: Modern browsers (primary storage)
- **localStorage**: Fallback for limited environments
- **Web Locks API**: Chrome 69+, Firefox 96+ (preferred leader election)
- **BroadcastChannel**: All modern browsers (fallback leader election)

### React Native

- **React Native**: 0.60+ (tested with latest versions)
- **Expo**: SDK 40+ (tested with latest versions)
- **Required peer dependency**: `@react-native-community/netinfo` for network connectivity detection
- **Storage**: Uses AsyncStorage or custom storage adapters

## License

MIT
24 changes: 24 additions & 0 deletions packages/offline-transactions/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,16 @@
"default": "./dist/cjs/index.cjs"
}
},
"./react-native": {
"import": {
"types": "./dist/esm/react-native/index.d.ts",
"default": "./dist/esm/react-native/index.js"
},
"require": {
"types": "./dist/cjs/react-native/index.d.cts",
"default": "./dist/cjs/react-native/index.cjs"
}
},
"./package.json": "./package.json"
},
"sideEffects": false,
Expand All @@ -50,9 +60,23 @@
"dependencies": {
"@tanstack/db": "workspace:*"
},
"peerDependencies": {
"@react-native-community/netinfo": ">=11.0.0",
"react-native": ">=0.70.0"
},
"peerDependenciesMeta": {
"@react-native-community/netinfo": {
"optional": true
},
"react-native": {
"optional": true
}
},
"devDependencies": {
"@react-native-community/netinfo": "^11.4.1",
"@types/node": "^24.6.2",
"eslint": "^9.39.2",
"react-native": "^0.83.1",
"typescript": "^5.9.2",
"vitest": "^3.2.4"
}
Expand Down
9 changes: 5 additions & 4 deletions packages/offline-transactions/src/OfflineExecutor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { WebLocksLeader } from './coordination/WebLocksLeader'
import { BroadcastChannelLeader } from './coordination/BroadcastChannelLeader'

// Connectivity
import { DefaultOnlineDetector } from './connectivity/OnlineDetector'
import { WebOnlineDetector } from './connectivity/OnlineDetector'

// API
import { OfflineTransaction as OfflineTransactionAPI } from './api/OfflineTransaction'
Expand All @@ -30,6 +30,7 @@ import type {
OfflineConfig,
OfflineMode,
OfflineTransaction,
OnlineDetector,
StorageAdapter,
StorageDiagnostic,
} from './types'
Expand All @@ -44,7 +45,7 @@ export class OfflineExecutor {
private scheduler: KeyScheduler
private executor: TransactionExecutor | null
private leaderElection: LeaderElection | null
private onlineDetector: DefaultOnlineDetector
private onlineDetector: OnlineDetector
private isLeaderState = false
private unsubscribeOnline: (() => void) | null = null
private unsubscribeLeadership: (() => void) | null = null
Expand All @@ -71,7 +72,7 @@ export class OfflineExecutor {
constructor(config: OfflineConfig) {
this.config = config
this.scheduler = new KeyScheduler()
this.onlineDetector = new DefaultOnlineDetector()
this.onlineDetector = config.onlineDetector ?? new WebOnlineDetector()

// Initialize as pending - will be set by async initialization
this.storage = null
Expand Down Expand Up @@ -491,7 +492,7 @@ export class OfflineExecutor {
return this.executor.getRunningCount()
}

getOnlineDetector(): DefaultOnlineDetector {
getOnlineDetector(): OnlineDetector {
return this.onlineDetector
}

Expand Down
13 changes: 12 additions & 1 deletion packages/offline-transactions/src/connectivity/OnlineDetector.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import type { OnlineDetector } from '../types'

export class DefaultOnlineDetector implements OnlineDetector {
/**
* Web-based online detector that uses browser APIs.
* Listens for:
* - `window.online` event for network connectivity changes
* - `document.visibilitychange` event for tab/window focus changes
*/
export class WebOnlineDetector implements OnlineDetector {
private listeners: Set<() => void> = new Set()
private isListening = false

Expand Down Expand Up @@ -85,3 +91,8 @@ export class DefaultOnlineDetector implements OnlineDetector {
this.listeners.clear()
}
}

/**
* @deprecated Use `WebOnlineDetector` instead. This alias is kept for backwards compatibility.
*/
export const DefaultOnlineDetector = WebOnlineDetector
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import NetInfo from '@react-native-community/netinfo'
import { AppState } from 'react-native'
import type { AppStateStatus, NativeEventSubscription } from 'react-native'
import type { OnlineDetector } from '../types'

/**
* React Native online detector that uses RN APIs.
* Listens for:
* - Network connectivity changes via `@react-native-community/netinfo`
* - App state changes (foreground/background) via `AppState`
*/
export class ReactNativeOnlineDetector implements OnlineDetector {
private listeners: Set<() => void> = new Set()
private netInfoUnsubscribe: (() => void) | null = null
private appStateSubscription: NativeEventSubscription | null = null
private isListening = false
private wasConnected = true

constructor() {
this.startListening()
}

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

this.isListening = true

// Subscribe to network state changes
this.netInfoUnsubscribe = NetInfo.addEventListener((state) => {
const isConnected =
state.isConnected === true && state.isInternetReachable !== false

// Only notify when transitioning to online
if (isConnected && !this.wasConnected) {
this.notifyListeners()
}

this.wasConnected = isConnected
})

// Subscribe to app state changes (foreground/background)
this.appStateSubscription = AppState.addEventListener(
`change`,
this.handleAppStateChange,
)
}

private handleAppStateChange = (nextState: AppStateStatus): void => {
// Notify when app becomes active (foreground)
if (nextState === `active`) {
this.notifyListeners()
}
}

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

this.isListening = false

if (this.netInfoUnsubscribe) {
this.netInfoUnsubscribe()
this.netInfoUnsubscribe = null
}

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

private notifyListeners(): void {
for (const listener of this.listeners) {
try {
listener()
} catch (error) {
console.warn(`ReactNativeOnlineDetector 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()
}
}
5 changes: 4 additions & 1 deletion packages/offline-transactions/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,10 @@ export { WebLocksLeader } from './coordination/WebLocksLeader'
export { BroadcastChannelLeader } from './coordination/BroadcastChannelLeader'

// Connectivity
export { DefaultOnlineDetector } from './connectivity/OnlineDetector'
export {
WebOnlineDetector,
DefaultOnlineDetector,
} from './connectivity/OnlineDetector'

// API components
export { OfflineTransaction as OfflineTransactionAPI } from './api/OfflineTransaction'
Expand Down
24 changes: 24 additions & 0 deletions packages/offline-transactions/src/react-native/OfflineExecutor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { OfflineExecutor as BaseOfflineExecutor } from '../OfflineExecutor'
import { ReactNativeOnlineDetector } from '../connectivity/ReactNativeOnlineDetector'
import type { OfflineConfig } from '../types'

/**
* OfflineExecutor configured for React Native environments.
* Uses ReactNativeOnlineDetector by default instead of WebOnlineDetector.
*/
export class OfflineExecutor extends BaseOfflineExecutor {
constructor(config: OfflineConfig) {
super({
...config,
onlineDetector: config.onlineDetector ?? new ReactNativeOnlineDetector(),
})
}
}

/**
* Start an offline executor configured for React Native environments.
* Uses ReactNativeOnlineDetector by default instead of WebOnlineDetector.
*/
export function startOfflineExecutor(config: OfflineConfig): OfflineExecutor {
return new OfflineExecutor(config)
}
Loading