Skip to content
Open
7 changes: 7 additions & 0 deletions .changeset/dirty-papayas-attend.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@tanstack/offline-transactions': minor
---

Add `isOnline()` method to `OnlineDetector` interface and skip transaction execution when offline

This prevents unnecessary retry attempts when the device is known to be offline. The `TransactionExecutor` now checks `isOnline()` before attempting to execute queued transactions. Custom `OnlineDetector` implementations (e.g., for React Native/Expo) can provide accurate network status to avoid futile server requests.
82 changes: 81 additions & 1 deletion packages/offline-transactions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ Offline-first transaction capabilities for TanStack DB that provides durable per
## Features

- **Outbox Pattern**: Persist mutations before dispatch for zero data loss
- **Offline Detection**: Skip retries when offline, auto-resume when connectivity restored
- **Automatic Retry**: Exponential backoff with jitter for failed transactions
- **Multi-tab Coordination**: Leader election ensures safe storage access
- **FIFO Sequential Processing**: Transactions execute one at a time in creation order
Expand Down Expand Up @@ -129,9 +130,39 @@ interface OfflineConfig {
beforeRetry?: (transactions: OfflineTransaction[]) => OfflineTransaction[]
onUnknownMutationFn?: (name: string, tx: OfflineTransaction) => void
onLeadershipChange?: (isLeader: boolean) => void
onlineDetector?: OnlineDetector
}

interface OnlineDetector {
subscribe: (callback: () => void) => () => void
notifyOnline: () => void
isOnline: () => boolean
dispose: () => void
}
```

### onlineDetector

By default, `onlineDetector` is `undefined` and the system will use the built-in `DefaultOnlineDetector`.

**How it works:**
- Provides an `isOnline()` method to check connectivity status before executing transactions

**Transactions are skipped when offline**
- Avoid unnecessary retry attempts
- Allows subscribers to be notified when connectivity is restored, triggering pending transaction execution

**DefaultOnlineDetector behavior:**
- Uses the browser's `navigator.onLine` API to detect online/offline state
- Automatically triggers transaction execution on these events:
- `online` event (browser detects network connection)
- `visibilitychange` event (when tab becomes visible)

**Manual trigger:**
- `notifyOnline()` method can be used to manually trigger transaction execution
- Only succeeds if `isOnline()` returns `true`


### OfflineExecutor

#### Properties
Expand All @@ -144,7 +175,7 @@ interface OfflineConfig {
- `waitForTransactionCompletion(id)` - Wait for a specific transaction to complete
- `removeFromOutbox(id)` - Manually remove transaction from outbox
- `peekOutbox()` - View all pending transactions
- `notifyOnline()` - Manually trigger retry execution
- `notifyOnline()` - Manually trigger transaction execution (only succeeds if online)
- `dispose()` - Clean up resources

### Error Handling
Expand All @@ -168,6 +199,55 @@ const mutationFn = async ({ transaction }) => {

## Advanced Usage

### Custom Online Detector

By default, the executor uses the browser's `navigator.onLine` API to detect connectivity. You can provide a custom detector for more sophisticated detection logic:

```typescript
class CustomOnlineDetector implements OnlineDetector {
private listeners = new Set<() => void>()
private online = true

constructor() {
// Poll your API endpoint to check connectivity
setInterval(async () => {
try {
await fetch('/api/health', { method: 'HEAD' })
const wasOffline = !this.online
this.online = true
if (wasOffline) {
this.notifyOnline()
}
} catch {
this.online = false
}
}, 60000)
}

isOnline(): boolean {
return this.online
}

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

notifyOnline(): void {
this.listeners.forEach((cb) => cb())
}

dispose(): void {
this.listeners.clear()
}
}

const executor = startOfflineExecutor({
onlineDetector: new CustomOnlineDetector(),
// ... other config
})
```

### Custom Storage Adapter

```typescript
Expand Down
1 change: 1 addition & 0 deletions packages/offline-transactions/src/OfflineExecutor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,7 @@ export class OfflineExecutor {
this.outbox,
this.config,
this,
this.onlineDetector,
)
this.leaderElection = this.createLeaderElection()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,10 @@ export class WebOnlineDetector implements OnlineDetector {
}

notifyOnline(): void {
if (!this.isOnline()) {
console.info('notifyOnline called while offline, skipping notification')
return
}
this.notifyListeners()
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ import { NonRetriableError } from '../types'
import { withNestedSpan } from '../telemetry/tracer'
import type { KeyScheduler } from './KeyScheduler'
import type { OutboxManager } from '../outbox/OutboxManager'
import type { OfflineConfig, OfflineTransaction } from '../types'
import type {
OfflineConfig,
OfflineTransaction,
OnlineDetector,
} from '../types'

const HANDLED_EXECUTION_ERROR = Symbol(`HandledExecutionError`)

Expand All @@ -17,18 +21,21 @@ export class TransactionExecutor {
private executionPromise: Promise<void> | null = null
private offlineExecutor: any // Reference to OfflineExecutor for signaling
private retryTimer: ReturnType<typeof setTimeout> | null = null
private onlineDetector: OnlineDetector

constructor(
scheduler: KeyScheduler,
outbox: OutboxManager,
config: OfflineConfig,
offlineExecutor: any,
onlineDetector: OnlineDetector,
) {
this.scheduler = scheduler
this.outbox = outbox
this.config = config
this.retryPolicy = new DefaultRetryPolicy(10, config.jitter ?? true)
this.offlineExecutor = offlineExecutor
this.onlineDetector = onlineDetector
}

async execute(transaction: OfflineTransaction): Promise<void> {
Expand All @@ -55,6 +62,11 @@ export class TransactionExecutor {
private async runExecution(): Promise<void> {
const maxConcurrency = this.config.maxConcurrency ?? 3

// Check online status before executing transactions
if (!this.onlineDetector.isOnline()) {
return
}

while (this.scheduler.getPendingCount() > 0) {
const batch = this.scheduler.getNextBatch(maxConcurrency)

Expand Down Expand Up @@ -224,6 +236,7 @@ export class TransactionExecutor {
filteredTransactions = this.config.beforeRetry(transactions)
}


for (const transaction of filteredTransactions) {
this.scheduler.schedule(transaction)
}
Expand Down
1 change: 1 addition & 0 deletions packages/offline-transactions/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ export interface LeaderElection {
export interface OnlineDetector {
subscribe: (callback: () => void) => () => void
notifyOnline: () => void
isOnline: () => boolean
dispose: () => void
}

Expand Down
35 changes: 35 additions & 0 deletions packages/offline-transactions/tests/harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
LeaderElection,
OfflineConfig,
OfflineMutationFnParams,
OnlineDetector,
StorageAdapter,
} from '../src/types'

Expand Down Expand Up @@ -92,6 +93,39 @@ class FakeLeaderElection implements LeaderElection {
}
}

export class FakeOnlineDetector implements OnlineDetector {
private listeners = new Set<() => void>()
online = true

isOnline(): boolean {
return this.online
}

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

notifyOnline(): void {
if (!this.isOnline()) {
return
}
for (const listener of this.listeners) {
try {
listener()
} catch (error) {
console.warn(`FakeOnlineDetector listener error:`, error)
}
}
}

dispose(): void {
this.listeners.clear()
}
}

type TestMutationFn = (
params: OfflineMutationFnParams & { attempt: number },
) => Promise<any>
Expand Down Expand Up @@ -243,6 +277,7 @@ export function createTestOfflineEnvironment(
onUnknownMutationFn: options.config?.onUnknownMutationFn,
onLeadershipChange: options.config?.onLeadershipChange,
leaderElection: options.config?.leaderElection ?? leader,
onlineDetector: options.config?.onlineDetector,
}

const executor = startOfflineExecutor(config)
Expand Down
Loading