@signalium/query 0.1.0 → 1.0.1

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