ccstate-react 3.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,532 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import '@testing-library/jest-dom/vitest';
4
+ import { render, cleanup, screen } from '@testing-library/react';
5
+ import userEvent from '@testing-library/user-event';
6
+ import { afterEach, expect, it } from 'vitest';
7
+ import { computed, createStore, state } from 'ccstate';
8
+ import type { Computed, State } from 'ccstate';
9
+ import { StrictMode, useEffect } from 'react';
10
+ import { StoreProvider, useSet, useLoadable } from '..';
11
+ import { delay } from 'signal-timers';
12
+ import { useLastLoadable } from '../useLoadable';
13
+
14
+ afterEach(() => {
15
+ cleanup();
16
+ });
17
+
18
+ function makeDefered<T>(): {
19
+ resolve: (value: T) => void;
20
+ reject: (error: unknown) => void;
21
+ promise: Promise<T>;
22
+ } {
23
+ const deferred: {
24
+ resolve: (value: T) => void;
25
+ reject: (error: unknown) => void;
26
+ promise: Promise<T>;
27
+ } = {} as {
28
+ resolve: (value: T) => void;
29
+ reject: (error: unknown) => void;
30
+ promise: Promise<T>;
31
+ };
32
+
33
+ deferred.promise = new Promise((resolve, reject) => {
34
+ deferred.resolve = resolve;
35
+ deferred.reject = reject;
36
+ });
37
+
38
+ return deferred;
39
+ }
40
+
41
+ it('convert promise to loadable', async () => {
42
+ const base = state(Promise.resolve('foo'));
43
+ const App = () => {
44
+ const ret = useLoadable(base);
45
+ if (ret.state === 'loading' || ret.state === 'hasError') {
46
+ return <div>loading</div>;
47
+ }
48
+ return <div>{ret.data}</div>;
49
+ };
50
+ const store = createStore();
51
+ render(
52
+ <StoreProvider value={store}>
53
+ <App />
54
+ </StoreProvider>,
55
+ { wrapper: StrictMode },
56
+ );
57
+
58
+ expect(screen.getByText('loading')).toBeTruthy();
59
+ expect(await screen.findByText('foo')).toBeTruthy();
60
+ });
61
+
62
+ it('reset promise atom will reset loadable', async () => {
63
+ const base = state(Promise.resolve('foo'));
64
+ const App = () => {
65
+ const ret = useLoadable(base);
66
+ if (ret.state === 'loading' || ret.state === 'hasError') {
67
+ return <div>loading</div>;
68
+ }
69
+ return <div>{ret.data}</div>;
70
+ };
71
+ const store = createStore();
72
+ render(
73
+ <StoreProvider value={store}>
74
+ <App />
75
+ </StoreProvider>,
76
+ { wrapper: StrictMode },
77
+ );
78
+
79
+ expect(await screen.findByText('foo')).toBeTruthy();
80
+
81
+ const [, promise] = (() => {
82
+ let ret;
83
+ const promise = new Promise((r) => (ret = r));
84
+ return [ret, promise];
85
+ })();
86
+
87
+ store.set(base, promise);
88
+ expect(await screen.findByText('loading')).toBeTruthy();
89
+ });
90
+
91
+ it('switchMap', async () => {
92
+ const base = state(Promise.resolve('foo'));
93
+ const App = () => {
94
+ const ret = useLoadable(base);
95
+ if (ret.state === 'loading' || ret.state === 'hasError') {
96
+ return <div>loading</div>;
97
+ }
98
+ return <div>{ret.data}</div>;
99
+ };
100
+ const store = createStore();
101
+ render(
102
+ <StoreProvider value={store}>
103
+ <App />
104
+ </StoreProvider>,
105
+ { wrapper: StrictMode },
106
+ );
107
+
108
+ expect(await screen.findByText('foo')).toBeTruthy();
109
+
110
+ const defered = makeDefered();
111
+
112
+ store.set(base, defered.promise);
113
+ expect(await screen.findByText('loading')).toBeTruthy();
114
+
115
+ store.set(base, Promise.resolve('bar'));
116
+ expect(await screen.findByText('bar')).toBeTruthy();
117
+
118
+ defered.resolve('baz');
119
+ await delay(0);
120
+ expect(() => screen.getByText('baz')).toThrow();
121
+ });
122
+
123
+ it('loadable turns suspense into values', async () => {
124
+ let resolve: (x: number) => void = () => void 0;
125
+ const asyncAtom = computed(() => {
126
+ return new Promise<number>((r) => (resolve = r));
127
+ });
128
+
129
+ const store = createStore();
130
+ render(
131
+ <StrictMode>
132
+ <StoreProvider value={store}>
133
+ <LoadableComponent asyncAtom={asyncAtom} />
134
+ </StoreProvider>
135
+ </StrictMode>,
136
+ );
137
+
138
+ await screen.findByText('Loading...');
139
+ resolve(5);
140
+ await screen.findByText('Data: 5');
141
+ });
142
+
143
+ it('loadable turns errors into values', async () => {
144
+ const deferred = makeDefered<number>();
145
+
146
+ const asyncAtom = state(deferred.promise);
147
+
148
+ const store = createStore();
149
+ render(
150
+ <StrictMode>
151
+ <StoreProvider value={store}>
152
+ <LoadableComponent asyncAtom={asyncAtom} />
153
+ </StoreProvider>
154
+ </StrictMode>,
155
+ );
156
+
157
+ await screen.findByText('Loading...');
158
+ deferred.reject(new Error('An error occurred'));
159
+ await screen.findByText('Error: An error occurred');
160
+ });
161
+
162
+ it('loadable turns primitive throws into values', async () => {
163
+ const deferred = makeDefered<number>();
164
+
165
+ const asyncAtom = state(deferred.promise);
166
+
167
+ const store = createStore();
168
+ render(
169
+ <StrictMode>
170
+ <StoreProvider value={store}>
171
+ <LoadableComponent asyncAtom={asyncAtom} />
172
+ </StoreProvider>
173
+ </StrictMode>,
174
+ );
175
+
176
+ await screen.findByText('Loading...');
177
+ deferred.reject('An error occurred');
178
+ await screen.findByText('An error occurred');
179
+ });
180
+
181
+ it('loadable goes back to loading after re-fetch', async () => {
182
+ let resolve: (x: number) => void = () => void 0;
183
+ const refreshAtom = state(0);
184
+ const asyncAtom = computed((get) => {
185
+ get(refreshAtom);
186
+ return new Promise<number>((r) => (resolve = r));
187
+ });
188
+
189
+ const Refresh = () => {
190
+ const setRefresh = useSet(refreshAtom);
191
+ return (
192
+ <>
193
+ <button
194
+ onClick={() => {
195
+ setRefresh((value) => {
196
+ return value + 1;
197
+ });
198
+ }}
199
+ >
200
+ refresh
201
+ </button>
202
+ </>
203
+ );
204
+ };
205
+
206
+ const store = createStore();
207
+ render(
208
+ <StrictMode>
209
+ <StoreProvider value={store}>
210
+ <Refresh />
211
+ <LoadableComponent asyncAtom={asyncAtom} />
212
+ </StoreProvider>
213
+ </StrictMode>,
214
+ );
215
+
216
+ screen.getByText('Loading...');
217
+ resolve(5);
218
+ await screen.findByText('Data: 5');
219
+ await userEvent.click(screen.getByText('refresh'));
220
+ await screen.findByText('Loading...');
221
+ resolve(6);
222
+ await screen.findByText('Data: 6');
223
+ });
224
+
225
+ it('loadable can recover from error', async () => {
226
+ let resolve: (x: number) => void = () => void 0;
227
+ let reject: (error: unknown) => void = () => void 0;
228
+ const refreshAtom = state(0);
229
+ const asyncAtom = computed((get) => {
230
+ get(refreshAtom);
231
+ return new Promise<number>((res, rej) => {
232
+ resolve = res;
233
+ reject = rej;
234
+ });
235
+ });
236
+
237
+ const Refresh = () => {
238
+ const setRefresh = useSet(refreshAtom);
239
+ return (
240
+ <>
241
+ <button
242
+ onClick={() => {
243
+ setRefresh((value) => value + 1);
244
+ }}
245
+ >
246
+ refresh
247
+ </button>
248
+ </>
249
+ );
250
+ };
251
+
252
+ const store = createStore();
253
+ render(
254
+ <StrictMode>
255
+ <StoreProvider value={store}>
256
+ <Refresh />
257
+ <LoadableComponent asyncAtom={asyncAtom} />
258
+ </StoreProvider>
259
+ </StrictMode>,
260
+ );
261
+
262
+ screen.getByText('Loading...');
263
+ reject(new Error('An error occurred'));
264
+ await screen.findByText('Error: An error occurred');
265
+ await userEvent.click(screen.getByText('refresh'));
266
+ await screen.findByText('Loading...');
267
+ resolve(6);
268
+ await screen.findByText('Data: 6');
269
+ });
270
+
271
+ it('loadable of a derived async atom does not trigger infinite loop (#1114)', async () => {
272
+ let resolve: (x: number) => void = () => void 0;
273
+ const baseAtom = state(0);
274
+ const asyncAtom = computed((get) => {
275
+ get(baseAtom);
276
+ return new Promise<number>((r) => (resolve = r));
277
+ });
278
+
279
+ const Trigger = () => {
280
+ const trigger = useSet(baseAtom);
281
+ return (
282
+ <>
283
+ <button
284
+ onClick={() => {
285
+ trigger((value) => value);
286
+ }}
287
+ >
288
+ trigger
289
+ </button>
290
+ </>
291
+ );
292
+ };
293
+
294
+ const store = createStore();
295
+ render(
296
+ <StrictMode>
297
+ <StoreProvider value={store}>
298
+ <Trigger />
299
+ <LoadableComponent asyncAtom={asyncAtom} />
300
+ </StoreProvider>
301
+ </StrictMode>,
302
+ );
303
+
304
+ screen.getByText('Loading...');
305
+ await userEvent.click(screen.getByText('trigger'));
306
+ resolve(5);
307
+ await screen.findByText('Data: 5');
308
+ });
309
+
310
+ it('loadable of a derived async atom with error does not trigger infinite loop (#1330)', async () => {
311
+ const baseAtom = computed(() => {
312
+ throw new Error('thrown in baseAtom');
313
+ });
314
+ // eslint-disable-next-line @typescript-eslint/require-await
315
+ const asyncAtom = computed(async (get) => {
316
+ get(baseAtom);
317
+ return '';
318
+ });
319
+
320
+ const store = createStore();
321
+ render(
322
+ <StrictMode>
323
+ <StoreProvider value={store}>
324
+ <LoadableComponent asyncAtom={asyncAtom} />
325
+ </StoreProvider>
326
+ </StrictMode>,
327
+ );
328
+
329
+ screen.getByText('Loading...');
330
+ await screen.findByText('Error: thrown in baseAtom');
331
+ });
332
+
333
+ it('does not repeatedly attempt to get the value of an unresolved promise atom wrapped in a loadable (#1481)', async () => {
334
+ const baseAtom = state(new Promise<number>(() => void 0));
335
+
336
+ let callsToGetBaseAtom = 0;
337
+ const derivedAtom = computed((get) => {
338
+ callsToGetBaseAtom++;
339
+ return get(baseAtom);
340
+ });
341
+
342
+ const store = createStore();
343
+ render(
344
+ <StrictMode>
345
+ <StoreProvider value={store}>
346
+ <LoadableComponent asyncAtom={derivedAtom} />
347
+ </StoreProvider>
348
+ </StrictMode>,
349
+ );
350
+
351
+ // we need a small delay to reproduce the issue
352
+ await new Promise((r) => setTimeout(r, 10));
353
+ // depending on provider-less mode or versioned-write mode, there will be
354
+ // either 2 or 3 calls.
355
+ expect(callsToGetBaseAtom).toBeLessThanOrEqual(3);
356
+ });
357
+
358
+ it('should handle async error', async () => {
359
+ // eslint-disable-next-line @typescript-eslint/require-await
360
+ const syncAtom = computed(async () => {
361
+ throw new Error('thrown in syncAtom');
362
+ });
363
+
364
+ const store = createStore();
365
+ render(
366
+ <StrictMode>
367
+ <StoreProvider value={store}>
368
+ <LoadableComponent asyncAtom={syncAtom} />
369
+ </StoreProvider>
370
+ </StrictMode>,
371
+ );
372
+
373
+ await screen.findByText('Error: thrown in syncAtom');
374
+ });
375
+
376
+ interface LoadableComponentProps {
377
+ asyncAtom: State<Promise<number | string>> | Computed<Promise<number | string>>;
378
+ effectCallback?: (loadableValue: unknown) => void;
379
+ }
380
+
381
+ const LoadableComponent = ({ asyncAtom, effectCallback }: LoadableComponentProps) => {
382
+ const value = useLoadable(asyncAtom);
383
+
384
+ useEffect(() => {
385
+ if (effectCallback) {
386
+ effectCallback(value);
387
+ }
388
+ }, [value, effectCallback]);
389
+
390
+ if (value.state === 'loading') {
391
+ return <>Loading...</>;
392
+ }
393
+
394
+ if (value.state === 'hasError') {
395
+ return <>{String(value.error)}</>;
396
+ }
397
+
398
+ // this is to ensure correct typing
399
+ const data: number | string = value.data;
400
+
401
+ return <>Data: {data}</>;
402
+ };
403
+
404
+ it('use lastLoadable should not update when new promise pending', async () => {
405
+ const async$ = state(Promise.resolve(1));
406
+
407
+ const store = createStore();
408
+ function App() {
409
+ const number = useLastLoadable(async$);
410
+ if (number.state !== 'hasData') {
411
+ return <div>loading</div>;
412
+ }
413
+ return <div>num{number.data}</div>;
414
+ }
415
+
416
+ render(
417
+ <StoreProvider value={store}>
418
+ <App />
419
+ </StoreProvider>,
420
+ );
421
+
422
+ expect(await screen.findByText('num1')).toBeInTheDocument();
423
+
424
+ const defered = makeDefered();
425
+ store.set(async$, defered.promise);
426
+
427
+ await delay(0);
428
+ expect(screen.getByText('num1')).toBeInTheDocument(); // keep num1 instead 'Loading...'
429
+ defered.resolve(2);
430
+ await delay(0);
431
+ expect(screen.getByText('num2')).toBeInTheDocument();
432
+ });
433
+
434
+ it('use lastLoadable should keep error', async () => {
435
+ const async$ = state(Promise.reject(new Error('error')));
436
+
437
+ const store = createStore();
438
+ function App() {
439
+ const number = useLastLoadable(async$);
440
+ if (number.state === 'loading') {
441
+ return <div>loading</div>;
442
+ }
443
+ if (number.state === 'hasError') {
444
+ return <div>{String(number.error)}</div>;
445
+ }
446
+
447
+ return <div>num{number.data}</div>;
448
+ }
449
+
450
+ render(
451
+ <StoreProvider value={store}>
452
+ <App />
453
+ </StoreProvider>,
454
+ );
455
+
456
+ expect(await screen.findByText('Error: error')).toBeInTheDocument();
457
+
458
+ const defered = makeDefered();
459
+ store.set(async$, defered.promise);
460
+
461
+ await delay(0);
462
+ expect(screen.getByText('Error: error')).toBeInTheDocument(); // keep num1 instead 'Loading...'
463
+ defered.resolve(2);
464
+ await delay(0);
465
+ expect(screen.getByText('num2')).toBeInTheDocument();
466
+ });
467
+
468
+ it('use lastLoadable will will not use old promise value if new promise is made', async () => {
469
+ const oldDefered = makeDefered<number>();
470
+ const async$ = state(oldDefered.promise);
471
+
472
+ const store = createStore();
473
+ function App() {
474
+ const number = useLastLoadable(async$);
475
+ if (number.state !== 'hasData') {
476
+ return <div>loading</div>;
477
+ }
478
+
479
+ return <div>num{number.data}</div>;
480
+ }
481
+
482
+ render(
483
+ <StoreProvider value={store}>
484
+ <App />
485
+ </StoreProvider>,
486
+ );
487
+
488
+ expect(await screen.findByText('loading')).toBeInTheDocument();
489
+
490
+ const newDefered = makeDefered<number>();
491
+ store.set(async$, newDefered.promise);
492
+ oldDefered.resolve(1);
493
+
494
+ await delay(0);
495
+ expect(screen.getByText('loading')).toBeInTheDocument(); // keep num1 instead 'Loading...'
496
+ newDefered.resolve(2);
497
+ await delay(0);
498
+ expect(screen.getByText('num2')).toBeInTheDocument();
499
+ });
500
+
501
+ it('use lastLoadable will will not use old promise error if new promise is made', async () => {
502
+ const oldDefered = makeDefered<number>();
503
+ const async$ = state(oldDefered.promise);
504
+
505
+ const store = createStore();
506
+ function App() {
507
+ const number = useLastLoadable(async$);
508
+ if (number.state !== 'hasData') {
509
+ return <div>loading</div>;
510
+ }
511
+
512
+ return <div>num{number.data}</div>;
513
+ }
514
+
515
+ render(
516
+ <StoreProvider value={store}>
517
+ <App />
518
+ </StoreProvider>,
519
+ );
520
+
521
+ expect(await screen.findByText('loading')).toBeInTheDocument();
522
+
523
+ const newDefered = makeDefered<number>();
524
+ store.set(async$, newDefered.promise);
525
+ oldDefered.reject(new Error('error'));
526
+
527
+ await delay(0);
528
+ expect(screen.getByText('loading')).toBeInTheDocument(); // keep num1 instead 'Loading...'
529
+ newDefered.resolve(2);
530
+ await delay(0);
531
+ expect(screen.getByText('num2')).toBeInTheDocument();
532
+ });
@@ -0,0 +1,62 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import LeakDetector from 'jest-leak-detector';
4
+ import { expect, it } from 'vitest';
5
+ import { state, createStore } from 'ccstate';
6
+ import type { State } from 'ccstate';
7
+ import { useGet, StoreProvider, useLastResolved } from '../';
8
+ import { cleanup, render } from '@testing-library/react';
9
+ import '@testing-library/jest-dom/vitest';
10
+ import { delay } from 'signal-timers';
11
+
12
+ it('should release memory after component unmount', async () => {
13
+ const store = createStore();
14
+ let base: State<{ foo: string }> | undefined = state({
15
+ foo: 'bar',
16
+ });
17
+
18
+ const detector = new LeakDetector(store.get(base));
19
+
20
+ function App() {
21
+ const ret = useGet(base as State<{ foo: string }>);
22
+ return <div>{ret.foo}</div>;
23
+ }
24
+
25
+ render(
26
+ <StoreProvider value={store}>
27
+ <App />
28
+ </StoreProvider>,
29
+ );
30
+
31
+ base = undefined;
32
+ cleanup();
33
+
34
+ expect(await detector.isLeaking()).toBe(false);
35
+ });
36
+
37
+ it('should release memory for promise & loadable', async () => {
38
+ const store = createStore();
39
+ let base$: State<Promise<string>> | undefined = state(Promise.resolve('bar'));
40
+
41
+ const detector = new LeakDetector(store.get(base$));
42
+
43
+ function App() {
44
+ if (!base$) {
45
+ return null;
46
+ }
47
+ const ret = useLastResolved(base$);
48
+ return <div>{ret}</div>;
49
+ }
50
+
51
+ render(
52
+ <StoreProvider value={store}>
53
+ <App />
54
+ </StoreProvider>,
55
+ );
56
+
57
+ base$ = undefined;
58
+ cleanup();
59
+ await delay(0); // wait promise reject to free promise callback
60
+
61
+ expect(await detector.isLeaking()).toBe(false);
62
+ });
@@ -0,0 +1,102 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import '@testing-library/jest-dom/vitest';
4
+ import { cleanup, render, screen } from '@testing-library/react';
5
+ import { afterEach, expect, it } from 'vitest';
6
+ import { state, createStore } from 'ccstate';
7
+ import { StoreProvider } from '../provider';
8
+ import { StrictMode } from 'react';
9
+ import { useLastResolved, useResolved } from '../useResolved';
10
+ import { delay } from 'signal-timers';
11
+
12
+ afterEach(() => {
13
+ cleanup();
14
+ });
15
+
16
+ function makeDefered<T>(): {
17
+ resolve: (value: T) => void;
18
+ reject: (error: unknown) => void;
19
+ promise: Promise<T>;
20
+ } {
21
+ const deferred: {
22
+ resolve: (value: T) => void;
23
+ reject: (error: unknown) => void;
24
+ promise: Promise<T>;
25
+ } = {} as {
26
+ resolve: (value: T) => void;
27
+ reject: (error: unknown) => void;
28
+ promise: Promise<T>;
29
+ };
30
+
31
+ deferred.promise = new Promise((resolve, reject) => {
32
+ deferred.resolve = resolve;
33
+ deferred.reject = reject;
34
+ });
35
+
36
+ return deferred;
37
+ }
38
+
39
+ it('convert promise to awaited value', async () => {
40
+ const base = state(Promise.resolve('foo'));
41
+ const App = () => {
42
+ const ret = useResolved(base);
43
+ return <div>{ret}</div>;
44
+ };
45
+ const store = createStore();
46
+ render(
47
+ <StoreProvider value={store}>
48
+ <App />
49
+ </StoreProvider>,
50
+ { wrapper: StrictMode },
51
+ );
52
+
53
+ expect(await screen.findByText('foo')).toBeTruthy();
54
+ });
55
+
56
+ it('loading state', async () => {
57
+ const deferred = makeDefered<string>();
58
+ const base = state(deferred.promise);
59
+ const App = () => {
60
+ const ret = useResolved(base);
61
+ return <div>{String(ret ?? 'loading')}</div>;
62
+ };
63
+
64
+ const store = createStore();
65
+ render(
66
+ <StoreProvider value={store}>
67
+ <App />
68
+ </StoreProvider>,
69
+ { wrapper: StrictMode },
70
+ );
71
+
72
+ expect(await screen.findByText('loading')).toBeTruthy();
73
+ deferred.resolve('foo');
74
+ expect(await screen.findByText('foo')).toBeTruthy();
75
+ });
76
+
77
+ it('use lastLoadable should not update when new promise pending', async () => {
78
+ const async$ = state(Promise.resolve(1));
79
+
80
+ const store = createStore();
81
+ function App() {
82
+ const number = useLastResolved(async$);
83
+ return <div>num{number}</div>;
84
+ }
85
+
86
+ render(
87
+ <StoreProvider value={store}>
88
+ <App />
89
+ </StoreProvider>,
90
+ );
91
+
92
+ expect(await screen.findByText('num1')).toBeInTheDocument();
93
+
94
+ const defered = makeDefered();
95
+ store.set(async$, defered.promise);
96
+
97
+ await delay(0);
98
+ expect(screen.getByText('num1')).toBeInTheDocument();
99
+ defered.resolve(2);
100
+ await delay(0);
101
+ expect(screen.getByText('num2')).toBeInTheDocument();
102
+ });
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ export { useGet } from './useGet';
2
+ export { useSet } from './useSet';
3
+ export { useResolved, useLastResolved } from './useResolved';
4
+ export { useLoadable, useLastLoadable } from './useLoadable';
5
+ export { StoreProvider } from './provider';
@@ -0,0 +1,16 @@
1
+ import { createContext, useContext } from 'react';
2
+ import type { Store } from 'ccstate';
3
+
4
+ const StoreContext = createContext<Store | null>(null);
5
+
6
+ export const StoreProvider = StoreContext.Provider;
7
+
8
+ export function useStore(): Store {
9
+ const store = useContext(StoreContext);
10
+
11
+ if (!store) {
12
+ throw new Error('Store context not found - did you forget to wrap your app with StoreProvider?');
13
+ }
14
+
15
+ return store;
16
+ }