@syncular/client-react 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/createSyncularReact.d.ts +221 -0
- package/dist/createSyncularReact.d.ts.map +1 -0
- package/dist/createSyncularReact.js +773 -0
- package/dist/createSyncularReact.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/package.json +50 -0
- package/src/__tests__/SyncEngine.test.ts +653 -0
- package/src/__tests__/SyncProvider.strictmode.test.tsx +117 -0
- package/src/__tests__/fingerprint.test.ts +181 -0
- package/src/__tests__/hooks/useMutation.test.tsx +468 -0
- package/src/__tests__/hooks.test.tsx +384 -0
- package/src/__tests__/integration/conflict-resolution.test.ts +439 -0
- package/src/__tests__/integration/provider-reconfig.test.ts +291 -0
- package/src/__tests__/integration/push-flow.test.ts +320 -0
- package/src/__tests__/integration/realtime-sync.test.ts +222 -0
- package/src/__tests__/integration/self-conflict.test.ts +91 -0
- package/src/__tests__/integration/test-setup.ts +538 -0
- package/src/__tests__/integration/two-client-sync.test.ts +373 -0
- package/src/__tests__/setup.ts +7 -0
- package/src/__tests__/test-utils.ts +187 -0
- package/src/__tests__/useMutations.test.tsx +198 -0
- package/src/createSyncularReact.tsx +1340 -0
- package/src/index.ts +9 -0
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for React hooks
|
|
3
|
+
*
|
|
4
|
+
* These tests use `autoStart={false}` to prevent the SyncProvider from
|
|
5
|
+
* auto-starting the engine, allowing tests to control the lifecycle.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { beforeEach, describe, expect, it } from 'bun:test';
|
|
9
|
+
import type { SyncClientDb } from '@syncular/client';
|
|
10
|
+
import { act, renderHook, waitFor } from '@testing-library/react';
|
|
11
|
+
import type { Kysely } from 'kysely';
|
|
12
|
+
import type { ReactNode } from 'react';
|
|
13
|
+
import { createSyncularReact } from '../index';
|
|
14
|
+
import {
|
|
15
|
+
createMockDb,
|
|
16
|
+
createMockShapeRegistry,
|
|
17
|
+
createMockTransport,
|
|
18
|
+
} from './test-utils';
|
|
19
|
+
|
|
20
|
+
const {
|
|
21
|
+
SyncProvider,
|
|
22
|
+
useConflicts,
|
|
23
|
+
useOutbox,
|
|
24
|
+
useResolveConflict,
|
|
25
|
+
useSyncConnection,
|
|
26
|
+
useSyncEngine,
|
|
27
|
+
useSyncStatus,
|
|
28
|
+
} = createSyncularReact<SyncClientDb>();
|
|
29
|
+
|
|
30
|
+
describe('React Hooks', () => {
|
|
31
|
+
let db: Kysely<SyncClientDb>;
|
|
32
|
+
|
|
33
|
+
beforeEach(async () => {
|
|
34
|
+
db = await createMockDb();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
function createWrapper(options?: { autoStart?: boolean }) {
|
|
38
|
+
const transport = createMockTransport();
|
|
39
|
+
const shapes = createMockShapeRegistry();
|
|
40
|
+
|
|
41
|
+
const Wrapper = ({ children }: { children: ReactNode }) => (
|
|
42
|
+
<SyncProvider
|
|
43
|
+
db={db}
|
|
44
|
+
transport={transport}
|
|
45
|
+
shapes={shapes}
|
|
46
|
+
actorId="test-actor"
|
|
47
|
+
clientId="test-client"
|
|
48
|
+
subscriptions={[]}
|
|
49
|
+
pollIntervalMs={999999} // Long poll interval to prevent continuous polling
|
|
50
|
+
autoStart={options?.autoStart ?? false} // Disable auto-start for tests
|
|
51
|
+
>
|
|
52
|
+
{children}
|
|
53
|
+
</SyncProvider>
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
return Wrapper;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
describe('useSyncEngine', () => {
|
|
60
|
+
it('should return engine state', async () => {
|
|
61
|
+
const { result } = renderHook(() => useSyncEngine(), {
|
|
62
|
+
wrapper: createWrapper(),
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
expect(result.current.state).toBeDefined();
|
|
66
|
+
expect(result.current.state.enabled).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('should provide sync function', async () => {
|
|
70
|
+
const { result } = renderHook(() => useSyncEngine(), {
|
|
71
|
+
wrapper: createWrapper(),
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
expect(typeof result.current.sync).toBe('function');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('should provide control functions', () => {
|
|
78
|
+
const { result } = renderHook(() => useSyncEngine(), {
|
|
79
|
+
wrapper: createWrapper(),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
expect(typeof result.current.reconnect).toBe('function');
|
|
83
|
+
expect(typeof result.current.disconnect).toBe('function');
|
|
84
|
+
expect(typeof result.current.start).toBe('function');
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('useSyncStatus', () => {
|
|
89
|
+
it('should return status object', () => {
|
|
90
|
+
const { result } = renderHook(() => useSyncStatus(), {
|
|
91
|
+
wrapper: createWrapper(),
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
expect(result.current).toMatchObject({
|
|
95
|
+
enabled: true,
|
|
96
|
+
isOnline: expect.any(Boolean),
|
|
97
|
+
isSyncing: expect.any(Boolean),
|
|
98
|
+
pendingCount: expect.any(Number),
|
|
99
|
+
error: null,
|
|
100
|
+
isRetrying: false,
|
|
101
|
+
retryCount: 0,
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('should show lastSyncAt after manual sync', async () => {
|
|
106
|
+
const { result } = renderHook(
|
|
107
|
+
() => ({
|
|
108
|
+
status: useSyncStatus(),
|
|
109
|
+
engine: useSyncEngine(),
|
|
110
|
+
}),
|
|
111
|
+
{ wrapper: createWrapper() }
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
// Start the engine manually
|
|
115
|
+
await act(async () => {
|
|
116
|
+
await result.current.engine.start();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// Trigger a sync
|
|
120
|
+
await act(async () => {
|
|
121
|
+
await result.current.engine.sync();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
expect(result.current.status.lastSyncAt).not.toBe(null);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe('useSyncConnection', () => {
|
|
129
|
+
it('should return connection state', () => {
|
|
130
|
+
const { result } = renderHook(() => useSyncConnection(), {
|
|
131
|
+
wrapper: createWrapper(),
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
expect(result.current.state).toBeDefined();
|
|
135
|
+
expect(result.current.mode).toBe('polling');
|
|
136
|
+
expect(typeof result.current.isConnected).toBe('boolean');
|
|
137
|
+
expect(typeof result.current.isReconnecting).toBe('boolean');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('should provide reconnect and disconnect functions', () => {
|
|
141
|
+
const { result } = renderHook(() => useSyncConnection(), {
|
|
142
|
+
wrapper: createWrapper(),
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
expect(typeof result.current.reconnect).toBe('function');
|
|
146
|
+
expect(typeof result.current.disconnect).toBe('function');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// Note: Connection lifecycle tests are covered in integration tests
|
|
150
|
+
// The SyncEngine.test.ts tests the engine directly
|
|
151
|
+
// These hook tests verify the React binding works
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe('useOutbox', () => {
|
|
155
|
+
it('should return outbox stats', async () => {
|
|
156
|
+
const { result } = renderHook(() => useOutbox(), {
|
|
157
|
+
wrapper: createWrapper(),
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
await waitFor(() => {
|
|
161
|
+
expect(result.current.isLoading).toBe(false);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
expect(result.current.stats).toMatchObject({
|
|
165
|
+
pending: 0,
|
|
166
|
+
sending: 0,
|
|
167
|
+
failed: 0,
|
|
168
|
+
acked: 0,
|
|
169
|
+
total: 0,
|
|
170
|
+
});
|
|
171
|
+
expect(result.current.hasUnsent).toBe(false);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('should return empty pending and failed arrays initially', async () => {
|
|
175
|
+
const { result } = renderHook(() => useOutbox(), {
|
|
176
|
+
wrapper: createWrapper(),
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
await waitFor(() => {
|
|
180
|
+
expect(result.current.isLoading).toBe(false);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
expect(result.current.pending).toEqual([]);
|
|
184
|
+
expect(result.current.failed).toEqual([]);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe('useConflicts', () => {
|
|
189
|
+
it('should return empty conflicts initially', async () => {
|
|
190
|
+
const { result } = renderHook(() => useConflicts(), {
|
|
191
|
+
wrapper: createWrapper(),
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
await waitFor(() => {
|
|
195
|
+
expect(result.current.isLoading).toBe(false);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
expect(result.current.conflicts).toEqual([]);
|
|
199
|
+
expect(result.current.count).toBe(0);
|
|
200
|
+
expect(result.current.hasConflicts).toBe(false);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
describe('useResolveConflict', () => {
|
|
205
|
+
it('should return resolve function and state', () => {
|
|
206
|
+
const { result } = renderHook(() => useResolveConflict(), {
|
|
207
|
+
wrapper: createWrapper(),
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
expect(typeof result.current.resolve).toBe('function');
|
|
211
|
+
expect(result.current.isPending).toBe(false);
|
|
212
|
+
expect(result.current.error).toBe(null);
|
|
213
|
+
expect(typeof result.current.reset).toBe('function');
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('should set isPending during resolution', async () => {
|
|
217
|
+
const { result } = renderHook(() => useResolveConflict(), {
|
|
218
|
+
wrapper: createWrapper(),
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// Try to resolve a non-existent conflict (will throw but we test the pending state)
|
|
222
|
+
const resolvePromise = act(async () => {
|
|
223
|
+
try {
|
|
224
|
+
await result.current.resolve('non-existent', 'accept');
|
|
225
|
+
} catch {
|
|
226
|
+
// Expected to fail - conflict doesn't exist
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
await resolvePromise;
|
|
231
|
+
|
|
232
|
+
// After resolution attempt, isPending should be false
|
|
233
|
+
expect(result.current.isPending).toBe(false);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('should handle accept resolution type', async () => {
|
|
237
|
+
// Create a conflict in the database first
|
|
238
|
+
await db
|
|
239
|
+
.insertInto('sync_conflicts')
|
|
240
|
+
.values({
|
|
241
|
+
id: 'test-conflict-1',
|
|
242
|
+
outbox_commit_id: 'commit-1',
|
|
243
|
+
client_commit_id: 'client-commit-1',
|
|
244
|
+
op_index: 0,
|
|
245
|
+
result_status: 'conflict',
|
|
246
|
+
message: 'Version conflict',
|
|
247
|
+
code: 'VERSION_MISMATCH',
|
|
248
|
+
server_version: 2,
|
|
249
|
+
server_row_json: JSON.stringify({
|
|
250
|
+
id: 'row-1',
|
|
251
|
+
title: 'Server Title',
|
|
252
|
+
}),
|
|
253
|
+
created_at: Date.now(),
|
|
254
|
+
resolved_at: null,
|
|
255
|
+
resolution: null,
|
|
256
|
+
})
|
|
257
|
+
.execute();
|
|
258
|
+
|
|
259
|
+
const { result } = renderHook(
|
|
260
|
+
() => ({
|
|
261
|
+
resolve: useResolveConflict({ syncAfterResolve: false }),
|
|
262
|
+
conflicts: useConflicts(),
|
|
263
|
+
}),
|
|
264
|
+
{ wrapper: createWrapper() }
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
// Wait for conflicts to load
|
|
268
|
+
await waitFor(() => {
|
|
269
|
+
expect(result.current.conflicts.isLoading).toBe(false);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
// Resolve the conflict
|
|
273
|
+
await act(async () => {
|
|
274
|
+
await result.current.resolve.resolve('test-conflict-1', 'accept');
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
// Verify conflict was resolved
|
|
278
|
+
const resolved = await db
|
|
279
|
+
.selectFrom('sync_conflicts')
|
|
280
|
+
.where('id', '=', 'test-conflict-1')
|
|
281
|
+
.selectAll()
|
|
282
|
+
.executeTakeFirst();
|
|
283
|
+
|
|
284
|
+
expect(resolved?.resolution).toBe('accept');
|
|
285
|
+
expect(resolved?.resolved_at).not.toBe(null);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it('should handle reject resolution type', async () => {
|
|
289
|
+
// Create a conflict
|
|
290
|
+
await db
|
|
291
|
+
.insertInto('sync_conflicts')
|
|
292
|
+
.values({
|
|
293
|
+
id: 'test-conflict-2',
|
|
294
|
+
outbox_commit_id: 'commit-2',
|
|
295
|
+
client_commit_id: 'client-commit-2',
|
|
296
|
+
op_index: 0,
|
|
297
|
+
result_status: 'conflict',
|
|
298
|
+
message: 'Version conflict',
|
|
299
|
+
code: 'VERSION_MISMATCH',
|
|
300
|
+
server_version: 3,
|
|
301
|
+
server_row_json: null,
|
|
302
|
+
created_at: Date.now(),
|
|
303
|
+
resolved_at: null,
|
|
304
|
+
resolution: null,
|
|
305
|
+
})
|
|
306
|
+
.execute();
|
|
307
|
+
|
|
308
|
+
const { result } = renderHook(
|
|
309
|
+
() => useResolveConflict({ syncAfterResolve: false }),
|
|
310
|
+
{ wrapper: createWrapper() }
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
await act(async () => {
|
|
314
|
+
await result.current.resolve('test-conflict-2', 'reject');
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
const resolved = await db
|
|
318
|
+
.selectFrom('sync_conflicts')
|
|
319
|
+
.where('id', '=', 'test-conflict-2')
|
|
320
|
+
.selectAll()
|
|
321
|
+
.executeTakeFirst();
|
|
322
|
+
|
|
323
|
+
expect(resolved?.resolution).toBe('reject');
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it('should call onSuccess callback on successful resolution', async () => {
|
|
327
|
+
await db
|
|
328
|
+
.insertInto('sync_conflicts')
|
|
329
|
+
.values({
|
|
330
|
+
id: 'test-conflict-3',
|
|
331
|
+
outbox_commit_id: 'commit-3',
|
|
332
|
+
client_commit_id: 'client-commit-3',
|
|
333
|
+
op_index: 0,
|
|
334
|
+
result_status: 'conflict',
|
|
335
|
+
message: 'Version conflict',
|
|
336
|
+
code: null,
|
|
337
|
+
server_version: 1,
|
|
338
|
+
server_row_json: null,
|
|
339
|
+
created_at: Date.now(),
|
|
340
|
+
resolved_at: null,
|
|
341
|
+
resolution: null,
|
|
342
|
+
})
|
|
343
|
+
.execute();
|
|
344
|
+
|
|
345
|
+
let successId: string | null = null;
|
|
346
|
+
const onSuccess = (id: string) => {
|
|
347
|
+
successId = id;
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
const { result } = renderHook(
|
|
351
|
+
() => useResolveConflict({ onSuccess, syncAfterResolve: false }),
|
|
352
|
+
{ wrapper: createWrapper() }
|
|
353
|
+
);
|
|
354
|
+
|
|
355
|
+
await act(async () => {
|
|
356
|
+
await result.current.resolve('test-conflict-3', 'accept');
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
expect(successId!).toBe('test-conflict-3');
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it('should reset error state', async () => {
|
|
363
|
+
const { result } = renderHook(() => useResolveConflict(), {
|
|
364
|
+
wrapper: createWrapper(),
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
// Trigger an error by resolving non-existent conflict
|
|
368
|
+
await act(async () => {
|
|
369
|
+
try {
|
|
370
|
+
await result.current.resolve('non-existent', 'accept');
|
|
371
|
+
} catch {
|
|
372
|
+
// Expected
|
|
373
|
+
}
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
// Reset should clear the error
|
|
377
|
+
act(() => {
|
|
378
|
+
result.current.reset();
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
expect(result.current.error).toBe(null);
|
|
382
|
+
});
|
|
383
|
+
});
|
|
384
|
+
});
|