@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.
@@ -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
+ });