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