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.
- package/CHANGELOG.md +10 -0
- package/babel.config.json +3 -0
- package/dist/LICENSE +21 -0
- package/dist/README.md +849 -0
- package/dist/index.cjs +150 -0
- package/dist/index.d.cts +26 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.js +142 -0
- package/dist/package.json +33 -0
- package/package.json +58 -0
- package/rollup.config.mjs +84 -0
- package/src/__tests__/get-and-set.test.tsx +267 -0
- package/src/__tests__/items.test.tsx +242 -0
- package/src/__tests__/loadable.test.tsx +532 -0
- package/src/__tests__/memory.test.tsx +62 -0
- package/src/__tests__/resolved.test.tsx +102 -0
- package/src/index.ts +5 -0
- package/src/provider.ts +16 -0
- package/src/useGet.ts +20 -0
- package/src/useLoadable.ts +69 -0
- package/src/useResolved.ts +12 -0
- package/src/useSet.ts +22 -0
- package/tsconfig.json +8 -0
- package/vitest.config.ts +3 -0
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { render, cleanup, screen } from '@testing-library/react';
|
|
3
|
+
import userEvent from '@testing-library/user-event';
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import { computed, createStore, command, state, createDebugStore } from 'ccstate';
|
|
6
|
+
import { StoreProvider, useGet, useSet } from '..';
|
|
7
|
+
import { StrictMode, useState } from 'react';
|
|
8
|
+
import '@testing-library/jest-dom/vitest';
|
|
9
|
+
|
|
10
|
+
describe('react', () => {
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
cleanup();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('using ccstate in react', async () => {
|
|
16
|
+
const store = createStore();
|
|
17
|
+
const base = state(0);
|
|
18
|
+
|
|
19
|
+
const trace = vi.fn();
|
|
20
|
+
function App() {
|
|
21
|
+
trace();
|
|
22
|
+
const ret = useGet(base);
|
|
23
|
+
return <div>{ret}</div>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
render(
|
|
27
|
+
<StoreProvider value={store}>
|
|
28
|
+
<App />
|
|
29
|
+
</StoreProvider>,
|
|
30
|
+
);
|
|
31
|
+
expect(trace).toHaveBeenCalledTimes(1);
|
|
32
|
+
|
|
33
|
+
expect(screen.getByText('0')).toBeInTheDocument();
|
|
34
|
+
store.set(base, 1);
|
|
35
|
+
expect(screen.getByText('0')).toBeInTheDocument();
|
|
36
|
+
await Promise.resolve();
|
|
37
|
+
expect(trace).toHaveBeenCalledTimes(2);
|
|
38
|
+
expect(screen.getByText('1')).toBeInTheDocument();
|
|
39
|
+
await Promise.resolve();
|
|
40
|
+
expect(trace).toHaveBeenCalledTimes(2);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('computed should re-render', async () => {
|
|
44
|
+
const store = createStore();
|
|
45
|
+
const base = state(0);
|
|
46
|
+
const derived = computed((get) => get(base) * 2);
|
|
47
|
+
|
|
48
|
+
const trace = vi.fn();
|
|
49
|
+
function App() {
|
|
50
|
+
const ret = useGet(derived);
|
|
51
|
+
trace();
|
|
52
|
+
return <div>{ret}</div>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
render(
|
|
56
|
+
<StoreProvider value={store}>
|
|
57
|
+
<App />
|
|
58
|
+
</StoreProvider>,
|
|
59
|
+
);
|
|
60
|
+
expect(trace).toHaveBeenCalledTimes(1);
|
|
61
|
+
|
|
62
|
+
trace.mockClear();
|
|
63
|
+
expect(screen.getByText('0')).toBeInTheDocument();
|
|
64
|
+
store.set(base, 1);
|
|
65
|
+
expect(trace).not.toBeCalled();
|
|
66
|
+
|
|
67
|
+
await Promise.resolve();
|
|
68
|
+
expect(trace).toBeCalledTimes(1);
|
|
69
|
+
expect(screen.getByText('2')).toBeInTheDocument();
|
|
70
|
+
|
|
71
|
+
trace.mockClear();
|
|
72
|
+
store.set(base, 1);
|
|
73
|
+
await Promise.resolve();
|
|
74
|
+
expect(trace).not.toBeCalled();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('user click counter should increment', async () => {
|
|
78
|
+
const store = createStore();
|
|
79
|
+
const count$ = state(0);
|
|
80
|
+
const onClick$ = command(({ get, set }) => {
|
|
81
|
+
const ret = get(count$);
|
|
82
|
+
set(count$, ret + 1);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const trace = vi.fn();
|
|
86
|
+
function App() {
|
|
87
|
+
trace();
|
|
88
|
+
const ret = useGet(count$);
|
|
89
|
+
const onClick = useSet(onClick$);
|
|
90
|
+
|
|
91
|
+
return <button onClick={onClick}>{ret}</button>;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
render(
|
|
95
|
+
<StoreProvider value={store}>
|
|
96
|
+
<App />
|
|
97
|
+
</StoreProvider>,
|
|
98
|
+
);
|
|
99
|
+
const button = screen.getByText('0');
|
|
100
|
+
expect(button).toBeInTheDocument();
|
|
101
|
+
|
|
102
|
+
const user = userEvent.setup();
|
|
103
|
+
await user.click(button);
|
|
104
|
+
expect(screen.getByText('1')).toBeInTheDocument();
|
|
105
|
+
await user.click(button);
|
|
106
|
+
expect(screen.getByText('2')).toBeInTheDocument();
|
|
107
|
+
|
|
108
|
+
expect(trace).toHaveBeenCalledTimes(3);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('two atom changes should re-render once', async () => {
|
|
112
|
+
const store = createStore();
|
|
113
|
+
const state1 = state(0);
|
|
114
|
+
const state2 = state(0);
|
|
115
|
+
const trace = vi.fn();
|
|
116
|
+
function App() {
|
|
117
|
+
trace();
|
|
118
|
+
const ret1 = useGet(state1);
|
|
119
|
+
const ret2 = useGet(state2);
|
|
120
|
+
return <div>{ret1 + ret2}</div>;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
render(
|
|
124
|
+
<StoreProvider value={store}>
|
|
125
|
+
<App />
|
|
126
|
+
</StoreProvider>,
|
|
127
|
+
);
|
|
128
|
+
expect(screen.getByText('0')).toBeInTheDocument();
|
|
129
|
+
expect(trace).toHaveBeenCalledTimes(1);
|
|
130
|
+
|
|
131
|
+
store.set(state1, 1);
|
|
132
|
+
store.set(state2, 2);
|
|
133
|
+
await Promise.resolve();
|
|
134
|
+
expect(trace).toHaveBeenCalledTimes(2);
|
|
135
|
+
expect(screen.getByText('3')).toBeInTheDocument();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('async callback will trigger rerender', async () => {
|
|
139
|
+
const store = createStore();
|
|
140
|
+
const count$ = state(0);
|
|
141
|
+
const onClick$ = command(({ get, set }) => {
|
|
142
|
+
return Promise.resolve().then(() => {
|
|
143
|
+
set(count$, get(count$) + 1);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
function App() {
|
|
148
|
+
const val = useGet(count$);
|
|
149
|
+
const onClick = useSet(onClick$);
|
|
150
|
+
return (
|
|
151
|
+
<button
|
|
152
|
+
onClick={() => {
|
|
153
|
+
void onClick();
|
|
154
|
+
}}
|
|
155
|
+
>
|
|
156
|
+
{val}
|
|
157
|
+
</button>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
render(
|
|
162
|
+
<StoreProvider value={store}>
|
|
163
|
+
<App />
|
|
164
|
+
</StoreProvider>,
|
|
165
|
+
);
|
|
166
|
+
const button = screen.getByText('0');
|
|
167
|
+
expect(button).toBeInTheDocument();
|
|
168
|
+
|
|
169
|
+
const user = userEvent.setup();
|
|
170
|
+
await user.click(button);
|
|
171
|
+
expect(screen.getByText('1')).toBeInTheDocument();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('floating promise trigger rerender', async () => {
|
|
175
|
+
const store = createStore();
|
|
176
|
+
const count$ = state(0);
|
|
177
|
+
const onClick$ = command(({ get, set }) => {
|
|
178
|
+
void Promise.resolve().then(() => {
|
|
179
|
+
set(count$, get(count$) + 1);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
function App() {
|
|
184
|
+
const val = useGet(count$);
|
|
185
|
+
const onClick = useSet(onClick$);
|
|
186
|
+
return <button onClick={onClick}>{val}</button>;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
render(
|
|
190
|
+
<StoreProvider value={store}>
|
|
191
|
+
<App />
|
|
192
|
+
</StoreProvider>,
|
|
193
|
+
);
|
|
194
|
+
const button = screen.getByText('0');
|
|
195
|
+
expect(button).toBeInTheDocument();
|
|
196
|
+
|
|
197
|
+
const user = userEvent.setup();
|
|
198
|
+
await user.click(button);
|
|
199
|
+
expect(await screen.findByText('1')).toBeInTheDocument();
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('will throw error if no provider', () => {
|
|
203
|
+
const count$ = state(0);
|
|
204
|
+
|
|
205
|
+
function App() {
|
|
206
|
+
const count = useGet(count$);
|
|
207
|
+
return <div>{count}</div>;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// suppress react render error message in console
|
|
211
|
+
const mock = vi.spyOn(console, 'error').mockImplementation(() => void 0);
|
|
212
|
+
expect(() =>
|
|
213
|
+
render(
|
|
214
|
+
<StrictMode>
|
|
215
|
+
<App />
|
|
216
|
+
</StrictMode>,
|
|
217
|
+
),
|
|
218
|
+
).toThrow();
|
|
219
|
+
|
|
220
|
+
mock.mockRestore();
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('will unmount when component cleanup', async () => {
|
|
224
|
+
const store = createDebugStore();
|
|
225
|
+
const base$ = state(0);
|
|
226
|
+
|
|
227
|
+
function App() {
|
|
228
|
+
const ret = useGet(base$);
|
|
229
|
+
return <div>{ret}</div>;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function Container() {
|
|
233
|
+
const [show, setShow] = useState(true);
|
|
234
|
+
if (show) {
|
|
235
|
+
return (
|
|
236
|
+
<div>
|
|
237
|
+
<App />
|
|
238
|
+
<button
|
|
239
|
+
onClick={() => {
|
|
240
|
+
setShow(false);
|
|
241
|
+
}}
|
|
242
|
+
>
|
|
243
|
+
hide
|
|
244
|
+
</button>
|
|
245
|
+
</div>
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
return <div>unmounted</div>;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
render(
|
|
252
|
+
<StrictMode>
|
|
253
|
+
<StoreProvider value={store}>
|
|
254
|
+
<Container />
|
|
255
|
+
</StoreProvider>
|
|
256
|
+
</StrictMode>,
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
const user = userEvent.setup();
|
|
260
|
+
expect(store.getSubscribeGraph()).toHaveLength(1);
|
|
261
|
+
const button = screen.getByText('hide');
|
|
262
|
+
expect(button).toBeInTheDocument();
|
|
263
|
+
await user.click(button);
|
|
264
|
+
expect(await screen.findByText('unmounted')).toBeInTheDocument();
|
|
265
|
+
expect(store.getSubscribeGraph()).toHaveLength(0);
|
|
266
|
+
});
|
|
267
|
+
});
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { StrictMode } from 'react';
|
|
4
|
+
|
|
5
|
+
import { cleanup, render, screen, waitFor } from '@testing-library/react';
|
|
6
|
+
import userEvent from '@testing-library/user-event';
|
|
7
|
+
import { afterEach, it } from 'vitest';
|
|
8
|
+
import { computed, command, state, createStore, type Updater, type State } from 'ccstate';
|
|
9
|
+
import { useGet } from '../useGet';
|
|
10
|
+
import { useSet } from '../useSet';
|
|
11
|
+
import { StoreProvider } from '../provider';
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
cleanup();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('remove an item, then add another', async () => {
|
|
18
|
+
interface Item {
|
|
19
|
+
text: string;
|
|
20
|
+
checked: boolean;
|
|
21
|
+
}
|
|
22
|
+
let itemIndex = 0;
|
|
23
|
+
const itemsAtom = state<State<Item>[]>([]);
|
|
24
|
+
|
|
25
|
+
const ListItem = ({ itemAtom, remove }: { itemAtom: State<Item>; remove: () => void }) => {
|
|
26
|
+
const item = useGet(itemAtom);
|
|
27
|
+
const setItem = useSet(itemAtom);
|
|
28
|
+
|
|
29
|
+
const toggle = () => {
|
|
30
|
+
setItem((prev) => {
|
|
31
|
+
return { ...prev, checked: !prev.checked };
|
|
32
|
+
});
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<>
|
|
37
|
+
<div>
|
|
38
|
+
{item.text} checked: {item.checked ? 'yes' : 'no'}
|
|
39
|
+
</div>
|
|
40
|
+
<button onClick={toggle}>Check {item.text}</button>
|
|
41
|
+
<button onClick={remove}>Remove {item.text}</button>
|
|
42
|
+
</>
|
|
43
|
+
);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const List = () => {
|
|
47
|
+
const items = useGet(itemsAtom);
|
|
48
|
+
const setItems = useSet(itemsAtom);
|
|
49
|
+
|
|
50
|
+
const addItem = () => {
|
|
51
|
+
setItems((prev) => {
|
|
52
|
+
return [
|
|
53
|
+
...prev,
|
|
54
|
+
state({
|
|
55
|
+
text: `item${String(++itemIndex)}`,
|
|
56
|
+
checked: false,
|
|
57
|
+
}),
|
|
58
|
+
];
|
|
59
|
+
});
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const removeItem = (itemAtom: State<Item>) => {
|
|
63
|
+
setItems((prev) => {
|
|
64
|
+
return prev.filter((x) => x !== itemAtom);
|
|
65
|
+
});
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<ul>
|
|
70
|
+
{items.map((itemAtom) => (
|
|
71
|
+
<ListItem
|
|
72
|
+
key={itemAtom.toString()}
|
|
73
|
+
itemAtom={itemAtom}
|
|
74
|
+
remove={() => {
|
|
75
|
+
removeItem(itemAtom);
|
|
76
|
+
}}
|
|
77
|
+
/>
|
|
78
|
+
))}
|
|
79
|
+
<li>
|
|
80
|
+
<button onClick={addItem}>Add</button>
|
|
81
|
+
</li>
|
|
82
|
+
</ul>
|
|
83
|
+
);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const store = createStore();
|
|
87
|
+
render(
|
|
88
|
+
<StrictMode>
|
|
89
|
+
<StoreProvider value={store}>
|
|
90
|
+
<List />
|
|
91
|
+
</StoreProvider>
|
|
92
|
+
</StrictMode>,
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
await userEvent.click(screen.getByText('Add'));
|
|
96
|
+
await screen.findByText('item1 checked: no');
|
|
97
|
+
|
|
98
|
+
await userEvent.click(screen.getByText('Add'));
|
|
99
|
+
await waitFor(() => {
|
|
100
|
+
screen.getByText('item1 checked: no');
|
|
101
|
+
screen.getByText('item2 checked: no');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
await userEvent.click(screen.getByText('Check item2'));
|
|
105
|
+
await waitFor(() => {
|
|
106
|
+
screen.getByText('item1 checked: no');
|
|
107
|
+
screen.getByText('item2 checked: yes');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
await userEvent.click(screen.getByText('Remove item1'));
|
|
111
|
+
await screen.findByText('item2 checked: yes');
|
|
112
|
+
|
|
113
|
+
await userEvent.click(screen.getByText('Add'));
|
|
114
|
+
await waitFor(() => {
|
|
115
|
+
screen.getByText('item2 checked: yes');
|
|
116
|
+
screen.getByText('item3 checked: no');
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('add an item with filtered list', async () => {
|
|
121
|
+
interface Item {
|
|
122
|
+
text: string;
|
|
123
|
+
checked: boolean;
|
|
124
|
+
}
|
|
125
|
+
type ItemAtoms = State<Item>[];
|
|
126
|
+
|
|
127
|
+
let itemIndex = 0;
|
|
128
|
+
const itemAtomsAtom = state<ItemAtoms>([]);
|
|
129
|
+
const setItemsAtom = command(({ set }, update: Updater<ItemAtoms>) => {
|
|
130
|
+
set(itemAtomsAtom, update);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const filterAtom = state<'all' | 'checked' | 'not-checked'>('all');
|
|
134
|
+
const filteredAtom = computed((get) => {
|
|
135
|
+
const filter = get(filterAtom);
|
|
136
|
+
const items = get(itemAtomsAtom);
|
|
137
|
+
if (filter === 'all') {
|
|
138
|
+
return items;
|
|
139
|
+
}
|
|
140
|
+
if (filter === 'checked') {
|
|
141
|
+
return items.filter((atom) => get(atom).checked);
|
|
142
|
+
}
|
|
143
|
+
return items.filter((atom) => !get(atom).checked);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const ListItem = ({ itemAtom, remove }: { itemAtom: State<Item>; remove: () => void }) => {
|
|
147
|
+
const item = useGet(itemAtom);
|
|
148
|
+
const setItem = useSet(itemAtom);
|
|
149
|
+
const toggle = () => {
|
|
150
|
+
setItem((prev) => ({ ...prev, checked: !prev.checked }));
|
|
151
|
+
};
|
|
152
|
+
return (
|
|
153
|
+
<>
|
|
154
|
+
<div>
|
|
155
|
+
{item.text} checked: {item.checked ? 'yes' : 'no'}
|
|
156
|
+
</div>
|
|
157
|
+
<button onClick={toggle}>Check {item.text}</button>
|
|
158
|
+
<button onClick={remove}>Remove {item.text}</button>
|
|
159
|
+
</>
|
|
160
|
+
);
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const Filter = () => {
|
|
164
|
+
const filter = useGet(filterAtom);
|
|
165
|
+
const setFilter = useSet(filterAtom);
|
|
166
|
+
|
|
167
|
+
return (
|
|
168
|
+
<>
|
|
169
|
+
<div>{filter}</div>
|
|
170
|
+
<button
|
|
171
|
+
onClick={() => {
|
|
172
|
+
setFilter('all');
|
|
173
|
+
}}
|
|
174
|
+
>
|
|
175
|
+
All
|
|
176
|
+
</button>
|
|
177
|
+
<button
|
|
178
|
+
onClick={() => {
|
|
179
|
+
setFilter('checked');
|
|
180
|
+
}}
|
|
181
|
+
>
|
|
182
|
+
Checked
|
|
183
|
+
</button>
|
|
184
|
+
<button
|
|
185
|
+
onClick={() => {
|
|
186
|
+
setFilter('not-checked');
|
|
187
|
+
}}
|
|
188
|
+
>
|
|
189
|
+
Not Checked
|
|
190
|
+
</button>
|
|
191
|
+
</>
|
|
192
|
+
);
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const FilteredList = ({ removeItem }: { removeItem: (itemAtom: State<Item>) => void }) => {
|
|
196
|
+
const items = useGet(filteredAtom);
|
|
197
|
+
return (
|
|
198
|
+
<ul>
|
|
199
|
+
{items.map((itemAtom) => (
|
|
200
|
+
<ListItem
|
|
201
|
+
key={itemAtom.toString()}
|
|
202
|
+
itemAtom={itemAtom}
|
|
203
|
+
remove={() => {
|
|
204
|
+
removeItem(itemAtom);
|
|
205
|
+
}}
|
|
206
|
+
/>
|
|
207
|
+
))}
|
|
208
|
+
</ul>
|
|
209
|
+
);
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
const List = () => {
|
|
213
|
+
const setItems = useSet(setItemsAtom);
|
|
214
|
+
const addItem = () => {
|
|
215
|
+
setItems((prev) => [...prev, state<Item>({ text: `item${String(++itemIndex)}`, checked: false })]);
|
|
216
|
+
};
|
|
217
|
+
const removeItem = (itemAtom: State<Item>) => {
|
|
218
|
+
setItems((prev) => prev.filter((x) => x !== itemAtom));
|
|
219
|
+
};
|
|
220
|
+
return (
|
|
221
|
+
<>
|
|
222
|
+
<Filter />
|
|
223
|
+
<button onClick={addItem}>Add</button>
|
|
224
|
+
<FilteredList removeItem={removeItem} />
|
|
225
|
+
</>
|
|
226
|
+
);
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const store = createStore();
|
|
230
|
+
render(
|
|
231
|
+
<StoreProvider value={store}>
|
|
232
|
+
<StrictMode>
|
|
233
|
+
<List />
|
|
234
|
+
</StrictMode>
|
|
235
|
+
</StoreProvider>,
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
await userEvent.click(screen.getByText('Checked'));
|
|
239
|
+
await userEvent.click(screen.getByText('Add'));
|
|
240
|
+
await userEvent.click(screen.getByText('All'));
|
|
241
|
+
await screen.findByText('item1 checked: no');
|
|
242
|
+
});
|