@syncular/client-react 0.0.6-168 → 0.0.6-177
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-public.d.ts +6 -0
- package/dist/createSyncularReact-public.d.ts.map +1 -0
- package/dist/createSyncularReact-public.js +146 -0
- package/dist/createSyncularReact-public.js.map +1 -0
- package/dist/createSyncularReact.d.ts +14 -1
- package/dist/createSyncularReact.d.ts.map +1 -1
- package/dist/createSyncularReact.js +99 -3
- package/dist/createSyncularReact.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
- package/src/__tests__/hooks.test.tsx +148 -0
- package/src/__tests__/useSyncQuery.branching.test.tsx +155 -0
- package/src/__tests__/useSyncQuery.structural-sharing.test.tsx +134 -0
- package/src/createSyncularReact-public.tsx +220 -0
- package/src/createSyncularReact.tsx +147 -2
- package/src/index.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/client-react",
|
|
3
|
-
"version": "0.0.6-
|
|
3
|
+
"version": "0.0.6-177",
|
|
4
4
|
"description": "React hooks and bindings for the Syncular client",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Benjamin Kniffler",
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
"release": "bunx syncular-publish"
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
|
-
"@syncular/client": "0.0.6-
|
|
47
|
+
"@syncular/client": "0.0.6-177"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
50
|
"kysely": "^0.28.0",
|
|
@@ -53,9 +53,9 @@
|
|
|
53
53
|
"devDependencies": {
|
|
54
54
|
"@happy-dom/global-registrator": "^20.7.0",
|
|
55
55
|
"@syncular/config": "0.0.0",
|
|
56
|
-
"@syncular/core": "0.0.6-
|
|
57
|
-
"@syncular/dialect-bun-sqlite": "0.0.6-
|
|
58
|
-
"@syncular/testkit": "0.0.6-
|
|
56
|
+
"@syncular/core": "0.0.6-177",
|
|
57
|
+
"@syncular/dialect-bun-sqlite": "0.0.6-177",
|
|
58
|
+
"@syncular/testkit": "0.0.6-177",
|
|
59
59
|
"@testing-library/react": "^16.3.2",
|
|
60
60
|
"@types/react": "^19.2.14",
|
|
61
61
|
"happy-dom": "^20.7.0",
|
|
@@ -9,6 +9,7 @@ import { beforeEach, describe, expect, it } from 'bun:test';
|
|
|
9
9
|
import type { SyncClientDb } from '@syncular/client';
|
|
10
10
|
import { act, renderHook, waitFor } from '@testing-library/react';
|
|
11
11
|
import type { Kysely } from 'kysely';
|
|
12
|
+
import { sql } from 'kysely';
|
|
12
13
|
import type { ReactNode } from 'react';
|
|
13
14
|
import { createSyncularReact } from '../index';
|
|
14
15
|
import {
|
|
@@ -506,6 +507,153 @@ describe('React Hooks', () => {
|
|
|
506
507
|
}
|
|
507
508
|
);
|
|
508
509
|
});
|
|
510
|
+
|
|
511
|
+
it('tracks joined tables and refreshes when joined data changes', async () => {
|
|
512
|
+
await db.schema
|
|
513
|
+
.createTable('users')
|
|
514
|
+
.ifNotExists()
|
|
515
|
+
.addColumn('id', 'text', (col) => col.primaryKey())
|
|
516
|
+
.addColumn('name', 'text', (col) => col.notNull())
|
|
517
|
+
.execute();
|
|
518
|
+
|
|
519
|
+
await db.schema
|
|
520
|
+
.createTable('tasks')
|
|
521
|
+
.ifNotExists()
|
|
522
|
+
.addColumn('id', 'text', (col) => col.primaryKey())
|
|
523
|
+
.addColumn('user_id', 'text', (col) => col.notNull())
|
|
524
|
+
.addColumn('title', 'text', (col) => col.notNull())
|
|
525
|
+
.execute();
|
|
526
|
+
|
|
527
|
+
await sql`
|
|
528
|
+
insert into ${sql.table('users')} (${sql.ref('id')}, ${sql.ref('name')})
|
|
529
|
+
values (${sql.val('user-1')}, ${sql.val('Alice')})
|
|
530
|
+
`.execute(db);
|
|
531
|
+
|
|
532
|
+
await sql`
|
|
533
|
+
insert into ${sql.table('tasks')} (
|
|
534
|
+
${sql.ref('id')},
|
|
535
|
+
${sql.ref('user_id')},
|
|
536
|
+
${sql.ref('title')}
|
|
537
|
+
)
|
|
538
|
+
values (
|
|
539
|
+
${sql.val('task-1')},
|
|
540
|
+
${sql.val('user-1')},
|
|
541
|
+
${sql.val('First task')}
|
|
542
|
+
)
|
|
543
|
+
`.execute(db);
|
|
544
|
+
|
|
545
|
+
const { result } = renderHook(
|
|
546
|
+
() => {
|
|
547
|
+
const engine = useEngine();
|
|
548
|
+
const query = useSyncQuery(({ selectFrom }) =>
|
|
549
|
+
selectFrom('tasks')
|
|
550
|
+
.innerJoin('users', 'users.id', 'tasks.user_id')
|
|
551
|
+
.select([
|
|
552
|
+
'tasks.id as id',
|
|
553
|
+
'tasks.title as title',
|
|
554
|
+
'users.name as ownerName',
|
|
555
|
+
])
|
|
556
|
+
.orderBy('tasks.id', 'asc')
|
|
557
|
+
);
|
|
558
|
+
|
|
559
|
+
return { engine, query };
|
|
560
|
+
},
|
|
561
|
+
{ wrapper: createWrapper() }
|
|
562
|
+
);
|
|
563
|
+
|
|
564
|
+
await waitFor(() => {
|
|
565
|
+
expect(result.current.query.isLoading).toBe(false);
|
|
566
|
+
expect(result.current.query.data?.[0]).toMatchObject({
|
|
567
|
+
id: 'task-1',
|
|
568
|
+
ownerName: 'Alice',
|
|
569
|
+
});
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
await act(async () => {
|
|
573
|
+
await sql`
|
|
574
|
+
update ${sql.table('users')}
|
|
575
|
+
set ${sql.ref('name')} = ${sql.val('Bob')}
|
|
576
|
+
where ${sql.ref('id')} = ${sql.val('user-1')}
|
|
577
|
+
`.execute(db);
|
|
578
|
+
|
|
579
|
+
result.current.engine.recordLocalMutations([
|
|
580
|
+
{
|
|
581
|
+
table: 'users',
|
|
582
|
+
rowId: 'user-1',
|
|
583
|
+
op: 'upsert',
|
|
584
|
+
},
|
|
585
|
+
]);
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
await waitFor(() => {
|
|
589
|
+
expect(result.current.query.data?.[0]).toMatchObject({
|
|
590
|
+
id: 'task-1',
|
|
591
|
+
ownerName: 'Bob',
|
|
592
|
+
});
|
|
593
|
+
});
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
it('preserves unchanged row references across keyed array refreshes', async () => {
|
|
597
|
+
await db.schema
|
|
598
|
+
.createTable('tasks')
|
|
599
|
+
.ifNotExists()
|
|
600
|
+
.addColumn('id', 'text', (col) => col.primaryKey())
|
|
601
|
+
.addColumn('title', 'text', (col) => col.notNull())
|
|
602
|
+
.execute();
|
|
603
|
+
|
|
604
|
+
await sql`
|
|
605
|
+
insert into ${sql.table('tasks')} (${sql.ref('id')}, ${sql.ref('title')})
|
|
606
|
+
values
|
|
607
|
+
(${sql.val('task-1')}, ${sql.val('First task')}),
|
|
608
|
+
(${sql.val('task-2')}, ${sql.val('Second task')})
|
|
609
|
+
`.execute(db);
|
|
610
|
+
|
|
611
|
+
const { result } = renderHook(
|
|
612
|
+
() => {
|
|
613
|
+
const engine = useEngine();
|
|
614
|
+
const query = useSyncQuery(({ selectFrom }) =>
|
|
615
|
+
selectFrom('tasks').selectAll().orderBy('id', 'asc')
|
|
616
|
+
);
|
|
617
|
+
|
|
618
|
+
return { engine, query };
|
|
619
|
+
},
|
|
620
|
+
{ wrapper: createWrapper() }
|
|
621
|
+
);
|
|
622
|
+
|
|
623
|
+
await waitFor(() => {
|
|
624
|
+
expect(result.current.query.isLoading).toBe(false);
|
|
625
|
+
expect(result.current.query.data?.length).toBe(2);
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
const firstRow = result.current.query.data?.[0];
|
|
629
|
+
const secondRow = result.current.query.data?.[1];
|
|
630
|
+
|
|
631
|
+
await act(async () => {
|
|
632
|
+
await sql`
|
|
633
|
+
update ${sql.table('tasks')}
|
|
634
|
+
set ${sql.ref('title')} = ${sql.val('Second task updated')}
|
|
635
|
+
where ${sql.ref('id')} = ${sql.val('task-2')}
|
|
636
|
+
`.execute(db);
|
|
637
|
+
|
|
638
|
+
result.current.engine.recordLocalMutations([
|
|
639
|
+
{
|
|
640
|
+
table: 'tasks',
|
|
641
|
+
rowId: 'task-2',
|
|
642
|
+
op: 'upsert',
|
|
643
|
+
},
|
|
644
|
+
]);
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
await waitFor(() => {
|
|
648
|
+
expect(result.current.query.data?.[1]).toMatchObject({
|
|
649
|
+
id: 'task-2',
|
|
650
|
+
title: 'Second task updated',
|
|
651
|
+
});
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
expect(result.current.query.data?.[0]).toBe(firstRow);
|
|
655
|
+
expect(result.current.query.data?.[1]).not.toBe(secondRow);
|
|
656
|
+
});
|
|
509
657
|
});
|
|
510
658
|
|
|
511
659
|
describe('usePresenceWithJoin', () => {
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it } from 'bun:test';
|
|
2
|
+
import type { SyncClientDb } from '@syncular/client';
|
|
3
|
+
import { act, renderHook, waitFor } from '@testing-library/react';
|
|
4
|
+
import type { Kysely } from 'kysely';
|
|
5
|
+
import { sql } from 'kysely';
|
|
6
|
+
import type { ReactNode } from 'react';
|
|
7
|
+
import { createSyncularReact } from '../index';
|
|
8
|
+
import {
|
|
9
|
+
createMockDb,
|
|
10
|
+
createMockHandlerRegistry,
|
|
11
|
+
createMockSync,
|
|
12
|
+
createMockTransport,
|
|
13
|
+
} from './test-utils';
|
|
14
|
+
|
|
15
|
+
const { SyncProvider, useEngine, useSyncQuery } =
|
|
16
|
+
createSyncularReact<SyncClientDb>();
|
|
17
|
+
|
|
18
|
+
describe('useSyncQuery branching', () => {
|
|
19
|
+
let db: Kysely<SyncClientDb>;
|
|
20
|
+
|
|
21
|
+
beforeEach(async () => {
|
|
22
|
+
db = await createMockDb();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
function createWrapper() {
|
|
26
|
+
const transport = createMockTransport();
|
|
27
|
+
const handlers = createMockHandlerRegistry();
|
|
28
|
+
const sync = createMockSync({ handlers });
|
|
29
|
+
|
|
30
|
+
const Wrapper = ({ children }: { children: ReactNode }) => (
|
|
31
|
+
<SyncProvider
|
|
32
|
+
db={db}
|
|
33
|
+
transport={transport}
|
|
34
|
+
sync={sync}
|
|
35
|
+
identity={{ actorId: 'test-actor' }}
|
|
36
|
+
clientId="test-client"
|
|
37
|
+
pollIntervalMs={999999}
|
|
38
|
+
autoStart={false}
|
|
39
|
+
>
|
|
40
|
+
{children}
|
|
41
|
+
</SyncProvider>
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
return Wrapper;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
it('does not leak joined tables from an abandoned builder branch', async () => {
|
|
48
|
+
await db.schema
|
|
49
|
+
.createTable('users')
|
|
50
|
+
.ifNotExists()
|
|
51
|
+
.addColumn('id', 'text', (col) => col.primaryKey())
|
|
52
|
+
.addColumn('name', 'text', (col) => col.notNull())
|
|
53
|
+
.execute();
|
|
54
|
+
|
|
55
|
+
await db.schema
|
|
56
|
+
.createTable('tasks')
|
|
57
|
+
.ifNotExists()
|
|
58
|
+
.addColumn('id', 'text', (col) => col.primaryKey())
|
|
59
|
+
.addColumn('user_id', 'text', (col) => col.notNull())
|
|
60
|
+
.addColumn('title', 'text', (col) => col.notNull())
|
|
61
|
+
.execute();
|
|
62
|
+
|
|
63
|
+
await sql`
|
|
64
|
+
insert into ${sql.table('users')} (${sql.ref('id')}, ${sql.ref('name')})
|
|
65
|
+
values (${sql.val('user-1')}, ${sql.val('Alice')})
|
|
66
|
+
`.execute(db);
|
|
67
|
+
|
|
68
|
+
await sql`
|
|
69
|
+
insert into ${sql.table('tasks')} (
|
|
70
|
+
${sql.ref('id')},
|
|
71
|
+
${sql.ref('user_id')},
|
|
72
|
+
${sql.ref('title')}
|
|
73
|
+
)
|
|
74
|
+
values (
|
|
75
|
+
${sql.val('task-1')},
|
|
76
|
+
${sql.val('user-1')},
|
|
77
|
+
${sql.val('First task')}
|
|
78
|
+
)
|
|
79
|
+
`.execute(db);
|
|
80
|
+
|
|
81
|
+
let executions = 0;
|
|
82
|
+
|
|
83
|
+
const { result } = renderHook(
|
|
84
|
+
() => {
|
|
85
|
+
const engine = useEngine();
|
|
86
|
+
const query = useSyncQuery(({ selectFrom }) => {
|
|
87
|
+
executions += 1;
|
|
88
|
+
|
|
89
|
+
const base = selectFrom('tasks');
|
|
90
|
+
void base.innerJoin('users', 'users.id', 'tasks.user_id');
|
|
91
|
+
|
|
92
|
+
return base
|
|
93
|
+
.select(['tasks.id as id', 'tasks.title as title'])
|
|
94
|
+
.orderBy('tasks.id', 'asc');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
return { engine, query };
|
|
98
|
+
},
|
|
99
|
+
{ wrapper: createWrapper() }
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
await waitFor(() => {
|
|
103
|
+
expect(result.current.query.isLoading).toBe(false);
|
|
104
|
+
expect(result.current.query.data?.[0]).toMatchObject({
|
|
105
|
+
id: 'task-1',
|
|
106
|
+
title: 'First task',
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const initialExecutions = executions;
|
|
111
|
+
|
|
112
|
+
await act(async () => {
|
|
113
|
+
await sql`
|
|
114
|
+
update ${sql.table('users')}
|
|
115
|
+
set ${sql.ref('name')} = ${sql.val('Bob')}
|
|
116
|
+
where ${sql.ref('id')} = ${sql.val('user-1')}
|
|
117
|
+
`.execute(db);
|
|
118
|
+
|
|
119
|
+
result.current.engine.recordLocalMutations([
|
|
120
|
+
{
|
|
121
|
+
table: 'users',
|
|
122
|
+
rowId: 'user-1',
|
|
123
|
+
op: 'upsert',
|
|
124
|
+
},
|
|
125
|
+
]);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
129
|
+
expect(executions).toBe(initialExecutions);
|
|
130
|
+
|
|
131
|
+
await act(async () => {
|
|
132
|
+
await sql`
|
|
133
|
+
update ${sql.table('tasks')}
|
|
134
|
+
set ${sql.ref('title')} = ${sql.val('First task updated')}
|
|
135
|
+
where ${sql.ref('id')} = ${sql.val('task-1')}
|
|
136
|
+
`.execute(db);
|
|
137
|
+
|
|
138
|
+
result.current.engine.recordLocalMutations([
|
|
139
|
+
{
|
|
140
|
+
table: 'tasks',
|
|
141
|
+
rowId: 'task-1',
|
|
142
|
+
op: 'upsert',
|
|
143
|
+
},
|
|
144
|
+
]);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
await waitFor(() => {
|
|
148
|
+
expect(executions).toBeGreaterThan(initialExecutions);
|
|
149
|
+
expect(result.current.query.data?.[0]).toMatchObject({
|
|
150
|
+
id: 'task-1',
|
|
151
|
+
title: 'First task updated',
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it } from 'bun:test';
|
|
2
|
+
import type { SyncClientDb } from '@syncular/client';
|
|
3
|
+
import { act, renderHook, waitFor } from '@testing-library/react';
|
|
4
|
+
import type { Kysely } from 'kysely';
|
|
5
|
+
import { sql } from 'kysely';
|
|
6
|
+
import type { ReactNode } from 'react';
|
|
7
|
+
import { createSyncularReact } from '../index';
|
|
8
|
+
import {
|
|
9
|
+
createMockDb,
|
|
10
|
+
createMockHandlerRegistry,
|
|
11
|
+
createMockSync,
|
|
12
|
+
createMockTransport,
|
|
13
|
+
} from './test-utils';
|
|
14
|
+
|
|
15
|
+
const { SyncProvider, useEngine, useSyncQuery } =
|
|
16
|
+
createSyncularReact<SyncClientDb>();
|
|
17
|
+
|
|
18
|
+
describe('useSyncQuery structural sharing', () => {
|
|
19
|
+
let db: Kysely<SyncClientDb>;
|
|
20
|
+
|
|
21
|
+
beforeEach(async () => {
|
|
22
|
+
db = await createMockDb();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
function createWrapper() {
|
|
26
|
+
const transport = createMockTransport();
|
|
27
|
+
const handlers = createMockHandlerRegistry();
|
|
28
|
+
const sync = createMockSync({ handlers });
|
|
29
|
+
|
|
30
|
+
const Wrapper = ({ children }: { children: ReactNode }) => (
|
|
31
|
+
<SyncProvider
|
|
32
|
+
db={db}
|
|
33
|
+
transport={transport}
|
|
34
|
+
sync={sync}
|
|
35
|
+
identity={{ actorId: 'test-actor' }}
|
|
36
|
+
clientId="test-client"
|
|
37
|
+
pollIntervalMs={999999}
|
|
38
|
+
autoStart={false}
|
|
39
|
+
>
|
|
40
|
+
{children}
|
|
41
|
+
</SyncProvider>
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
return Wrapper;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
it('preserves positional identity when joined rows share the same keyField', async () => {
|
|
48
|
+
await db.schema
|
|
49
|
+
.createTable('tasks')
|
|
50
|
+
.ifNotExists()
|
|
51
|
+
.addColumn('id', 'text', (col) => col.primaryKey())
|
|
52
|
+
.addColumn('title', 'text', (col) => col.notNull())
|
|
53
|
+
.execute();
|
|
54
|
+
|
|
55
|
+
await db.schema
|
|
56
|
+
.createTable('task_notes')
|
|
57
|
+
.ifNotExists()
|
|
58
|
+
.addColumn('id', 'text', (col) => col.primaryKey())
|
|
59
|
+
.addColumn('task_id', 'text', (col) => col.notNull())
|
|
60
|
+
.addColumn('note', 'text', (col) => col.notNull())
|
|
61
|
+
.execute();
|
|
62
|
+
|
|
63
|
+
await sql`
|
|
64
|
+
insert into ${sql.table('tasks')} (${sql.ref('id')}, ${sql.ref('title')})
|
|
65
|
+
values (${sql.val('task-1')}, ${sql.val('Task')})
|
|
66
|
+
`.execute(db);
|
|
67
|
+
|
|
68
|
+
await sql`
|
|
69
|
+
insert into ${sql.table('task_notes')} (
|
|
70
|
+
${sql.ref('id')},
|
|
71
|
+
${sql.ref('task_id')},
|
|
72
|
+
${sql.ref('note')}
|
|
73
|
+
)
|
|
74
|
+
values
|
|
75
|
+
(${sql.val('note-1')}, ${sql.val('task-1')}, ${sql.val('First note')}),
|
|
76
|
+
(${sql.val('note-2')}, ${sql.val('task-1')}, ${sql.val('Second note')})
|
|
77
|
+
`.execute(db);
|
|
78
|
+
|
|
79
|
+
const { result } = renderHook(
|
|
80
|
+
() => {
|
|
81
|
+
const engine = useEngine();
|
|
82
|
+
const query = useSyncQuery(({ selectFrom }) =>
|
|
83
|
+
selectFrom('tasks')
|
|
84
|
+
.innerJoin('task_notes', 'task_notes.task_id', 'tasks.id')
|
|
85
|
+
.select([
|
|
86
|
+
'tasks.id as id',
|
|
87
|
+
'tasks.title as title',
|
|
88
|
+
'task_notes.id as noteId',
|
|
89
|
+
'task_notes.note as note',
|
|
90
|
+
])
|
|
91
|
+
.orderBy('task_notes.id', 'asc')
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
return { engine, query };
|
|
95
|
+
},
|
|
96
|
+
{ wrapper: createWrapper() }
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
await waitFor(() => {
|
|
100
|
+
expect(result.current.query.isLoading).toBe(false);
|
|
101
|
+
expect(result.current.query.data?.length).toBe(2);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const firstRow = result.current.query.data?.[0];
|
|
105
|
+
const secondRow = result.current.query.data?.[1];
|
|
106
|
+
|
|
107
|
+
await act(async () => {
|
|
108
|
+
await sql`
|
|
109
|
+
update ${sql.table('task_notes')}
|
|
110
|
+
set ${sql.ref('note')} = ${sql.val('Second note updated')}
|
|
111
|
+
where ${sql.ref('id')} = ${sql.val('note-2')}
|
|
112
|
+
`.execute(db);
|
|
113
|
+
|
|
114
|
+
result.current.engine.recordLocalMutations([
|
|
115
|
+
{
|
|
116
|
+
table: 'task_notes',
|
|
117
|
+
rowId: 'note-2',
|
|
118
|
+
op: 'upsert',
|
|
119
|
+
},
|
|
120
|
+
]);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
await waitFor(() => {
|
|
124
|
+
expect(result.current.query.data?.[1]).toMatchObject({
|
|
125
|
+
id: 'task-1',
|
|
126
|
+
noteId: 'note-2',
|
|
127
|
+
note: 'Second note updated',
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
expect(result.current.query.data?.[0]).toBe(firstRow);
|
|
132
|
+
expect(result.current.query.data?.[1]).not.toBe(secondRow);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import type { QueryContext, SyncClientDb } from '@syncular/client';
|
|
2
|
+
import { useMemo, useRef } from 'react';
|
|
3
|
+
import {
|
|
4
|
+
createSyncularReact as createSyncularReactBase,
|
|
5
|
+
type UseQueryOptions,
|
|
6
|
+
type UseQueryResult,
|
|
7
|
+
type UseSyncQueryOptions,
|
|
8
|
+
type UseSyncQueryResult,
|
|
9
|
+
} from './createSyncularReact';
|
|
10
|
+
|
|
11
|
+
type ExecutableQuery<TResult> = {
|
|
12
|
+
execute: () => Promise<TResult>;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
type QueryFn<DB extends SyncClientDb, TResult> = (
|
|
16
|
+
ctx: QueryContext<DB>
|
|
17
|
+
) => ExecutableQuery<TResult> | Promise<TResult>;
|
|
18
|
+
|
|
19
|
+
type SyncularReactBindings<DB extends SyncClientDb> = ReturnType<
|
|
20
|
+
typeof createSyncularReactBase<DB>
|
|
21
|
+
>;
|
|
22
|
+
|
|
23
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
24
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function shallowEqualRecords(
|
|
28
|
+
left: Record<string, unknown>,
|
|
29
|
+
right: Record<string, unknown>
|
|
30
|
+
): boolean {
|
|
31
|
+
if (left === right) return true;
|
|
32
|
+
|
|
33
|
+
const leftKeys = Object.keys(left);
|
|
34
|
+
const rightKeys = Object.keys(right);
|
|
35
|
+
if (leftKeys.length !== rightKeys.length) return false;
|
|
36
|
+
|
|
37
|
+
for (const key of leftKeys) {
|
|
38
|
+
if (!(key in right)) return false;
|
|
39
|
+
if (!Object.is(left[key], right[key])) return false;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function shallowEqualQueryValues(left: unknown, right: unknown): boolean {
|
|
46
|
+
if (Object.is(left, right)) return true;
|
|
47
|
+
if (!isRecord(left) || !isRecord(right)) return false;
|
|
48
|
+
return shallowEqualRecords(left, right);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function getKeyedQueryValueKey<T>(value: T, keyField: string): string | null {
|
|
52
|
+
if (!isRecord(value) || !(keyField in value)) return null;
|
|
53
|
+
|
|
54
|
+
const key = value[keyField];
|
|
55
|
+
if (key === null || key === undefined) return null;
|
|
56
|
+
return String(key);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function buildUniqueKeyMap<T>(
|
|
60
|
+
items: T[],
|
|
61
|
+
keyField: string
|
|
62
|
+
): Map<string, T> | null {
|
|
63
|
+
const itemsByKey = new Map<string, T>();
|
|
64
|
+
|
|
65
|
+
for (const item of items) {
|
|
66
|
+
const key = getKeyedQueryValueKey(item, keyField);
|
|
67
|
+
if (key === null || itemsByKey.has(key)) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
itemsByKey.set(key, item);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return itemsByKey;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function shareArrayResult<T>(previous: T[], next: T[], keyField: string): T[] {
|
|
77
|
+
if (previous.length === 0 || next.length === 0) {
|
|
78
|
+
return next;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const previousByKey = buildUniqueKeyMap(previous, keyField);
|
|
82
|
+
const nextByKey = buildUniqueKeyMap(next, keyField);
|
|
83
|
+
const shared = next.slice();
|
|
84
|
+
|
|
85
|
+
if (previousByKey && nextByKey) {
|
|
86
|
+
for (const [index, item] of next.entries()) {
|
|
87
|
+
const key = getKeyedQueryValueKey(item, keyField);
|
|
88
|
+
if (key === null) {
|
|
89
|
+
return next;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const previousItem = previousByKey.get(key);
|
|
93
|
+
if (
|
|
94
|
+
previousItem !== undefined &&
|
|
95
|
+
shallowEqualQueryValues(previousItem, item)
|
|
96
|
+
) {
|
|
97
|
+
shared[index] = previousItem;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
} else {
|
|
101
|
+
const limit = Math.min(previous.length, next.length);
|
|
102
|
+
for (let index = 0; index < limit; index += 1) {
|
|
103
|
+
const previousItem = previous[index];
|
|
104
|
+
const nextItem = next[index];
|
|
105
|
+
if (
|
|
106
|
+
previousItem !== undefined &&
|
|
107
|
+
nextItem !== undefined &&
|
|
108
|
+
shallowEqualQueryValues(previousItem, nextItem)
|
|
109
|
+
) {
|
|
110
|
+
shared[index] = previousItem;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (shared.length !== previous.length) {
|
|
116
|
+
return shared;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
for (let index = 0; index < shared.length; index += 1) {
|
|
120
|
+
if (!Object.is(shared[index], previous[index])) {
|
|
121
|
+
return shared;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return previous;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function shareQueryResult<TResult>(
|
|
129
|
+
previous: TResult | undefined,
|
|
130
|
+
next: TResult | undefined,
|
|
131
|
+
keyField: string,
|
|
132
|
+
enabled: boolean
|
|
133
|
+
): TResult | undefined {
|
|
134
|
+
if (!enabled || previous === undefined || next === undefined) {
|
|
135
|
+
return next;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (Array.isArray(previous) && Array.isArray(next)) {
|
|
139
|
+
return shareArrayResult(previous, next, keyField) as TResult;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (
|
|
143
|
+
isRecord(previous) &&
|
|
144
|
+
isRecord(next) &&
|
|
145
|
+
shallowEqualRecords(previous, next)
|
|
146
|
+
) {
|
|
147
|
+
return previous;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return next;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function createSyncularReact<
|
|
154
|
+
DB extends SyncClientDb,
|
|
155
|
+
>(): SyncularReactBindings<DB> {
|
|
156
|
+
const base = createSyncularReactBase<DB>();
|
|
157
|
+
|
|
158
|
+
function useSyncQuery<TResult>(
|
|
159
|
+
queryFn: QueryFn<DB, TResult>,
|
|
160
|
+
options: UseSyncQueryOptions = {}
|
|
161
|
+
): UseSyncQueryResult<TResult> {
|
|
162
|
+
const keyField = options.keyField ?? 'id';
|
|
163
|
+
const structuralSharing = options.structuralSharing !== false;
|
|
164
|
+
const sharedDataRef = useRef<TResult | undefined>(undefined);
|
|
165
|
+
const query = base.useSyncQuery(queryFn, {
|
|
166
|
+
...options,
|
|
167
|
+
structuralSharing: false,
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
const data = useMemo(() => {
|
|
171
|
+
const shared = shareQueryResult(
|
|
172
|
+
sharedDataRef.current,
|
|
173
|
+
query.data,
|
|
174
|
+
keyField,
|
|
175
|
+
structuralSharing
|
|
176
|
+
);
|
|
177
|
+
sharedDataRef.current = shared;
|
|
178
|
+
return shared;
|
|
179
|
+
}, [query.data, keyField, structuralSharing]);
|
|
180
|
+
|
|
181
|
+
return useMemo(
|
|
182
|
+
() => ({
|
|
183
|
+
...query,
|
|
184
|
+
data,
|
|
185
|
+
}),
|
|
186
|
+
[query, data]
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function useQuery<TResult>(
|
|
191
|
+
queryFn: QueryFn<DB, TResult>,
|
|
192
|
+
options: UseQueryOptions = {}
|
|
193
|
+
): UseQueryResult<TResult> {
|
|
194
|
+
const { enabled = true, deps = [], keyField = 'id' } = options;
|
|
195
|
+
const query = useSyncQuery(queryFn, {
|
|
196
|
+
enabled,
|
|
197
|
+
deps,
|
|
198
|
+
keyField,
|
|
199
|
+
refreshOnDataChange: false,
|
|
200
|
+
loadingOnRefresh: true,
|
|
201
|
+
transitionUpdates: false,
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
return useMemo(
|
|
205
|
+
() => ({
|
|
206
|
+
data: query.data,
|
|
207
|
+
isLoading: query.isLoading,
|
|
208
|
+
error: query.error,
|
|
209
|
+
refetch: query.refetch,
|
|
210
|
+
}),
|
|
211
|
+
[query.data, query.isLoading, query.error, query.refetch]
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
...base,
|
|
217
|
+
useQuery,
|
|
218
|
+
useSyncQuery,
|
|
219
|
+
} as SyncularReactBindings<DB>;
|
|
220
|
+
}
|