From 0a9b6ed713bb5f95e34344b235dc504a886b1299 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Thu, 19 Mar 2026 15:07:58 +0100 Subject: [PATCH 01/13] test(query-db-collection): add e2e test for offline tx + query refresh race Adds an integration test that reproduces the race condition where a query-backed collection reverts to stale server state when coming back online with pending offline transactions. The queryFn refetch returns pre-mutation data before the offline transaction reaches the server, and when the transaction completes the optimistic state is cleaned up with nothing to replace it. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../e2e/offline-refresh.e2e.test.ts | 294 ++++++++++++++++++ packages/query-db-collection/package.json | 1 + pnpm-lock.yaml | 3 + 3 files changed, 298 insertions(+) create mode 100644 packages/query-db-collection/e2e/offline-refresh.e2e.test.ts diff --git a/packages/query-db-collection/e2e/offline-refresh.e2e.test.ts b/packages/query-db-collection/e2e/offline-refresh.e2e.test.ts new file mode 100644 index 000000000..6693ddc68 --- /dev/null +++ b/packages/query-db-collection/e2e/offline-refresh.e2e.test.ts @@ -0,0 +1,294 @@ +/** + * Integration test: offline transactions + query collection refresh + * + * Verifies that a query-backed collection does not revert to stale server + * state when coming back online with pending offline transactions. + */ + +import { describe, expect, it, vi } from 'vitest' +import { createCollection } from '@tanstack/db' +import { QueryClient } from '@tanstack/query-core' +import { startOfflineExecutor } from '@tanstack/offline-transactions' +import { queryCollectionOptions } from '../src/query' +import type { Collection, PendingMutation } from '@tanstack/db' +import type { + LeaderElection, + OfflineConfig, + OnlineDetector, + StorageAdapter, +} from '@tanstack/offline-transactions' + +// --- Browser API mocks needed by @tanstack/offline-transactions --- +// jsdom doesn't provide navigator.locks, which the WebLocksLeader uses. +// We pass custom implementations (FakeLeaderElection, ManualOnlineDetector, +// FakeStorageAdapter) so these mocks just prevent initialization errors. + +if (!(globalThis.navigator as any)?.locks) { + Object.defineProperty(globalThis.navigator, `locks`, { + value: { request: vi.fn().mockResolvedValue(false) }, + configurable: true, + }) +} + +// --- Test helpers --- + +const flushMicrotasks = () => new Promise((resolve) => setTimeout(resolve, 0)) + +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() + } +} + +class FakeStorageAdapter implements StorageAdapter { + private store = new Map() + + get(key: string): Promise { + return Promise.resolve(this.store.has(key) ? this.store.get(key)! : null) + } + + set(key: string, value: string): Promise { + this.store.set(key, value) + return Promise.resolve() + } + + delete(key: string): Promise { + this.store.delete(key) + return Promise.resolve() + } + + keys(): Promise> { + return Promise.resolve(Array.from(this.store.keys())) + } + + clear(): Promise { + this.store.clear() + return Promise.resolve() + } +} + +class FakeLeaderElection implements LeaderElection { + private listeners = new Set<(isLeader: boolean) => void>() + private leader = true + + requestLeadership(): Promise { + this.notify(this.leader) + return Promise.resolve(this.leader) + } + + releaseLeadership(): void { + this.leader = false + this.notify(false) + } + + isLeader(): boolean { + return this.leader + } + + onLeadershipChange(callback: (isLeader: boolean) => void): () => void { + this.listeners.add(callback) + return () => { + this.listeners.delete(callback) + } + } + + private notify(isLeader: boolean): void { + for (const listener of this.listeners) { + listener(isLeader) + } + } +} + +// --- Test item type --- + +interface TestItem { + id: string + value: string +} + +// --- Tests --- + +describe(`offline transactions + query collection refresh`, () => { + it(`should not revert optimistic state when query refetches before pending offline transactions complete`, async () => { + // This test verifies that when a user goes offline, queues a mutation, + // and comes back online, the collection does not temporarily lose the + // optimistic insert. In a query-backed collection, data flows through + // query refetches (queryFn), not directly from the mutation function. + // When refetchOnReconnect fires before the offline transaction reaches + // the server, the refetch returns stale data. The optimistic state + // should remain visible until the transaction completes and a fresh + // refetch confirms the data. + + const onlineDetector = new ManualOnlineDetector(false) // Start offline + const storage = new FakeStorageAdapter() + + // --- Mock server state --- + const serverItems: Array = [ + { id: `item-1`, value: `server-data` }, + ] + + // Control when the mutation fn resolves + let resolveMutation: (() => void) | null = null + + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 0, + retry: false, + }, + }, + }) + + // queryFn reads from serverItems (simulating a real API GET endpoint) + const queryFn = vi.fn().mockImplementation(() => { + return Promise.resolve([...serverItems]) + }) + + // Create the query-backed collection + const collection = createCollection( + queryCollectionOptions({ + id: `offline-refresh-test`, + queryClient, + queryKey: [`offline-refresh-test`], + queryFn, + getKey: (item: TestItem) => item.id, + startSync: true, + }), + ) + + // Wait for initial query to populate the collection + await vi.waitFor(() => { + expect(queryFn).toHaveBeenCalledTimes(1) + expect(collection.size).toBe(1) + }) + expect(collection.get(`item-1`)?.value).toBe(`server-data`) + + // --- Set up offline executor --- + const mutationFnName = `syncData` + const offlineConfig: OfflineConfig = { + collections: { [`offline-refresh-test`]: collection as any }, + mutationFns: { + [mutationFnName]: async (params) => { + // Block until the test explicitly resolves (simulating slow API POST) + await new Promise((resolve) => { + resolveMutation = resolve + }) + + // Update server state (simulating the server processing the mutation) + const mutations = params.transaction.mutations as Array< + PendingMutation + > + for (const mutation of mutations) { + if (mutation.type === `insert`) { + serverItems.push(mutation.modified) + } + } + + return { ok: true } + }, + }, + storage, + leaderElection: new FakeLeaderElection(), + onlineDetector, + } + + const executor = startOfflineExecutor(offlineConfig) + await executor.waitForInit() + + // --- Go offline and create an offline mutation --- + const offlineTx = executor.createOfflineTransaction({ + mutationFnName, + autoCommit: false, + }) + + offlineTx.mutate(() => { + ;(collection as Collection).insert({ + id: `item-2`, + value: `offline-insert`, + }) + }) + + // Commit while offline: persists to outbox, mutation fn NOT called yet + const commitPromise = offlineTx.commit() + await flushMicrotasks() + + // Verify: item-2 is visible through optimistic state + expect(collection.get(`item-2`)?.value).toBe(`offline-insert`) + expect(collection.get(`item-1`)?.value).toBe(`server-data`) + + // --- Come online --- + // This triggers both: + // 1. The offline executor replaying pending transactions (mutationFn called) + // 2. TanStack Query potentially refetching (refetchOnReconnect default) + onlineDetector.setOnline(true) + await flushMicrotasks() + + // Trigger a query refetch that returns stale server state. + // The server doesn't have item-2 yet (the mutation is still in progress). + // This simulates what refetchOnReconnect would do. + await collection.utils.refetch() + + // The refetch returned stale data (only item-1), but item-2 should + // still be visible because the offline transaction is still pending + // and the optimistic state should cover the gap. + expect(collection.get(`item-2`)?.value).toBe(`offline-insert`) + + // --- Complete the mutation (server processes it) --- + expect(resolveMutation).not.toBeNull() + resolveMutation!() + + // Wait for the transaction to fully complete + await commitPromise + + // After the transaction completes, item-2 should remain visible. + // + // Without the fix: the stale refetch overwrote syncedData with only + // item-1, the optimistic state was cleaned up, and item-2 is gone + // permanently (no fresh refetch is triggered). + // + // With the fix: the stale refetch was skipped (barrier), and a fresh + // refetch is triggered once the barrier resolves. The fresh refetch + // includes item-2 because the server now has it. We use waitFor to + // allow the barrier-triggered refetch to complete. + await vi.waitFor( + () => { + expect(collection.get(`item-2`)?.value).toBe(`offline-insert`) + }, + { timeout: 1000 }, + ) + + executor.dispose() + queryClient.clear() + }) +}) diff --git a/packages/query-db-collection/package.json b/packages/query-db-collection/package.json index 369250d82..87da1ac72 100644 --- a/packages/query-db-collection/package.json +++ b/packages/query-db-collection/package.json @@ -54,6 +54,7 @@ "typescript": ">=4.7" }, "devDependencies": { + "@tanstack/offline-transactions": "workspace:*", "@tanstack/query-core": "^5.90.20", "@vitest/coverage-istanbul": "^3.2.4" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ba6b76d92..295400433 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1153,6 +1153,9 @@ importers: specifier: '>=4.7' version: 5.9.3 devDependencies: + '@tanstack/offline-transactions': + specifier: workspace:* + version: link:../offline-transactions '@tanstack/query-core': specifier: ^5.90.20 version: 5.90.20 From 0cbaaec881e31235dbacbadaede264fcf85a0390 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 19 Mar 2026 14:09:39 +0000 Subject: [PATCH 02/13] ci: apply automated fixes --- packages/query-db-collection/e2e/offline-refresh.e2e.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/query-db-collection/e2e/offline-refresh.e2e.test.ts b/packages/query-db-collection/e2e/offline-refresh.e2e.test.ts index 6693ddc68..c8594b68b 100644 --- a/packages/query-db-collection/e2e/offline-refresh.e2e.test.ts +++ b/packages/query-db-collection/e2e/offline-refresh.e2e.test.ts @@ -22,7 +22,7 @@ import type { // jsdom doesn't provide navigator.locks, which the WebLocksLeader uses. // We pass custom implementations (FakeLeaderElection, ManualOnlineDetector, // FakeStorageAdapter) so these mocks just prevent initialization errors. - + if (!(globalThis.navigator as any)?.locks) { Object.defineProperty(globalThis.navigator, `locks`, { value: { request: vi.fn().mockResolvedValue(false) }, From c4eaee1fa495cdeee2b74bc30916f63fc3e9eee0 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Thu, 19 Mar 2026 15:30:35 +0100 Subject: [PATCH 03/13] fix(query-db-collection): exclude e2e tests from default vitest run The e2e tests import @tanstack/offline-transactions which may not be built during the default `pnpm test` CI step. Adds a vitest.config.ts that excludes the e2e directory from the default run while preserving typecheck on the regular test files. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/query-db-collection/tsconfig.json | 2 +- packages/query-db-collection/vitest.config.ts | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 packages/query-db-collection/vitest.config.ts diff --git a/packages/query-db-collection/tsconfig.json b/packages/query-db-collection/tsconfig.json index 3d3c48bdd..0f73295e1 100644 --- a/packages/query-db-collection/tsconfig.json +++ b/packages/query-db-collection/tsconfig.json @@ -18,6 +18,6 @@ "@tanstack/db-collection-e2e": ["../db-collection-e2e/src"] } }, - "include": ["src", "tests", "e2e", "vite.config.ts", "vitest.e2e.config.ts"], + "include": ["src", "tests", "e2e", "vite.config.ts", "vitest.config.ts", "vitest.e2e.config.ts"], "exclude": ["node_modules", "dist"] } diff --git a/packages/query-db-collection/vitest.config.ts b/packages/query-db-collection/vitest.config.ts new file mode 100644 index 000000000..56aa740fc --- /dev/null +++ b/packages/query-db-collection/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + exclude: [`e2e/**`, `**/node_modules/**`], + typecheck: { + enabled: true, + include: [`tests/**/*.test.ts`], + }, + }, +}) From 3f9a3b253137f1b32548d4a5544956b3af50ac80 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Thu, 19 Mar 2026 15:38:02 +0100 Subject: [PATCH 04/13] ci: apply automated fixes Co-Authored-By: Claude Opus 4.6 (1M context) --- pnpm-lock.yaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 295400433..e28fcce7a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -212,7 +212,7 @@ importers: version: link:../../../packages/react-db '@tanstack/react-query': specifier: ^5.90.20 - version: 5.90.21(react@19.2.4) + version: 5.90.20(react@19.2.4) better-sqlite3: specifier: ^12.6.2 version: 12.8.0 @@ -240,13 +240,13 @@ importers: version: 5.0.6 '@types/react': specifier: ^19.2.13 - version: 19.2.14 + version: 19.2.13 '@types/react-dom': specifier: ^19.2.3 - version: 19.2.3(@types/react@19.2.14) + version: 19.2.3(@types/react@19.2.13) '@vitejs/plugin-react': specifier: ^5.1.3 - version: 5.1.4(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)) + version: 5.1.3(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)) concurrently: specifier: ^9.2.1 version: 9.2.1 @@ -267,7 +267,7 @@ importers: version: 5.9.3 vite: specifier: ^7.3.0 - version: 7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1) + version: 7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1) wait-on: specifier: ^8.0.3 version: 8.0.5 From 463af24fa604a8502e72d0db6d334043b2280e35 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 19 Mar 2026 14:39:27 +0000 Subject: [PATCH 05/13] ci: apply automated fixes --- packages/query-db-collection/tsconfig.json | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/query-db-collection/tsconfig.json b/packages/query-db-collection/tsconfig.json index 0f73295e1..f3c555fc6 100644 --- a/packages/query-db-collection/tsconfig.json +++ b/packages/query-db-collection/tsconfig.json @@ -18,6 +18,13 @@ "@tanstack/db-collection-e2e": ["../db-collection-e2e/src"] } }, - "include": ["src", "tests", "e2e", "vite.config.ts", "vitest.config.ts", "vitest.e2e.config.ts"], + "include": [ + "src", + "tests", + "e2e", + "vite.config.ts", + "vitest.config.ts", + "vitest.e2e.config.ts" + ], "exclude": ["node_modules", "dist"] } From 016c6f3bc77421a5b7cf677649716f612ea5b64a Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Thu, 19 Mar 2026 16:01:31 +0100 Subject: [PATCH 06/13] fix(query-db-collection): fix type error in e2e test Co-Authored-By: Claude Opus 4.6 (1M context) --- .../query-db-collection/e2e/offline-refresh.e2e.test.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/query-db-collection/e2e/offline-refresh.e2e.test.ts b/packages/query-db-collection/e2e/offline-refresh.e2e.test.ts index c8594b68b..3ddd6d089 100644 --- a/packages/query-db-collection/e2e/offline-refresh.e2e.test.ts +++ b/packages/query-db-collection/e2e/offline-refresh.e2e.test.ts @@ -10,7 +10,7 @@ import { createCollection } from '@tanstack/db' import { QueryClient } from '@tanstack/query-core' import { startOfflineExecutor } from '@tanstack/offline-transactions' import { queryCollectionOptions } from '../src/query' -import type { Collection, PendingMutation } from '@tanstack/db' +import type { Collection } from '@tanstack/db' import type { LeaderElection, OfflineConfig, @@ -206,12 +206,9 @@ describe(`offline transactions + query collection refresh`, () => { }) // Update server state (simulating the server processing the mutation) - const mutations = params.transaction.mutations as Array< - PendingMutation - > - for (const mutation of mutations) { + for (const mutation of params.transaction.mutations) { if (mutation.type === `insert`) { - serverItems.push(mutation.modified) + serverItems.push(mutation.modified as TestItem) } } From 0d389b123dd8eb73b655138d4b7051cbee47a29b Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Thu, 19 Mar 2026 16:25:59 +0100 Subject: [PATCH 07/13] ci: apply automated fixes Co-Authored-By: Claude Opus 4.6 (1M context) --- pnpm-lock.yaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e28fcce7a..295400433 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -212,7 +212,7 @@ importers: version: link:../../../packages/react-db '@tanstack/react-query': specifier: ^5.90.20 - version: 5.90.20(react@19.2.4) + version: 5.90.21(react@19.2.4) better-sqlite3: specifier: ^12.6.2 version: 12.8.0 @@ -240,13 +240,13 @@ importers: version: 5.0.6 '@types/react': specifier: ^19.2.13 - version: 19.2.13 + version: 19.2.14 '@types/react-dom': specifier: ^19.2.3 - version: 19.2.3(@types/react@19.2.13) + version: 19.2.3(@types/react@19.2.14) '@vitejs/plugin-react': specifier: ^5.1.3 - version: 5.1.3(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)) + version: 5.1.4(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)) concurrently: specifier: ^9.2.1 version: 9.2.1 @@ -267,7 +267,7 @@ importers: version: 5.9.3 vite: specifier: ^7.3.0 - version: 7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1) + version: 7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1) wait-on: specifier: ^8.0.3 version: 8.0.5 From 20fc2d6d9ba449fab32afddf6f889ec6b20d1374 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Thu, 19 Mar 2026 16:30:28 +0100 Subject: [PATCH 08/13] ci: build offline-transactions before query-db-collection e2e tests The new e2e test imports @tanstack/offline-transactions, which needs to be built before the query e2e tests run. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/e2e-tests.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 3b26c63a0..65367a313 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -41,6 +41,7 @@ jobs: pnpm --filter @tanstack/db-ivm build pnpm --filter @tanstack/db build pnpm --filter @tanstack/electric-db-collection build + pnpm --filter @tanstack/offline-transactions build pnpm --filter @tanstack/query-db-collection build - name: Run Electric E2E tests From 1ffd49c0c1aa7800a6b188a9ecb1d624cdfd7e12 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Thu, 19 Mar 2026 16:41:57 +0100 Subject: [PATCH 09/13] fix(query-db-collection): fix type cast in e2e test Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/query-db-collection/e2e/offline-refresh.e2e.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/query-db-collection/e2e/offline-refresh.e2e.test.ts b/packages/query-db-collection/e2e/offline-refresh.e2e.test.ts index 3ddd6d089..4331ae531 100644 --- a/packages/query-db-collection/e2e/offline-refresh.e2e.test.ts +++ b/packages/query-db-collection/e2e/offline-refresh.e2e.test.ts @@ -208,7 +208,7 @@ describe(`offline transactions + query collection refresh`, () => { // Update server state (simulating the server processing the mutation) for (const mutation of params.transaction.mutations) { if (mutation.type === `insert`) { - serverItems.push(mutation.modified as TestItem) + serverItems.push(mutation.modified as unknown as TestItem) } } From b3ba9afc79bbb60a0f60f41449220ce01d57dcc4 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Thu, 19 Mar 2026 16:54:41 +0100 Subject: [PATCH 10/13] feat: defer query refresh until pending offline transactions complete When coming back online with pending offline transactions, query-backed collections would refetch stale server state before the mutations reached the server. After the transaction completed, the optimistic state was cleaned up but syncedData still had the stale data, causing items to temporarily disappear. The fix adds a `deferDataRefresh` property on Collection that the offline executor sets while replaying pending transactions. The query-db-collection checks this barrier in handleQueryResult: stale results are skipped, and a fresh refetch is triggered once the barrier resolves. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/db/src/collection/index.ts | 7 ++++ .../src/OfflineExecutor.ts | 42 ++++++++++++++++--- packages/query-db-collection/src/query.ts | 15 +++++++ 3 files changed, 58 insertions(+), 6 deletions(-) diff --git a/packages/db/src/collection/index.ts b/packages/db/src/collection/index.ts index d95103267..2cb975f91 100644 --- a/packages/db/src/collection/index.ts +++ b/packages/db/src/collection/index.ts @@ -301,6 +301,13 @@ export class CollectionImpl< // and for debugging public _state: CollectionStateManager + /** + * When set, collection consumers should defer processing incoming data + * refreshes until this promise resolves. This prevents stale data from + * overwriting optimistic state while pending writes are being applied. + */ + public deferDataRefresh: Promise | null = null + private comparisonOpts: StringCollationConfig /** diff --git a/packages/offline-transactions/src/OfflineExecutor.ts b/packages/offline-transactions/src/OfflineExecutor.ts index cc537b3a3..72b4844b5 100644 --- a/packages/offline-transactions/src/OfflineExecutor.ts +++ b/packages/offline-transactions/src/OfflineExecutor.ts @@ -221,12 +221,38 @@ export class OfflineExecutor { this.unsubscribeOnline = this.onlineDetector.subscribe(() => { if (this.isOfflineEnabled && this.executor) { this.executor.resetRetryDelays() - this.executor.executeAll().catch((error) => { - console.warn( - `Failed to execute transactions on connectivity change:`, - error, - ) - }) + + if (this.scheduler.getPendingCount() > 0) { + const barrierPromise = this.executor.executeAll() + + for (const collection of Object.values(this.config.collections)) { + collection.deferDataRefresh = barrierPromise + } + + barrierPromise + .catch((error) => { + console.warn( + `Failed to execute transactions on connectivity change:`, + error, + ) + }) + .finally(() => { + for (const collection of Object.values( + this.config.collections, + )) { + if (collection.deferDataRefresh === barrierPromise) { + collection.deferDataRefresh = null + } + } + }) + } else { + this.executor.executeAll().catch((error) => { + console.warn( + `Failed to execute transactions on connectivity change:`, + error, + ) + }) + } } }) } @@ -568,6 +594,10 @@ export class OfflineExecutor { } dispose(): void { + for (const collection of Object.values(this.config.collections)) { + collection.deferDataRefresh = null + } + if (this.unsubscribeOnline) { this.unsubscribeOnline() this.unsubscribeOnline = null diff --git a/packages/query-db-collection/src/query.ts b/packages/query-db-collection/src/query.ts index 8ca8e0c91..746c5d3ca 100644 --- a/packages/query-db-collection/src/query.ts +++ b/packages/query-db-collection/src/query.ts @@ -837,6 +837,21 @@ export function queryCollectionOptions( const hashedQueryKey = hashKey(queryKey) const handleQueryResult: UpdateHandler = (result) => { if (result.isSuccess) { + // Skip processing this result while data refreshes are deferred. + // Optimistic state covers the gap. Once the barrier resolves, + // trigger a fresh refetch to get authoritative data. + if (collection.deferDataRefresh) { + collection.deferDataRefresh.then(() => { + const observer = state.observers.get(hashedQueryKey) + if (observer) { + observer.refetch().catch(() => { + // Errors handled by the next handleQueryResult invocation + }) + } + }) + return + } + // Clear error state state.lastError = undefined state.errorCount = 0 From ecd2ee76df0be803de04306070b5fba1c310ac95 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 19 Mar 2026 15:55:51 +0000 Subject: [PATCH 11/13] ci: apply automated fixes --- packages/offline-transactions/src/OfflineExecutor.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/offline-transactions/src/OfflineExecutor.ts b/packages/offline-transactions/src/OfflineExecutor.ts index 72b4844b5..8f443277c 100644 --- a/packages/offline-transactions/src/OfflineExecutor.ts +++ b/packages/offline-transactions/src/OfflineExecutor.ts @@ -237,9 +237,7 @@ export class OfflineExecutor { ) }) .finally(() => { - for (const collection of Object.values( - this.config.collections, - )) { + for (const collection of Object.values(this.config.collections)) { if (collection.deferDataRefresh === barrierPromise) { collection.deferDataRefresh = null } From c47bdb7ed5892274225f2531f14cd5354bddbcc7 Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Sat, 21 Mar 2026 12:31:02 +0000 Subject: [PATCH 12/13] chore: add changeset for offline query refresh fix Made-with: Cursor --- .changeset/olive-coins-sleep.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .changeset/olive-coins-sleep.md diff --git a/.changeset/olive-coins-sleep.md b/.changeset/olive-coins-sleep.md new file mode 100644 index 000000000..624ca768c --- /dev/null +++ b/.changeset/olive-coins-sleep.md @@ -0,0 +1,9 @@ +--- +'@tanstack/db': patch +'@tanstack/offline-transactions': patch +'@tanstack/query-db-collection': patch +--- + +fix: prevent stale query refreshes from overwriting optimistic offline changes on reconnect + +When reconnecting with pending offline transactions, query-backed collections now defer processing query refreshes until queued writes finish replaying, avoiding temporary reverts to stale server data. From 3fe0b7751e22d145f937a7408f5db9c8e2419488 Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Sat, 21 Mar 2026 12:36:09 +0000 Subject: [PATCH 13/13] fix(query-db-collection): resolve e2e test typecheck setup Made-with: Cursor --- packages/query-db-collection/e2e/offline-refresh.e2e.test.ts | 4 +++- packages/query-db-collection/tsconfig.json | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/query-db-collection/e2e/offline-refresh.e2e.test.ts b/packages/query-db-collection/e2e/offline-refresh.e2e.test.ts index 4331ae531..db07f7048 100644 --- a/packages/query-db-collection/e2e/offline-refresh.e2e.test.ts +++ b/packages/query-db-collection/e2e/offline-refresh.e2e.test.ts @@ -137,6 +137,8 @@ interface TestItem { value: string } +type OfflineMutationParams = Parameters[0] + // --- Tests --- describe(`offline transactions + query collection refresh`, () => { @@ -199,7 +201,7 @@ describe(`offline transactions + query collection refresh`, () => { const offlineConfig: OfflineConfig = { collections: { [`offline-refresh-test`]: collection as any }, mutationFns: { - [mutationFnName]: async (params) => { + [mutationFnName]: async (params: OfflineMutationParams) => { // Block until the test explicitly resolves (simulating slow API POST) await new Promise((resolve) => { resolveMutation = resolve diff --git a/packages/query-db-collection/tsconfig.json b/packages/query-db-collection/tsconfig.json index f3c555fc6..a28b5d501 100644 --- a/packages/query-db-collection/tsconfig.json +++ b/packages/query-db-collection/tsconfig.json @@ -15,6 +15,7 @@ "@tanstack/store": ["../store/src"], "@tanstack/db": ["../db/src"], "@tanstack/db-ivm": ["../db-ivm/src"], + "@tanstack/offline-transactions": ["../offline-transactions/src"], "@tanstack/db-collection-e2e": ["../db-collection-e2e/src"] } },