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/dirty-papayas-attend.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/offline-transactions': minor
---

Enrich the OfflineConfig interface with an onlineDetector option and Check online status before executing transactions.
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 @@ -98,9 +99,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 @@ -113,7 +144,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 @@ -137,6 +168,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
12 changes: 9 additions & 3 deletions packages/offline-transactions/src/OfflineExecutor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,11 @@ export class OfflineExecutor {
constructor(config: OfflineConfig) {
this.config = config
this.scheduler = new KeyScheduler()
this.onlineDetector = new DefaultOnlineDetector()

// Initialize onlineDetector based on config
// undefined = use DefaultOnlineDetector (default)
// custom = user-provided detector
this.onlineDetector = config.onlineDetector ?? new DefaultOnlineDetector()

// Initialize as pending - will be set by async initialization
this.storage = null
Expand Down Expand Up @@ -259,6 +264,7 @@ export class OfflineExecutor {
this.outbox,
this.config,
this,
this.onlineDetector,
)
this.leaderElection = this.createLeaderElection()

Expand Down Expand Up @@ -485,7 +491,7 @@ export class OfflineExecutor {
return this.executor.getRunningCount()
}

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ export class DefaultOnlineDetector 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 @@ -3,7 +3,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 @@ -16,18 +20,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 @@ -54,6 +61,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 @@ -223,6 +235,7 @@ export class TransactionExecutor {
filteredTransactions = this.config.beforeRetry(transactions)
}


for (const transaction of filteredTransactions) {
this.scheduler.schedule(transaction)
}
Expand Down
5 changes: 4 additions & 1 deletion packages/offline-transactions/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ export interface StorageDiagnostic {
}

export interface OfflineConfig {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
collections: Record<string, Collection<any, any, any, any, any>>
mutationFns: Record<string, OfflineMutationFn>
storage?: StorageAdapter
Expand All @@ -101,6 +101,7 @@ export interface OfflineConfig {
onLeadershipChange?: (isLeader: boolean) => void
onStorageFailure?: (diagnostic: StorageDiagnostic) => void
leaderElection?: LeaderElection
onlineDetector?: OnlineDetector
}

export interface StorageAdapter {
Expand All @@ -126,6 +127,8 @@ export interface LeaderElection {
export interface OnlineDetector {
subscribe: (callback: () => void) => () => void
notifyOnline: () => void
isOnline: () => boolean
dispose: () => void
}

export interface CreateOfflineTransactionOptions {
Expand Down
32 changes: 32 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,36 @@ 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 {
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 +274,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