Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
sagemathinc
GitHub Repository: sagemathinc/cocalc
Path: blob/master/src/packages/frontend/app-framework/redux-hooks.test.tsx
6570 views
1
/*
2
* This file is part of CoCalc: Copyright © 2020-2026 Sagemath, Inc.
3
* License: MS-RSL – see LICENSE.md for details
4
*/
5
6
// cocalc/src/packages/frontend/app-framework/redux-hooks.test.tsx
7
8
import { act, cleanup, render, waitFor } from "@testing-library/react";
9
import { List, Map } from "immutable";
10
import { useEffect } from "react";
11
12
import type { AccountState } from "@cocalc/frontend/account/types";
13
import {
14
project_redux_name,
15
redux,
16
redux_name,
17
} from "@cocalc/frontend/app-framework";
18
import { Store } from "@cocalc/util/redux/Store";
19
import { useEditorRedux, useRedux, useTypedRedux } from "./redux-hooks";
20
21
// Avoid opening real socket connections during unit tests. Some imports in
22
// app-framework pull in webapp-client, which otherwise starts a client.
23
jest.mock("@cocalc/frontend/webapp-client", () => ({
24
WebappClient: function WebappClient() {},
25
webapp_client: {
26
sync_client: {
27
synctable_no_changefeed: jest.fn(() => ({
28
on: jest.fn(),
29
close: jest.fn(),
30
set: jest.fn(),
31
save: jest.fn(),
32
})),
33
sync_table: jest.fn(() => ({
34
on: jest.fn(),
35
close: jest.fn(),
36
set: jest.fn(),
37
save: jest.fn(),
38
})),
39
},
40
},
41
}));
42
43
type EditorState = {
44
tasks: number;
45
pages: number;
46
};
47
48
type SelectorProjectState = {
49
base: number;
50
other: number;
51
doubled: number;
52
};
53
54
class SelectorProjectStore extends Store<SelectorProjectState> {
55
constructor(name: string, appRedux: any) {
56
super(name, appRedux);
57
this.setup_selectors();
58
}
59
60
selectors = {
61
doubled: {
62
dependencies: ["base"] as const,
63
fn: () => this.get("base") * 2,
64
},
65
};
66
}
67
68
const PROJECT_ID = "00000000-0000-4000-8000-000000000000";
69
const NOTEBOOK_PATH = "notebooks/example.ipynb";
70
71
let storeSeq = 0;
72
const cleanupStores: string[] = [];
73
74
function createStoreName(prefix: string) {
75
storeSeq += 1;
76
return `${prefix}-${storeSeq}`;
77
}
78
79
function trackStore(name: string) {
80
cleanupStores.push(name);
81
return name;
82
}
83
84
afterEach(() => {
85
cleanup();
86
for (const name of cleanupStores.splice(0)) {
87
redux.removeStore(name);
88
}
89
});
90
91
describe("redux-hooks", () => {
92
it("useRedux only re-renders when the selected field changes (named store)", async () => {
93
const storeName = trackStore(createStoreName("redux-hooks-test"));
94
const store = redux.createStore<{ foo: number; bar: number }>(storeName);
95
store.setState({ foo: 1, bar: 1 });
96
const onRender = jest.fn();
97
98
function Foo() {
99
const foo = useRedux([storeName, "foo"]);
100
useEffect(() => {
101
onRender(foo);
102
});
103
return <div>{foo}</div>;
104
}
105
106
render(<Foo />);
107
await waitFor(() => expect(onRender).toHaveBeenCalledTimes(1));
108
109
// Update an unrelated field; useRedux should not trigger a re-render.
110
act(() => {
111
store.setState({ bar: 2 });
112
});
113
expect(onRender).toHaveBeenCalledTimes(1);
114
115
// Update the watched field; useRedux should re-render once.
116
act(() => {
117
store.setState({ foo: 2 });
118
});
119
await waitFor(() => expect(onRender).toHaveBeenCalledTimes(2));
120
});
121
122
it("useRedux does not re-render when setting the same primitive value", async () => {
123
const storeName = trackStore(createStoreName("redux-hooks-test"));
124
const store = redux.createStore<{ foo: number; bar: number }>(storeName);
125
store.setState({ foo: 1, bar: 1 });
126
const onRender = jest.fn();
127
128
function Foo() {
129
const foo = useRedux([storeName, "foo"]);
130
useEffect(() => {
131
onRender(foo);
132
});
133
return <div>{foo}</div>;
134
}
135
136
render(<Foo />);
137
await waitFor(() => expect(onRender).toHaveBeenCalledTimes(1));
138
139
// Setting the same primitive value should not cause a re-render.
140
act(() => {
141
store.setState({ foo: 1 });
142
});
143
expect(onRender).toHaveBeenCalledTimes(1);
144
});
145
146
it("useRedux re-renders for immutable values when the reference changes", async () => {
147
const storeName = trackStore(createStoreName("redux-hooks-test"));
148
const store = redux.createStore<{ items: List<number>; other: number }>(
149
storeName,
150
);
151
store.setState({ items: List([1, 2]), other: 1 });
152
const onRender = jest.fn();
153
154
function Items() {
155
const items = useRedux([storeName, "items"]);
156
useEffect(() => {
157
onRender(items);
158
});
159
return <div>{items?.size ?? 0}</div>;
160
}
161
162
render(<Items />);
163
await waitFor(() => expect(onRender).toHaveBeenCalledTimes(1));
164
165
// Update unrelated field; should not re-render.
166
act(() => {
167
store.setState({ other: 2 });
168
});
169
expect(onRender).toHaveBeenCalledTimes(1);
170
171
// New List reference (even with same contents) should re-render.
172
act(() => {
173
store.setState({ items: List([1, 2]) });
174
});
175
await waitFor(() => expect(onRender).toHaveBeenCalledTimes(2));
176
});
177
178
it("useRedux preserves function-valued fields without invoking them", async () => {
179
const storeName = trackStore(createStoreName("redux-hooks-test"));
180
const fn = jest.fn(() => 7);
181
const store = redux.createStore<{
182
fn: () => number;
183
other: number;
184
}>(storeName);
185
store.setState({ fn, other: 1 });
186
const onRender = jest.fn();
187
188
function FunctionField() {
189
const selected = useRedux([storeName, "fn"]);
190
useEffect(() => {
191
onRender(selected);
192
});
193
return <div>{typeof selected}</div>;
194
}
195
196
render(<FunctionField />);
197
await waitFor(() => expect(onRender).toHaveBeenCalledTimes(1));
198
const selectedFn = onRender.mock.calls.at(-1)?.[0];
199
expect(typeof selectedFn).toBe("function");
200
expect(selectedFn()).toBe(7);
201
202
// Updating unrelated data should not re-render.
203
act(() => {
204
store.setState({ other: 2 });
205
});
206
expect(onRender).toHaveBeenCalledTimes(1);
207
208
// Updating to a new function reference should re-render.
209
const fn2 = jest.fn(() => 9);
210
act(() => {
211
store.setState({ fn: fn2 });
212
});
213
await waitFor(() => expect(onRender).toHaveBeenCalledTimes(2));
214
const selectedFn2 = onRender.mock.calls.at(-1)?.[0];
215
expect(selectedFn2).toBe(fn2);
216
expect(selectedFn2()).toBe(9);
217
});
218
219
it("useRedux supports the project-store code path", async () => {
220
const storeName = trackStore(project_redux_name(PROJECT_ID));
221
const store = redux.createStore<{ status: string; other: number }>(
222
storeName,
223
);
224
store.setState({ status: "ready", other: 1 });
225
const onRender = jest.fn();
226
227
function ProjectStatus() {
228
const status = useRedux(["status"], PROJECT_ID);
229
useEffect(() => {
230
onRender(status);
231
});
232
return <div>{status}</div>;
233
}
234
235
render(<ProjectStatus />);
236
await waitFor(() => expect(onRender).toHaveBeenCalledTimes(1));
237
expect(onRender).toHaveBeenLastCalledWith("ready");
238
239
act(() => {
240
store.setState({ other: 2 });
241
});
242
expect(onRender).toHaveBeenCalledTimes(1);
243
244
act(() => {
245
store.setState({ status: "running" });
246
});
247
await waitFor(() => expect(onRender).toHaveBeenCalledTimes(2));
248
expect(onRender).toHaveBeenLastCalledWith("running");
249
});
250
251
it("useRedux keeps selector-backed project fields stable across updates", async () => {
252
const storeName = trackStore(project_redux_name(PROJECT_ID));
253
redux.removeStore(storeName);
254
const store = redux.createStore<SelectorProjectState, SelectorProjectStore>(
255
storeName,
256
SelectorProjectStore,
257
);
258
store.setState({ base: 2, other: 1 });
259
const onRender = jest.fn();
260
261
function ProjectSelectorValue() {
262
const doubled = useRedux(["doubled"], PROJECT_ID);
263
useEffect(() => {
264
onRender(doubled);
265
});
266
return <div>{doubled}</div>;
267
}
268
269
render(<ProjectSelectorValue />);
270
await waitFor(() => expect(onRender).toHaveBeenCalledTimes(1));
271
expect(onRender).toHaveBeenLastCalledWith(4);
272
273
act(() => {
274
store.setState({ other: 2 });
275
});
276
expect(onRender).toHaveBeenCalledTimes(1);
277
expect(onRender).toHaveBeenLastCalledWith(4);
278
279
act(() => {
280
store.setState({ base: 3 });
281
});
282
await waitFor(() => expect(onRender).toHaveBeenCalledTimes(2));
283
expect(onRender).toHaveBeenLastCalledWith(6);
284
});
285
286
it("useRedux with an empty store name returns undefined", async () => {
287
const onRender = jest.fn();
288
289
function UnknownStore() {
290
const value = useRedux(["", "whatever"]);
291
useEffect(() => {
292
onRender(value);
293
});
294
return <div>{value ?? "none"}</div>;
295
}
296
297
render(<UnknownStore />);
298
await waitFor(() => expect(onRender).toHaveBeenCalledTimes(1));
299
expect(onRender).toHaveBeenLastCalledWith(undefined);
300
});
301
302
it("useRedux removes change listeners on unmount", async () => {
303
const storeName = trackStore(createStoreName("redux-hooks-test"));
304
const store = redux.createStore<{ foo: number }>(storeName);
305
store.setState({ foo: 1 });
306
307
function Foo() {
308
const foo = useRedux([storeName, "foo"]);
309
return <div>{foo}</div>;
310
}
311
312
const { unmount } = render(<Foo />);
313
await waitFor(() => expect(store.listeners("change").length).toBe(1));
314
315
unmount();
316
expect(store.listeners("change").length).toBe(0);
317
});
318
319
it("useEditorRedux tracks fields per-render and avoids unrelated updates", async () => {
320
const storeName = trackStore(redux_name(PROJECT_ID, NOTEBOOK_PATH));
321
const store = redux.createStore<EditorState>(storeName);
322
store.setState({ tasks: 1, pages: 1 });
323
const onRender = jest.fn();
324
325
function EditorTasks() {
326
const useEditor = useEditorRedux<EditorState>({
327
project_id: PROJECT_ID,
328
path: NOTEBOOK_PATH,
329
});
330
const tasks = useEditor("tasks");
331
useEffect(() => {
332
onRender(tasks);
333
});
334
return <div>{tasks}</div>;
335
}
336
337
render(<EditorTasks />);
338
await waitFor(() => expect(onRender).toHaveBeenCalledTimes(1));
339
340
// Setting the tracked field to the same primitive value should not re-render.
341
act(() => {
342
store.setState({ tasks: 1 });
343
});
344
expect(onRender).toHaveBeenCalledTimes(1);
345
346
// Update an unrelated field; useEditorRedux should not re-render.
347
act(() => {
348
store.setState({ pages: 2 });
349
});
350
expect(onRender).toHaveBeenCalledTimes(1);
351
352
// Update the tracked field; useEditorRedux should re-render once.
353
act(() => {
354
store.setState({ tasks: 2 });
355
});
356
await waitFor(() => expect(onRender).toHaveBeenCalledTimes(2));
357
});
358
359
it("useEditorRedux updates tracked fields when selected field changes across renders", async () => {
360
const storeName = trackStore(redux_name(PROJECT_ID, NOTEBOOK_PATH));
361
const store = redux.createStore<EditorState>(storeName);
362
store.setState({ tasks: 1, pages: 10 });
363
const onRender = jest.fn();
364
365
function DynamicField({ usePages }: { usePages: boolean }) {
366
const useEditor = useEditorRedux<EditorState>({
367
project_id: PROJECT_ID,
368
path: NOTEBOOK_PATH,
369
});
370
const value = useEditor(usePages ? "pages" : "tasks");
371
useEffect(() => {
372
onRender(value);
373
});
374
return <div>{value}</div>;
375
}
376
377
const rendered = render(<DynamicField usePages={false} />);
378
await waitFor(() => expect(onRender).toHaveBeenCalledTimes(1));
379
expect(onRender).toHaveBeenLastCalledWith(1);
380
381
act(() => {
382
store.setState({ pages: 11 });
383
});
384
expect(onRender).toHaveBeenCalledTimes(1);
385
386
rendered.rerender(<DynamicField usePages />);
387
await waitFor(() => expect(onRender).toHaveBeenCalledTimes(2));
388
expect(onRender).toHaveBeenLastCalledWith(11);
389
390
act(() => {
391
store.setState({ tasks: 2 });
392
});
393
expect(onRender).toHaveBeenCalledTimes(2);
394
expect(onRender).toHaveBeenLastCalledWith(11); // selects pages
395
396
act(() => {
397
store.setState({ pages: 12 });
398
});
399
await waitFor(() => expect(onRender).toHaveBeenCalledTimes(3));
400
expect(onRender).toHaveBeenLastCalledWith(12);
401
402
act(() => {
403
store.setState({ tasks: 3 });
404
});
405
expect(onRender).toHaveBeenCalledTimes(3); // not called again
406
expect(onRender).toHaveBeenLastCalledWith(12); // still selects pages
407
});
408
409
it("useEditorRedux re-renders for immutable values when the reference changes", async () => {
410
const storeName = trackStore(redux_name(PROJECT_ID, NOTEBOOK_PATH));
411
const store = redux.createStore<{
412
tasks: List<number>;
413
pages: List<number>;
414
}>(storeName);
415
store.setState({ tasks: List([1]), pages: List([1, 2]) });
416
const onRender = jest.fn();
417
418
function EditorTasks() {
419
const useEditor = useEditorRedux<{ tasks: List<number> }>({
420
project_id: PROJECT_ID,
421
path: NOTEBOOK_PATH,
422
});
423
const tasks = useEditor("tasks");
424
useEffect(() => {
425
onRender(tasks);
426
});
427
return <div>{tasks?.size ?? 0}</div>;
428
}
429
430
render(<EditorTasks />);
431
await waitFor(() => expect(onRender).toHaveBeenCalledTimes(1));
432
433
// Update unrelated field; should not re-render.
434
act(() => {
435
store.setState({ pages: List([3]) });
436
});
437
expect(onRender).toHaveBeenCalledTimes(1);
438
439
// New List reference (even with same contents) should re-render.
440
act(() => {
441
store.setState({ tasks: List([1]) });
442
});
443
await waitFor(() => expect(onRender).toHaveBeenCalledTimes(2));
444
});
445
446
it("useEditorRedux handles editor store being created after initial render", async () => {
447
const missingEditorPath = "notebooks/missing-editor-useeditor.ipynb";
448
redux.removeStore(redux_name(PROJECT_ID, missingEditorPath));
449
expect(redux.getEditorStore(PROJECT_ID, missingEditorPath)).toBeUndefined();
450
const onRender = jest.fn();
451
452
function WaitingForEditorStore() {
453
const useEditor = useEditorRedux<{ tasks: number }>({
454
project_id: PROJECT_ID,
455
path: missingEditorPath,
456
});
457
const tasks = useEditor("tasks");
458
useEffect(() => {
459
onRender(tasks);
460
});
461
return <div>{tasks ?? "none"}</div>;
462
}
463
464
render(<WaitingForEditorStore />);
465
await waitFor(() => expect(onRender).toHaveBeenCalledTimes(1));
466
expect(onRender).toHaveBeenLastCalledWith(undefined);
467
468
const storeName = trackStore(redux_name(PROJECT_ID, missingEditorPath));
469
const store = redux.createStore<{ tasks: number }>(storeName);
470
act(() => {
471
store.setState({ tasks: 7 });
472
});
473
await waitFor(() => expect(onRender).toHaveBeenCalledTimes(2));
474
expect(onRender).toHaveBeenLastCalledWith(7);
475
});
476
477
it("useRedux handles editor store being created after initial render", async () => {
478
const missingEditorPath = "notebooks/missing-editor.ipynb";
479
redux.removeStore(redux_name(PROJECT_ID, missingEditorPath));
480
expect(redux.getEditorStore(PROJECT_ID, missingEditorPath)).toBeUndefined();
481
const onRender = jest.fn();
482
483
function WaitingForEditorStore() {
484
const tasks = useRedux(["tasks"], PROJECT_ID, missingEditorPath);
485
useEffect(() => {
486
onRender(tasks);
487
});
488
return <div>{tasks ?? "none"}</div>;
489
}
490
491
render(<WaitingForEditorStore />);
492
await waitFor(() => expect(onRender).toHaveBeenCalledTimes(1));
493
expect(onRender).toHaveBeenLastCalledWith(undefined);
494
495
const storeName = trackStore(redux_name(PROJECT_ID, missingEditorPath));
496
const store = redux.createStore<{ tasks: number }>(storeName);
497
act(() => {
498
store.setState({ tasks: 7 });
499
});
500
await waitFor(() => expect(onRender).toHaveBeenCalledTimes(2));
501
expect(onRender).toHaveBeenLastCalledWith(7);
502
});
503
504
it("useTypedRedux preserves types and only re-renders when the field changes", async () => {
505
const storeName = "account";
506
redux.removeStore(storeName);
507
trackStore(storeName);
508
const store = redux.createStore<AccountState>(storeName);
509
store.setState({
510
editor_settings: { theme: "light" },
511
other_settings: { dark_mode: false },
512
});
513
const onRender = jest.fn();
514
515
function AccountSettings() {
516
const editorSettings = useTypedRedux("account", "editor_settings");
517
const typedSettings: AccountState["editor_settings"] = editorSettings;
518
const theme = typedSettings.get("theme");
519
useEffect(() => {
520
onRender(theme);
521
});
522
return <div>{theme}</div>;
523
}
524
525
render(<AccountSettings />);
526
await waitFor(() => expect(onRender).toHaveBeenCalledTimes(1));
527
528
// Update an unrelated field; useTypedRedux should not re-render.
529
act(() => {
530
store.setState({ other_settings: { dark_mode: true } });
531
});
532
expect(onRender).toHaveBeenCalledTimes(1);
533
534
// Update the typed field; useTypedRedux should re-render once.
535
act(() => {
536
store.setState({ editor_settings: { theme: "dark" } });
537
});
538
await waitFor(() => expect(onRender).toHaveBeenCalledTimes(2));
539
});
540
541
it("useTypedRedux re-renders for immutable values when the reference changes", async () => {
542
const storeName = "account";
543
redux.removeStore(storeName);
544
trackStore(storeName);
545
const store = redux.createStore<AccountState>(storeName);
546
store.setState({
547
editor_settings: Map({ theme: "light" }),
548
other_settings: Map({ dark_mode: false }),
549
});
550
const onRender = jest.fn();
551
552
function AccountSettings() {
553
const editorSettings = useTypedRedux("account", "editor_settings");
554
const typedSettings: AccountState["editor_settings"] = editorSettings;
555
const theme = typedSettings.get("theme");
556
useEffect(() => {
557
onRender(theme);
558
});
559
return <div>{theme}</div>;
560
}
561
562
render(<AccountSettings />);
563
await waitFor(() => expect(onRender).toHaveBeenCalledTimes(1));
564
565
// Update unrelated field; should not re-render.
566
act(() => {
567
store.setState({ other_settings: Map({ dark_mode: true }) });
568
});
569
expect(onRender).toHaveBeenCalledTimes(1);
570
571
// Update the typed field to a new immutable value; should re-render.
572
act(() => {
573
store.setState({ editor_settings: Map({ theme: "dark" }) });
574
});
575
await waitFor(() => expect(onRender).toHaveBeenCalledTimes(2));
576
});
577
});
578
579