From 94f78f32d98349e64d2e145ee116de750bcbe02b Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Wed, 25 Feb 2026 15:32:48 -0700 Subject: [PATCH 1/4] fix(offline-transactions): infinite retries by default and online-status gating The hardcoded 10-retry limit caused transactions to be permanently dropped. Retries were also burned while offline. This changes the default to infinite retries with exponential backoff and gates execution on online status so retries are only attempted when connected. - DefaultRetryPolicy defaults to Infinity instead of 10 - Execution loop and retry timer skip while offline via isOnline() - OnlineDetector.isOnline() now required on the interface - ReactNativeOnlineDetector adds isOnline() and initial NetInfo.fetch - Extract TransactionSignaler interface to replace any type - Simplify test harness config with spread Co-Authored-By: Claude Opus 4.6 --- packages/offline-transactions/README.md | 18 +--- .../src/OfflineExecutor.ts | 8 ++ .../connectivity/ReactNativeOnlineDetector.ts | 24 ++++- .../src/executor/TransactionExecutor.ts | 32 ++++++- .../src/retry/RetryPolicy.ts | 2 +- packages/offline-transactions/src/types.ts | 14 ++- .../offline-transactions/tests/harness.ts | 6 +- .../tests/offline-e2e.test.ts | 92 ++++++++++++++++++- 8 files changed, 165 insertions(+), 31 deletions(-) diff --git a/packages/offline-transactions/README.md b/packages/offline-transactions/README.md index 351480258..b3d548025 100644 --- a/packages/offline-transactions/README.md +++ b/packages/offline-transactions/README.md @@ -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 @@ -129,6 +129,7 @@ interface OfflineConfig { beforeRetry?: (transactions: OfflineTransaction[]) => OfflineTransaction[] onUnknownMutationFn?: (name: string, tx: OfflineTransaction) => void onLeadershipChange?: (isLeader: boolean) => void + onlineDetector?: OnlineDetector } ``` @@ -183,21 +184,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 diff --git a/packages/offline-transactions/src/OfflineExecutor.ts b/packages/offline-transactions/src/OfflineExecutor.ts index ccfb08c66..e8321b064 100644 --- a/packages/offline-transactions/src/OfflineExecutor.ts +++ b/packages/offline-transactions/src/OfflineExecutor.ts @@ -220,6 +220,10 @@ export class OfflineExecutor { this.unsubscribeOnline = this.onlineDetector.subscribe(() => { if (this.isOfflineEnabled && this.executor) { + if (!this.isOnline()) { + return + } + // Reset retry delays so transactions can execute immediately when back online this.executor.resetRetryDelays() this.executor.executeAll().catch((error) => { @@ -568,6 +572,10 @@ export class OfflineExecutor { return this.onlineDetector } + isOnline(): boolean { + return this.onlineDetector.isOnline() + } + dispose(): void { if (this.unsubscribeOnline) { this.unsubscribeOnline() diff --git a/packages/offline-transactions/src/connectivity/ReactNativeOnlineDetector.ts b/packages/offline-transactions/src/connectivity/ReactNativeOnlineDetector.ts index e969732df..d18f4dc7c 100644 --- a/packages/offline-transactions/src/connectivity/ReactNativeOnlineDetector.ts +++ b/packages/offline-transactions/src/connectivity/ReactNativeOnlineDetector.ts @@ -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) { @@ -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 + } } diff --git a/packages/offline-transactions/src/executor/TransactionExecutor.ts b/packages/offline-transactions/src/executor/TransactionExecutor.ts index 5853a243b..745de9bac 100644 --- a/packages/offline-transactions/src/executor/TransactionExecutor.ts +++ b/packages/offline-transactions/src/executor/TransactionExecutor.ts @@ -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`) @@ -15,19 +19,22 @@ export class TransactionExecutor { private retryPolicy: DefaultRetryPolicy private isExecuting = false private executionPromise: Promise | null = null - private offlineExecutor: any // Reference to OfflineExecutor for signaling + private offlineExecutor: TransactionSignaler private retryTimer: ReturnType | 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 } @@ -56,6 +63,10 @@ export class TransactionExecutor { const maxConcurrency = this.config.maxConcurrency ?? 3 while (this.scheduler.getPendingCount() > 0) { + if (!this.isOnline()) { + break + } + const batch = this.scheduler.getNextBatch(maxConcurrency) if (batch.length === 0) { @@ -183,7 +194,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, @@ -325,6 +339,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() @@ -358,6 +376,10 @@ export class TransactionExecutor { } } + private isOnline(): boolean { + return this.offlineExecutor.isOnline() + } + getRunningCount(): number { return this.scheduler.getRunningCount() } diff --git a/packages/offline-transactions/src/retry/RetryPolicy.ts b/packages/offline-transactions/src/retry/RetryPolicy.ts index 9fb4f6bf5..420fe057d 100644 --- a/packages/offline-transactions/src/retry/RetryPolicy.ts +++ b/packages/offline-transactions/src/retry/RetryPolicy.ts @@ -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 } diff --git a/packages/offline-transactions/src/types.ts b/packages/offline-transactions/src/types.ts index e0b1f6df7..e18a287cb 100644 --- a/packages/offline-transactions/src/types.ts +++ b/packages/offline-transactions/src/types.ts @@ -2,6 +2,7 @@ import type { Collection, MutationFnParams, PendingMutation, + Transaction, } from '@tanstack/db' // Extended mutation function that includes idempotency key @@ -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 } @@ -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 } diff --git a/packages/offline-transactions/tests/harness.ts b/packages/offline-transactions/tests/harness.ts index f12a026c6..b971a7670 100644 --- a/packages/offline-transactions/tests/harness.ts +++ b/packages/offline-transactions/tests/harness.ts @@ -228,6 +228,7 @@ export function createTestOfflineEnvironment( } const config: OfflineConfig = { + ...options.config, collections: { ...(options.config?.collections ?? {}), [collection.id]: collection, @@ -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, } diff --git a/packages/offline-transactions/tests/offline-e2e.test.ts b/packages/offline-transactions/tests/offline-e2e.test.ts index 290ef07ce..9f9190b85 100644 --- a/packages/offline-transactions/tests/offline-e2e.test.ts +++ b/packages/offline-transactions/tests/offline-e2e.test.ts @@ -1,8 +1,9 @@ import { describe, expect, it } from 'vitest' import { NonRetriableError } from '../src/types' +import { DefaultRetryPolicy } from '../src/retry/RetryPolicy' import { FakeStorageAdapter, createTestOfflineEnvironment } from './harness' import type { TestItem } from './harness' -import type { OfflineMutationFnParams } from '../src/types' +import type { OfflineMutationFnParams, OnlineDetector } from '../src/types' import type { PendingMutation } from '@tanstack/db' const flushMicrotasks = () => new Promise((resolve) => setTimeout(resolve, 0)) @@ -22,6 +23,45 @@ const waitUntil = async ( throw new Error(`Timed out waiting for condition`) } +class ManualOnlineDetector implements OnlineDetector { + private listeners = new Set<() => void>() + private online: boolean + + constructor(initialOnline: boolean) { + this.online = initialOnline + } + + subscribe(callback: () => void): () => void { + this.listeners.add(callback) + + return () => { + this.listeners.delete(callback) + } + } + + notifyOnline(): void { + for (const listener of this.listeners) { + listener() + } + } + + isOnline(): boolean { + return this.online + } + + setOnline(isOnline: boolean): void { + this.online = isOnline + + if (isOnline) { + this.notifyOnline() + } + } + + dispose(): void { + this.listeners.clear() + } +} + describe(`offline executor end-to-end`, () => { it(`resolves waiting promises for successful transactions`, async () => { const env = createTestOfflineEnvironment() @@ -132,6 +172,56 @@ describe(`offline executor end-to-end`, () => { env.executor.dispose() }) + it(`retries beyond 10 attempts by default`, () => { + const policy = new DefaultRetryPolicy() + const error = new Error(`transient`) + + for (let i = 0; i < 50; i++) { + expect(policy.shouldRetry(error, i)).toBe(true) + } + + expect(policy.shouldRetry(new NonRetriableError(`permanent`), 0)).toBe(false) + }) + + it(`does not execute mutations while offline`, async () => { + const onlineDetector = new ManualOnlineDetector(false) + const env = createTestOfflineEnvironment({ + config: { + onlineDetector, + }, + }) + + await env.waitForLeader() + + const offlineTx = env.executor.createOfflineTransaction({ + mutationFnName: env.mutationFnName, + autoCommit: false, + }) + + offlineTx.mutate(() => { + env.collection.insert({ + id: `queued-while-offline`, + value: `queued`, + completed: false, + updatedAt: new Date(), + }) + }) + + const commitPromise = offlineTx.commit() + + await flushMicrotasks() + expect(env.mutationCalls).toHaveLength(0) + expect(await env.executor.peekOutbox()).toHaveLength(1) + + onlineDetector.setOnline(true) + + await expect(commitPromise).resolves.toBeDefined() + expect(env.mutationCalls).toHaveLength(1) + expect(await env.executor.peekOutbox()).toEqual([]) + + env.executor.dispose() + }) + it(`rejects waiting promises for permanent failures and rolls back optimistic state`, async () => { const error = new NonRetriableError(`permanent`) const env = createTestOfflineEnvironment({ From a6d758352bfdb206c501c0eb81bc386aa0082dcf Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Wed, 25 Feb 2026 15:33:21 -0700 Subject: [PATCH 2/4] chore: add changeset for offline-transactions retry fix Co-Authored-By: Claude Opus 4.6 --- .changeset/fix-offline-retry-and-online-gating.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fix-offline-retry-and-online-gating.md diff --git a/.changeset/fix-offline-retry-and-online-gating.md b/.changeset/fix-offline-retry-and-online-gating.md new file mode 100644 index 000000000..615485810 --- /dev/null +++ b/.changeset/fix-offline-retry-and-online-gating.md @@ -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. From e159841f9aff9e8333f60e963eff3f7e53a4a845 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 25 Feb 2026 22:53:59 +0000 Subject: [PATCH 3/4] ci: apply automated fixes --- packages/offline-transactions/tests/offline-e2e.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/offline-transactions/tests/offline-e2e.test.ts b/packages/offline-transactions/tests/offline-e2e.test.ts index 9f9190b85..e1778e943 100644 --- a/packages/offline-transactions/tests/offline-e2e.test.ts +++ b/packages/offline-transactions/tests/offline-e2e.test.ts @@ -180,7 +180,9 @@ describe(`offline executor end-to-end`, () => { expect(policy.shouldRetry(error, i)).toBe(true) } - expect(policy.shouldRetry(new NonRetriableError(`permanent`), 0)).toBe(false) + expect(policy.shouldRetry(new NonRetriableError(`permanent`), 0)).toBe( + false, + ) }) it(`does not execute mutations while offline`, async () => { From 533ba70decbb07dcdc01a5a80f3447b9662f821f Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Wed, 25 Feb 2026 16:02:51 -0700 Subject: [PATCH 4/4] fix(offline-transactions): remove notifyOnline() and redundant subscriber gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit notifyOnline() on OfflineExecutor was broken after online-gating changes (execution loop bails when detector reports offline). Rather than adding a force-bypass mechanism, remove the method — the OnlineDetector handles connectivity changes automatically. Users who need imperative control can build a custom OnlineDetector. Also removes the redundant isOnline() guard in the subscriber callback since the execution loop already gates on online status. Co-Authored-By: Claude Opus 4.6 --- .changeset/fix-offline-retry-and-online-gating.md | 2 +- packages/offline-transactions/README.md | 1 - packages/offline-transactions/src/OfflineExecutor.ts | 9 --------- packages/offline-transactions/tests/offline-e2e.test.ts | 2 +- 4 files changed, 2 insertions(+), 12 deletions(-) diff --git a/.changeset/fix-offline-retry-and-online-gating.md b/.changeset/fix-offline-retry-and-online-gating.md index 615485810..47f1f3953 100644 --- a/.changeset/fix-offline-retry-and-online-gating.md +++ b/.changeset/fix-offline-retry-and-online-gating.md @@ -2,4 +2,4 @@ '@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. +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. diff --git a/packages/offline-transactions/README.md b/packages/offline-transactions/README.md index b3d548025..d73c1b20c 100644 --- a/packages/offline-transactions/README.md +++ b/packages/offline-transactions/README.md @@ -145,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 diff --git a/packages/offline-transactions/src/OfflineExecutor.ts b/packages/offline-transactions/src/OfflineExecutor.ts index e8321b064..cc537b3a3 100644 --- a/packages/offline-transactions/src/OfflineExecutor.ts +++ b/packages/offline-transactions/src/OfflineExecutor.ts @@ -220,11 +220,6 @@ export class OfflineExecutor { this.unsubscribeOnline = this.onlineDetector.subscribe(() => { if (this.isOfflineEnabled && this.executor) { - if (!this.isOnline()) { - return - } - - // Reset retry delays so transactions can execute immediately when back online this.executor.resetRetryDelays() this.executor.executeAll().catch((error) => { console.warn( @@ -550,10 +545,6 @@ export class OfflineExecutor { this.executor.clear() } - notifyOnline(): void { - this.onlineDetector.notifyOnline() - } - getPendingCount(): number { if (!this.executor) { return 0 diff --git a/packages/offline-transactions/tests/offline-e2e.test.ts b/packages/offline-transactions/tests/offline-e2e.test.ts index e1778e943..981315c26 100644 --- a/packages/offline-transactions/tests/offline-e2e.test.ts +++ b/packages/offline-transactions/tests/offline-e2e.test.ts @@ -152,7 +152,7 @@ describe(`offline executor end-to-end`, () => { // Now bring the system back online online = true - env.executor.notifyOnline() + env.executor.getOnlineDetector().notifyOnline() // Wait for the retry to succeed await waitUntil(() => env.mutationCalls.length >= 2)