Skip to content

Commit 53d6f2f

Browse files
committed
Implement local-first sync with exponential backoff to fix the todo item displacement issues with large network latencies
1 parent 2b7129a commit 53d6f2f

4 files changed

Lines changed: 356 additions & 73 deletions

File tree

src/collections/todoItems.ts

Lines changed: 237 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,236 @@ import { toast } from "sonner";
44
import type { TodoItemRecord } from "@/db/schema";
55
import * as TanstackQuery from "@/integrations/tanstack-query/root-provider";
66
import type { TodoItemCreateDataType } from "@/local-api/api.todo-items";
7+
import {
8+
TODO_ITEMS_SYNC_STATE_ID,
9+
todoItemsSyncCollection,
10+
} from "./todoItemsSync";
11+
12+
type TodoItemSyncPayload = Pick<
13+
TodoItemRecord,
14+
"id" | "boardId" | "priority" | "title" | "description" | "position"
15+
>;
16+
17+
const UNSYNCED_TOAST_ID = "todo-items-unsynced";
18+
19+
const desiredPayloadById = new Map<string, TodoItemSyncPayload>();
20+
const inFlightItemIds = new Set<string>();
21+
const lastSyncedSignatureById = new Map<string, string>();
22+
const retryAttemptById = new Map<string, number>();
23+
const retryTimeoutById = new Map<string, ReturnType<typeof setTimeout>>();
24+
const unsyncedItemIds = new Set<string>();
25+
const failedItemIds = new Set<string>();
26+
27+
function syncStateCollection() {
28+
todoItemsSyncCollection.update(TODO_ITEMS_SYNC_STATE_ID, (draft) => {
29+
draft.unsyncedItemIds = [...unsyncedItemIds].sort();
30+
draft.inFlightItemIds = [...inFlightItemIds].sort();
31+
draft.failedItemIds = [...failedItemIds].sort();
32+
});
33+
}
34+
35+
function updateUnsyncedToast() {
36+
const failedCount = failedItemIds.size;
37+
38+
if (failedCount === 0) {
39+
toast.dismiss(UNSYNCED_TOAST_ID);
40+
return;
41+
}
42+
43+
toast.error(
44+
failedCount === 1
45+
? "1 todo change failed to sync. Local state is preserved."
46+
: `${failedCount} todo changes failed to sync. Local state is preserved.`,
47+
{
48+
id: UNSYNCED_TOAST_ID,
49+
action: {
50+
label: "Retry now",
51+
onClick: () => {
52+
void retryUnsyncedTodoItemsSync();
53+
},
54+
},
55+
},
56+
);
57+
}
58+
59+
function syncUiIndicators() {
60+
syncStateCollection();
61+
updateUnsyncedToast();
62+
}
63+
64+
function buildPayload(item: TodoItemRecord): TodoItemSyncPayload {
65+
return {
66+
id: item.id,
67+
boardId: item.boardId,
68+
priority: item.priority,
69+
title: item.title,
70+
description: item.description,
71+
position: item.position,
72+
};
73+
}
74+
75+
function payloadSignature(payload: TodoItemSyncPayload): string {
76+
return JSON.stringify(payload);
77+
}
78+
79+
function clearRetryTimeout(itemId: string) {
80+
const timeoutId = retryTimeoutById.get(itemId);
81+
if (timeoutId) {
82+
clearTimeout(timeoutId);
83+
retryTimeoutById.delete(itemId);
84+
}
85+
}
86+
87+
function getRetryDelayMs(attempt: number): number {
88+
return Math.min(30_000, 1_000 * 2 ** Math.max(0, attempt - 1));
89+
}
90+
91+
function getTodoItem(itemId: string): TodoItemRecord | undefined {
92+
return todoItemsCollection.get(itemId);
93+
}
94+
95+
function syncTodoItemsQueryCache(modified: TodoItemRecord) {
96+
const queryClient = TanstackQuery.getContext().queryClient;
97+
queryClient.setQueriesData<TodoItemRecord[]>(
98+
{ queryKey: todoItemsQueryKey },
99+
(oldData) => {
100+
if (!oldData) {
101+
return oldData;
102+
}
103+
104+
return oldData.map((item) => (item.id === modified.id ? modified : item));
105+
},
106+
);
107+
}
108+
109+
function markUnsynced(itemId: string) {
110+
unsyncedItemIds.add(itemId);
111+
syncUiIndicators();
112+
}
113+
114+
function markSynced(itemId: string) {
115+
unsyncedItemIds.delete(itemId);
116+
failedItemIds.delete(itemId);
117+
syncUiIndicators();
118+
}
119+
120+
function clearSyncStateForItem(itemId: string) {
121+
clearRetryTimeout(itemId);
122+
desiredPayloadById.delete(itemId);
123+
inFlightItemIds.delete(itemId);
124+
lastSyncedSignatureById.delete(itemId);
125+
retryAttemptById.delete(itemId);
126+
unsyncedItemIds.delete(itemId);
127+
failedItemIds.delete(itemId);
128+
syncUiIndicators();
129+
}
130+
131+
async function flushItemSync(itemId: string) {
132+
if (inFlightItemIds.has(itemId)) {
133+
return;
134+
}
135+
136+
const desiredPayload = desiredPayloadById.get(itemId);
137+
138+
if (!desiredPayload) {
139+
clearSyncStateForItem(itemId);
140+
return;
141+
}
142+
143+
const desiredSignature = payloadSignature(desiredPayload);
144+
145+
if (lastSyncedSignatureById.get(itemId) === desiredSignature) {
146+
markSynced(itemId);
147+
return;
148+
}
149+
150+
inFlightItemIds.add(itemId);
151+
syncUiIndicators();
152+
153+
try {
154+
await updateTodoItem({ data: desiredPayload });
155+
lastSyncedSignatureById.set(itemId, desiredSignature);
156+
retryAttemptById.set(itemId, 0);
157+
} catch (error) {
158+
const nextAttempt = (retryAttemptById.get(itemId) ?? 0) + 1;
159+
retryAttemptById.set(itemId, nextAttempt);
160+
unsyncedItemIds.add(itemId);
161+
failedItemIds.add(itemId);
162+
syncUiIndicators();
163+
164+
const delay = getRetryDelayMs(nextAttempt);
165+
const timeoutId = setTimeout(() => {
166+
retryTimeoutById.delete(itemId);
167+
void flushItemSync(itemId);
168+
}, delay);
169+
170+
retryTimeoutById.set(itemId, timeoutId);
171+
172+
console.error(`Failed to sync todo item ${itemId}:`, error);
173+
return;
174+
} finally {
175+
inFlightItemIds.delete(itemId);
176+
syncUiIndicators();
177+
}
178+
179+
const latestLocalItem = getTodoItem(itemId);
180+
181+
if (!latestLocalItem) {
182+
clearSyncStateForItem(itemId);
183+
return;
184+
}
185+
186+
const latestPayload = buildPayload(latestLocalItem);
187+
const latestSignature = payloadSignature(latestPayload);
188+
189+
desiredPayloadById.set(itemId, latestPayload);
190+
191+
if (lastSyncedSignatureById.get(itemId) !== latestSignature) {
192+
markUnsynced(itemId);
193+
void flushItemSync(itemId);
194+
return;
195+
}
196+
197+
markSynced(itemId);
198+
}
199+
200+
function queueLatestSync(itemId: string) {
201+
const todoItem = getTodoItem(itemId);
202+
203+
if (!todoItem) {
204+
clearSyncStateForItem(itemId);
205+
return;
206+
}
207+
208+
clearRetryTimeout(itemId);
209+
210+
const nextPayload = buildPayload(todoItem);
211+
desiredPayloadById.set(itemId, nextPayload);
212+
213+
const nextSignature = payloadSignature(nextPayload);
214+
const lastSyncedSignature = lastSyncedSignatureById.get(itemId);
215+
216+
if (lastSyncedSignature !== nextSignature) {
217+
markUnsynced(itemId);
218+
}
219+
220+
void flushItemSync(itemId);
221+
}
222+
223+
export async function retryUnsyncedTodoItemsSync() {
224+
const idsToRetry = [...failedItemIds];
225+
226+
idsToRetry.forEach((itemId) => {
227+
failedItemIds.delete(itemId);
228+
clearRetryTimeout(itemId);
229+
retryAttemptById.set(itemId, 0);
230+
void flushItemSync(itemId);
231+
});
232+
233+
syncUiIndicators();
234+
235+
return idsToRetry.length;
236+
}
7237

8238
async function updateTodoItem({
9239
data,
@@ -94,60 +324,16 @@ export const todoItemsCollection = createCollection<TodoItemRecord>(
94324
}
95325
},
96326
onUpdate: async ({ transaction }) => {
97-
/**
98-
NOTE: This is a temporary solution for updating todo items.
99-
**Do not use this in production code!**
100-
101-
Update strategy:
102-
1. Optimistically update the local cache when a todo item is moved/updated
103-
2. Update the server via API call
104-
3. If the API call fails, refetch the data from the server and revert the local cache
105-
106-
The server state is only fetched from the server if the update fails.
107-
Proper synchronization of moving/reordering items requires a sync engine
108-
to handle client-server conflicts effectively, which is outside the scope
109-
of this demo app.
110-
111-
Check out the available built-in sync collections here:
112-
https://tanstack.com/db/latest/docs/overview#built-in-collection-types
113-
*/
114-
115-
const { original, changes } = transaction.mutations[0];
327+
const { modified } = transaction.mutations[0];
116328

117-
try {
118-
// Send the updates to the server
119-
await updateTodoItem({
120-
data: {
121-
id: original.id,
122-
...changes,
123-
},
124-
});
329+
// Keep TanStack Query cache aligned with local optimistic state
330+
// so switching projects still shows the latest local edits.
331+
syncTodoItemsQueryCache(modified);
125332

126-
// Update the TanStack Query cache so switching projects shows correct data
127-
const queryClient = TanstackQuery.getContext().queryClient;
128-
queryClient.setQueriesData<TodoItemRecord[]>(
129-
{ queryKey: todoItemsQueryKey },
130-
(oldData) => {
131-
if (!oldData) return oldData;
132-
return oldData.map((item) =>
133-
item.id === original.id ? { ...item, ...changes } : item,
134-
);
135-
},
136-
);
137-
} catch (error) {
138-
toast.error(`Failed to update todo item "${original.title}"`);
139-
140-
// TODO: handle this one later properly
141-
// with queryClient.invalidateQueries(todoItemsQueryKey);
142-
// // Do not sync if the collection is already refetching
143-
// if (todoItemsCollection.utils.isRefetching === false) {
144-
// // Sync back the server's data
145-
// todoItemsCollection.utils.refetch();
146-
// }
147-
throw error;
148-
}
333+
// Local-first, latest-wins sync queue per item.
334+
// We never throw here, so local optimistic state is never rolled back.
335+
queueLatestSync(modified.id);
149336

150-
// Do not sync back the server's data by default
151337
return {
152338
refetch: false,
153339
};

src/collections/todoItemsSync.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import {
2+
createCollection,
3+
localOnlyCollectionOptions,
4+
} from "@tanstack/react-db";
5+
6+
export const TODO_ITEMS_SYNC_STATE_ID = "todo-items-sync-state";
7+
8+
export type TodoItemsSyncState = {
9+
id: string;
10+
unsyncedItemIds: string[];
11+
inFlightItemIds: string[];
12+
failedItemIds: string[];
13+
};
14+
15+
const initialTodoItemsSyncState: TodoItemsSyncState[] = [
16+
{
17+
id: TODO_ITEMS_SYNC_STATE_ID,
18+
unsyncedItemIds: [],
19+
inFlightItemIds: [],
20+
failedItemIds: [],
21+
},
22+
];
23+
24+
export const todoItemsSyncCollection = createCollection(
25+
localOnlyCollectionOptions({
26+
id: "todo-items-sync",
27+
getKey: (item) => item.id,
28+
initialData: initialTodoItemsSyncState,
29+
}),
30+
);

0 commit comments

Comments
 (0)