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