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