jotai-state-tree 0.1.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/LICENSE +21 -0
- package/README.md +168 -0
- package/dist/chunk-XXZK62DD.mjs +931 -0
- package/dist/index.d.mts +1109 -0
- package/dist/index.d.ts +1109 -0
- package/dist/index.js +3579 -0
- package/dist/index.mjs +2625 -0
- package/dist/react.d.mts +144 -0
- package/dist/react.d.ts +144 -0
- package/dist/react.js +1259 -0
- package/dist/react.mjs +372 -0
- package/package.json +77 -0
- package/src/__tests__/index.test.ts +1371 -0
- package/src/__tests__/memory.test.ts +681 -0
- package/src/__tests__/performance.test.ts +667 -0
- package/src/__tests__/react.react.test.tsx +811 -0
- package/src/__tests__/registry.test.ts +589 -0
- package/src/array.ts +335 -0
- package/src/compat.ts +294 -0
- package/src/index.ts +647 -0
- package/src/lifecycle.ts +580 -0
- package/src/map.ts +276 -0
- package/src/model.ts +832 -0
- package/src/primitives.ts +400 -0
- package/src/react.ts +626 -0
- package/src/registry.ts +741 -0
- package/src/tree.ts +1275 -0
- package/src/types.ts +520 -0
- package/src/undo.ts +566 -0
- package/src/utilities.ts +616 -0
|
@@ -0,0 +1,811 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import React, { useState, useEffect } from "react";
|
|
6
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
7
|
+
import { render, screen, act, waitFor, cleanup } from "@testing-library/react";
|
|
8
|
+
import userEvent from "@testing-library/user-event";
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
types,
|
|
12
|
+
destroy,
|
|
13
|
+
getSnapshot,
|
|
14
|
+
onSnapshot,
|
|
15
|
+
clearAllRegistries,
|
|
16
|
+
resetGlobalStore,
|
|
17
|
+
getRegistryStats,
|
|
18
|
+
} from "../index";
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
observer,
|
|
22
|
+
Observer,
|
|
23
|
+
useLocalObservable,
|
|
24
|
+
useSnapshot,
|
|
25
|
+
useIsAlive,
|
|
26
|
+
Provider,
|
|
27
|
+
useStore,
|
|
28
|
+
useStoreSnapshot,
|
|
29
|
+
useSyncedStore,
|
|
30
|
+
batch,
|
|
31
|
+
createStoreContext,
|
|
32
|
+
} from "../react";
|
|
33
|
+
|
|
34
|
+
import type { Instance } from "../index";
|
|
35
|
+
|
|
36
|
+
// ============================================================================
|
|
37
|
+
// Test Setup
|
|
38
|
+
// ============================================================================
|
|
39
|
+
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
clearAllRegistries();
|
|
42
|
+
resetGlobalStore();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
afterEach(() => {
|
|
46
|
+
cleanup();
|
|
47
|
+
clearAllRegistries();
|
|
48
|
+
resetGlobalStore();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// ============================================================================
|
|
52
|
+
// Model Definitions for Tests
|
|
53
|
+
// ============================================================================
|
|
54
|
+
|
|
55
|
+
const CounterModel = types
|
|
56
|
+
.model("Counter", {
|
|
57
|
+
count: types.number,
|
|
58
|
+
})
|
|
59
|
+
.actions((self) => ({
|
|
60
|
+
increment() {
|
|
61
|
+
self.count += 1;
|
|
62
|
+
},
|
|
63
|
+
decrement() {
|
|
64
|
+
self.count -= 1;
|
|
65
|
+
},
|
|
66
|
+
setCount(value: number) {
|
|
67
|
+
self.count = value;
|
|
68
|
+
},
|
|
69
|
+
}));
|
|
70
|
+
|
|
71
|
+
const TodoModel = types.model("Todo", {
|
|
72
|
+
id: types.identifier,
|
|
73
|
+
text: types.string,
|
|
74
|
+
completed: types.boolean,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const TodoListModel = types
|
|
78
|
+
.model("TodoList", {
|
|
79
|
+
todos: types.array(TodoModel),
|
|
80
|
+
})
|
|
81
|
+
.views((self) => ({
|
|
82
|
+
get completedCount() {
|
|
83
|
+
return self.todos.filter((t) => t.completed).length;
|
|
84
|
+
},
|
|
85
|
+
get pendingCount() {
|
|
86
|
+
return self.todos.filter((t) => !t.completed).length;
|
|
87
|
+
},
|
|
88
|
+
}))
|
|
89
|
+
.actions((self) => ({
|
|
90
|
+
addTodo(id: string, text: string) {
|
|
91
|
+
self.todos.push({ id, text, completed: false });
|
|
92
|
+
},
|
|
93
|
+
toggleTodo(id: string) {
|
|
94
|
+
const todo = self.todos.find((t) => t.id === id);
|
|
95
|
+
if (todo) {
|
|
96
|
+
todo.completed = !todo.completed;
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
removeTodo(id: string) {
|
|
100
|
+
const index = self.todos.findIndex((t) => t.id === id);
|
|
101
|
+
if (index >= 0) {
|
|
102
|
+
self.todos.splice(index, 1);
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
}));
|
|
106
|
+
|
|
107
|
+
// ============================================================================
|
|
108
|
+
// Observer HOC Tests
|
|
109
|
+
// ============================================================================
|
|
110
|
+
|
|
111
|
+
describe("React Integration", () => {
|
|
112
|
+
describe("observer HOC", () => {
|
|
113
|
+
it("should re-render when observed state changes", async () => {
|
|
114
|
+
const counter = CounterModel.create({ count: 0 });
|
|
115
|
+
let renderCount = 0;
|
|
116
|
+
|
|
117
|
+
const CounterDisplay = observer(function CounterDisplay({
|
|
118
|
+
store,
|
|
119
|
+
}: {
|
|
120
|
+
store: typeof counter;
|
|
121
|
+
}) {
|
|
122
|
+
renderCount++;
|
|
123
|
+
return <div data-testid="count">{store.count}</div>;
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
render(<CounterDisplay store={counter} />);
|
|
127
|
+
|
|
128
|
+
expect(screen.getByTestId("count").textContent).toBe("0");
|
|
129
|
+
expect(renderCount).toBe(1);
|
|
130
|
+
|
|
131
|
+
act(() => {
|
|
132
|
+
counter.increment();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
await waitFor(() => {
|
|
136
|
+
expect(screen.getByTestId("count").textContent).toBe("1");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
expect(renderCount).toBeGreaterThanOrEqual(2);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("should not re-render when unrelated state changes", async () => {
|
|
143
|
+
const Store = types
|
|
144
|
+
.model("Store", {
|
|
145
|
+
count: types.number,
|
|
146
|
+
unrelated: types.string,
|
|
147
|
+
})
|
|
148
|
+
.actions((self) => ({
|
|
149
|
+
setUnrelated(val: string) {
|
|
150
|
+
self.unrelated = val;
|
|
151
|
+
},
|
|
152
|
+
increment() {
|
|
153
|
+
self.count += 1;
|
|
154
|
+
},
|
|
155
|
+
}));
|
|
156
|
+
|
|
157
|
+
const store = Store.create({ count: 0, unrelated: "initial" });
|
|
158
|
+
let renderCount = 0;
|
|
159
|
+
|
|
160
|
+
// Component only accesses count, not unrelated
|
|
161
|
+
const CountOnly = observer(function CountOnly({
|
|
162
|
+
s,
|
|
163
|
+
}: {
|
|
164
|
+
s: typeof store;
|
|
165
|
+
}) {
|
|
166
|
+
renderCount++;
|
|
167
|
+
return <div data-testid="count">{s.count}</div>;
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
render(<CountOnly s={store} />);
|
|
171
|
+
expect(renderCount).toBe(1);
|
|
172
|
+
|
|
173
|
+
// Change unrelated field - should still trigger since we subscribe to the whole node
|
|
174
|
+
act(() => {
|
|
175
|
+
store.setUnrelated("changed");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// Give time for any potential re-renders
|
|
179
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
180
|
+
|
|
181
|
+
// The observer subscribes to snapshot changes on the node, so it will re-render
|
|
182
|
+
// This is expected behavior - fine-grained tracking would require more complex implementation
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("should handle nested state tree nodes", async () => {
|
|
186
|
+
const todoList = TodoListModel.create({
|
|
187
|
+
todos: [
|
|
188
|
+
{ id: "1", text: "First", completed: false },
|
|
189
|
+
{ id: "2", text: "Second", completed: true },
|
|
190
|
+
],
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
const TodoListView = observer(function TodoListView({
|
|
194
|
+
list,
|
|
195
|
+
}: {
|
|
196
|
+
list: typeof todoList;
|
|
197
|
+
}) {
|
|
198
|
+
return (
|
|
199
|
+
<div>
|
|
200
|
+
<div data-testid="completed">{list.completedCount}</div>
|
|
201
|
+
<div data-testid="pending">{list.pendingCount}</div>
|
|
202
|
+
<ul>
|
|
203
|
+
{list.todos.map((todo) => (
|
|
204
|
+
<li key={todo.id} data-testid={`todo-${todo.id}`}>
|
|
205
|
+
{todo.text}: {todo.completed ? "done" : "pending"}
|
|
206
|
+
</li>
|
|
207
|
+
))}
|
|
208
|
+
</ul>
|
|
209
|
+
</div>
|
|
210
|
+
);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
render(<TodoListView list={todoList} />);
|
|
214
|
+
|
|
215
|
+
expect(screen.getByTestId("completed").textContent).toBe("1");
|
|
216
|
+
expect(screen.getByTestId("pending").textContent).toBe("1");
|
|
217
|
+
|
|
218
|
+
act(() => {
|
|
219
|
+
todoList.toggleTodo("1");
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
await waitFor(() => {
|
|
223
|
+
expect(screen.getByTestId("completed").textContent).toBe("2");
|
|
224
|
+
expect(screen.getByTestId("pending").textContent).toBe("0");
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// ============================================================================
|
|
230
|
+
// Observer Component (Render Props) Tests
|
|
231
|
+
// ============================================================================
|
|
232
|
+
|
|
233
|
+
describe("Observer component", () => {
|
|
234
|
+
it("should work with render props pattern when store is passed as prop", async () => {
|
|
235
|
+
const counter = CounterModel.create({ count: 5 });
|
|
236
|
+
|
|
237
|
+
// Observer works best when the store is passed as a prop to the wrapper
|
|
238
|
+
// For closure-based access, use useSnapshot hook instead
|
|
239
|
+
const ObserverWrapper = observer(function ObserverWrapper({
|
|
240
|
+
store,
|
|
241
|
+
}: {
|
|
242
|
+
store: typeof counter;
|
|
243
|
+
}) {
|
|
244
|
+
return <div data-testid="count">{store.count}</div>;
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
render(<ObserverWrapper store={counter} />);
|
|
248
|
+
|
|
249
|
+
expect(screen.getByTestId("count").textContent).toBe("5");
|
|
250
|
+
|
|
251
|
+
act(() => {
|
|
252
|
+
counter.increment();
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
await waitFor(() => {
|
|
256
|
+
expect(screen.getByTestId("count").textContent).toBe("6");
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("should work with useSnapshot for closure-based access", async () => {
|
|
261
|
+
const counter = CounterModel.create({ count: 5 });
|
|
262
|
+
|
|
263
|
+
function CounterDisplay() {
|
|
264
|
+
const snapshot = useSnapshot<{ count: number }>(counter);
|
|
265
|
+
return <div data-testid="count">{snapshot.count}</div>;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
render(<CounterDisplay />);
|
|
269
|
+
|
|
270
|
+
expect(screen.getByTestId("count").textContent).toBe("5");
|
|
271
|
+
|
|
272
|
+
act(() => {
|
|
273
|
+
counter.increment();
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
await waitFor(() => {
|
|
277
|
+
expect(screen.getByTestId("count").textContent).toBe("6");
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
// ============================================================================
|
|
283
|
+
// useLocalObservable Tests
|
|
284
|
+
// ============================================================================
|
|
285
|
+
|
|
286
|
+
describe("useLocalObservable", () => {
|
|
287
|
+
it("should create and manage local state", async () => {
|
|
288
|
+
function LocalCounter() {
|
|
289
|
+
const store = useLocalObservable(() =>
|
|
290
|
+
CounterModel.create({ count: 0 }),
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
return (
|
|
294
|
+
<div>
|
|
295
|
+
<span data-testid="count">{store.count}</span>
|
|
296
|
+
<button onClick={() => store.increment()}>+</button>
|
|
297
|
+
</div>
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
render(<LocalCounter />);
|
|
302
|
+
|
|
303
|
+
expect(screen.getByTestId("count").textContent).toBe("0");
|
|
304
|
+
|
|
305
|
+
await act(async () => {
|
|
306
|
+
await userEvent.click(screen.getByText("+"));
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
await waitFor(() => {
|
|
310
|
+
expect(screen.getByTestId("count").textContent).toBe("1");
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("should cleanup on unmount", async () => {
|
|
315
|
+
const statsBefore = getRegistryStats();
|
|
316
|
+
|
|
317
|
+
function LocalCounter() {
|
|
318
|
+
const store = useLocalObservable(() =>
|
|
319
|
+
CounterModel.create({ count: 0 }),
|
|
320
|
+
);
|
|
321
|
+
return <div>{store.count}</div>;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const { unmount } = render(<LocalCounter />);
|
|
325
|
+
|
|
326
|
+
const statsAfterMount = getRegistryStats();
|
|
327
|
+
expect(statsAfterMount.liveNodeCount).toBeGreaterThan(
|
|
328
|
+
statsBefore.liveNodeCount,
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
unmount();
|
|
332
|
+
|
|
333
|
+
// Note: The store itself isn't automatically destroyed on unmount
|
|
334
|
+
// Users need to handle that in their own cleanup if needed
|
|
335
|
+
});
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
// ============================================================================
|
|
339
|
+
// useSnapshot Tests
|
|
340
|
+
// ============================================================================
|
|
341
|
+
|
|
342
|
+
describe("useSnapshot", () => {
|
|
343
|
+
it("should return current snapshot and update on changes", async () => {
|
|
344
|
+
const counter = CounterModel.create({ count: 10 });
|
|
345
|
+
|
|
346
|
+
function SnapshotDisplay({ store }: { store: typeof counter }) {
|
|
347
|
+
const snapshot = useSnapshot<{ count: number }>(store);
|
|
348
|
+
return <div data-testid="snapshot">{snapshot.count}</div>;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
render(<SnapshotDisplay store={counter} />);
|
|
352
|
+
|
|
353
|
+
expect(screen.getByTestId("snapshot").textContent).toBe("10");
|
|
354
|
+
|
|
355
|
+
act(() => {
|
|
356
|
+
counter.setCount(20);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
await waitFor(() => {
|
|
360
|
+
expect(screen.getByTestId("snapshot").textContent).toBe("20");
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
// ============================================================================
|
|
366
|
+
// useIsAlive Tests
|
|
367
|
+
// ============================================================================
|
|
368
|
+
|
|
369
|
+
describe("useIsAlive", () => {
|
|
370
|
+
it("should return true for alive nodes", () => {
|
|
371
|
+
const counter = CounterModel.create({ count: 0 });
|
|
372
|
+
|
|
373
|
+
function AliveCheck({ store }: { store: typeof counter }) {
|
|
374
|
+
const isAlive = useIsAlive(store);
|
|
375
|
+
return <div data-testid="alive">{isAlive ? "yes" : "no"}</div>;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
render(<AliveCheck store={counter} />);
|
|
379
|
+
expect(screen.getByTestId("alive").textContent).toBe("yes");
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it("should update when node is destroyed", async () => {
|
|
383
|
+
const counter = CounterModel.create({ count: 0 });
|
|
384
|
+
|
|
385
|
+
function AliveCheck({ store }: { store: typeof counter }) {
|
|
386
|
+
const isAlive = useIsAlive(store);
|
|
387
|
+
return <div data-testid="alive">{isAlive ? "yes" : "no"}</div>;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
render(<AliveCheck store={counter} />);
|
|
391
|
+
expect(screen.getByTestId("alive").textContent).toBe("yes");
|
|
392
|
+
|
|
393
|
+
act(() => {
|
|
394
|
+
destroy(counter);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
await waitFor(() => {
|
|
398
|
+
expect(screen.getByTestId("alive").textContent).toBe("no");
|
|
399
|
+
});
|
|
400
|
+
});
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
// ============================================================================
|
|
404
|
+
// Provider/useStore Tests
|
|
405
|
+
// ============================================================================
|
|
406
|
+
|
|
407
|
+
describe("Provider and useStore", () => {
|
|
408
|
+
it("should provide store to children", () => {
|
|
409
|
+
const counter = CounterModel.create({ count: 42 });
|
|
410
|
+
|
|
411
|
+
function CounterConsumer() {
|
|
412
|
+
const store = useStore<typeof counter>();
|
|
413
|
+
return <div data-testid="count">{store.count}</div>;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
render(
|
|
417
|
+
<Provider store={counter}>
|
|
418
|
+
<CounterConsumer />
|
|
419
|
+
</Provider>,
|
|
420
|
+
);
|
|
421
|
+
|
|
422
|
+
expect(screen.getByTestId("count").textContent).toBe("42");
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
it("should throw when useStore is called outside Provider", () => {
|
|
426
|
+
function BadComponent() {
|
|
427
|
+
const store = useStore();
|
|
428
|
+
return <div>{String(store)}</div>;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
expect(() => render(<BadComponent />)).toThrow(
|
|
432
|
+
"[jotai-state-tree] useStore must be used within a Provider",
|
|
433
|
+
);
|
|
434
|
+
});
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
// ============================================================================
|
|
438
|
+
// useStoreSnapshot Tests
|
|
439
|
+
// ============================================================================
|
|
440
|
+
|
|
441
|
+
describe("useStoreSnapshot (legacy)", () => {
|
|
442
|
+
it("should return store and update on changes", async () => {
|
|
443
|
+
type CounterInstance = Instance<typeof CounterModel>;
|
|
444
|
+
const counter = CounterModel.create({ count: 100 });
|
|
445
|
+
|
|
446
|
+
function StoreConsumer() {
|
|
447
|
+
// Legacy API requires explicit type parameter
|
|
448
|
+
const store = useStoreSnapshot<CounterInstance>();
|
|
449
|
+
return <div data-testid="count">{store.count}</div>;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
render(
|
|
453
|
+
<Provider store={counter}>
|
|
454
|
+
<StoreConsumer />
|
|
455
|
+
</Provider>,
|
|
456
|
+
);
|
|
457
|
+
|
|
458
|
+
expect(screen.getByTestId("count").textContent).toBe("100");
|
|
459
|
+
|
|
460
|
+
act(() => {
|
|
461
|
+
counter.setCount(200);
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
await waitFor(() => {
|
|
465
|
+
expect(screen.getByTestId("count").textContent).toBe("200");
|
|
466
|
+
});
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
it("should work with selector", async () => {
|
|
470
|
+
type TodoListInstance = Instance<typeof TodoListModel>;
|
|
471
|
+
const todoList = TodoListModel.create({
|
|
472
|
+
todos: [
|
|
473
|
+
{ id: "1", text: "One", completed: false },
|
|
474
|
+
{ id: "2", text: "Two", completed: true },
|
|
475
|
+
],
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
function CompletedCounter() {
|
|
479
|
+
// Legacy API with selector - explicitly type both store and return
|
|
480
|
+
const count = useStoreSnapshot<TodoListInstance, number>(
|
|
481
|
+
(store) => store.completedCount,
|
|
482
|
+
);
|
|
483
|
+
return <div data-testid="completed">{count}</div>;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
render(
|
|
487
|
+
<Provider store={todoList}>
|
|
488
|
+
<CompletedCounter />
|
|
489
|
+
</Provider>,
|
|
490
|
+
);
|
|
491
|
+
|
|
492
|
+
expect(screen.getByTestId("completed").textContent).toBe("1");
|
|
493
|
+
|
|
494
|
+
act(() => {
|
|
495
|
+
todoList.toggleTodo("1");
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
await waitFor(() => {
|
|
499
|
+
expect(screen.getByTestId("completed").textContent).toBe("2");
|
|
500
|
+
});
|
|
501
|
+
});
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
// ============================================================================
|
|
505
|
+
// useSyncedStore Tests
|
|
506
|
+
// ============================================================================
|
|
507
|
+
|
|
508
|
+
describe("useSyncedStore", () => {
|
|
509
|
+
it("should work with useSyncExternalStore", async () => {
|
|
510
|
+
const counter = CounterModel.create({ count: 0 });
|
|
511
|
+
|
|
512
|
+
function SyncedCounter({ store }: { store: typeof counter }) {
|
|
513
|
+
const syncedStore = useSyncedStore(store);
|
|
514
|
+
return <div data-testid="count">{syncedStore.count}</div>;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
render(<SyncedCounter store={counter} />);
|
|
518
|
+
|
|
519
|
+
expect(screen.getByTestId("count").textContent).toBe("0");
|
|
520
|
+
|
|
521
|
+
act(() => {
|
|
522
|
+
counter.increment();
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
await waitFor(() => {
|
|
526
|
+
expect(screen.getByTestId("count").textContent).toBe("1");
|
|
527
|
+
});
|
|
528
|
+
});
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
// ============================================================================
|
|
532
|
+
// Batch Updates Tests
|
|
533
|
+
// ============================================================================
|
|
534
|
+
|
|
535
|
+
describe("batch", () => {
|
|
536
|
+
it("should batch multiple updates", async () => {
|
|
537
|
+
const counter = CounterModel.create({ count: 0 });
|
|
538
|
+
let snapshotCallCount = 0;
|
|
539
|
+
|
|
540
|
+
onSnapshot(counter, () => {
|
|
541
|
+
snapshotCallCount++;
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
act(() => {
|
|
545
|
+
batch(() => {
|
|
546
|
+
counter.increment();
|
|
547
|
+
counter.increment();
|
|
548
|
+
counter.increment();
|
|
549
|
+
});
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
// Each increment triggers its own snapshot notification
|
|
553
|
+
// batch() helps with React scheduling, not MST internal notifications
|
|
554
|
+
expect(counter.count).toBe(3);
|
|
555
|
+
});
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
// ============================================================================
|
|
559
|
+
// Memory Leak Prevention Tests
|
|
560
|
+
// ============================================================================
|
|
561
|
+
|
|
562
|
+
describe("Memory management in React", () => {
|
|
563
|
+
it("should cleanup subscriptions on unmount", async () => {
|
|
564
|
+
const counter = CounterModel.create({ count: 0 });
|
|
565
|
+
|
|
566
|
+
function CounterDisplay({ store }: { store: typeof counter }) {
|
|
567
|
+
const snapshot = useSnapshot<{ count: number }>(store);
|
|
568
|
+
return <div data-testid="count">{snapshot.count}</div>;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const { unmount } = render(<CounterDisplay store={counter} />);
|
|
572
|
+
|
|
573
|
+
// Component should have subscribed
|
|
574
|
+
expect(screen.getByTestId("count").textContent).toBe("0");
|
|
575
|
+
|
|
576
|
+
// Unmount - subscriptions should be cleaned up
|
|
577
|
+
unmount();
|
|
578
|
+
|
|
579
|
+
// Changing state should not cause issues (no dangling listeners)
|
|
580
|
+
act(() => {
|
|
581
|
+
counter.increment();
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
// No errors should occur, state should be updated
|
|
585
|
+
expect(counter.count).toBe(1);
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
it("should handle rapid mount/unmount cycles", async () => {
|
|
589
|
+
const counter = CounterModel.create({ count: 0 });
|
|
590
|
+
|
|
591
|
+
function CounterDisplay({ store }: { store: typeof counter }) {
|
|
592
|
+
const isAlive = useIsAlive(store);
|
|
593
|
+
return <div data-testid="alive">{isAlive ? "yes" : "no"}</div>;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// Mount and unmount rapidly
|
|
597
|
+
for (let i = 0; i < 10; i++) {
|
|
598
|
+
const { unmount } = render(<CounterDisplay store={counter} />);
|
|
599
|
+
unmount();
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// Should not have leaked listeners or caused errors
|
|
603
|
+
expect(counter.count).toBe(0);
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
it("should handle store destruction during component lifecycle", async () => {
|
|
607
|
+
const counter = CounterModel.create({ count: 0 });
|
|
608
|
+
|
|
609
|
+
function CounterDisplay({ store }: { store: typeof counter }) {
|
|
610
|
+
const isAlive = useIsAlive(store);
|
|
611
|
+
const [error, setError] = useState<string | null>(null);
|
|
612
|
+
|
|
613
|
+
useEffect(() => {
|
|
614
|
+
try {
|
|
615
|
+
if (!isAlive) {
|
|
616
|
+
// Store was destroyed
|
|
617
|
+
}
|
|
618
|
+
} catch (e) {
|
|
619
|
+
setError(String(e));
|
|
620
|
+
}
|
|
621
|
+
}, [isAlive]);
|
|
622
|
+
|
|
623
|
+
if (error) return <div data-testid="error">{error}</div>;
|
|
624
|
+
return <div data-testid="alive">{isAlive ? "yes" : "no"}</div>;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
render(<CounterDisplay store={counter} />);
|
|
628
|
+
|
|
629
|
+
expect(screen.getByTestId("alive").textContent).toBe("yes");
|
|
630
|
+
|
|
631
|
+
act(() => {
|
|
632
|
+
destroy(counter);
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
await waitFor(() => {
|
|
636
|
+
expect(screen.getByTestId("alive").textContent).toBe("no");
|
|
637
|
+
});
|
|
638
|
+
});
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
// ============================================================================
|
|
642
|
+
// Edge Cases
|
|
643
|
+
// ============================================================================
|
|
644
|
+
|
|
645
|
+
describe("Edge cases", () => {
|
|
646
|
+
it("should handle null/undefined props gracefully", () => {
|
|
647
|
+
const NullableDisplay = observer(function NullableDisplay({
|
|
648
|
+
store,
|
|
649
|
+
}: {
|
|
650
|
+
store: ReturnType<typeof CounterModel.create> | null;
|
|
651
|
+
}) {
|
|
652
|
+
if (!store) return <div data-testid="empty">No store</div>;
|
|
653
|
+
return <div data-testid="count">{store.count}</div>;
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
render(<NullableDisplay store={null} />);
|
|
657
|
+
expect(screen.getByTestId("empty").textContent).toBe("No store");
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
it("should handle store prop changes", async () => {
|
|
661
|
+
const counter1 = CounterModel.create({ count: 1 });
|
|
662
|
+
const counter2 = CounterModel.create({ count: 2 });
|
|
663
|
+
|
|
664
|
+
function Wrapper() {
|
|
665
|
+
const [store, setStore] = useState(counter1);
|
|
666
|
+
|
|
667
|
+
return (
|
|
668
|
+
<div>
|
|
669
|
+
<Observer>
|
|
670
|
+
{() => <div data-testid="count">{store.count}</div>}
|
|
671
|
+
</Observer>
|
|
672
|
+
<button onClick={() => setStore(counter2)}>Switch</button>
|
|
673
|
+
</div>
|
|
674
|
+
);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
render(<Wrapper />);
|
|
678
|
+
expect(screen.getByTestId("count").textContent).toBe("1");
|
|
679
|
+
|
|
680
|
+
await act(async () => {
|
|
681
|
+
await userEvent.click(screen.getByText("Switch"));
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
expect(screen.getByTestId("count").textContent).toBe("2");
|
|
685
|
+
});
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
// ============================================================================
|
|
689
|
+
// Typed Store Context Tests
|
|
690
|
+
// ============================================================================
|
|
691
|
+
|
|
692
|
+
describe("createStoreContext (typed)", () => {
|
|
693
|
+
// Create typed context once for these tests
|
|
694
|
+
type CounterInstance = Instance<typeof CounterModel>;
|
|
695
|
+
const CounterContext = createStoreContext<CounterInstance>();
|
|
696
|
+
|
|
697
|
+
it("should provide fully typed store access", () => {
|
|
698
|
+
const counter = CounterModel.create({ count: 42 });
|
|
699
|
+
|
|
700
|
+
function TypedCounterConsumer() {
|
|
701
|
+
// store is fully typed - no need for type assertion
|
|
702
|
+
const store = CounterContext.useStore();
|
|
703
|
+
// TypeScript knows store.count is a number and store.increment() exists
|
|
704
|
+
return (
|
|
705
|
+
<div>
|
|
706
|
+
<span data-testid="count">{store.count}</span>
|
|
707
|
+
<button onClick={() => store.increment()}>+</button>
|
|
708
|
+
</div>
|
|
709
|
+
);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
render(
|
|
713
|
+
<CounterContext.Provider store={counter}>
|
|
714
|
+
<TypedCounterConsumer />
|
|
715
|
+
</CounterContext.Provider>,
|
|
716
|
+
);
|
|
717
|
+
|
|
718
|
+
expect(screen.getByTestId("count").textContent).toBe("42");
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
it("should provide typed snapshot with updates", async () => {
|
|
722
|
+
const counter = CounterModel.create({ count: 0 });
|
|
723
|
+
|
|
724
|
+
function TypedSnapshotConsumer() {
|
|
725
|
+
// Fully typed - knows it returns CounterInstance
|
|
726
|
+
const store = CounterContext.useStoreSnapshot();
|
|
727
|
+
return <div data-testid="count">{store.count}</div>;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
render(
|
|
731
|
+
<CounterContext.Provider store={counter}>
|
|
732
|
+
<TypedSnapshotConsumer />
|
|
733
|
+
</CounterContext.Provider>,
|
|
734
|
+
);
|
|
735
|
+
|
|
736
|
+
expect(screen.getByTestId("count").textContent).toBe("0");
|
|
737
|
+
|
|
738
|
+
act(() => {
|
|
739
|
+
counter.increment();
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
await waitFor(() => {
|
|
743
|
+
expect(screen.getByTestId("count").textContent).toBe("1");
|
|
744
|
+
});
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
it("should support typed selector", async () => {
|
|
748
|
+
type TodoListInstance = Instance<typeof TodoListModel>;
|
|
749
|
+
const TodoContext = createStoreContext<TodoListInstance>();
|
|
750
|
+
|
|
751
|
+
const todoList = TodoListModel.create({
|
|
752
|
+
todos: [
|
|
753
|
+
{ id: "1", text: "One", completed: false },
|
|
754
|
+
{ id: "2", text: "Two", completed: true },
|
|
755
|
+
],
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
function CompletedCount() {
|
|
759
|
+
// Selector is typed: (store: TodoListInstance) => number
|
|
760
|
+
const count = TodoContext.useStoreSnapshot(
|
|
761
|
+
(store) => store.completedCount,
|
|
762
|
+
);
|
|
763
|
+
return <div data-testid="completed">{count}</div>;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
render(
|
|
767
|
+
<TodoContext.Provider store={todoList}>
|
|
768
|
+
<CompletedCount />
|
|
769
|
+
</TodoContext.Provider>,
|
|
770
|
+
);
|
|
771
|
+
|
|
772
|
+
expect(screen.getByTestId("completed").textContent).toBe("1");
|
|
773
|
+
|
|
774
|
+
act(() => {
|
|
775
|
+
todoList.toggleTodo("1");
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
await waitFor(() => {
|
|
779
|
+
expect(screen.getByTestId("completed").textContent).toBe("2");
|
|
780
|
+
});
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
it("should throw when used outside provider", () => {
|
|
784
|
+
function BadComponent() {
|
|
785
|
+
const store = CounterContext.useStore();
|
|
786
|
+
return <div>{store.count}</div>;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
expect(() => render(<BadComponent />)).toThrow(
|
|
790
|
+
"[jotai-state-tree] useStore must be used within a Provider",
|
|
791
|
+
);
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
it("should provide typed useIsAlive hook", () => {
|
|
795
|
+
const counter = CounterModel.create({ count: 0 });
|
|
796
|
+
|
|
797
|
+
function AliveChecker() {
|
|
798
|
+
const isAlive = CounterContext.useIsAlive();
|
|
799
|
+
return <div data-testid="alive">{isAlive ? "yes" : "no"}</div>;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
render(
|
|
803
|
+
<CounterContext.Provider store={counter}>
|
|
804
|
+
<AliveChecker />
|
|
805
|
+
</CounterContext.Provider>,
|
|
806
|
+
);
|
|
807
|
+
|
|
808
|
+
expect(screen.getByTestId("alive").textContent).toBe("yes");
|
|
809
|
+
});
|
|
810
|
+
});
|
|
811
|
+
});
|