Skip to content

Commit cbd1bb2

Browse files
KyleAMathewsclaudeautofix-ci[bot]
authored
Restore offline transactions to optimistic store upon restart the app (#1169)
* test: add bug reproduction for optimistic state not restored on page refresh Add a failing test that demonstrates the issue where offline transactions do not restore optimistic state to the collection when the page is refreshed while offline. Users have to manually handle this in beforeRetry by replaying all transactions into the collection. The test asserts the expected behavior (optimistic data should be present after page refresh) and currently fails, demonstrating the bug. * fix: restore optimistic state on page refresh while offline When the page is refreshed while offline with pending transactions, the optimistic state was not being restored to collections. Users had to manually replay transactions in `beforeRetry` to restore UI state. This fix: 1. In `loadPendingTransactions()`, creates restoration transactions that hold the deserialized mutations and registers them with the collection's state manager to display optimistic data immediately 2. Properly reconstructs the mutation `key` during deserialization using the collection's `getKeyFromItem()` method, which is needed for optimistic state lookup 3. Cleans up restoration transactions when the offline transaction completes or fails, allowing sync data to take over 4. Adds `waitForInit()` method to allow waiting for full initialization including pending transaction loading 5. Updates `loadAndReplayTransactions()` to not block on execution, so initialization completes as soon as optimistic state is restored * ci: apply automated fixes * chore: add changeset for optimistic state restoration fix Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor: simplify restoration transaction cleanup - Consolidate restorationTransactions.delete() to single point - Improve comments on restoration transaction purpose Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: improve error handling and add rollback test - Add try-catch isolation in restoreOptimisticState to prevent one bad transaction from breaking all restoration - Add defensive null check for mutation.collection in cleanup methods - Add test for rollback of restored optimistic state on permanent failure Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: prevent unhandled promise rejection from restoration transaction cleanup Add catch handler to restoration transaction's isPersisted promise to prevent unhandled rejection when rollback() is called during cleanup. The rollback calls reject(undefined) which would otherwise cause an unhandled rejection error. --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent fa0ef31 commit cbd1bb2

File tree

7 files changed

+358
-9
lines changed

7 files changed

+358
-9
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@tanstack/offline-transactions': patch
3+
---
4+
5+
Fix optimistic state not being restored to collections on page refresh while offline. Pending transactions are now automatically rehydrated from storage and their optimistic mutations applied to the UI immediately on startup, providing a seamless offline experience.

packages/offline-transactions/src/OfflineExecutor.ts

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,9 @@ export class OfflineExecutor {
6969
}
7070
> = new Map()
7171

72+
// Track restoration transactions for cleanup when offline transactions complete
73+
private restorationTransactions: Map<string, Transaction> = new Map()
74+
7275
constructor(config: OfflineConfig) {
7376
this.config = config
7477
this.scheduler = new KeyScheduler()
@@ -298,8 +301,14 @@ export class OfflineExecutor {
298301
}
299302

300303
try {
304+
// Load pending transactions and restore optimistic state
301305
await this.executor.loadPendingTransactions()
302-
await this.executor.executeAll()
306+
307+
// Start execution in the background - don't await to avoid blocking initialization
308+
// The transactions will execute and complete asynchronously
309+
this.executor.executeAll().catch((error) => {
310+
console.warn(`Failed to execute transactions:`, error)
311+
})
303312
} catch (error) {
304313
console.warn(`Failed to load and replay transactions:`, error)
305314
}
@@ -309,6 +318,14 @@ export class OfflineExecutor {
309318
return this.mode === `offline` && this.isLeaderState
310319
}
311320

321+
/**
322+
* Wait for the executor to fully initialize.
323+
* This ensures that pending transactions are loaded and optimistic state is restored.
324+
*/
325+
async waitForInit(): Promise<void> {
326+
return this.initPromise
327+
}
328+
312329
createOfflineTransaction(
313330
options: CreateOfflineTransactionOptions,
314331
): Transaction | OfflineTransactionAPI {
@@ -441,6 +458,9 @@ export class OfflineExecutor {
441458
deferred.resolve(result)
442459
this.pendingTransactionPromises.delete(transactionId)
443460
}
461+
462+
// Clean up the restoration transaction - the sync will provide authoritative data
463+
this.cleanupRestorationTransaction(transactionId)
444464
}
445465

446466
// Method for TransactionExecutor to signal failure
@@ -450,6 +470,58 @@ export class OfflineExecutor {
450470
deferred.reject(error)
451471
this.pendingTransactionPromises.delete(transactionId)
452472
}
473+
474+
// Clean up the restoration transaction and rollback optimistic state
475+
this.cleanupRestorationTransaction(transactionId, true)
476+
}
477+
478+
// Method for TransactionExecutor to register restoration transactions
479+
registerRestorationTransaction(
480+
offlineTransactionId: string,
481+
restorationTransaction: Transaction,
482+
): void {
483+
this.restorationTransactions.set(
484+
offlineTransactionId,
485+
restorationTransaction,
486+
)
487+
}
488+
489+
private cleanupRestorationTransaction(
490+
transactionId: string,
491+
shouldRollback = false,
492+
): void {
493+
const restorationTx = this.restorationTransactions.get(transactionId)
494+
if (!restorationTx) {
495+
return
496+
}
497+
498+
this.restorationTransactions.delete(transactionId)
499+
500+
if (shouldRollback) {
501+
restorationTx.rollback()
502+
return
503+
}
504+
505+
// Mark as completed so recomputeOptimisticState removes it from consideration.
506+
// The actual data will come from the sync.
507+
restorationTx.setState(`completed`)
508+
509+
// Remove from each collection's transaction map and recompute
510+
const touchedCollections = new Set<string>()
511+
for (const mutation of restorationTx.mutations) {
512+
// Defensive check for corrupted deserialized data
513+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
514+
if (!mutation.collection) {
515+
continue
516+
}
517+
const collectionId = mutation.collection.id
518+
if (touchedCollections.has(collectionId)) {
519+
continue
520+
}
521+
touchedCollections.add(collectionId)
522+
mutation.collection._state.transactions.delete(restorationTx.id)
523+
mutation.collection._state.recomputeOptimisticState(false)
524+
}
453525
}
454526

455527
async removeFromOutbox(id: string): Promise<void> {

packages/offline-transactions/src/executor/TransactionExecutor.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { createTransaction } from '@tanstack/db'
12
import { DefaultRetryPolicy } from '../retry/RetryPolicy'
23
import { NonRetriableError } from '../types'
34
import { withNestedSpan } from '../telemetry/tracer'
@@ -227,6 +228,10 @@ export class TransactionExecutor {
227228
this.scheduler.schedule(transaction)
228229
}
229230

231+
// Restore optimistic state for loaded transactions
232+
// This ensures the UI shows the optimistic data while transactions are pending
233+
this.restoreOptimisticState(filteredTransactions)
234+
230235
// Reset retry delays for all loaded transactions so they can run immediately
231236
this.resetRetryDelays()
232237

@@ -242,6 +247,71 @@ export class TransactionExecutor {
242247
}
243248
}
244249

250+
/**
251+
* Restore optimistic state from loaded transactions.
252+
* Creates internal transactions to hold the mutations so the collection's
253+
* state manager can show optimistic data while waiting for sync.
254+
*/
255+
private restoreOptimisticState(
256+
transactions: Array<OfflineTransaction>,
257+
): void {
258+
for (const offlineTx of transactions) {
259+
if (offlineTx.mutations.length === 0) {
260+
continue
261+
}
262+
263+
try {
264+
// Create a restoration transaction that holds mutations for optimistic state display.
265+
// It will never commit - the real mutation is handled by the offline executor.
266+
const restorationTx = createTransaction({
267+
id: offlineTx.id,
268+
autoCommit: false,
269+
mutationFn: async () => {},
270+
})
271+
272+
// Prevent unhandled promise rejection when cleanup calls rollback()
273+
// We don't care about this promise - it's just for holding mutations
274+
restorationTx.isPersisted.promise.catch(() => {
275+
// Intentionally ignored - restoration transactions are cleaned up
276+
// via cleanupRestorationTransaction, not through normal commit flow
277+
})
278+
279+
restorationTx.applyMutations(offlineTx.mutations)
280+
281+
// Register with each affected collection's state manager
282+
const touchedCollections = new Set<string>()
283+
for (const mutation of offlineTx.mutations) {
284+
// Defensive check for corrupted deserialized data
285+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
286+
if (!mutation.collection) {
287+
continue
288+
}
289+
const collectionId = mutation.collection.id
290+
if (touchedCollections.has(collectionId)) {
291+
continue
292+
}
293+
touchedCollections.add(collectionId)
294+
295+
mutation.collection._state.transactions.set(
296+
restorationTx.id,
297+
restorationTx,
298+
)
299+
mutation.collection._state.recomputeOptimisticState(true)
300+
}
301+
302+
this.offlineExecutor.registerRestorationTransaction(
303+
offlineTx.id,
304+
restorationTx,
305+
)
306+
} catch (error) {
307+
console.warn(
308+
`Failed to restore optimistic state for transaction ${offlineTx.id}:`,
309+
error,
310+
)
311+
}
312+
}
313+
}
314+
245315
clear(): void {
246316
this.scheduler.clear()
247317
this.clearRetryTimer()

packages/offline-transactions/src/outbox/TransactionSerializer.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,18 +77,24 @@ export class TransactionSerializer {
7777
throw new Error(`Collection with id ${data.collectionId} not found`)
7878
}
7979

80+
const modified = this.deserializeValue(data.modified)
81+
82+
// Extract the key from the modified data using the collection's getKey function
83+
// This is needed for optimistic state restoration to work correctly
84+
const key = modified ? collection.getKeyFromItem(modified) : null
85+
8086
// Create a partial PendingMutation - we can't fully reconstruct it but
8187
// we provide what we can. The executor will need to handle the rest.
8288
return {
8389
globalKey: data.globalKey,
8490
type: data.type as any,
85-
modified: this.deserializeValue(data.modified),
91+
modified,
8692
original: this.deserializeValue(data.original),
8793
changes: this.deserializeValue(data.changes) ?? {},
8894
collection,
8995
// These fields would need to be reconstructed by the executor
9096
mutationId: ``, // Will be regenerated
91-
key: null, // Will be extracted from the data
97+
key,
9298
metadata: undefined,
9399
syncMetadata: {},
94100
optimistic: true,

packages/offline-transactions/tests/TransactionSerializer.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { PendingMutation } from '@tanstack/db'
66
describe(`TransactionSerializer`, () => {
77
const mockCollection = {
88
id: `test-collection`,
9+
getKeyFromItem: (item: any) => item.id,
910
}
1011

1112
const createSerializer = () => {

packages/offline-transactions/tests/harness.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -248,12 +248,10 @@ export function createTestOfflineEnvironment(
248248
const executor = startOfflineExecutor(config)
249249

250250
const waitForLeader = async () => {
251-
const start = Date.now()
252-
while (!executor.isOfflineEnabled) {
253-
if (Date.now() - start > 1000) {
254-
throw new Error(`Executor did not become leader within timeout`)
255-
}
256-
await new Promise((resolve) => setTimeout(resolve, 10))
251+
// Wait for full initialization including loading pending transactions
252+
await executor.waitForInit()
253+
if (!executor.isOfflineEnabled) {
254+
throw new Error(`Executor did not become leader`)
257255
}
258256
}
259257

0 commit comments

Comments
 (0)