@syncular/client-react 0.0.4-26 → 0.0.6-100
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/async-init-registry.d.ts +25 -0
- package/dist/async-init-registry.d.ts.map +1 -0
- package/dist/async-init-registry.js +32 -0
- package/dist/async-init-registry.js.map +1 -0
- package/dist/createSyncularReact.d.ts +15 -8
- package/dist/createSyncularReact.d.ts.map +1 -1
- package/dist/createSyncularReact.js +20 -19
- package/dist/createSyncularReact.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/use-cached-async-value.d.ts +19 -0
- package/dist/use-cached-async-value.d.ts.map +1 -0
- package/dist/use-cached-async-value.js +45 -0
- package/dist/use-cached-async-value.js.map +1 -0
- package/package.json +8 -8
- package/src/__tests__/SyncEngine.test.ts +43 -39
- package/src/__tests__/SyncProvider.strictmode.test.tsx +4 -3
- package/src/__tests__/async-init-registry.test.ts +64 -0
- package/src/__tests__/hooks/useMutation.test.tsx +4 -3
- package/src/__tests__/hooks.test.tsx +8 -7
- package/src/__tests__/integration/provider-reconfig.test.ts +38 -21
- package/src/__tests__/integration/push-flow.test.ts +3 -3
- package/src/__tests__/test-utils.ts +30 -11
- package/src/__tests__/use-cached-async-value.test.tsx +111 -0
- package/src/__tests__/useMutations.test.tsx +4 -3
- package/src/async-init-registry.ts +60 -0
- package/src/createSyncularReact.tsx +38 -29
- package/src/index.ts +8 -0
- package/src/use-cached-async-value.ts +68 -0
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
|
|
5
5
|
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
6
6
|
import {
|
|
7
|
-
ClientTableRegistry,
|
|
8
7
|
enqueueOutboxCommit,
|
|
9
8
|
type SyncClientDb,
|
|
10
9
|
SyncEngine,
|
|
@@ -377,7 +376,7 @@ describe('SyncEngine', () => {
|
|
|
377
376
|
|
|
378
377
|
it('should preserve first pull round commits when additional rounds run', async () => {
|
|
379
378
|
const handlers = createMockHandlerRegistry();
|
|
380
|
-
handlers.
|
|
379
|
+
handlers.push({
|
|
381
380
|
table: 'sync_outbox_commits',
|
|
382
381
|
applySnapshot: async () => {},
|
|
383
382
|
clearAll: async () => {},
|
|
@@ -857,15 +856,16 @@ describe('SyncEngine', () => {
|
|
|
857
856
|
async function createTestEngine(
|
|
858
857
|
args: { includeProjects?: boolean } = {}
|
|
859
858
|
): Promise<SyncEngine<TestDb>> {
|
|
860
|
-
const handlers
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
859
|
+
const handlers: SyncEngineConfig<TestDb>['handlers'] = [
|
|
860
|
+
{
|
|
861
|
+
table: 'tasks',
|
|
862
|
+
applySnapshot: async () => {},
|
|
863
|
+
clearAll: async () => {},
|
|
864
|
+
applyChange: async () => {},
|
|
865
|
+
},
|
|
866
|
+
];
|
|
867
867
|
if (args.includeProjects) {
|
|
868
|
-
handlers.
|
|
868
|
+
handlers.push({
|
|
869
869
|
table: 'projects',
|
|
870
870
|
applySnapshot: async () => {},
|
|
871
871
|
clearAll: async () => {},
|
|
@@ -1088,13 +1088,14 @@ describe('SyncEngine', () => {
|
|
|
1088
1088
|
});
|
|
1089
1089
|
const rt = createRealtimeTransport(base);
|
|
1090
1090
|
|
|
1091
|
-
const handlers =
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1091
|
+
const handlers: SyncEngineConfig<SyncClientDb>['handlers'] = [
|
|
1092
|
+
{
|
|
1093
|
+
table: 'tasks',
|
|
1094
|
+
applySnapshot: async () => {},
|
|
1095
|
+
clearAll: async () => {},
|
|
1096
|
+
applyChange: async () => {},
|
|
1097
|
+
},
|
|
1098
|
+
];
|
|
1098
1099
|
|
|
1099
1100
|
const engine = createEngine({
|
|
1100
1101
|
transport: rt,
|
|
@@ -1222,13 +1223,14 @@ describe('SyncEngine', () => {
|
|
|
1222
1223
|
});
|
|
1223
1224
|
const rt = createRealtimeTransport(base);
|
|
1224
1225
|
|
|
1225
|
-
const handlers =
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1226
|
+
const handlers: SyncEngineConfig<SyncClientDb>['handlers'] = [
|
|
1227
|
+
{
|
|
1228
|
+
table: 'tasks',
|
|
1229
|
+
applySnapshot: async () => {},
|
|
1230
|
+
clearAll: async () => {},
|
|
1231
|
+
applyChange: async () => {},
|
|
1232
|
+
},
|
|
1233
|
+
];
|
|
1232
1234
|
|
|
1233
1235
|
const engine = createEngine({
|
|
1234
1236
|
transport: rt,
|
|
@@ -1289,15 +1291,16 @@ describe('SyncEngine', () => {
|
|
|
1289
1291
|
});
|
|
1290
1292
|
const rt = createRealtimeTransport(base);
|
|
1291
1293
|
|
|
1292
|
-
const handlers =
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1294
|
+
const handlers: SyncEngineConfig<SyncClientDb>['handlers'] = [
|
|
1295
|
+
{
|
|
1296
|
+
table: 'tasks',
|
|
1297
|
+
applySnapshot: async () => {},
|
|
1298
|
+
clearAll: async () => {},
|
|
1299
|
+
applyChange: async () => {
|
|
1300
|
+
inlineApplyCount++;
|
|
1301
|
+
},
|
|
1299
1302
|
},
|
|
1300
|
-
|
|
1303
|
+
];
|
|
1301
1304
|
|
|
1302
1305
|
const engine = createEngine({
|
|
1303
1306
|
transport: rt,
|
|
@@ -1348,13 +1351,14 @@ describe('SyncEngine', () => {
|
|
|
1348
1351
|
const base = createMockTransport();
|
|
1349
1352
|
const rt = createRealtimeTransport(base);
|
|
1350
1353
|
|
|
1351
|
-
const handlers =
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1354
|
+
const handlers: SyncEngineConfig<SyncClientDb>['handlers'] = [
|
|
1355
|
+
{
|
|
1356
|
+
table: 'tasks',
|
|
1357
|
+
applySnapshot: async () => {},
|
|
1358
|
+
clearAll: async () => {},
|
|
1359
|
+
applyChange: async () => {},
|
|
1360
|
+
},
|
|
1361
|
+
];
|
|
1358
1362
|
|
|
1359
1363
|
const engine = createEngine({
|
|
1360
1364
|
transport: rt,
|
|
@@ -22,6 +22,7 @@ import { createSyncularReact } from '../index';
|
|
|
22
22
|
import {
|
|
23
23
|
createMockDb,
|
|
24
24
|
createMockHandlerRegistry,
|
|
25
|
+
createMockSync,
|
|
25
26
|
createMockTransport,
|
|
26
27
|
} from './test-utils';
|
|
27
28
|
|
|
@@ -67,16 +68,16 @@ describe('SyncProvider (StrictMode)', () => {
|
|
|
67
68
|
function renderWithProvider(node: React.ReactNode) {
|
|
68
69
|
const transport = createMockTransport();
|
|
69
70
|
const handlers = createMockHandlerRegistry();
|
|
71
|
+
const sync = createMockSync({ handlers });
|
|
70
72
|
|
|
71
73
|
return render(
|
|
72
74
|
<React.StrictMode>
|
|
73
75
|
<SyncProvider
|
|
74
76
|
db={db}
|
|
75
77
|
transport={transport}
|
|
76
|
-
|
|
77
|
-
actorId
|
|
78
|
+
sync={sync}
|
|
79
|
+
identity={{ actorId: 'test-actor' }}
|
|
78
80
|
clientId="test-client"
|
|
79
|
-
subscriptions={[]}
|
|
80
81
|
pollIntervalMs={999999}
|
|
81
82
|
>
|
|
82
83
|
{node}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { createAsyncInitRegistry } from '../async-init-registry';
|
|
3
|
+
|
|
4
|
+
describe('createAsyncInitRegistry', () => {
|
|
5
|
+
it('runs initializer once per key and shares the same result', async () => {
|
|
6
|
+
const registry = createAsyncInitRegistry<string, string>();
|
|
7
|
+
let runs = 0;
|
|
8
|
+
|
|
9
|
+
const first = registry.run('client-a', async () => {
|
|
10
|
+
runs += 1;
|
|
11
|
+
return 'ok';
|
|
12
|
+
});
|
|
13
|
+
const second = registry.run('client-a', async () => {
|
|
14
|
+
runs += 1;
|
|
15
|
+
return 'unexpected';
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
await expect(first).resolves.toBe('ok');
|
|
19
|
+
await expect(second).resolves.toBe('ok');
|
|
20
|
+
expect(runs).toBe(1);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('evicts failed initializers so retry can succeed', async () => {
|
|
24
|
+
const registry = createAsyncInitRegistry<string, string>();
|
|
25
|
+
let runs = 0;
|
|
26
|
+
|
|
27
|
+
await expect(
|
|
28
|
+
registry.run('client-a', async () => {
|
|
29
|
+
runs += 1;
|
|
30
|
+
throw new Error('boom');
|
|
31
|
+
})
|
|
32
|
+
).rejects.toThrow('boom');
|
|
33
|
+
|
|
34
|
+
await expect(
|
|
35
|
+
registry.run('client-a', async () => {
|
|
36
|
+
runs += 1;
|
|
37
|
+
return 'recovered';
|
|
38
|
+
})
|
|
39
|
+
).resolves.toBe('recovered');
|
|
40
|
+
|
|
41
|
+
expect(runs).toBe(2);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('supports explicit invalidation', async () => {
|
|
45
|
+
const registry = createAsyncInitRegistry<string, number>();
|
|
46
|
+
let seed = 0;
|
|
47
|
+
|
|
48
|
+
await expect(
|
|
49
|
+
registry.run('client-a', async () => {
|
|
50
|
+
seed += 1;
|
|
51
|
+
return seed;
|
|
52
|
+
})
|
|
53
|
+
).resolves.toBe(1);
|
|
54
|
+
|
|
55
|
+
registry.invalidate('client-a');
|
|
56
|
+
|
|
57
|
+
await expect(
|
|
58
|
+
registry.run('client-a', async () => {
|
|
59
|
+
seed += 1;
|
|
60
|
+
return seed;
|
|
61
|
+
})
|
|
62
|
+
).resolves.toBe(2);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -21,6 +21,7 @@ import { createSyncularReact } from '../../index';
|
|
|
21
21
|
import {
|
|
22
22
|
createMockDb,
|
|
23
23
|
createMockHandlerRegistry,
|
|
24
|
+
createMockSync,
|
|
24
25
|
createMockTransport,
|
|
25
26
|
} from '../test-utils';
|
|
26
27
|
|
|
@@ -60,15 +61,15 @@ describe('useMutation', () => {
|
|
|
60
61
|
function createWrapper() {
|
|
61
62
|
const transport = createMockTransport();
|
|
62
63
|
const handlers = createMockHandlerRegistry<TestDb>();
|
|
64
|
+
const sync = createMockSync<TestDb>({ handlers });
|
|
63
65
|
|
|
64
66
|
const Wrapper = ({ children }: { children: ReactNode }) => (
|
|
65
67
|
<SyncProvider
|
|
66
68
|
db={db}
|
|
67
69
|
transport={transport}
|
|
68
|
-
|
|
69
|
-
actorId
|
|
70
|
+
sync={sync}
|
|
71
|
+
identity={{ actorId: 'test-actor' }}
|
|
70
72
|
clientId="test-client"
|
|
71
|
-
subscriptions={[]}
|
|
72
73
|
pollIntervalMs={999999}
|
|
73
74
|
autoStart={false}
|
|
74
75
|
>
|
|
@@ -14,6 +14,7 @@ import { createSyncularReact } from '../index';
|
|
|
14
14
|
import {
|
|
15
15
|
createMockDb,
|
|
16
16
|
createMockHandlerRegistry,
|
|
17
|
+
createMockSync,
|
|
17
18
|
createMockTransport,
|
|
18
19
|
} from './test-utils';
|
|
19
20
|
|
|
@@ -41,15 +42,15 @@ describe('React Hooks', () => {
|
|
|
41
42
|
function createWrapper(options?: { autoStart?: boolean }) {
|
|
42
43
|
const transport = createMockTransport();
|
|
43
44
|
const handlers = createMockHandlerRegistry();
|
|
45
|
+
const sync = createMockSync({ handlers });
|
|
44
46
|
|
|
45
47
|
const Wrapper = ({ children }: { children: ReactNode }) => (
|
|
46
48
|
<SyncProvider
|
|
47
49
|
db={db}
|
|
48
50
|
transport={transport}
|
|
49
|
-
|
|
50
|
-
actorId
|
|
51
|
+
sync={sync}
|
|
52
|
+
identity={{ actorId: 'test-actor' }}
|
|
51
53
|
clientId="test-client"
|
|
52
|
-
subscriptions={[]}
|
|
53
54
|
pollIntervalMs={999999} // Long poll interval to prevent continuous polling
|
|
54
55
|
autoStart={options?.autoStart ?? false} // Disable auto-start for tests
|
|
55
56
|
>
|
|
@@ -213,21 +214,21 @@ describe('React Hooks', () => {
|
|
|
213
214
|
it('supports watchTables invalidation on matching data:change scopes', async () => {
|
|
214
215
|
const transport = createMockTransport();
|
|
215
216
|
const handlers = createMockHandlerRegistry();
|
|
216
|
-
handlers.
|
|
217
|
+
handlers.push({
|
|
217
218
|
table: 'tasks',
|
|
218
219
|
applySnapshot: async () => {},
|
|
219
220
|
clearAll: async () => {},
|
|
220
221
|
applyChange: async () => {},
|
|
221
222
|
});
|
|
223
|
+
const sync = createMockSync({ handlers });
|
|
222
224
|
|
|
223
225
|
const Wrapper = ({ children }: { children: ReactNode }) => (
|
|
224
226
|
<SyncProvider
|
|
225
227
|
db={db}
|
|
226
228
|
transport={transport}
|
|
227
|
-
|
|
228
|
-
actorId
|
|
229
|
+
sync={sync}
|
|
230
|
+
identity={{ actorId: 'test-actor' }}
|
|
229
231
|
clientId="test-client"
|
|
230
|
-
subscriptions={[]}
|
|
231
232
|
pollIntervalMs={999999}
|
|
232
233
|
autoStart={false}
|
|
233
234
|
>
|
|
@@ -6,12 +6,13 @@
|
|
|
6
6
|
|
|
7
7
|
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
8
8
|
import {
|
|
9
|
-
|
|
9
|
+
type ClientHandlerCollection,
|
|
10
10
|
ensureClientSyncSchema,
|
|
11
11
|
type SyncClientDb,
|
|
12
12
|
SyncEngine,
|
|
13
13
|
} from '@syncular/client';
|
|
14
|
-
import {
|
|
14
|
+
import { createDatabase } from '@syncular/core';
|
|
15
|
+
import { createBunSqliteDialect } from '@syncular/dialect-bun-sqlite';
|
|
15
16
|
import { cleanup, render } from '@testing-library/react';
|
|
16
17
|
import type { Kysely } from 'kysely';
|
|
17
18
|
import { createElement } from 'react';
|
|
@@ -38,20 +39,23 @@ interface ClientDb extends SyncClientDb {
|
|
|
38
39
|
|
|
39
40
|
const { SyncProvider } = createSyncularReact<ClientDb>();
|
|
40
41
|
|
|
41
|
-
// Create
|
|
42
|
-
function
|
|
43
|
-
return
|
|
42
|
+
// Create mock handlers for tests
|
|
43
|
+
function createMockClientHandlers(): ClientHandlerCollection<ClientDb> {
|
|
44
|
+
return [];
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
describe('SyncProvider Reconfiguration', () => {
|
|
47
48
|
let server: TestServer;
|
|
48
49
|
let db: Kysely<ClientDb>;
|
|
49
|
-
let mockHandlers:
|
|
50
|
+
let mockHandlers: ClientHandlerCollection<ClientDb>;
|
|
50
51
|
|
|
51
52
|
beforeEach(async () => {
|
|
52
53
|
server = await createTestServer();
|
|
53
|
-
db =
|
|
54
|
-
|
|
54
|
+
db = createDatabase<ClientDb>({
|
|
55
|
+
dialect: createBunSqliteDialect({ path: ':memory:' }),
|
|
56
|
+
family: 'sqlite',
|
|
57
|
+
});
|
|
58
|
+
mockHandlers = createMockClientHandlers();
|
|
55
59
|
|
|
56
60
|
await ensureClientSyncSchema(db);
|
|
57
61
|
|
|
@@ -145,14 +149,16 @@ describe('SyncProvider Reconfiguration', () => {
|
|
|
145
149
|
const message =
|
|
146
150
|
`[SyncProvider] Critical props changed after mount: ${changedProps.join(', ')}. ` +
|
|
147
151
|
'This is not supported. Use a React key prop to force remount, e.g., ' +
|
|
148
|
-
|
|
152
|
+
"<SyncProvider key={identity.actorId + ':' + clientId} ...>";
|
|
149
153
|
|
|
150
154
|
expect(message).toContain(
|
|
151
155
|
'[SyncProvider] Critical props changed after mount'
|
|
152
156
|
);
|
|
153
157
|
expect(message).toContain('actorId');
|
|
154
158
|
expect(message).toContain('This is not supported');
|
|
155
|
-
expect(message).toContain(
|
|
159
|
+
expect(message).toContain(
|
|
160
|
+
"<SyncProvider key={identity.actorId + ':' + clientId} ...>"
|
|
161
|
+
);
|
|
156
162
|
});
|
|
157
163
|
|
|
158
164
|
it('engine config is immutable after creation', () => {
|
|
@@ -188,7 +194,7 @@ describe('SyncProvider Reconfiguration', () => {
|
|
|
188
194
|
|
|
189
195
|
describe('SyncProvider React render tests', () => {
|
|
190
196
|
let db: Kysely<ClientDb>;
|
|
191
|
-
let mockHandlers:
|
|
197
|
+
let mockHandlers: ClientHandlerCollection<ClientDb>;
|
|
192
198
|
const mockTransport = {
|
|
193
199
|
async sync() {
|
|
194
200
|
return { ok: true as const };
|
|
@@ -199,8 +205,11 @@ describe('SyncProvider React render tests', () => {
|
|
|
199
205
|
};
|
|
200
206
|
|
|
201
207
|
beforeEach(async () => {
|
|
202
|
-
db =
|
|
203
|
-
|
|
208
|
+
db = createDatabase<ClientDb>({
|
|
209
|
+
dialect: createBunSqliteDialect({ path: ':memory:' }),
|
|
210
|
+
family: 'sqlite',
|
|
211
|
+
});
|
|
212
|
+
mockHandlers = [];
|
|
204
213
|
await ensureClientSyncSchema(db);
|
|
205
214
|
});
|
|
206
215
|
|
|
@@ -211,14 +220,18 @@ describe('SyncProvider React render tests', () => {
|
|
|
211
220
|
|
|
212
221
|
it('warns when critical props change after mount', () => {
|
|
213
222
|
const child = createElement('div', null, 'Test Child');
|
|
223
|
+
const sync = {
|
|
224
|
+
handlers: mockHandlers,
|
|
225
|
+
subscriptions: () => [],
|
|
226
|
+
};
|
|
214
227
|
|
|
215
228
|
// Render with initial props
|
|
216
229
|
const { rerender } = render(
|
|
217
230
|
createElement(SyncProvider, {
|
|
218
231
|
db,
|
|
219
232
|
transport: mockTransport,
|
|
220
|
-
|
|
221
|
-
actorId: 'user-1',
|
|
233
|
+
sync,
|
|
234
|
+
identity: { actorId: 'user-1' },
|
|
222
235
|
clientId: 'client-1',
|
|
223
236
|
autoStart: false, // Disable auto-start for faster test
|
|
224
237
|
// biome-ignore lint/correctness/noChildrenProp: createElement requires children prop
|
|
@@ -232,8 +245,8 @@ describe('SyncProvider React render tests', () => {
|
|
|
232
245
|
createElement(SyncProvider, {
|
|
233
246
|
db,
|
|
234
247
|
transport: mockTransport,
|
|
235
|
-
|
|
236
|
-
actorId: 'user-2', // Changed!
|
|
248
|
+
sync,
|
|
249
|
+
identity: { actorId: 'user-2' }, // Changed!
|
|
237
250
|
clientId: 'client-1',
|
|
238
251
|
autoStart: false,
|
|
239
252
|
// biome-ignore lint/correctness/noChildrenProp: createElement requires children prop
|
|
@@ -245,12 +258,16 @@ describe('SyncProvider React render tests', () => {
|
|
|
245
258
|
|
|
246
259
|
it('does not throw when non-critical props change', () => {
|
|
247
260
|
const child = createElement('div', null, 'Test Child');
|
|
261
|
+
const sync = {
|
|
262
|
+
handlers: mockHandlers,
|
|
263
|
+
subscriptions: () => [],
|
|
264
|
+
};
|
|
248
265
|
const { rerender } = render(
|
|
249
266
|
createElement(SyncProvider, {
|
|
250
267
|
db,
|
|
251
268
|
transport: mockTransport,
|
|
252
|
-
|
|
253
|
-
actorId: 'user-1',
|
|
269
|
+
sync,
|
|
270
|
+
identity: { actorId: 'user-1' },
|
|
254
271
|
clientId: 'client-1',
|
|
255
272
|
autoStart: false,
|
|
256
273
|
pollIntervalMs: 1000,
|
|
@@ -265,8 +282,8 @@ describe('SyncProvider React render tests', () => {
|
|
|
265
282
|
createElement(SyncProvider, {
|
|
266
283
|
db,
|
|
267
284
|
transport: mockTransport,
|
|
268
|
-
|
|
269
|
-
actorId: 'user-1',
|
|
285
|
+
sync,
|
|
286
|
+
identity: { actorId: 'user-1' },
|
|
270
287
|
clientId: 'client-1',
|
|
271
288
|
autoStart: false,
|
|
272
289
|
pollIntervalMs: 5000, // Changed non-critical prop
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
8
8
|
import {
|
|
9
9
|
enqueueOutboxCommit,
|
|
10
|
+
getClientHandlerOrThrow,
|
|
10
11
|
getNextSendableOutboxCommit,
|
|
11
12
|
} from '@syncular/client';
|
|
12
13
|
|
|
@@ -109,11 +110,10 @@ describe('Push Flow', () => {
|
|
|
109
110
|
|
|
110
111
|
it('sync updates local row with server version after push', async () => {
|
|
111
112
|
// First, apply the mutation locally (this is what useMutation does)
|
|
112
|
-
const
|
|
113
|
-
const handler = handlers.get('tasks');
|
|
113
|
+
const handler = getClientHandlerOrThrow(client.handlers, 'tasks');
|
|
114
114
|
|
|
115
115
|
await client.db.transaction().execute(async (trx) => {
|
|
116
|
-
await handler
|
|
116
|
+
await handler.applyChange(
|
|
117
117
|
{ trx },
|
|
118
118
|
{
|
|
119
119
|
table: 'tasks',
|
|
@@ -3,13 +3,15 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import type {
|
|
6
|
+
ClientHandlerCollection,
|
|
7
|
+
ClientSyncConfig,
|
|
8
|
+
SyncClientDb,
|
|
6
9
|
SyncPullRequest,
|
|
7
10
|
SyncPullResponse,
|
|
8
11
|
SyncPushRequest,
|
|
9
12
|
SyncPushResponse,
|
|
10
13
|
SyncTransport,
|
|
11
14
|
} from '@syncular/client';
|
|
12
|
-
import { ClientTableRegistry, type SyncClientDb } from '@syncular/client';
|
|
13
15
|
import type { Kysely } from 'kysely';
|
|
14
16
|
|
|
15
17
|
/**
|
|
@@ -68,8 +70,25 @@ export function createMockTransport(
|
|
|
68
70
|
*/
|
|
69
71
|
export function createMockHandlerRegistry<
|
|
70
72
|
DB extends SyncClientDb = SyncClientDb,
|
|
71
|
-
>():
|
|
72
|
-
return
|
|
73
|
+
>(): ClientHandlerCollection<DB> {
|
|
74
|
+
return [];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function createMockSync<DB extends SyncClientDb = SyncClientDb>(args?: {
|
|
78
|
+
handlers?: ClientHandlerCollection<DB>;
|
|
79
|
+
subscriptions?: Array<{
|
|
80
|
+
id: string;
|
|
81
|
+
table: string;
|
|
82
|
+
scopes?: Record<string, unknown>;
|
|
83
|
+
}>;
|
|
84
|
+
}): ClientSyncConfig<DB, { actorId: string }> {
|
|
85
|
+
const handlers = args?.handlers ?? createMockHandlerRegistry<DB>();
|
|
86
|
+
const subscriptions = args?.subscriptions ?? [];
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
handlers,
|
|
90
|
+
subscriptions: () => subscriptions,
|
|
91
|
+
};
|
|
73
92
|
}
|
|
74
93
|
|
|
75
94
|
/**
|
|
@@ -79,14 +98,14 @@ export async function createMockDb<
|
|
|
79
98
|
DB extends SyncClientDb = SyncClientDb,
|
|
80
99
|
>(): Promise<Kysely<DB>> {
|
|
81
100
|
// Dynamic import to avoid bundling issues
|
|
82
|
-
const {
|
|
83
|
-
const {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
101
|
+
const { createDatabase } = await import('@syncular/core');
|
|
102
|
+
const { createBunSqliteDialect } = await import(
|
|
103
|
+
'@syncular/dialect-bun-sqlite'
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
const db = createDatabase<DB>({
|
|
107
|
+
dialect: createBunSqliteDialect({ path: ':memory:' }),
|
|
108
|
+
family: 'sqlite',
|
|
90
109
|
});
|
|
91
110
|
|
|
92
111
|
// Create sync tables
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { renderHook, waitFor } from '@testing-library/react';
|
|
3
|
+
import type { ReactNode } from 'react';
|
|
4
|
+
import { StrictMode } from 'react';
|
|
5
|
+
import {
|
|
6
|
+
clearCachedAsyncValues,
|
|
7
|
+
useCachedAsyncValue,
|
|
8
|
+
} from '../use-cached-async-value';
|
|
9
|
+
|
|
10
|
+
function strictModeWrapper(props: { children: ReactNode }) {
|
|
11
|
+
return <StrictMode>{props.children}</StrictMode>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('useCachedAsyncValue', () => {
|
|
15
|
+
it('deduplicates StrictMode initialization by key', async () => {
|
|
16
|
+
clearCachedAsyncValues();
|
|
17
|
+
let runs = 0;
|
|
18
|
+
|
|
19
|
+
const { result } = renderHook(
|
|
20
|
+
() =>
|
|
21
|
+
useCachedAsyncValue(
|
|
22
|
+
async () => {
|
|
23
|
+
runs += 1;
|
|
24
|
+
return 'ready';
|
|
25
|
+
},
|
|
26
|
+
{ key: 'strictmode-init' }
|
|
27
|
+
),
|
|
28
|
+
{ wrapper: strictModeWrapper }
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
await waitFor(() => {
|
|
32
|
+
expect(result.current[0]).toBe('ready');
|
|
33
|
+
});
|
|
34
|
+
expect(result.current[1]).toBeNull();
|
|
35
|
+
expect(runs).toBe(1);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('retries after a failure when dependencies trigger rerun', async () => {
|
|
39
|
+
clearCachedAsyncValues();
|
|
40
|
+
let runs = 0;
|
|
41
|
+
|
|
42
|
+
const { result, rerender } = renderHook(
|
|
43
|
+
({ attempt }: { attempt: number }) =>
|
|
44
|
+
useCachedAsyncValue(
|
|
45
|
+
async () => {
|
|
46
|
+
runs += 1;
|
|
47
|
+
if (attempt === 0) {
|
|
48
|
+
throw new Error('boom');
|
|
49
|
+
}
|
|
50
|
+
return 'recovered';
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
key: 'retryable-init',
|
|
54
|
+
deps: [attempt],
|
|
55
|
+
}
|
|
56
|
+
),
|
|
57
|
+
{
|
|
58
|
+
initialProps: { attempt: 0 },
|
|
59
|
+
}
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
await waitFor(() => {
|
|
63
|
+
expect(result.current[1]?.message).toBe('boom');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
rerender({ attempt: 1 });
|
|
67
|
+
|
|
68
|
+
await waitFor(() => {
|
|
69
|
+
expect(result.current[0]).toBe('recovered');
|
|
70
|
+
});
|
|
71
|
+
expect(result.current[1]).toBeNull();
|
|
72
|
+
expect(runs).toBe(2);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('reuses cached values across component remounts', async () => {
|
|
76
|
+
clearCachedAsyncValues();
|
|
77
|
+
let runs = 0;
|
|
78
|
+
|
|
79
|
+
const first = renderHook(() =>
|
|
80
|
+
useCachedAsyncValue(
|
|
81
|
+
async () => {
|
|
82
|
+
runs += 1;
|
|
83
|
+
return 7;
|
|
84
|
+
},
|
|
85
|
+
{ key: 'shared-init' }
|
|
86
|
+
)
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
await waitFor(() => {
|
|
90
|
+
expect(first.result.current[0]).toBe(7);
|
|
91
|
+
});
|
|
92
|
+
first.unmount();
|
|
93
|
+
|
|
94
|
+
const second = renderHook(() =>
|
|
95
|
+
useCachedAsyncValue(
|
|
96
|
+
async () => {
|
|
97
|
+
runs += 1;
|
|
98
|
+
return 9;
|
|
99
|
+
},
|
|
100
|
+
{ key: 'shared-init' }
|
|
101
|
+
)
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
await waitFor(() => {
|
|
105
|
+
expect(second.result.current[0]).toBe(7);
|
|
106
|
+
});
|
|
107
|
+
expect(second.result.current[1]).toBeNull();
|
|
108
|
+
expect(runs).toBe(1);
|
|
109
|
+
second.unmount();
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -11,6 +11,7 @@ import { createSyncularReact } from '../index';
|
|
|
11
11
|
import {
|
|
12
12
|
createMockDb,
|
|
13
13
|
createMockHandlerRegistry,
|
|
14
|
+
createMockSync,
|
|
14
15
|
createMockTransport,
|
|
15
16
|
} from './test-utils';
|
|
16
17
|
|
|
@@ -54,15 +55,15 @@ describe('useMutations', () => {
|
|
|
54
55
|
function createWrapper() {
|
|
55
56
|
const transport = createMockTransport();
|
|
56
57
|
const handlers = createMockHandlerRegistry<TestDb>();
|
|
58
|
+
const sync = createMockSync<TestDb>({ handlers });
|
|
57
59
|
|
|
58
60
|
const Wrapper = ({ children }: { children: ReactNode }) => (
|
|
59
61
|
<SyncProvider
|
|
60
62
|
db={db}
|
|
61
63
|
transport={transport}
|
|
62
|
-
|
|
63
|
-
actorId
|
|
64
|
+
sync={sync}
|
|
65
|
+
identity={{ actorId: 'test-actor' }}
|
|
64
66
|
clientId="test-client"
|
|
65
|
-
subscriptions={[]}
|
|
66
67
|
pollIntervalMs={999999}
|
|
67
68
|
autoStart={false}
|
|
68
69
|
>
|