@signalium/query 0.0.0
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/ENTITY_STORE_DESIGN.md +386 -0
- package/package.json +71 -0
- package/src/EntityMap.ts +63 -0
- package/src/QueryClient.ts +266 -0
- package/src/QueryStore.ts +314 -0
- package/src/__tests__/caching-persistence.test.ts +954 -0
- package/src/__tests__/entity-system.test.ts +552 -0
- package/src/__tests__/mock-fetch.test.ts +182 -0
- package/src/__tests__/parse-entities.test.ts +421 -0
- package/src/__tests__/path-interpolation.test.ts +225 -0
- package/src/__tests__/reactivity.test.ts +420 -0
- package/src/__tests__/rest-query-api.test.ts +564 -0
- package/src/__tests__/type-to-string.test.ts +129 -0
- package/src/__tests__/utils.ts +242 -0
- package/src/__tests__/validation-edge-cases.test.ts +820 -0
- package/src/errors.ts +124 -0
- package/src/index.ts +7 -0
- package/src/parseEntities.ts +213 -0
- package/src/pathInterpolator.ts +74 -0
- package/src/proxy.ts +257 -0
- package/src/query.ts +163 -0
- package/src/react/__tests__/basic.test.tsx +921 -0
- package/src/react/__tests__/component.test.tsx +977 -0
- package/src/react/__tests__/utils.tsx +71 -0
- package/src/typeDefs.ts +351 -0
- package/src/types.ts +121 -0
- package/src/utils.ts +66 -0
- package/tsconfig.cjs.json +14 -0
- package/tsconfig.esm.json +13 -0
- package/tsconfig.json +20 -0
- package/vitest.config.ts +71 -0
|
@@ -0,0 +1,921 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { render } from 'vitest-browser-react';
|
|
3
|
+
import { ContextProvider, useReactive } from 'signalium/react';
|
|
4
|
+
import React, { memo } from 'react';
|
|
5
|
+
import { SyncQueryStore, MemoryPersistentStore } from '../../QueryStore.js';
|
|
6
|
+
import { QueryClient, QueryClientContext } from '../../QueryClient.js';
|
|
7
|
+
import { entity, t } from '../../typeDefs.js';
|
|
8
|
+
import { query } from '../../query.js';
|
|
9
|
+
import { createMockFetch, sleep } from '../../__tests__/utils.js';
|
|
10
|
+
import { createRenderCounter } from './utils.js';
|
|
11
|
+
import { DiscriminatedQueryResult } from '../../types.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* React Tests for Query Package
|
|
15
|
+
*
|
|
16
|
+
* These tests focus on end-to-end user-facing behavior in React components.
|
|
17
|
+
* They verify that query results properly trigger component re-renders,
|
|
18
|
+
* handle loading/error states, and work correctly with React patterns.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
describe('React Query Integration', () => {
|
|
22
|
+
let client: QueryClient;
|
|
23
|
+
let mockFetch: ReturnType<typeof createMockFetch>;
|
|
24
|
+
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
const store = new SyncQueryStore(new MemoryPersistentStore());
|
|
27
|
+
mockFetch = createMockFetch();
|
|
28
|
+
client = new QueryClient(store, { fetch: mockFetch as any });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe('Basic Query Usage', () => {
|
|
32
|
+
it('should show loading state then data in component', async () => {
|
|
33
|
+
mockFetch.get('/item', { id: 1, name: 'Test Item' }, { delay: 50 });
|
|
34
|
+
|
|
35
|
+
const getItem = query(t => ({
|
|
36
|
+
path: '/item',
|
|
37
|
+
response: { id: t.number, name: t.string },
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
function Component(): React.ReactNode {
|
|
41
|
+
const item = useReactive(getItem);
|
|
42
|
+
|
|
43
|
+
if (item.isPending) {
|
|
44
|
+
return <div>Loading...</div>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (item.isRejected) {
|
|
48
|
+
return <div>Error: {String(item.error)}</div>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return <div>{item.value!.name}</div>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const { getByText } = render(
|
|
55
|
+
<ContextProvider contexts={[[QueryClientContext, client]]}>
|
|
56
|
+
<Component />
|
|
57
|
+
</ContextProvider>,
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
await expect.element(getByText('Loading...')).toBeInTheDocument();
|
|
61
|
+
await expect.element(getByText('Test Item')).toBeInTheDocument();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('should show error state on fetch failure', async () => {
|
|
65
|
+
const error = new Error('Failed to fetch');
|
|
66
|
+
mockFetch.get('/item', null, { error });
|
|
67
|
+
|
|
68
|
+
const getItem = query(t => ({
|
|
69
|
+
path: '/item',
|
|
70
|
+
response: { id: t.number, name: t.string },
|
|
71
|
+
}));
|
|
72
|
+
|
|
73
|
+
function Component(): React.ReactNode {
|
|
74
|
+
const item = useReactive(getItem);
|
|
75
|
+
|
|
76
|
+
if (item.isPending) {
|
|
77
|
+
return <div>Loading...</div>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (item.isRejected) {
|
|
81
|
+
return <div>Error occurred</div>;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return <div>{item.value!.name}</div>;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const { getByText } = render(
|
|
88
|
+
<ContextProvider contexts={[[QueryClientContext, client]]}>
|
|
89
|
+
<Component />
|
|
90
|
+
</ContextProvider>,
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
await expect.element(getByText('Loading...')).toBeInTheDocument();
|
|
94
|
+
await expect.element(getByText('Error occurred')).toBeInTheDocument();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('should handle multiple queries in one component', async () => {
|
|
98
|
+
mockFetch.get('/user', { id: 1, name: 'Alice' });
|
|
99
|
+
mockFetch.get('/posts', { posts: [{ id: 1, title: 'Hello' }] });
|
|
100
|
+
|
|
101
|
+
const getUser = query(t => ({
|
|
102
|
+
path: '/user',
|
|
103
|
+
response: { id: t.number, name: t.string },
|
|
104
|
+
}));
|
|
105
|
+
|
|
106
|
+
const getPosts = query(t => ({
|
|
107
|
+
path: '/posts',
|
|
108
|
+
response: {
|
|
109
|
+
posts: t.array(
|
|
110
|
+
t.object({
|
|
111
|
+
id: t.number,
|
|
112
|
+
title: t.string,
|
|
113
|
+
}),
|
|
114
|
+
),
|
|
115
|
+
},
|
|
116
|
+
}));
|
|
117
|
+
|
|
118
|
+
function Component(): React.ReactNode {
|
|
119
|
+
const user = useReactive(getUser);
|
|
120
|
+
const posts = useReactive(getPosts);
|
|
121
|
+
|
|
122
|
+
if (user.isPending || posts.isPending) {
|
|
123
|
+
return <div>Loading...</div>;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return (
|
|
127
|
+
<div>
|
|
128
|
+
<span data-testid="user">{user.value!.name}</span>
|
|
129
|
+
<span data-testid="posts">{posts.value!.posts.length} posts</span>
|
|
130
|
+
</div>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const { getByTestId } = render(
|
|
135
|
+
<ContextProvider contexts={[[QueryClientContext, client]]}>
|
|
136
|
+
<Component />
|
|
137
|
+
</ContextProvider>,
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
await expect.element(getByTestId('user')).toBeInTheDocument();
|
|
141
|
+
await expect.element(getByTestId('posts')).toBeInTheDocument();
|
|
142
|
+
|
|
143
|
+
expect(getByTestId('user').element().textContent).toBe('Alice');
|
|
144
|
+
expect(getByTestId('posts').element().textContent).toBe('1 posts');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('should handle query with path parameters', async () => {
|
|
148
|
+
mockFetch.get('/users/[id]', { id: 123, name: 'Bob' });
|
|
149
|
+
|
|
150
|
+
const getUser = query(t => ({
|
|
151
|
+
path: '/users/[id]',
|
|
152
|
+
response: { id: t.number, name: t.string },
|
|
153
|
+
}));
|
|
154
|
+
|
|
155
|
+
function Component(): React.ReactNode {
|
|
156
|
+
const user = useReactive(getUser, { id: '123' });
|
|
157
|
+
|
|
158
|
+
if (!user.isReady) {
|
|
159
|
+
return <div>Loading...</div>;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return <div>{user.value!.name}</div>;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const { getByText } = render(
|
|
166
|
+
<ContextProvider contexts={[[QueryClientContext, client]]}>
|
|
167
|
+
<Component />
|
|
168
|
+
</ContextProvider>,
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
await expect.element(getByText('Bob')).toBeInTheDocument();
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe('Entity Updates and Reactivity', () => {
|
|
176
|
+
it('should update component when entity data changes', async () => {
|
|
177
|
+
const User = entity(t => ({
|
|
178
|
+
__typename: t.typename('User'),
|
|
179
|
+
id: t.id,
|
|
180
|
+
name: t.string,
|
|
181
|
+
}));
|
|
182
|
+
|
|
183
|
+
mockFetch.get('/user/[id]', { __typename: 'User', id: '1', name: 'Alice' });
|
|
184
|
+
mockFetch.get('/user/[id]', { __typename: 'User', id: '1', name: 'Alice Updated' });
|
|
185
|
+
|
|
186
|
+
const getUser = query(t => ({
|
|
187
|
+
path: '/user/[id]',
|
|
188
|
+
response: User,
|
|
189
|
+
}));
|
|
190
|
+
|
|
191
|
+
const Counter = createRenderCounter(({ user }: { user: { name: string } }) => <div>{user.name}</div>);
|
|
192
|
+
|
|
193
|
+
let userQuery: DiscriminatedQueryResult<{ name: string }>;
|
|
194
|
+
|
|
195
|
+
function Component(): React.ReactNode {
|
|
196
|
+
userQuery = useReactive(getUser, { id: '1' });
|
|
197
|
+
|
|
198
|
+
if (!userQuery.isReady) {
|
|
199
|
+
return <div>Loading...</div>;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return <Counter user={userQuery.value!} />;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const { getByText, getByTestId } = render(
|
|
206
|
+
<ContextProvider contexts={[[QueryClientContext, client]]}>
|
|
207
|
+
<Component />
|
|
208
|
+
</ContextProvider>,
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
await expect.element(getByText('Alice')).toBeInTheDocument();
|
|
212
|
+
expect(Counter.renderCount).toBe(1);
|
|
213
|
+
|
|
214
|
+
// Trigger refetch
|
|
215
|
+
await userQuery!.refetch();
|
|
216
|
+
|
|
217
|
+
await expect.element(getByText('Alice Updated')).toBeInTheDocument();
|
|
218
|
+
expect(getByTestId(String(Counter.testId))).toBeDefined();
|
|
219
|
+
expect(Counter.renderCount).toBe(3);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('should keep multiple components in sync when sharing entity data', async () => {
|
|
223
|
+
const User = entity(t => ({
|
|
224
|
+
__typename: t.typename('User'),
|
|
225
|
+
id: t.id,
|
|
226
|
+
name: t.string,
|
|
227
|
+
}));
|
|
228
|
+
|
|
229
|
+
mockFetch.get('/user/[id]', { __typename: 'User', id: '1', name: 'Alice' });
|
|
230
|
+
|
|
231
|
+
const getUser = query(t => ({
|
|
232
|
+
path: '/user/[id]',
|
|
233
|
+
response: User,
|
|
234
|
+
}));
|
|
235
|
+
|
|
236
|
+
function UserName({ user }: { user: { name: string } }): React.ReactNode {
|
|
237
|
+
return <span data-testid="name">{user.name}</span>;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function UserGreeting({ user }: { user: { name: string } }): React.ReactNode {
|
|
241
|
+
return <span data-testid="greeting">Hello, {user.name}!</span>;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function Component(): React.ReactNode {
|
|
245
|
+
const result = useReactive(getUser, { id: '1' });
|
|
246
|
+
|
|
247
|
+
if (!result.isReady) {
|
|
248
|
+
return <div>Loading...</div>;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const user = result.value!;
|
|
252
|
+
return (
|
|
253
|
+
<div>
|
|
254
|
+
<UserName user={user} />
|
|
255
|
+
<UserGreeting user={user} />
|
|
256
|
+
<button
|
|
257
|
+
onClick={async () => {
|
|
258
|
+
mockFetch.get('/user/[id]', { __typename: 'User', id: '1', name: 'Alice Smith' });
|
|
259
|
+
await result.refetch();
|
|
260
|
+
}}
|
|
261
|
+
>
|
|
262
|
+
Update
|
|
263
|
+
</button>
|
|
264
|
+
</div>
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const { getByTestId, getByText } = render(
|
|
269
|
+
<ContextProvider contexts={[[QueryClientContext, client]]}>
|
|
270
|
+
<Component />
|
|
271
|
+
</ContextProvider>,
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
await expect.element(getByTestId('name')).toBeInTheDocument();
|
|
275
|
+
await expect.element(getByTestId('greeting')).toBeInTheDocument();
|
|
276
|
+
|
|
277
|
+
expect(getByTestId('name').element().textContent).toBe('Alice');
|
|
278
|
+
expect(getByTestId('greeting').element().textContent).toBe('Hello, Alice!');
|
|
279
|
+
|
|
280
|
+
// Click button to refetch and update entity
|
|
281
|
+
await getByText('Update').click();
|
|
282
|
+
await sleep(10);
|
|
283
|
+
|
|
284
|
+
// Both components should show updated data
|
|
285
|
+
expect(getByTestId('name').element().textContent).toBe('Alice Smith');
|
|
286
|
+
expect(getByTestId('greeting').element().textContent).toBe('Hello, Alice Smith!');
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it('should sync entity updates across different queries that reference the same entity', async () => {
|
|
290
|
+
const User = entity(t => ({
|
|
291
|
+
__typename: t.typename('User'),
|
|
292
|
+
id: t.id,
|
|
293
|
+
name: t.string,
|
|
294
|
+
email: t.string,
|
|
295
|
+
}));
|
|
296
|
+
|
|
297
|
+
const Post = entity(t => ({
|
|
298
|
+
__typename: t.typename('Post'),
|
|
299
|
+
id: t.id,
|
|
300
|
+
title: t.string,
|
|
301
|
+
content: t.string,
|
|
302
|
+
author: User,
|
|
303
|
+
}));
|
|
304
|
+
|
|
305
|
+
// Two completely different API endpoints
|
|
306
|
+
mockFetch.get('/user/[id]', { __typename: 'User', id: '1', name: 'Alice', email: 'alice@example.com' });
|
|
307
|
+
mockFetch.get('/posts/[postId]', {
|
|
308
|
+
__typename: 'Post',
|
|
309
|
+
id: '100',
|
|
310
|
+
title: 'My Post',
|
|
311
|
+
content: 'Post content here',
|
|
312
|
+
author: { __typename: 'User', id: '1', name: 'Alice', email: 'alice@example.com' },
|
|
313
|
+
});
|
|
314
|
+
mockFetch.get('/user/[id]', {
|
|
315
|
+
__typename: 'User',
|
|
316
|
+
id: '1',
|
|
317
|
+
name: 'Alice Updated',
|
|
318
|
+
email: 'alice.updated@example.com',
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
// Two separate query definitions
|
|
322
|
+
const getUser = query(t => ({
|
|
323
|
+
path: '/user/[id]',
|
|
324
|
+
response: User,
|
|
325
|
+
}));
|
|
326
|
+
|
|
327
|
+
const getPost = query(t => ({
|
|
328
|
+
path: '/posts/[postId]',
|
|
329
|
+
response: Post,
|
|
330
|
+
}));
|
|
331
|
+
|
|
332
|
+
// First component - displays user profile from user endpoint
|
|
333
|
+
function UserProfile(): React.ReactNode {
|
|
334
|
+
const result = useReactive(getUser, { id: '1' });
|
|
335
|
+
|
|
336
|
+
if (!result.isReady) {
|
|
337
|
+
return <div>Profile Loading...</div>;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return (
|
|
341
|
+
<div data-testid="profile">
|
|
342
|
+
<div data-testid="profile-name">{result.value!.name}</div>
|
|
343
|
+
<div data-testid="profile-email">{result.value!.email}</div>
|
|
344
|
+
</div>
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Second component - displays post with nested author from post endpoint
|
|
349
|
+
function PostView(): React.ReactNode {
|
|
350
|
+
const result = useReactive(getPost, { postId: '100' });
|
|
351
|
+
const userResult = useReactive(getUser, { id: '1' });
|
|
352
|
+
|
|
353
|
+
if (!result.isReady) {
|
|
354
|
+
return <div>Post Loading...</div>;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const post = result.value!;
|
|
358
|
+
return (
|
|
359
|
+
<div data-testid="post">
|
|
360
|
+
<div data-testid="post-title">{post.title}</div>
|
|
361
|
+
<div data-testid="post-author-name">{post.author.name}</div>
|
|
362
|
+
<div data-testid="post-author-email">{post.author.email}</div>
|
|
363
|
+
<button
|
|
364
|
+
onClick={async () => {
|
|
365
|
+
// Refetch the USER endpoint, not the post
|
|
366
|
+
await userResult.refetch();
|
|
367
|
+
}}
|
|
368
|
+
>
|
|
369
|
+
Refresh User
|
|
370
|
+
</button>
|
|
371
|
+
</div>
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// App component - renders both independently
|
|
376
|
+
function App(): React.ReactNode {
|
|
377
|
+
return (
|
|
378
|
+
<div>
|
|
379
|
+
<UserProfile />
|
|
380
|
+
<PostView />
|
|
381
|
+
</div>
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const { getByTestId, getByText } = render(
|
|
386
|
+
<ContextProvider contexts={[[QueryClientContext, client]]}>
|
|
387
|
+
<App />
|
|
388
|
+
</ContextProvider>,
|
|
389
|
+
);
|
|
390
|
+
|
|
391
|
+
// Wait for both components to load
|
|
392
|
+
await expect.element(getByTestId('profile')).toBeInTheDocument();
|
|
393
|
+
await expect.element(getByTestId('post')).toBeInTheDocument();
|
|
394
|
+
|
|
395
|
+
// Both queries should show the same User entity (Alice) with initial data
|
|
396
|
+
expect(getByTestId('profile-name').element().textContent).toBe('Alice');
|
|
397
|
+
expect(getByTestId('profile-email').element().textContent).toBe('alice@example.com');
|
|
398
|
+
expect(getByTestId('post-author-name').element().textContent).toBe('Alice');
|
|
399
|
+
expect(getByTestId('post-author-email').element().textContent).toBe('alice@example.com');
|
|
400
|
+
|
|
401
|
+
// Click refresh button in the Post component (which refetches the USER endpoint)
|
|
402
|
+
await getByText('Refresh User').click();
|
|
403
|
+
await sleep(10);
|
|
404
|
+
|
|
405
|
+
// BOTH queries should show updated User entity data
|
|
406
|
+
// This proves entities are shared across different query definitions
|
|
407
|
+
expect(getByTestId('profile-name').element().textContent).toBe('Alice Updated');
|
|
408
|
+
expect(getByTestId('profile-email').element().textContent).toBe('alice.updated@example.com');
|
|
409
|
+
expect(getByTestId('post-author-name').element().textContent).toBe('Alice Updated');
|
|
410
|
+
expect(getByTestId('post-author-email').element().textContent).toBe('alice.updated@example.com');
|
|
411
|
+
|
|
412
|
+
// Verify we made 3 fetches: getUser, getPost, refetch getUser
|
|
413
|
+
expect(mockFetch.calls.length).toBe(3);
|
|
414
|
+
expect(mockFetch.calls[0].url).toBe('/user/1');
|
|
415
|
+
expect(mockFetch.calls[1].url).toBe('/posts/100');
|
|
416
|
+
expect(mockFetch.calls[2].url).toBe('/user/1');
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it('should handle nested entities correctly', async () => {
|
|
420
|
+
const User = entity(t => ({
|
|
421
|
+
__typename: t.typename('User'),
|
|
422
|
+
id: t.id,
|
|
423
|
+
name: t.string,
|
|
424
|
+
}));
|
|
425
|
+
|
|
426
|
+
const Post = entity(t => ({
|
|
427
|
+
__typename: t.typename('Post'),
|
|
428
|
+
id: t.id,
|
|
429
|
+
title: t.string,
|
|
430
|
+
author: User,
|
|
431
|
+
}));
|
|
432
|
+
|
|
433
|
+
mockFetch.get('/post/[id]', {
|
|
434
|
+
__typename: 'Post',
|
|
435
|
+
id: '1',
|
|
436
|
+
title: 'My Post',
|
|
437
|
+
author: { __typename: 'User', id: '5', name: 'Alice' },
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
const getPost = query(t => ({
|
|
441
|
+
path: '/post/[id]',
|
|
442
|
+
response: Post,
|
|
443
|
+
}));
|
|
444
|
+
|
|
445
|
+
function Component(): React.ReactNode {
|
|
446
|
+
const result = useReactive(getPost, { id: '1' });
|
|
447
|
+
|
|
448
|
+
if (!result.isReady) {
|
|
449
|
+
return <div>Loading...</div>;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const post = result.value!;
|
|
453
|
+
return (
|
|
454
|
+
<div>
|
|
455
|
+
<div data-testid="title">{post.title}</div>
|
|
456
|
+
<div data-testid="author">{post.author.name}</div>
|
|
457
|
+
</div>
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const { getByTestId } = render(
|
|
462
|
+
<ContextProvider contexts={[[QueryClientContext, client]]}>
|
|
463
|
+
<Component />
|
|
464
|
+
</ContextProvider>,
|
|
465
|
+
);
|
|
466
|
+
|
|
467
|
+
await expect.element(getByTestId('title')).toBeInTheDocument();
|
|
468
|
+
expect(getByTestId('title').element().textContent).toBe('My Post');
|
|
469
|
+
expect(getByTestId('author').element().textContent).toBe('Alice');
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
it('should conditionally render based on entity data', async () => {
|
|
473
|
+
const User = entity(t => ({
|
|
474
|
+
__typename: t.typename('User'),
|
|
475
|
+
id: t.id,
|
|
476
|
+
name: t.string,
|
|
477
|
+
isAdmin: t.boolean,
|
|
478
|
+
}));
|
|
479
|
+
|
|
480
|
+
mockFetch.get('/user', { __typename: 'User', id: '1', name: 'Alice', isAdmin: true });
|
|
481
|
+
|
|
482
|
+
const getUser = query(t => ({
|
|
483
|
+
path: '/user',
|
|
484
|
+
response: User,
|
|
485
|
+
}));
|
|
486
|
+
|
|
487
|
+
function Component(): React.ReactNode {
|
|
488
|
+
const result = useReactive(getUser);
|
|
489
|
+
|
|
490
|
+
if (!result.isReady) {
|
|
491
|
+
return <div>Loading...</div>;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const user = result.value!;
|
|
495
|
+
return (
|
|
496
|
+
<div>
|
|
497
|
+
<div data-testid="name">{user.name}</div>
|
|
498
|
+
{user.isAdmin && <div data-testid="admin-badge">Admin</div>}
|
|
499
|
+
</div>
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const { getByTestId } = render(
|
|
504
|
+
<ContextProvider contexts={[[QueryClientContext, client]]}>
|
|
505
|
+
<Component />
|
|
506
|
+
</ContextProvider>,
|
|
507
|
+
);
|
|
508
|
+
|
|
509
|
+
await expect.element(getByTestId('name')).toBeInTheDocument();
|
|
510
|
+
await expect.element(getByTestId('admin-badge')).toBeInTheDocument();
|
|
511
|
+
|
|
512
|
+
expect(getByTestId('admin-badge').element().textContent).toBe('Admin');
|
|
513
|
+
});
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
describe('React-Specific Edge Cases', () => {
|
|
517
|
+
it('should work with React.memo components', async () => {
|
|
518
|
+
mockFetch.get('/item', { id: 1, name: 'Test' });
|
|
519
|
+
|
|
520
|
+
const getItem = query(t => ({
|
|
521
|
+
path: '/item',
|
|
522
|
+
response: { id: t.number, name: t.string },
|
|
523
|
+
}));
|
|
524
|
+
|
|
525
|
+
let childRenderCount = 0;
|
|
526
|
+
let itemQuery: DiscriminatedQueryResult<{ id: number; name: string }>;
|
|
527
|
+
|
|
528
|
+
const Child = memo(({ name }: { name: string }): React.ReactNode => {
|
|
529
|
+
childRenderCount++;
|
|
530
|
+
return <div data-testid="child">{name}</div>;
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
function Parent(): React.ReactNode {
|
|
534
|
+
itemQuery = useReactive(getItem);
|
|
535
|
+
|
|
536
|
+
if (!itemQuery.isReady) {
|
|
537
|
+
return <div>Loading...</div>;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
return <Child name={itemQuery.value!.name} />;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const { getByTestId } = render(
|
|
544
|
+
<ContextProvider contexts={[[QueryClientContext, client]]}>
|
|
545
|
+
<Parent />
|
|
546
|
+
</ContextProvider>,
|
|
547
|
+
);
|
|
548
|
+
|
|
549
|
+
await expect.element(getByTestId('child')).toBeInTheDocument();
|
|
550
|
+
expect(childRenderCount).toBe(1);
|
|
551
|
+
|
|
552
|
+
// Refetch with same data - child should not re-render due to memo
|
|
553
|
+
mockFetch.get('/item', { id: 1, name: 'Test' });
|
|
554
|
+
await itemQuery!.refetch();
|
|
555
|
+
await sleep(10);
|
|
556
|
+
|
|
557
|
+
expect(childRenderCount).toBe(1); // Should still be 1
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
it('should deduplicate multiple components using same query', async () => {
|
|
561
|
+
mockFetch.get('/counter', { count: 5 });
|
|
562
|
+
|
|
563
|
+
const getCounter = query(t => ({
|
|
564
|
+
path: '/counter',
|
|
565
|
+
response: { count: t.number },
|
|
566
|
+
}));
|
|
567
|
+
|
|
568
|
+
function ComponentA(): React.ReactNode {
|
|
569
|
+
const result = useReactive(getCounter);
|
|
570
|
+
return <div data-testid="a">{result.isReady ? result.value!.count : 'Loading'}</div>;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function ComponentB(): React.ReactNode {
|
|
574
|
+
const result = useReactive(getCounter);
|
|
575
|
+
return <div data-testid="b">{result.isReady ? result.value!.count : 'Loading'}</div>;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function App(): React.ReactNode {
|
|
579
|
+
return (
|
|
580
|
+
<div>
|
|
581
|
+
<ComponentA />
|
|
582
|
+
<ComponentB />
|
|
583
|
+
</div>
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
const { getByTestId } = render(
|
|
588
|
+
<ContextProvider contexts={[[QueryClientContext, client]]}>
|
|
589
|
+
<App />
|
|
590
|
+
</ContextProvider>,
|
|
591
|
+
);
|
|
592
|
+
|
|
593
|
+
await expect.element(getByTestId('a')).toBeInTheDocument();
|
|
594
|
+
await expect.element(getByTestId('b')).toBeInTheDocument();
|
|
595
|
+
|
|
596
|
+
// Wait for data to load
|
|
597
|
+
await sleep(10);
|
|
598
|
+
|
|
599
|
+
expect(getByTestId('a').element().textContent).toBe('5');
|
|
600
|
+
expect(getByTestId('b').element().textContent).toBe('5');
|
|
601
|
+
|
|
602
|
+
// Verify only one fetch was made
|
|
603
|
+
expect(mockFetch.calls.length).toBe(1);
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
it('should handle query refetch and update components', async () => {
|
|
607
|
+
mockFetch.get('/counter', { count: 0 });
|
|
608
|
+
|
|
609
|
+
const getCounter = query(t => ({
|
|
610
|
+
path: '/counter',
|
|
611
|
+
response: { count: t.number },
|
|
612
|
+
}));
|
|
613
|
+
|
|
614
|
+
function Component(): React.ReactNode {
|
|
615
|
+
const result = useReactive(getCounter);
|
|
616
|
+
|
|
617
|
+
return (
|
|
618
|
+
<div>
|
|
619
|
+
<div data-testid="count">{result.isReady ? result.value!.count : 'Loading'}</div>
|
|
620
|
+
<button
|
|
621
|
+
onClick={async () => {
|
|
622
|
+
mockFetch.get('/counter', { count: (result.value?.count ?? 0) + 1 });
|
|
623
|
+
await result.refetch();
|
|
624
|
+
}}
|
|
625
|
+
>
|
|
626
|
+
Refetch
|
|
627
|
+
</button>
|
|
628
|
+
</div>
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
const { getByTestId, getByText } = render(
|
|
633
|
+
<ContextProvider contexts={[[QueryClientContext, client]]}>
|
|
634
|
+
<Component />
|
|
635
|
+
</ContextProvider>,
|
|
636
|
+
);
|
|
637
|
+
|
|
638
|
+
await expect.element(getByTestId('count')).toBeInTheDocument();
|
|
639
|
+
|
|
640
|
+
// Wait for initial data to load
|
|
641
|
+
await sleep(10);
|
|
642
|
+
expect(getByTestId('count').element().textContent).toBe('0');
|
|
643
|
+
|
|
644
|
+
// Click to refetch
|
|
645
|
+
mockFetch.get('/counter', { count: 1 });
|
|
646
|
+
await getByText('Refetch').click();
|
|
647
|
+
await sleep(10);
|
|
648
|
+
|
|
649
|
+
expect(getByTestId('count').element().textContent).toBe('1');
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
it('should pass query results as props to children correctly', async () => {
|
|
653
|
+
mockFetch.get('/data', { value: 'Hello World' });
|
|
654
|
+
|
|
655
|
+
const getData = query(t => ({
|
|
656
|
+
path: '/data',
|
|
657
|
+
response: { value: t.string },
|
|
658
|
+
}));
|
|
659
|
+
|
|
660
|
+
function Child({ data }: { data: { value: string } }): React.ReactNode {
|
|
661
|
+
return <div data-testid="child">{data.value}</div>;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function Parent(): React.ReactNode {
|
|
665
|
+
const result = useReactive(getData);
|
|
666
|
+
|
|
667
|
+
if (!result.isReady) {
|
|
668
|
+
return <div>Loading...</div>;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
return <Child data={result.value!} />;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
const { getByTestId } = render(
|
|
675
|
+
<ContextProvider contexts={[[QueryClientContext, client]]}>
|
|
676
|
+
<Parent />
|
|
677
|
+
</ContextProvider>,
|
|
678
|
+
);
|
|
679
|
+
|
|
680
|
+
await expect.element(getByTestId('child')).toBeInTheDocument();
|
|
681
|
+
expect(getByTestId('child').element().textContent).toBe('Hello World');
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
it('should handle component unmount and remount', async () => {
|
|
685
|
+
mockFetch.get('/item', { id: 1, name: 'Persistent' });
|
|
686
|
+
|
|
687
|
+
const getItem = query(t => ({
|
|
688
|
+
path: '/item',
|
|
689
|
+
response: { id: t.number, name: t.string },
|
|
690
|
+
}));
|
|
691
|
+
|
|
692
|
+
function Child(): React.ReactNode {
|
|
693
|
+
const result = useReactive(getItem);
|
|
694
|
+
return <div data-testid="child">{result.isReady ? result.value!.name : 'Loading'}</div>;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function Parent({ show }: { show: boolean }): React.ReactNode {
|
|
698
|
+
return <div>{show && <Child />}</div>;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
const { rerender, getByTestId } = render(
|
|
702
|
+
<ContextProvider contexts={[[QueryClientContext, client]]}>
|
|
703
|
+
<Parent show={true} />
|
|
704
|
+
</ContextProvider>,
|
|
705
|
+
);
|
|
706
|
+
|
|
707
|
+
// Wait for initial load
|
|
708
|
+
await expect.element(getByTestId('child')).toBeInTheDocument();
|
|
709
|
+
await sleep(10);
|
|
710
|
+
expect(getByTestId('child').element().textContent).toBe('Persistent');
|
|
711
|
+
|
|
712
|
+
// Unmount child
|
|
713
|
+
rerender(
|
|
714
|
+
<ContextProvider contexts={[[QueryClientContext, client]]}>
|
|
715
|
+
<Parent show={false} />
|
|
716
|
+
</ContextProvider>,
|
|
717
|
+
);
|
|
718
|
+
await sleep(10);
|
|
719
|
+
|
|
720
|
+
// Remount child - should use cached data (loads immediately, not from network)
|
|
721
|
+
rerender(
|
|
722
|
+
<ContextProvider contexts={[[QueryClientContext, client]]}>
|
|
723
|
+
<Parent show={true} />
|
|
724
|
+
</ContextProvider>,
|
|
725
|
+
);
|
|
726
|
+
await expect.element(getByTestId('child')).toBeInTheDocument();
|
|
727
|
+
// Should show cached data immediately without delay
|
|
728
|
+
expect(getByTestId('child').element().textContent).toBe('Persistent');
|
|
729
|
+
|
|
730
|
+
// Note: The number of fetches may vary depending on context lifecycle,
|
|
731
|
+
// but the important thing is that cached data is available immediately
|
|
732
|
+
expect(mockFetch.calls.length).toBeGreaterThanOrEqual(1);
|
|
733
|
+
});
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
describe('Async Promise States', () => {
|
|
737
|
+
it('should update all promise properties correctly', async () => {
|
|
738
|
+
mockFetch.get('/item', { data: 'test' }, { delay: 50 });
|
|
739
|
+
|
|
740
|
+
const getItem = query(t => ({
|
|
741
|
+
path: '/item',
|
|
742
|
+
response: { data: t.string },
|
|
743
|
+
}));
|
|
744
|
+
|
|
745
|
+
const states: Array<{
|
|
746
|
+
isPending: boolean;
|
|
747
|
+
isResolved: boolean;
|
|
748
|
+
isRejected: boolean;
|
|
749
|
+
isSettled: boolean;
|
|
750
|
+
isReady: boolean;
|
|
751
|
+
hasValue: boolean;
|
|
752
|
+
hasError: boolean;
|
|
753
|
+
}> = [];
|
|
754
|
+
|
|
755
|
+
function Component(): React.ReactNode {
|
|
756
|
+
const result = useReactive(getItem);
|
|
757
|
+
|
|
758
|
+
states.push({
|
|
759
|
+
isPending: result.isPending,
|
|
760
|
+
isResolved: result.isResolved,
|
|
761
|
+
isRejected: result.isRejected,
|
|
762
|
+
isSettled: result.isSettled,
|
|
763
|
+
isReady: result.isReady,
|
|
764
|
+
hasValue: result.value !== undefined,
|
|
765
|
+
hasError: result.error !== undefined,
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
return <div data-testid="status">{result.isReady ? 'Ready' : 'Loading'}</div>;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
const { getByTestId } = render(
|
|
772
|
+
<ContextProvider contexts={[[QueryClientContext, client]]}>
|
|
773
|
+
<Component />
|
|
774
|
+
</ContextProvider>,
|
|
775
|
+
);
|
|
776
|
+
|
|
777
|
+
// Initial state - pending
|
|
778
|
+
expect(states[0].isPending).toBe(true);
|
|
779
|
+
expect(states[0].isReady).toBe(false);
|
|
780
|
+
expect(states[0].isSettled).toBe(false);
|
|
781
|
+
|
|
782
|
+
await expect.element(getByTestId('status')).toBeInTheDocument();
|
|
783
|
+
await sleep(100);
|
|
784
|
+
|
|
785
|
+
// After resolution
|
|
786
|
+
const finalState = states[states.length - 1];
|
|
787
|
+
expect(finalState.isPending).toBe(false);
|
|
788
|
+
expect(finalState.isResolved).toBe(true);
|
|
789
|
+
expect(finalState.isRejected).toBe(false);
|
|
790
|
+
expect(finalState.isReady).toBe(true);
|
|
791
|
+
expect(finalState.isSettled).toBe(true);
|
|
792
|
+
expect(finalState.hasValue).toBe(true);
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
it('should transition from pending to error state', async () => {
|
|
796
|
+
const error = new Error('Network error');
|
|
797
|
+
mockFetch.get('/item', null, { error, delay: 50 });
|
|
798
|
+
|
|
799
|
+
const getItem = query(t => ({
|
|
800
|
+
path: '/item',
|
|
801
|
+
response: { data: t.string },
|
|
802
|
+
}));
|
|
803
|
+
|
|
804
|
+
function Component(): React.ReactNode {
|
|
805
|
+
const result = useReactive(getItem);
|
|
806
|
+
|
|
807
|
+
if (result.isPending) {
|
|
808
|
+
return <div data-testid="status">Pending</div>;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
if (result.isRejected) {
|
|
812
|
+
return <div data-testid="status">Rejected</div>;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
return <div data-testid="status">Success</div>;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
const { getByTestId } = render(
|
|
819
|
+
<ContextProvider contexts={[[QueryClientContext, client]]}>
|
|
820
|
+
<Component />
|
|
821
|
+
</ContextProvider>,
|
|
822
|
+
);
|
|
823
|
+
|
|
824
|
+
await expect.element(getByTestId('status')).toBeInTheDocument();
|
|
825
|
+
expect(getByTestId('status').element().textContent).toBe('Pending');
|
|
826
|
+
|
|
827
|
+
await sleep(100);
|
|
828
|
+
|
|
829
|
+
expect(getByTestId('status').element().textContent).toBe('Rejected');
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
it('should show loading indicator during fetch', async () => {
|
|
833
|
+
mockFetch.get('/slow', { data: 'result' }, { delay: 100 });
|
|
834
|
+
|
|
835
|
+
const getItem = query(t => ({
|
|
836
|
+
path: '/slow',
|
|
837
|
+
response: { data: t.string },
|
|
838
|
+
}));
|
|
839
|
+
|
|
840
|
+
function Component(): React.ReactNode {
|
|
841
|
+
const result = useReactive(getItem);
|
|
842
|
+
|
|
843
|
+
return (
|
|
844
|
+
<div>
|
|
845
|
+
{result.isPending && <div data-testid="spinner">Loading...</div>}
|
|
846
|
+
{result.isReady && <div data-testid="content">{result.value!.data}</div>}
|
|
847
|
+
</div>
|
|
848
|
+
);
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
const { getByTestId } = render(
|
|
852
|
+
<ContextProvider contexts={[[QueryClientContext, client]]}>
|
|
853
|
+
<Component />
|
|
854
|
+
</ContextProvider>,
|
|
855
|
+
);
|
|
856
|
+
|
|
857
|
+
// Should show spinner initially
|
|
858
|
+
await expect.element(getByTestId('spinner')).toBeInTheDocument();
|
|
859
|
+
|
|
860
|
+
// Wait for data
|
|
861
|
+
await sleep(150);
|
|
862
|
+
|
|
863
|
+
// Should now show content
|
|
864
|
+
await expect.element(getByTestId('content')).toBeInTheDocument();
|
|
865
|
+
expect(getByTestId('content').element().textContent).toBe('result');
|
|
866
|
+
});
|
|
867
|
+
|
|
868
|
+
it('should keep previous value during refetch', async () => {
|
|
869
|
+
mockFetch.get('/item', { data: 'first' });
|
|
870
|
+
|
|
871
|
+
const getItem = query(t => ({
|
|
872
|
+
path: '/item',
|
|
873
|
+
response: { data: t.string },
|
|
874
|
+
}));
|
|
875
|
+
|
|
876
|
+
let itemQuery: DiscriminatedQueryResult<{ data: string }>;
|
|
877
|
+
|
|
878
|
+
function Component(): React.ReactNode {
|
|
879
|
+
itemQuery = useReactive(getItem);
|
|
880
|
+
|
|
881
|
+
return (
|
|
882
|
+
<div>
|
|
883
|
+
{itemQuery.isPending && <div data-testid="loading">Loading</div>}
|
|
884
|
+
{itemQuery.isReady && <div data-testid="data">{itemQuery.value!.data}</div>}
|
|
885
|
+
<button
|
|
886
|
+
onClick={async () => {
|
|
887
|
+
mockFetch.get('/item', { data: 'second' }, { delay: 50 });
|
|
888
|
+
await itemQuery.refetch();
|
|
889
|
+
}}
|
|
890
|
+
>
|
|
891
|
+
Refetch
|
|
892
|
+
</button>
|
|
893
|
+
</div>
|
|
894
|
+
);
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
const { getByTestId, getByText } = render(
|
|
898
|
+
<ContextProvider contexts={[[QueryClientContext, client]]}>
|
|
899
|
+
<Component />
|
|
900
|
+
</ContextProvider>,
|
|
901
|
+
);
|
|
902
|
+
|
|
903
|
+
await expect.element(getByTestId('data')).toBeInTheDocument();
|
|
904
|
+
expect(getByTestId('data').element().textContent).toBe('first');
|
|
905
|
+
|
|
906
|
+
// Trigger refetch with delay
|
|
907
|
+
await getByText('Refetch').click();
|
|
908
|
+
|
|
909
|
+
// During refetch, should show loading AND still have previous value
|
|
910
|
+
await sleep(10);
|
|
911
|
+
// Note: The value should still be accessible even during pending state
|
|
912
|
+
expect(itemQuery!.value?.data).toBe('first');
|
|
913
|
+
expect(itemQuery!.isPending).toBe(true);
|
|
914
|
+
|
|
915
|
+
await sleep(100);
|
|
916
|
+
|
|
917
|
+
// After refetch completes
|
|
918
|
+
expect(getByTestId('data').element().textContent).toBe('second');
|
|
919
|
+
});
|
|
920
|
+
});
|
|
921
|
+
});
|