@syncular/client-react 0.0.1-60

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.
@@ -0,0 +1,117 @@
1
+ /**
2
+ * SyncProvider StrictMode regression tests
3
+ *
4
+ * React StrictMode (dev) mounts + unmounts + re-mounts components to surface
5
+ * unsafe side effects. These tests ensure our SyncProvider lifecycle remains
6
+ * correct under that behavior.
7
+ */
8
+
9
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
10
+ import type { SyncClientDb } from '@syncular/client';
11
+ import {
12
+ act,
13
+ cleanup,
14
+ fireEvent,
15
+ render,
16
+ screen,
17
+ waitFor,
18
+ } from '@testing-library/react';
19
+ import type { Kysely } from 'kysely';
20
+ import React from 'react';
21
+ import { createSyncularReact } from '../index';
22
+ import {
23
+ createMockDb,
24
+ createMockShapeRegistry,
25
+ createMockTransport,
26
+ } from './test-utils';
27
+
28
+ const { SyncProvider, useSyncConnection, useSyncStatus } =
29
+ createSyncularReact<SyncClientDb>();
30
+
31
+ function StatusText() {
32
+ const { enabled, isOnline } = useSyncStatus();
33
+ const text = !enabled ? 'disabled' : isOnline ? 'online' : 'offline';
34
+ return <div data-testid="status">{text}</div>;
35
+ }
36
+
37
+ function ConnectionControls() {
38
+ const { isConnected, disconnect, reconnect } = useSyncConnection();
39
+ const { isOnline } = useSyncStatus();
40
+
41
+ return (
42
+ <div>
43
+ <div data-testid="connected">{String(isConnected)}</div>
44
+ <div data-testid="online">{String(isOnline)}</div>
45
+ <button type="button" onClick={disconnect}>
46
+ disconnect
47
+ </button>
48
+ <button type="button" onClick={reconnect}>
49
+ reconnect
50
+ </button>
51
+ </div>
52
+ );
53
+ }
54
+
55
+ describe('SyncProvider (StrictMode)', () => {
56
+ let db: Kysely<SyncClientDb>;
57
+
58
+ beforeEach(async () => {
59
+ db = await createMockDb();
60
+ });
61
+
62
+ afterEach(async () => {
63
+ cleanup();
64
+ await db.destroy();
65
+ });
66
+
67
+ function renderWithProvider(node: React.ReactNode) {
68
+ const transport = createMockTransport();
69
+ const shapes = createMockShapeRegistry();
70
+
71
+ return render(
72
+ <React.StrictMode>
73
+ <SyncProvider
74
+ db={db}
75
+ transport={transport}
76
+ shapes={shapes}
77
+ actorId="test-actor"
78
+ clientId="test-client"
79
+ subscriptions={[]}
80
+ pollIntervalMs={999999}
81
+ >
82
+ {node}
83
+ </SyncProvider>
84
+ </React.StrictMode>
85
+ );
86
+ }
87
+
88
+ it('autoStart brings provider online under StrictMode', async () => {
89
+ renderWithProvider(<StatusText />);
90
+
91
+ await waitFor(() => {
92
+ expect(screen.getByTestId('status').textContent).toBe('online');
93
+ });
94
+ });
95
+
96
+ it('disconnect + reconnect works under StrictMode (polling mode)', async () => {
97
+ renderWithProvider(<ConnectionControls />);
98
+
99
+ await waitFor(() => {
100
+ expect(screen.getByTestId('online').textContent).toBe('true');
101
+ });
102
+
103
+ await act(async () => {
104
+ fireEvent.click(screen.getByRole('button', { name: 'disconnect' }));
105
+ });
106
+
107
+ expect(screen.getByTestId('online').textContent).toBe('false');
108
+
109
+ await act(async () => {
110
+ fireEvent.click(screen.getByRole('button', { name: 'reconnect' }));
111
+ });
112
+
113
+ await waitFor(() => {
114
+ expect(screen.getByTestId('online').textContent).toBe('true');
115
+ });
116
+ });
117
+ });
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Tests for fingerprint utility functions
3
+ */
4
+
5
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
6
+ import type { SyncClientDb, SyncEngineConfig } from '@syncular/client';
7
+ import {
8
+ canFingerprint,
9
+ computeFingerprint,
10
+ SyncEngine,
11
+ } from '@syncular/client';
12
+ import type { Kysely } from 'kysely';
13
+ import {
14
+ createMockDb,
15
+ createMockShapeRegistry,
16
+ createMockTransport,
17
+ } from './test-utils';
18
+
19
+ describe('fingerprint utilities', () => {
20
+ let db: Kysely<SyncClientDb>;
21
+ let engine: SyncEngine;
22
+
23
+ beforeEach(async () => {
24
+ db = await createMockDb();
25
+ });
26
+
27
+ afterEach(() => {
28
+ engine?.destroy();
29
+ });
30
+
31
+ function createEngine(overrides: Partial<SyncEngineConfig> = {}): SyncEngine {
32
+ const config: SyncEngineConfig = {
33
+ db,
34
+ transport: createMockTransport(),
35
+ shapes: createMockShapeRegistry(),
36
+ actorId: 'test-actor',
37
+ clientId: 'test-client',
38
+ subscriptions: [],
39
+ ...overrides,
40
+ };
41
+ engine = new SyncEngine(config);
42
+ return engine;
43
+ }
44
+
45
+ describe('canFingerprint', () => {
46
+ it('should return true for empty array', () => {
47
+ expect(canFingerprint([])).toBe(true);
48
+ });
49
+
50
+ it('should return true when rows have the default id field', () => {
51
+ const rows = [
52
+ { id: '1', name: 'foo' },
53
+ { id: '2', name: 'bar' },
54
+ ];
55
+ expect(canFingerprint(rows)).toBe(true);
56
+ });
57
+
58
+ it('should return true when rows have a custom key field', () => {
59
+ const rows = [
60
+ { task_id: '1', name: 'foo' },
61
+ { task_id: '2', name: 'bar' },
62
+ ];
63
+ expect(canFingerprint(rows, 'task_id')).toBe(true);
64
+ });
65
+
66
+ it('should return false when rows lack the key field', () => {
67
+ const rows = [{ count: 42 }, { count: 100 }];
68
+ expect(canFingerprint(rows)).toBe(false);
69
+ });
70
+
71
+ it('should return false when rows lack a custom key field', () => {
72
+ const rows = [{ id: '1', name: 'foo' }];
73
+ expect(canFingerprint(rows, 'custom_key')).toBe(false);
74
+ });
75
+ });
76
+
77
+ describe('computeFingerprint', () => {
78
+ it('should return "0:" for empty array', () => {
79
+ const engine = createEngine();
80
+ expect(computeFingerprint([], engine, 'tasks')).toBe('0:');
81
+ });
82
+
83
+ it('should compute fingerprint with row count and ids', () => {
84
+ const engine = createEngine();
85
+ const rows = [
86
+ { id: 'abc', name: 'foo' },
87
+ { id: 'def', name: 'bar' },
88
+ ];
89
+
90
+ const fingerprint = computeFingerprint(rows, engine, 'tasks');
91
+
92
+ // Format: "length:id1@ts1,id2@ts2"
93
+ // With no mutations, timestamps are 0
94
+ expect(fingerprint).toBe('2:abc@0,def@0');
95
+ });
96
+
97
+ it('should use custom key field', () => {
98
+ const engine = createEngine();
99
+ const rows = [{ task_id: 'xyz', name: 'foo' }];
100
+
101
+ const fingerprint = computeFingerprint(rows, engine, 'tasks', 'task_id');
102
+
103
+ expect(fingerprint).toBe('1:xyz@0');
104
+ });
105
+
106
+ it('should include mutation timestamps from engine', async () => {
107
+ const engine = createEngine();
108
+ await engine.start();
109
+
110
+ // Simulate a mutation by calling applyLocalMutation
111
+ // This requires the shape handler to exist, so we'll test getMutationTimestamp directly
112
+ const beforeMutation = engine.getMutationTimestamp('tasks', 'abc');
113
+ expect(beforeMutation).toBe(0);
114
+
115
+ // We can't easily test the full mutation flow without proper shape setup,
116
+ // but we can verify the fingerprint changes when timestamps change
117
+ });
118
+
119
+ it('should handle rows with missing key values gracefully', () => {
120
+ const engine = createEngine();
121
+ const rows = [
122
+ { id: undefined, name: 'foo' },
123
+ { id: null, name: 'bar' },
124
+ { id: '', name: 'baz' },
125
+ ];
126
+
127
+ const fingerprint = computeFingerprint(
128
+ rows as Record<string, unknown>[],
129
+ engine,
130
+ 'tasks'
131
+ );
132
+
133
+ // undefined/null are converted to empty string via nullish coalescing (??)
134
+ expect(fingerprint).toBe('3:@0,@0,@0');
135
+ });
136
+
137
+ it('should produce different fingerprints for different row orders', () => {
138
+ const engine = createEngine();
139
+ const rows1 = [
140
+ { id: 'a', name: 'foo' },
141
+ { id: 'b', name: 'bar' },
142
+ ];
143
+ const rows2 = [
144
+ { id: 'b', name: 'bar' },
145
+ { id: 'a', name: 'foo' },
146
+ ];
147
+
148
+ const fp1 = computeFingerprint(rows1, engine, 'tasks');
149
+ const fp2 = computeFingerprint(rows2, engine, 'tasks');
150
+
151
+ expect(fp1).not.toBe(fp2);
152
+ });
153
+
154
+ it('should produce different fingerprints for different row counts', () => {
155
+ const engine = createEngine();
156
+ const rows1 = [{ id: 'a' }];
157
+ const rows2 = [{ id: 'a' }, { id: 'b' }];
158
+
159
+ const fp1 = computeFingerprint(rows1, engine, 'tasks');
160
+ const fp2 = computeFingerprint(rows2, engine, 'tasks');
161
+
162
+ expect(fp1).toBe('1:a@0');
163
+ expect(fp2).toBe('2:a@0,b@0');
164
+ expect(fp1).not.toBe(fp2);
165
+ });
166
+ });
167
+
168
+ describe('SyncEngine.getMutationTimestamp', () => {
169
+ it('should return 0 for unknown rows', () => {
170
+ const engine = createEngine();
171
+
172
+ expect(engine.getMutationTimestamp('tasks', 'unknown-id')).toBe(0);
173
+ });
174
+
175
+ it('should return 0 for different tables', () => {
176
+ const engine = createEngine();
177
+
178
+ expect(engine.getMutationTimestamp('other_table', 'some-id')).toBe(0);
179
+ });
180
+ });
181
+ });