ccstate-solid 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,233 @@
1
+ import { cleanup, render, screen, waitFor } from '@solidjs/testing-library';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { afterEach, it } from 'vitest';
4
+ import { computed, command, state, createStore, type Updater, type State } from 'ccstate';
5
+ import { useGet } from '../useGet';
6
+ import { useSet } from '../useSet';
7
+ import { StoreProvider } from '../provider';
8
+ import '@testing-library/jest-dom/vitest';
9
+
10
+ afterEach(() => {
11
+ cleanup();
12
+ });
13
+
14
+ it('remove an item, then add another', async () => {
15
+ interface Item {
16
+ text: string;
17
+ checked: boolean;
18
+ }
19
+ let itemIndex = 0;
20
+ const itemsAtom = state<State<Item>[]>([]);
21
+
22
+ const ListItem = ({ itemAtom, remove }: { itemAtom: State<Item>; remove: () => void }) => {
23
+ const item = useGet(itemAtom);
24
+ const setItem = useSet(itemAtom);
25
+
26
+ const toggle = () => {
27
+ setItem((prev) => {
28
+ return { ...prev, checked: !prev.checked };
29
+ });
30
+ };
31
+
32
+ return (
33
+ <>
34
+ <div>
35
+ {item().text} checked: {item().checked ? 'yes' : 'no'}
36
+ </div>
37
+ <button onClick={toggle}>Check {item().text}</button>
38
+ <button onClick={remove}>Remove {item().text}</button>
39
+ </>
40
+ );
41
+ };
42
+
43
+ const List = () => {
44
+ const items = useGet(itemsAtom);
45
+ const setItems = useSet(itemsAtom);
46
+
47
+ const addItem = () => {
48
+ setItems((prev) => {
49
+ return [
50
+ ...prev,
51
+ state({
52
+ text: `item${String(++itemIndex)}`,
53
+ checked: false,
54
+ }),
55
+ ];
56
+ });
57
+ };
58
+
59
+ const removeItem = (itemAtom: State<Item>) => {
60
+ setItems((prev) => {
61
+ return prev.filter((x) => x !== itemAtom);
62
+ });
63
+ };
64
+
65
+ return (
66
+ <ul>
67
+ {items().map((itemAtom) => (
68
+ <ListItem
69
+ itemAtom={itemAtom}
70
+ remove={() => {
71
+ removeItem(itemAtom);
72
+ }}
73
+ />
74
+ ))}
75
+ <li>
76
+ <button onClick={addItem}>Add</button>
77
+ </li>
78
+ </ul>
79
+ );
80
+ };
81
+
82
+ const store = createStore();
83
+ render(() => (
84
+ <StoreProvider value={store}>
85
+ <List />
86
+ </StoreProvider>
87
+ ));
88
+
89
+ await userEvent.click(screen.getByText('Add'));
90
+ await screen.findByText('item1 checked: no');
91
+
92
+ await userEvent.click(screen.getByText('Add'));
93
+ await waitFor(() => {
94
+ screen.getByText('item1 checked: no');
95
+ screen.getByText('item2 checked: no');
96
+ });
97
+
98
+ await userEvent.click(screen.getByText('Check item2'));
99
+ await waitFor(() => {
100
+ screen.getByText('item1 checked: no');
101
+ screen.getByText('item2 checked: yes');
102
+ });
103
+
104
+ await userEvent.click(screen.getByText('Remove item1'));
105
+ await screen.findByText('item2 checked: yes');
106
+
107
+ await userEvent.click(screen.getByText('Add'));
108
+ await waitFor(() => {
109
+ screen.getByText('item2 checked: yes');
110
+ screen.getByText('item3 checked: no');
111
+ });
112
+ });
113
+
114
+ it('add an item with filtered list', async () => {
115
+ interface Item {
116
+ text: string;
117
+ checked: boolean;
118
+ }
119
+ type ItemAtoms = State<Item>[];
120
+
121
+ let itemIndex = 0;
122
+ const itemAtomsAtom = state<ItemAtoms>([]);
123
+ const setItemsAtom = command(({ set }, update: Updater<ItemAtoms>) => {
124
+ set(itemAtomsAtom, update);
125
+ });
126
+
127
+ const filterAtom = state<'all' | 'checked' | 'not-checked'>('all');
128
+ const filteredAtom = computed((get) => {
129
+ const filter = get(filterAtom);
130
+ const items = get(itemAtomsAtom);
131
+ if (filter === 'all') {
132
+ return items;
133
+ }
134
+ if (filter === 'checked') {
135
+ return items.filter((atom) => get(atom).checked);
136
+ }
137
+ return items.filter((atom) => !get(atom).checked);
138
+ });
139
+
140
+ const ListItem = ({ itemAtom, remove }: { itemAtom: State<Item>; remove: () => void }) => {
141
+ const item = useGet(itemAtom);
142
+ const setItem = useSet(itemAtom);
143
+ const toggle = () => {
144
+ setItem((prev) => ({ ...prev, checked: !prev.checked }));
145
+ };
146
+ return (
147
+ <>
148
+ <div>
149
+ {item().text} checked: {item().checked ? 'yes' : 'no'}
150
+ </div>
151
+ <button onClick={toggle}>Check {item().text}</button>
152
+ <button onClick={remove}>Remove {item().text}</button>
153
+ </>
154
+ );
155
+ };
156
+
157
+ const Filter = () => {
158
+ const filter = useGet(filterAtom);
159
+ const setFilter = useSet(filterAtom);
160
+
161
+ return (
162
+ <>
163
+ <div>{filter()}</div>
164
+ <button
165
+ onClick={() => {
166
+ setFilter('all');
167
+ }}
168
+ >
169
+ All
170
+ </button>
171
+ <button
172
+ onClick={() => {
173
+ setFilter('checked');
174
+ }}
175
+ >
176
+ Checked
177
+ </button>
178
+ <button
179
+ onClick={() => {
180
+ setFilter('not-checked');
181
+ }}
182
+ >
183
+ Not Checked
184
+ </button>
185
+ </>
186
+ );
187
+ };
188
+
189
+ const FilteredList = ({ removeItem }: { removeItem: (itemAtom: State<Item>) => void }) => {
190
+ const items = useGet(filteredAtom);
191
+ return (
192
+ <ul>
193
+ {items().map((itemAtom) => (
194
+ <ListItem
195
+ itemAtom={itemAtom}
196
+ remove={() => {
197
+ removeItem(itemAtom);
198
+ }}
199
+ />
200
+ ))}
201
+ </ul>
202
+ );
203
+ };
204
+
205
+ const List = () => {
206
+ const setItems = useSet(setItemsAtom);
207
+ const addItem = () => {
208
+ setItems((prev) => [...prev, state<Item>({ text: `item${String(++itemIndex)}`, checked: false })]);
209
+ };
210
+ const removeItem = (itemAtom: State<Item>) => {
211
+ setItems((prev) => prev.filter((x) => x !== itemAtom));
212
+ };
213
+ return (
214
+ <>
215
+ <Filter />
216
+ <button onClick={addItem}>Add</button>
217
+ <FilteredList removeItem={removeItem} />
218
+ </>
219
+ );
220
+ };
221
+
222
+ const store = createStore();
223
+ render(() => (
224
+ <StoreProvider value={store}>
225
+ <List />
226
+ </StoreProvider>
227
+ ));
228
+
229
+ await userEvent.click(screen.getByText('Checked'));
230
+ await userEvent.click(screen.getByText('Add'));
231
+ await userEvent.click(screen.getByText('All'));
232
+ await screen.findByText('item1 checked: no');
233
+ });
@@ -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, useResource } from '../';
8
+ import { cleanup, render } from '@solidjs/testing-library';
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 = useResource(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,398 @@
1
+ import '@testing-library/jest-dom/vitest';
2
+ import { render, cleanup, screen } from '@solidjs/testing-library';
3
+ import userEvent from '@testing-library/user-event';
4
+ import { afterEach, expect, it } from 'vitest';
5
+ import { computed, createStore, state } from 'ccstate';
6
+ import type { Computed, State } from 'ccstate';
7
+ import { StoreProvider, useSet, useResource } from '..';
8
+ import { delay } from 'signal-timers';
9
+ import { onMount } from 'solid-js';
10
+ import { Show } from 'solid-js/web';
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 resource', async () => {
40
+ const base = state(Promise.resolve('foo'));
41
+ const App = () => {
42
+ const data = useResource(base);
43
+ return <div>{!data.loading && !data.error ? data() : 'loading'}</div>;
44
+ };
45
+ const store = createStore();
46
+ render(() => (
47
+ <StoreProvider value={store}>
48
+ <App />
49
+ </StoreProvider>
50
+ ));
51
+
52
+ expect(screen.getByText('loading')).toBeTruthy();
53
+ expect(await screen.findByText('foo')).toBeTruthy();
54
+ });
55
+
56
+ it('reset promise atom will reset loadable', async () => {
57
+ const base = state(Promise.resolve('foo'));
58
+ const App = () => {
59
+ const data = useResource(base);
60
+ return <div>{!data.loading && !data.error ? data() : 'loading'}</div>;
61
+ };
62
+ const store = createStore();
63
+ render(() => (
64
+ <StoreProvider value={store}>
65
+ <App />
66
+ </StoreProvider>
67
+ ));
68
+
69
+ expect(await screen.findByText('foo')).toBeTruthy();
70
+
71
+ const [, promise] = (() => {
72
+ let ret;
73
+ const promise = new Promise((r) => (ret = r));
74
+ return [ret, promise];
75
+ })();
76
+
77
+ store.set(base, promise);
78
+ expect(await screen.findByText('loading')).toBeTruthy();
79
+ });
80
+
81
+ it('switchMap', async () => {
82
+ const base = state(Promise.resolve('foo'));
83
+ const App = () => {
84
+ const data = useResource(base);
85
+ return <div>{!data.loading && !data.error ? data() : 'loading'}</div>;
86
+ };
87
+ const store = createStore();
88
+ render(() => (
89
+ <StoreProvider value={store}>
90
+ <App />
91
+ </StoreProvider>
92
+ ));
93
+
94
+ expect(await screen.findByText('foo')).toBeTruthy();
95
+
96
+ const defered = makeDefered();
97
+
98
+ store.set(base, defered.promise);
99
+ expect(await screen.findByText('loading')).toBeTruthy();
100
+
101
+ store.set(base, Promise.resolve('bar'));
102
+ expect(await screen.findByText('bar')).toBeTruthy();
103
+
104
+ defered.resolve('baz');
105
+ await delay(0);
106
+ expect(() => screen.getByText('baz')).toThrow();
107
+ });
108
+
109
+ it('loadable turns suspense into values', async () => {
110
+ let resolve: (x: number) => void = () => void 0;
111
+ const asyncAtom = computed(() => {
112
+ return new Promise<number>((r) => (resolve = r));
113
+ });
114
+
115
+ const store = createStore();
116
+ render(() => (
117
+ <StoreProvider value={store}>
118
+ <LoadableComponent asyncAtom={asyncAtom} />
119
+ </StoreProvider>
120
+ ));
121
+
122
+ await screen.findByText('Loading...');
123
+ resolve(5);
124
+ await screen.findByText('Data: 5');
125
+ });
126
+
127
+ it('loadable turns errors into values', async () => {
128
+ const deferred = makeDefered<number>();
129
+
130
+ const asyncAtom = state(deferred.promise);
131
+
132
+ const store = createStore();
133
+ render(() => (
134
+ <StoreProvider value={store}>
135
+ <LoadableComponent asyncAtom={asyncAtom} />
136
+ </StoreProvider>
137
+ ));
138
+
139
+ await screen.findByText('Loading...');
140
+ deferred.reject(new Error('An error occurred'));
141
+ await screen.findByText('Error: An error occurred');
142
+ });
143
+
144
+ it('loadable turns primitive throws into values', async () => {
145
+ const deferred = makeDefered<number>();
146
+
147
+ const asyncAtom = state(deferred.promise);
148
+
149
+ const store = createStore();
150
+ render(() => (
151
+ <StoreProvider value={store}>
152
+ <LoadableComponent asyncAtom={asyncAtom} />
153
+ </StoreProvider>
154
+ ));
155
+
156
+ await screen.findByText('Loading...');
157
+ deferred.reject('An error occurred');
158
+ await screen.findByText('Error: An error occurred');
159
+ });
160
+
161
+ it('loadable goes back to loading after re-fetch', async () => {
162
+ let resolve: (x: number) => void = () => void 0;
163
+ const refreshAtom = state(0);
164
+ const asyncAtom = computed((get) => {
165
+ get(refreshAtom);
166
+ return new Promise<number>((r) => (resolve = r));
167
+ });
168
+
169
+ const Refresh = () => {
170
+ const setRefresh = useSet(refreshAtom);
171
+ return (
172
+ <>
173
+ <button
174
+ onClick={() => {
175
+ setRefresh((value) => {
176
+ return value + 1;
177
+ });
178
+ }}
179
+ >
180
+ refresh
181
+ </button>
182
+ </>
183
+ );
184
+ };
185
+
186
+ const store = createStore();
187
+ render(() => (
188
+ <StoreProvider value={store}>
189
+ <Refresh />
190
+ <LoadableComponent asyncAtom={asyncAtom} />
191
+ </StoreProvider>
192
+ ));
193
+
194
+ screen.getByText('Loading...');
195
+ resolve(5);
196
+ await screen.findByText('Data: 5');
197
+ await userEvent.click(screen.getByText('refresh'));
198
+ await screen.findByText('Loading...');
199
+ resolve(6);
200
+ await screen.findByText('Data: 6');
201
+ });
202
+
203
+ it('loadable can recover from error', async () => {
204
+ let resolve: (x: number) => void = () => void 0;
205
+ let reject: (error: unknown) => void = () => void 0;
206
+ const refreshAtom = state(0);
207
+ const asyncAtom = computed((get) => {
208
+ get(refreshAtom);
209
+ return new Promise<number>((res, rej) => {
210
+ resolve = res;
211
+ reject = rej;
212
+ });
213
+ });
214
+
215
+ const Refresh = () => {
216
+ const setRefresh = useSet(refreshAtom);
217
+ return (
218
+ <>
219
+ <button
220
+ onClick={() => {
221
+ setRefresh((value) => value + 1);
222
+ }}
223
+ >
224
+ refresh
225
+ </button>
226
+ </>
227
+ );
228
+ };
229
+
230
+ const store = createStore();
231
+ render(() => (
232
+ <StoreProvider value={store}>
233
+ <Refresh />
234
+ <LoadableComponent asyncAtom={asyncAtom} />
235
+ </StoreProvider>
236
+ ));
237
+
238
+ screen.getByText('Loading...');
239
+ reject(new Error('An error occurred'));
240
+ await screen.findByText('Error: An error occurred');
241
+ await userEvent.click(screen.getByText('refresh'));
242
+ await screen.findByText('Loading...');
243
+ resolve(6);
244
+ await screen.findByText('Data: 6');
245
+ });
246
+
247
+ it('loadable of a derived async atom does not trigger infinite loop (#1114)', async () => {
248
+ let resolve: (x: number) => void = () => void 0;
249
+ const baseAtom = state(0);
250
+ const asyncAtom = computed((get) => {
251
+ get(baseAtom);
252
+ return new Promise<number>((r) => (resolve = r));
253
+ });
254
+
255
+ const Trigger = () => {
256
+ const trigger = useSet(baseAtom);
257
+ return (
258
+ <>
259
+ <button
260
+ onClick={() => {
261
+ trigger((value) => value);
262
+ }}
263
+ >
264
+ trigger
265
+ </button>
266
+ </>
267
+ );
268
+ };
269
+
270
+ const store = createStore();
271
+ render(() => (
272
+ <StoreProvider value={store}>
273
+ <Trigger />
274
+ <LoadableComponent asyncAtom={asyncAtom} />
275
+ </StoreProvider>
276
+ ));
277
+
278
+ screen.getByText('Loading...');
279
+ await userEvent.click(screen.getByText('trigger'));
280
+ resolve(5);
281
+ await screen.findByText('Data: 5');
282
+ });
283
+
284
+ it('loadable of a derived async atom with error does not trigger infinite loop (#1330)', async () => {
285
+ const baseAtom = computed(() => {
286
+ throw new Error('thrown in baseAtom');
287
+ });
288
+ // eslint-disable-next-line @typescript-eslint/require-await
289
+ const asyncAtom = computed(async (get) => {
290
+ get(baseAtom);
291
+ return '';
292
+ });
293
+
294
+ const store = createStore();
295
+ render(() => (
296
+ <StoreProvider value={store}>
297
+ <LoadableComponent asyncAtom={asyncAtom} />
298
+ </StoreProvider>
299
+ ));
300
+
301
+ screen.getByText('Loading...');
302
+ await screen.findByText('Error: thrown in baseAtom');
303
+ });
304
+
305
+ it('does not repeatedly attempt to get the value of an unresolved promise atom wrapped in a loadable (#1481)', async () => {
306
+ const baseAtom = state(new Promise<number>(() => void 0));
307
+
308
+ let callsToGetBaseAtom = 0;
309
+ const derivedAtom = computed((get) => {
310
+ callsToGetBaseAtom++;
311
+ return get(baseAtom);
312
+ });
313
+
314
+ const store = createStore();
315
+ render(() => (
316
+ <StoreProvider value={store}>
317
+ <LoadableComponent asyncAtom={derivedAtom} />
318
+ </StoreProvider>
319
+ ));
320
+
321
+ // we need a small delay to reproduce the issue
322
+ await new Promise((r) => setTimeout(r, 10));
323
+ // depending on provider-less mode or versioned-write mode, there will be
324
+ // either 2 or 3 calls.
325
+ expect(callsToGetBaseAtom).toBeLessThanOrEqual(3);
326
+ });
327
+
328
+ it('should handle async error', async () => {
329
+ // eslint-disable-next-line @typescript-eslint/require-await
330
+ const syncAtom = computed(async () => {
331
+ throw new Error('thrown in syncAtom');
332
+ });
333
+
334
+ const store = createStore();
335
+ render(() => (
336
+ <StoreProvider value={store}>
337
+ <LoadableComponent asyncAtom={syncAtom} />
338
+ </StoreProvider>
339
+ ));
340
+
341
+ await screen.findByText('Error: thrown in syncAtom');
342
+ });
343
+
344
+ interface LoadableComponentProps {
345
+ asyncAtom: State<Promise<number | string>> | Computed<Promise<number | string>>;
346
+ effectCallback?: (loadableValue: unknown) => void;
347
+ }
348
+
349
+ const LoadableComponent = ({ asyncAtom, effectCallback }: LoadableComponentProps) => {
350
+ const data = useResource(asyncAtom);
351
+
352
+ if (effectCallback) {
353
+ onMount(() => {
354
+ effectCallback(data);
355
+ });
356
+ }
357
+
358
+ return <>{data.loading ? 'Loading...' : data.error ? String(data.error) : `Data: ${String(data())}`}</>;
359
+ };
360
+
361
+ it('use lastLoadable should not update when new promise pending', async () => {
362
+ const async$ = state(Promise.resolve(1));
363
+
364
+ const store = createStore();
365
+ function App() {
366
+ const data = useResource(async$);
367
+
368
+ return (
369
+ <Show
370
+ when={data.latest}
371
+ fallback={
372
+ <Show when={!data.loading} fallback={<div>loading</div>}>
373
+ <div>num{data()}</div>
374
+ </Show>
375
+ }
376
+ >
377
+ <div>num{data.latest}</div>
378
+ </Show>
379
+ );
380
+ }
381
+
382
+ render(() => (
383
+ <StoreProvider value={store}>
384
+ <App />
385
+ </StoreProvider>
386
+ ));
387
+
388
+ expect(await screen.findByText('num1')).toBeInTheDocument();
389
+
390
+ const defered = makeDefered();
391
+ store.set(async$, defered.promise);
392
+
393
+ await delay(0);
394
+ expect(screen.getByText('num1')).toBeInTheDocument(); // keep num1 instead 'Loading...'
395
+ defered.resolve(2);
396
+ await delay(0);
397
+ expect(screen.getByText('num2')).toBeInTheDocument();
398
+ });
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ export { useGet } from './useGet';
2
+ export { useSet } from './useSet';
3
+ export { useResource } from './useResource';
4
+ export { StoreProvider } from './provider';
@@ -0,0 +1,17 @@
1
+ import { createContext, useContext } from 'solid-js';
2
+ import { getDefaultStore } from 'ccstate';
3
+ import type { Store } from 'ccstate';
4
+
5
+ const StoreContext = createContext<Store | null>(null);
6
+
7
+ export const StoreProvider = StoreContext.Provider;
8
+
9
+ export function useStore(): Store {
10
+ const store = useContext(StoreContext);
11
+
12
+ if (!store) {
13
+ return getDefaultStore();
14
+ }
15
+
16
+ return store;
17
+ }