Skip to content

Commit cc5d8ba

Browse files
KyleAMathewsclaude
andcommitted
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 <[email protected]>
1 parent e59691a commit cc5d8ba

File tree

3 files changed

+122
-24
lines changed

3 files changed

+122
-24
lines changed

packages/offline-transactions/src/OfflineExecutor.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -509,6 +509,11 @@ export class OfflineExecutor {
509509
// Remove from each collection's transaction map and recompute
510510
const touchedCollections = new Set<string>()
511511
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+
}
512517
const collectionId = mutation.collection.id
513518
if (touchedCollections.has(collectionId)) {
514519
continue

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

Lines changed: 36 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -260,36 +260,48 @@ export class TransactionExecutor {
260260
continue
261261
}
262262

263-
// Create a restoration transaction that holds mutations for optimistic state display.
264-
// It will never commit - the real mutation is handled by the offline executor.
265-
const restorationTx = createTransaction({
266-
id: offlineTx.id,
267-
autoCommit: false,
268-
mutationFn: async () => {},
269-
})
270-
271-
restorationTx.applyMutations(offlineTx.mutations)
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+
restorationTx.applyMutations(offlineTx.mutations)
273+
274+
// Register with each affected collection's state manager
275+
const touchedCollections = new Set<string>()
276+
for (const mutation of offlineTx.mutations) {
277+
// Defensive check for corrupted deserialized data
278+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
279+
if (!mutation.collection) {
280+
continue
281+
}
282+
const collectionId = mutation.collection.id
283+
if (touchedCollections.has(collectionId)) {
284+
continue
285+
}
286+
touchedCollections.add(collectionId)
272287

273-
// Register with each affected collection's state manager
274-
const touchedCollections = new Set<string>()
275-
for (const mutation of offlineTx.mutations) {
276-
const collectionId = mutation.collection.id
277-
if (touchedCollections.has(collectionId)) {
278-
continue
288+
mutation.collection._state.transactions.set(
289+
restorationTx.id,
290+
restorationTx,
291+
)
292+
mutation.collection._state.recomputeOptimisticState(true)
279293
}
280-
touchedCollections.add(collectionId)
281294

282-
mutation.collection._state.transactions.set(
283-
restorationTx.id,
295+
this.offlineExecutor.registerRestorationTransaction(
296+
offlineTx.id,
284297
restorationTx,
285298
)
286-
mutation.collection._state.recomputeOptimisticState(true)
299+
} catch (error) {
300+
console.warn(
301+
`Failed to restore optimistic state for transaction ${offlineTx.id}:`,
302+
error,
303+
)
287304
}
288-
289-
this.offlineExecutor.registerRestorationTransaction(
290-
offlineTx.id,
291-
restorationTx,
292-
)
293305
}
294306
}
295307

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

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -582,4 +582,85 @@ describe(`offline executor end-to-end`, () => {
582582

583583
secondEnv.executor.dispose()
584584
})
585+
586+
it(`should rollback restored optimistic state on permanent failure after page refresh`, async () => {
587+
// This test verifies that optimistic state restored from persisted transactions
588+
// is properly rolled back when the transaction fails permanently (NonRetriableError).
589+
//
590+
// Scenario: User creates transaction offline, refreshes page, transaction fails permanently
591+
// Expected: Optimistic state should be visible initially, then removed after failure
592+
593+
const storage = new FakeStorageAdapter()
594+
595+
// First environment: Create a transaction that gets stuck (simulating offline)
596+
const mutationPromise = () => new Promise<void>(() => {})
597+
598+
const firstEnv = createTestOfflineEnvironment({
599+
storage,
600+
mutationFn: async () => {
601+
await mutationPromise()
602+
throw new Error(`Should not reach here in first env`)
603+
},
604+
})
605+
606+
await firstEnv.waitForLeader()
607+
608+
const offlineTx = firstEnv.executor.createOfflineTransaction({
609+
mutationFnName: firstEnv.mutationFnName,
610+
autoCommit: false,
611+
})
612+
613+
// Apply optimistic update
614+
offlineTx.mutate(() => {
615+
firstEnv.collection.insert({
616+
id: `ghost-item`,
617+
value: `will-fail`,
618+
completed: false,
619+
updatedAt: new Date(),
620+
})
621+
})
622+
623+
// Verify the optimistic update is visible
624+
expect(firstEnv.collection.get(`ghost-item`)?.value).toBe(`will-fail`)
625+
626+
// Start commit - it will persist to outbox
627+
offlineTx.commit()
628+
629+
// Wait for the transaction to be persisted
630+
await waitUntil(async () => {
631+
const pendingEntries = await firstEnv.executor.peekOutbox()
632+
return pendingEntries.length === 1
633+
}, 5000)
634+
635+
// Dispose first environment (simulating page refresh)
636+
firstEnv.executor.dispose()
637+
638+
// Create a new environment where the mutation fails permanently
639+
const secondEnv = createTestOfflineEnvironment({
640+
storage,
641+
mutationFn: () => {
642+
throw new NonRetriableError(`Server rejected mutation permanently`)
643+
},
644+
})
645+
646+
await secondEnv.waitForLeader()
647+
648+
// Optimistic state should be restored immediately after initialization
649+
expect(secondEnv.collection.get(`ghost-item`)?.value).toBe(`will-fail`)
650+
651+
// Wait for the transaction to fail and be removed from outbox
652+
await waitUntil(async () => {
653+
const entries = await secondEnv.executor.peekOutbox()
654+
return entries.length === 0
655+
}, 5000)
656+
657+
// After permanent failure, the optimistic state should be rolled back
658+
// The item should no longer exist in the collection
659+
expect(secondEnv.collection.get(`ghost-item`)).toBeUndefined()
660+
661+
// And it should not exist on the server either
662+
expect(secondEnv.serverState.get(`ghost-item`)).toBeUndefined()
663+
664+
secondEnv.executor.dispose()
665+
})
585666
})

0 commit comments

Comments
 (0)