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/fix-offline-retry-and-online-gating.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/offline-transactions': patch
---

Fix retry behavior to not cap at 10 attempts and not burn retries while offline. Default retry policy now retries indefinitely with exponential backoff. Execution loop and retry timer check online status before attempting transactions. `OnlineDetector.isOnline()` is now a required method on the interface. `OfflineExecutor.notifyOnline()` has been removed — the online detector handles connectivity changes automatically.
19 changes: 2 additions & 17 deletions packages/offline-transactions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ Offline-first transaction capabilities for TanStack DB that provides durable per
## Features

- **Outbox Pattern**: Persist mutations before dispatch for zero data loss
- **Automatic Retry**: Exponential backoff with jitter for failed transactions
- **Automatic Retry**: Configurable retry behavior with exponential backoff + jitter by default
- **Multi-tab Coordination**: Leader election ensures safe storage access
- **FIFO Sequential Processing**: Transactions execute one at a time in creation order
- **Flexible Storage**: IndexedDB with localStorage fallback
Expand Down Expand Up @@ -129,6 +129,7 @@ interface OfflineConfig {
beforeRetry?: (transactions: OfflineTransaction[]) => OfflineTransaction[]
onUnknownMutationFn?: (name: string, tx: OfflineTransaction) => void
onLeadershipChange?: (isLeader: boolean) => void
onlineDetector?: OnlineDetector
}
```

Expand All @@ -144,7 +145,6 @@ 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
- `dispose()` - Clean up resources

### Error Handling
Expand Down Expand Up @@ -183,21 +183,6 @@ const executor = startOfflineExecutor({
})
```

### Custom Retry Policy

```typescript
const executor = startOfflineExecutor({
maxConcurrency: 5,
jitter: true,
beforeRetry: (transactions) => {
// Filter out old transactions
const cutoff = Date.now() - 24 * 60 * 60 * 1000 // 24 hours
return transactions.filter((tx) => tx.createdAt.getTime() > cutoff)
},
// ... other config
})
```

### Manual Transaction Control

```typescript
Expand Down
9 changes: 4 additions & 5 deletions packages/offline-transactions/src/OfflineExecutor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,6 @@ export class OfflineExecutor {

this.unsubscribeOnline = this.onlineDetector.subscribe(() => {
if (this.isOfflineEnabled && this.executor) {
// Reset retry delays so transactions can execute immediately when back online
this.executor.resetRetryDelays()
this.executor.executeAll().catch((error) => {
console.warn(
Expand Down Expand Up @@ -546,10 +545,6 @@ export class OfflineExecutor {
this.executor.clear()
}

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

getPendingCount(): number {
if (!this.executor) {
return 0
Expand All @@ -568,6 +563,10 @@ export class OfflineExecutor {
return this.onlineDetector
}

isOnline(): boolean {
return this.onlineDetector.isOnline()
}

dispose(): void {
if (this.unsubscribeOnline) {
this.unsubscribeOnline()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,19 @@ export class ReactNativeOnlineDetector implements OnlineDetector {

this.isListening = true

if (typeof NetInfo.fetch === `function`) {
void NetInfo.fetch()
.then((state) => {
this.wasConnected = this.toConnectivityState(state)
})
.catch(() => {
// Ignore initial fetch failures and rely on subscription updates.
})
}

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

// Only notify when transitioning to online
if (isConnected && !this.wasConnected) {
Expand Down Expand Up @@ -98,8 +107,19 @@ export class ReactNativeOnlineDetector implements OnlineDetector {
this.notifyListeners()
}

isOnline(): boolean {
return this.wasConnected
}

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

private toConnectivityState(state: {
isConnected: boolean | null
isInternetReachable: boolean | null
}): boolean {
return state.isConnected === true && state.isInternetReachable !== false
Copy link
Contributor

Choose a reason for hiding this comment

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

could write state.isConnected instead of === true.

}
}
32 changes: 27 additions & 5 deletions packages/offline-transactions/src/executor/TransactionExecutor.ts
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,
TransactionSignaler,
} from '../types'

const HANDLED_EXECUTION_ERROR = Symbol(`HandledExecutionError`)

Expand All @@ -15,19 +19,22 @@ export class TransactionExecutor {
private retryPolicy: DefaultRetryPolicy
private isExecuting = false
private executionPromise: Promise<void> | null = null
private offlineExecutor: any // Reference to OfflineExecutor for signaling
private offlineExecutor: TransactionSignaler
private retryTimer: ReturnType<typeof setTimeout> | null = null

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

Expand All @@ -54,6 +61,10 @@ export class TransactionExecutor {

private async runExecution(): Promise<void> {
while (this.scheduler.getPendingCount() > 0) {
if (!this.isOnline()) {
break
}

const transaction = this.scheduler.getNext()

if (!transaction) {
Expand Down Expand Up @@ -178,7 +189,10 @@ export class TransactionExecutor {
return
}

const delay = this.retryPolicy.calculateDelay(transaction.retryCount)
const delay = Math.max(
0,
this.retryPolicy.calculateDelay(transaction.retryCount),
)
const updatedTransaction: OfflineTransaction = {
...transaction,
retryCount: transaction.retryCount + 1,
Expand Down Expand Up @@ -320,6 +334,10 @@ export class TransactionExecutor {
// Clear existing timer
this.clearRetryTimer()

if (!this.isOnline()) {
return
}

// Find the earliest retry time among pending transactions
const earliestRetryTime = this.getEarliestRetryTime()

Expand Down Expand Up @@ -353,6 +371,10 @@ export class TransactionExecutor {
}
}

private isOnline(): boolean {
return this.offlineExecutor.isOnline()
}

getRunningCount(): number {
return this.scheduler.getRunningCount()
}
Expand Down
2 changes: 1 addition & 1 deletion packages/offline-transactions/src/retry/RetryPolicy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export class DefaultRetryPolicy implements RetryPolicy {
private backoffCalculator: BackoffCalculator
private maxRetries: number

constructor(maxRetries = 10, jitter = true) {
constructor(maxRetries = Number.POSITIVE_INFINITY, jitter = true) {
this.backoffCalculator = new BackoffCalculator(jitter)
this.maxRetries = maxRetries
}
Expand Down
14 changes: 13 additions & 1 deletion packages/offline-transactions/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type {
Collection,
MutationFnParams,
PendingMutation,
Transaction,
} from '@tanstack/db'

// Extended mutation function that includes idempotency key
Expand Down Expand Up @@ -104,7 +105,7 @@ export interface OfflineConfig {
/**
* Custom online detector implementation.
* Defaults to WebOnlineDetector for browser environments.
* Use ReactNativeOnlineDetector from '@tanstack/offline-transactions/react-native' for RN/Expo.
* The '@tanstack/offline-transactions/react-native' entry point uses ReactNativeOnlineDetector automatically.
*/
onlineDetector?: OnlineDetector
}
Expand All @@ -129,9 +130,20 @@ export interface LeaderElection {
onLeadershipChange: (callback: (isLeader: boolean) => void) => () => void
}

export interface TransactionSignaler {
resolveTransaction: (transactionId: string, result: any) => void
rejectTransaction: (transactionId: string, error: Error) => void
registerRestorationTransaction: (
offlineTransactionId: string,
restorationTransaction: Transaction,
) => void
isOnline: () => boolean
}

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

Expand Down
6 changes: 1 addition & 5 deletions packages/offline-transactions/tests/harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,7 @@ export function createTestOfflineEnvironment(
}

const config: OfflineConfig = {
...options.config,
collections: {
...(options.config?.collections ?? {}),
[collection.id]: collection,
Expand All @@ -237,11 +238,6 @@ export function createTestOfflineEnvironment(
[mutationFnName]: wrappedMutation,
},
storage,
maxConcurrency: options.config?.maxConcurrency,
jitter: options.config?.jitter,
beforeRetry: options.config?.beforeRetry,
onUnknownMutationFn: options.config?.onUnknownMutationFn,
onLeadershipChange: options.config?.onLeadershipChange,
leaderElection: options.config?.leaderElection ?? leader,
}

Expand Down
Loading
Loading