Skip to content

Conversation

@KyleAMathews
Copy link
Collaborator

@KyleAMathews KyleAMathews commented Jan 20, 2026

Summary

Fixes offline transactions not restoring optimistic state when the page is refreshed while offline. Users now see their pending changes immediately on page load, providing a seamless offline UX.


Root Cause

When the OfflineExecutor loaded pending transactions from storage on startup, it only scheduled them for execution—it never re-applied the mutations to the collection's state manager. The optimistic state lived only in memory and was lost on page refresh.

Approach

Created "restoration transactions" that shadow the persisted offline transactions:

// TransactionExecutor.restoreOptimisticState()
const restorationTx = createTransaction({
  id: offlineTx.id,
  autoCommit: false,
  mutationFn: async () => {},
})
restorationTx.applyMutations(offlineTx.mutations)
mutation.collection._state.transactions.set(restorationTx.id, restorationTx)
mutation.collection._state.recomputeOptimisticState(true)

When pending transactions are loaded from storage, we:

  1. Create a restoration transaction with the same ID
  2. Apply the persisted mutations to it
  3. Register it with each affected collection's state manager
  4. Clean up when the real offline transaction completes or fails

Key Invariants

  1. Restoration transactions never commit - they exist only to hold mutations for optimistic display
  2. Same ID as offline transaction - ensures proper cleanup when the real transaction resolves
  3. Cleanup on completion - whether success or failure, restoration transactions are removed so sync provides authoritative data
  4. Key extraction during deserialization - mutations need their key populated for optimistic state to work

Non-goals

  • Re-executing mutations on restore - we only restore the visual state, not re-run the mutation logic
  • Handling conflicts - conflict resolution happens when the actual sync completes, not during restoration

Trade-offs

Alternative considered: Storing the optimistic state separately from the transaction data.
Why this approach: Reusing the existing transaction/mutation infrastructure is simpler and leverages the collection's built-in optimistic state management. No new storage schema or state reconciliation logic needed.


Verification

pnpm test:pr

The new test should restore optimistic state to collection on startup verifies the complete flow:

  1. Create transaction with optimistic mutation
  2. Dispose executor (simulate page refresh)
  3. Create new executor with same storage
  4. Assert optimistic state is visible before transaction completes

Files Changed

File Description
OfflineExecutor.ts Added restorationTransactions map, registerRestorationTransaction(), cleanupRestorationTransaction(), and waitForInit() helper
TransactionExecutor.ts Added restoreOptimisticState() that creates restoration transactions on load
TransactionSerializer.ts Extract key from modified data during deserialization
offline-e2e.test.ts New test covering the page refresh scenario
harness.ts Minor test harness updates

@changeset-bot
Copy link

changeset-bot bot commented Jan 20, 2026

🦋 Changeset detected

Latest commit: 1eda746

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@tanstack/offline-transactions Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new
Copy link

pkg-pr-new bot commented Jan 20, 2026

More templates

@tanstack/angular-db

npm i https://pkg.pr.new/@tanstack/angular-db@1169

@tanstack/db

npm i https://pkg.pr.new/@tanstack/db@1169

@tanstack/db-ivm

npm i https://pkg.pr.new/@tanstack/db-ivm@1169

@tanstack/electric-db-collection

npm i https://pkg.pr.new/@tanstack/electric-db-collection@1169

@tanstack/offline-transactions

npm i https://pkg.pr.new/@tanstack/offline-transactions@1169

@tanstack/powersync-db-collection

npm i https://pkg.pr.new/@tanstack/powersync-db-collection@1169

@tanstack/query-db-collection

npm i https://pkg.pr.new/@tanstack/query-db-collection@1169

@tanstack/react-db

npm i https://pkg.pr.new/@tanstack/react-db@1169

@tanstack/rxdb-db-collection

npm i https://pkg.pr.new/@tanstack/rxdb-db-collection@1169

@tanstack/solid-db

npm i https://pkg.pr.new/@tanstack/solid-db@1169

@tanstack/svelte-db

npm i https://pkg.pr.new/@tanstack/svelte-db@1169

@tanstack/trailbase-db-collection

npm i https://pkg.pr.new/@tanstack/trailbase-db-collection@1169

@tanstack/vue-db

npm i https://pkg.pr.new/@tanstack/vue-db@1169

commit: 1eda746

…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.
@KyleAMathews KyleAMathews force-pushed the claude/investigate-offline-transactions-bug-2FDgc branch from c618c2c to 72f94f9 Compare January 20, 2026 19:39
@github-actions
Copy link
Contributor

github-actions bot commented Jan 20, 2026

Size Change: 0 B

Total Size: 90.8 kB

ℹ️ View Unchanged
Filename Size
./packages/db/dist/esm/collection/change-events.js 1.39 kB
./packages/db/dist/esm/collection/changes.js 1.19 kB
./packages/db/dist/esm/collection/events.js 388 B
./packages/db/dist/esm/collection/index.js 3.32 kB
./packages/db/dist/esm/collection/indexes.js 1.1 kB
./packages/db/dist/esm/collection/lifecycle.js 1.68 kB
./packages/db/dist/esm/collection/mutations.js 2.34 kB
./packages/db/dist/esm/collection/state.js 3.49 kB
./packages/db/dist/esm/collection/subscription.js 3.62 kB
./packages/db/dist/esm/collection/sync.js 2.41 kB
./packages/db/dist/esm/deferred.js 207 B
./packages/db/dist/esm/errors.js 4.7 kB
./packages/db/dist/esm/event-emitter.js 748 B
./packages/db/dist/esm/index.js 2.69 kB
./packages/db/dist/esm/indexes/auto-index.js 742 B
./packages/db/dist/esm/indexes/base-index.js 766 B
./packages/db/dist/esm/indexes/btree-index.js 1.93 kB
./packages/db/dist/esm/indexes/lazy-index.js 1.1 kB
./packages/db/dist/esm/indexes/reverse-index.js 513 B
./packages/db/dist/esm/local-only.js 837 B
./packages/db/dist/esm/local-storage.js 2.1 kB
./packages/db/dist/esm/optimistic-action.js 359 B
./packages/db/dist/esm/paced-mutations.js 496 B
./packages/db/dist/esm/proxy.js 3.75 kB
./packages/db/dist/esm/query/builder/functions.js 733 B
./packages/db/dist/esm/query/builder/index.js 4.08 kB
./packages/db/dist/esm/query/builder/ref-proxy.js 1.05 kB
./packages/db/dist/esm/query/compiler/evaluators.js 1.42 kB
./packages/db/dist/esm/query/compiler/expressions.js 430 B
./packages/db/dist/esm/query/compiler/group-by.js 1.81 kB
./packages/db/dist/esm/query/compiler/index.js 1.96 kB
./packages/db/dist/esm/query/compiler/joins.js 2 kB
./packages/db/dist/esm/query/compiler/order-by.js 1.45 kB
./packages/db/dist/esm/query/compiler/select.js 1.06 kB
./packages/db/dist/esm/query/expression-helpers.js 1.43 kB
./packages/db/dist/esm/query/ir.js 673 B
./packages/db/dist/esm/query/live-query-collection.js 360 B
./packages/db/dist/esm/query/live/collection-config-builder.js 5.42 kB
./packages/db/dist/esm/query/live/collection-registry.js 264 B
./packages/db/dist/esm/query/live/collection-subscriber.js 1.93 kB
./packages/db/dist/esm/query/live/internal.js 145 B
./packages/db/dist/esm/query/optimizer.js 2.56 kB
./packages/db/dist/esm/query/predicate-utils.js 2.97 kB
./packages/db/dist/esm/query/subset-dedupe.js 921 B
./packages/db/dist/esm/scheduler.js 1.3 kB
./packages/db/dist/esm/SortedMap.js 1.3 kB
./packages/db/dist/esm/strategies/debounceStrategy.js 247 B
./packages/db/dist/esm/strategies/queueStrategy.js 428 B
./packages/db/dist/esm/strategies/throttleStrategy.js 246 B
./packages/db/dist/esm/transactions.js 2.9 kB
./packages/db/dist/esm/utils.js 924 B
./packages/db/dist/esm/utils/browser-polyfills.js 304 B
./packages/db/dist/esm/utils/btree.js 5.61 kB
./packages/db/dist/esm/utils/comparison.js 852 B
./packages/db/dist/esm/utils/cursor.js 457 B
./packages/db/dist/esm/utils/index-optimization.js 1.51 kB
./packages/db/dist/esm/utils/type-guards.js 157 B

compressed-size-action::db-package-size

@github-actions
Copy link
Contributor

github-actions bot commented Jan 20, 2026

Size Change: 0 B

Total Size: 3.7 kB

ℹ️ View Unchanged
Filename Size
./packages/react-db/dist/esm/index.js 225 B
./packages/react-db/dist/esm/useLiveInfiniteQuery.js 1.17 kB
./packages/react-db/dist/esm/useLiveQuery.js 1.34 kB
./packages/react-db/dist/esm/useLiveSuspenseQuery.js 559 B
./packages/react-db/dist/esm/usePacedMutations.js 401 B

compressed-size-action::react-db-package-size

claude and others added 2 commits January 20, 2026 20:12
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
KyleAMathews and others added 4 commits January 20, 2026 13:24
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Consolidate restorationTransactions.delete() to single point
- Improve comments on restoration transaction purpose

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- 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>
… 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.
@KyleAMathews KyleAMathews requested a review from kevin-dp January 21, 2026 14:04
@TomasGonzalez
Copy link

I reported this bug and just tested this PR build locally. After refreshing while offline, the optimistic state is now restored correctly and pending changes show up immediately on page load.
Looks good on my end ✅ Thanks for the quick fix!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Ready for review

Development

Successfully merging this pull request may close these issues.

4 participants