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/restore-optimistic-state-offline.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/offline-transactions': patch
---

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.
74 changes: 73 additions & 1 deletion packages/offline-transactions/src/OfflineExecutor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,9 @@ export class OfflineExecutor {
}
> = new Map()

// Track restoration transactions for cleanup when offline transactions complete
private restorationTransactions: Map<string, Transaction> = new Map()

constructor(config: OfflineConfig) {
this.config = config
this.scheduler = new KeyScheduler()
Expand Down Expand Up @@ -298,8 +301,14 @@ export class OfflineExecutor {
}

try {
// Load pending transactions and restore optimistic state
await this.executor.loadPendingTransactions()
await this.executor.executeAll()

// Start execution in the background - don't await to avoid blocking initialization
// The transactions will execute and complete asynchronously
this.executor.executeAll().catch((error) => {
console.warn(`Failed to execute transactions:`, error)
})
} catch (error) {
console.warn(`Failed to load and replay transactions:`, error)
}
Expand All @@ -309,6 +318,14 @@ export class OfflineExecutor {
return this.mode === `offline` && this.isLeaderState
}

/**
* Wait for the executor to fully initialize.
* This ensures that pending transactions are loaded and optimistic state is restored.
*/
async waitForInit(): Promise<void> {
return this.initPromise
}

createOfflineTransaction(
options: CreateOfflineTransactionOptions,
): Transaction | OfflineTransactionAPI {
Expand Down Expand Up @@ -441,6 +458,9 @@ export class OfflineExecutor {
deferred.resolve(result)
this.pendingTransactionPromises.delete(transactionId)
}

// Clean up the restoration transaction - the sync will provide authoritative data
this.cleanupRestorationTransaction(transactionId)
}

// Method for TransactionExecutor to signal failure
Expand All @@ -450,6 +470,58 @@ export class OfflineExecutor {
deferred.reject(error)
this.pendingTransactionPromises.delete(transactionId)
}

// Clean up the restoration transaction and rollback optimistic state
this.cleanupRestorationTransaction(transactionId, true)
}

// Method for TransactionExecutor to register restoration transactions
registerRestorationTransaction(
offlineTransactionId: string,
restorationTransaction: Transaction,
): void {
this.restorationTransactions.set(
offlineTransactionId,
restorationTransaction,
)
}

private cleanupRestorationTransaction(
transactionId: string,
shouldRollback = false,
): void {
const restorationTx = this.restorationTransactions.get(transactionId)
if (!restorationTx) {
return
}

this.restorationTransactions.delete(transactionId)

if (shouldRollback) {
restorationTx.rollback()
return
}

// Mark as completed so recomputeOptimisticState removes it from consideration.
// The actual data will come from the sync.
restorationTx.setState(`completed`)

// Remove from each collection's transaction map and recompute
const touchedCollections = new Set<string>()
for (const mutation of restorationTx.mutations) {
// Defensive check for corrupted deserialized data
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!mutation.collection) {
continue
}
const collectionId = mutation.collection.id
if (touchedCollections.has(collectionId)) {
continue
}
touchedCollections.add(collectionId)
mutation.collection._state.transactions.delete(restorationTx.id)
mutation.collection._state.recomputeOptimisticState(false)
}
}

async removeFromOutbox(id: string): Promise<void> {
Expand Down
70 changes: 70 additions & 0 deletions packages/offline-transactions/src/executor/TransactionExecutor.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { createTransaction } from '@tanstack/db'
import { DefaultRetryPolicy } from '../retry/RetryPolicy'
import { NonRetriableError } from '../types'
import { withNestedSpan } from '../telemetry/tracer'
Expand Down Expand Up @@ -227,6 +228,10 @@ export class TransactionExecutor {
this.scheduler.schedule(transaction)
}

// Restore optimistic state for loaded transactions
// This ensures the UI shows the optimistic data while transactions are pending
this.restoreOptimisticState(filteredTransactions)

// Reset retry delays for all loaded transactions so they can run immediately
this.resetRetryDelays()

Expand All @@ -242,6 +247,71 @@ export class TransactionExecutor {
}
}

/**
* Restore optimistic state from loaded transactions.
* Creates internal transactions to hold the mutations so the collection's
* state manager can show optimistic data while waiting for sync.
*/
private restoreOptimisticState(
transactions: Array<OfflineTransaction>,
): void {
for (const offlineTx of transactions) {
if (offlineTx.mutations.length === 0) {
continue
}

try {
// Create a restoration transaction that holds mutations for optimistic state display.
// It will never commit - the real mutation is handled by the offline executor.
const restorationTx = createTransaction({
id: offlineTx.id,
autoCommit: false,
mutationFn: async () => {},
})

// Prevent unhandled promise rejection when cleanup calls rollback()
// We don't care about this promise - it's just for holding mutations
restorationTx.isPersisted.promise.catch(() => {
// Intentionally ignored - restoration transactions are cleaned up
// via cleanupRestorationTransaction, not through normal commit flow
})

restorationTx.applyMutations(offlineTx.mutations)

// Register with each affected collection's state manager
const touchedCollections = new Set<string>()
for (const mutation of offlineTx.mutations) {
// Defensive check for corrupted deserialized data
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!mutation.collection) {
continue
}
const collectionId = mutation.collection.id
if (touchedCollections.has(collectionId)) {
continue
}
touchedCollections.add(collectionId)

mutation.collection._state.transactions.set(
restorationTx.id,
restorationTx,
)
mutation.collection._state.recomputeOptimisticState(true)
}

this.offlineExecutor.registerRestorationTransaction(
offlineTx.id,
restorationTx,
)
} catch (error) {
console.warn(
`Failed to restore optimistic state for transaction ${offlineTx.id}:`,
error,
)
}
}
}

clear(): void {
this.scheduler.clear()
this.clearRetryTimer()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,18 +77,24 @@ export class TransactionSerializer {
throw new Error(`Collection with id ${data.collectionId} not found`)
}

const modified = this.deserializeValue(data.modified)

// Extract the key from the modified data using the collection's getKey function
// This is needed for optimistic state restoration to work correctly
const key = modified ? collection.getKeyFromItem(modified) : null

// Create a partial PendingMutation - we can't fully reconstruct it but
// we provide what we can. The executor will need to handle the rest.
return {
globalKey: data.globalKey,
type: data.type as any,
modified: this.deserializeValue(data.modified),
modified,
original: this.deserializeValue(data.original),
changes: this.deserializeValue(data.changes) ?? {},
collection,
// These fields would need to be reconstructed by the executor
mutationId: ``, // Will be regenerated
key: null, // Will be extracted from the data
key,
metadata: undefined,
syncMetadata: {},
optimistic: true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { PendingMutation } from '@tanstack/db'
describe(`TransactionSerializer`, () => {
const mockCollection = {
id: `test-collection`,
getKeyFromItem: (item: any) => item.id,
}

const createSerializer = () => {
Expand Down
10 changes: 4 additions & 6 deletions packages/offline-transactions/tests/harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,12 +248,10 @@ export function createTestOfflineEnvironment(
const executor = startOfflineExecutor(config)

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

Expand Down
Loading
Loading