Path: blob/master/src/packages/frontend/app-framework/redux-hooks.test.tsx
6570 views
/*1* This file is part of CoCalc: Copyright © 2020-2026 Sagemath, Inc.2* License: MS-RSL – see LICENSE.md for details3*/45// cocalc/src/packages/frontend/app-framework/redux-hooks.test.tsx67import { act, cleanup, render, waitFor } from "@testing-library/react";8import { List, Map } from "immutable";9import { useEffect } from "react";1011import type { AccountState } from "@cocalc/frontend/account/types";12import {13project_redux_name,14redux,15redux_name,16} from "@cocalc/frontend/app-framework";17import { Store } from "@cocalc/util/redux/Store";18import { useEditorRedux, useRedux, useTypedRedux } from "./redux-hooks";1920// Avoid opening real socket connections during unit tests. Some imports in21// app-framework pull in webapp-client, which otherwise starts a client.22jest.mock("@cocalc/frontend/webapp-client", () => ({23WebappClient: function WebappClient() {},24webapp_client: {25sync_client: {26synctable_no_changefeed: jest.fn(() => ({27on: jest.fn(),28close: jest.fn(),29set: jest.fn(),30save: jest.fn(),31})),32sync_table: jest.fn(() => ({33on: jest.fn(),34close: jest.fn(),35set: jest.fn(),36save: jest.fn(),37})),38},39},40}));4142type EditorState = {43tasks: number;44pages: number;45};4647type SelectorProjectState = {48base: number;49other: number;50doubled: number;51};5253class SelectorProjectStore extends Store<SelectorProjectState> {54constructor(name: string, appRedux: any) {55super(name, appRedux);56this.setup_selectors();57}5859selectors = {60doubled: {61dependencies: ["base"] as const,62fn: () => this.get("base") * 2,63},64};65}6667const PROJECT_ID = "00000000-0000-4000-8000-000000000000";68const NOTEBOOK_PATH = "notebooks/example.ipynb";6970let storeSeq = 0;71const cleanupStores: string[] = [];7273function createStoreName(prefix: string) {74storeSeq += 1;75return `${prefix}-${storeSeq}`;76}7778function trackStore(name: string) {79cleanupStores.push(name);80return name;81}8283afterEach(() => {84cleanup();85for (const name of cleanupStores.splice(0)) {86redux.removeStore(name);87}88});8990describe("redux-hooks", () => {91it("useRedux only re-renders when the selected field changes (named store)", async () => {92const storeName = trackStore(createStoreName("redux-hooks-test"));93const store = redux.createStore<{ foo: number; bar: number }>(storeName);94store.setState({ foo: 1, bar: 1 });95const onRender = jest.fn();9697function Foo() {98const foo = useRedux([storeName, "foo"]);99useEffect(() => {100onRender(foo);101});102return <div>{foo}</div>;103}104105render(<Foo />);106await waitFor(() => expect(onRender).toHaveBeenCalledTimes(1));107108// Update an unrelated field; useRedux should not trigger a re-render.109act(() => {110store.setState({ bar: 2 });111});112expect(onRender).toHaveBeenCalledTimes(1);113114// Update the watched field; useRedux should re-render once.115act(() => {116store.setState({ foo: 2 });117});118await waitFor(() => expect(onRender).toHaveBeenCalledTimes(2));119});120121it("useRedux does not re-render when setting the same primitive value", async () => {122const storeName = trackStore(createStoreName("redux-hooks-test"));123const store = redux.createStore<{ foo: number; bar: number }>(storeName);124store.setState({ foo: 1, bar: 1 });125const onRender = jest.fn();126127function Foo() {128const foo = useRedux([storeName, "foo"]);129useEffect(() => {130onRender(foo);131});132return <div>{foo}</div>;133}134135render(<Foo />);136await waitFor(() => expect(onRender).toHaveBeenCalledTimes(1));137138// Setting the same primitive value should not cause a re-render.139act(() => {140store.setState({ foo: 1 });141});142expect(onRender).toHaveBeenCalledTimes(1);143});144145it("useRedux re-renders for immutable values when the reference changes", async () => {146const storeName = trackStore(createStoreName("redux-hooks-test"));147const store = redux.createStore<{ items: List<number>; other: number }>(148storeName,149);150store.setState({ items: List([1, 2]), other: 1 });151const onRender = jest.fn();152153function Items() {154const items = useRedux([storeName, "items"]);155useEffect(() => {156onRender(items);157});158return <div>{items?.size ?? 0}</div>;159}160161render(<Items />);162await waitFor(() => expect(onRender).toHaveBeenCalledTimes(1));163164// Update unrelated field; should not re-render.165act(() => {166store.setState({ other: 2 });167});168expect(onRender).toHaveBeenCalledTimes(1);169170// New List reference (even with same contents) should re-render.171act(() => {172store.setState({ items: List([1, 2]) });173});174await waitFor(() => expect(onRender).toHaveBeenCalledTimes(2));175});176177it("useRedux preserves function-valued fields without invoking them", async () => {178const storeName = trackStore(createStoreName("redux-hooks-test"));179const fn = jest.fn(() => 7);180const store = redux.createStore<{181fn: () => number;182other: number;183}>(storeName);184store.setState({ fn, other: 1 });185const onRender = jest.fn();186187function FunctionField() {188const selected = useRedux([storeName, "fn"]);189useEffect(() => {190onRender(selected);191});192return <div>{typeof selected}</div>;193}194195render(<FunctionField />);196await waitFor(() => expect(onRender).toHaveBeenCalledTimes(1));197const selectedFn = onRender.mock.calls.at(-1)?.[0];198expect(typeof selectedFn).toBe("function");199expect(selectedFn()).toBe(7);200201// Updating unrelated data should not re-render.202act(() => {203store.setState({ other: 2 });204});205expect(onRender).toHaveBeenCalledTimes(1);206207// Updating to a new function reference should re-render.208const fn2 = jest.fn(() => 9);209act(() => {210store.setState({ fn: fn2 });211});212await waitFor(() => expect(onRender).toHaveBeenCalledTimes(2));213const selectedFn2 = onRender.mock.calls.at(-1)?.[0];214expect(selectedFn2).toBe(fn2);215expect(selectedFn2()).toBe(9);216});217218it("useRedux supports the project-store code path", async () => {219const storeName = trackStore(project_redux_name(PROJECT_ID));220const store = redux.createStore<{ status: string; other: number }>(221storeName,222);223store.setState({ status: "ready", other: 1 });224const onRender = jest.fn();225226function ProjectStatus() {227const status = useRedux(["status"], PROJECT_ID);228useEffect(() => {229onRender(status);230});231return <div>{status}</div>;232}233234render(<ProjectStatus />);235await waitFor(() => expect(onRender).toHaveBeenCalledTimes(1));236expect(onRender).toHaveBeenLastCalledWith("ready");237238act(() => {239store.setState({ other: 2 });240});241expect(onRender).toHaveBeenCalledTimes(1);242243act(() => {244store.setState({ status: "running" });245});246await waitFor(() => expect(onRender).toHaveBeenCalledTimes(2));247expect(onRender).toHaveBeenLastCalledWith("running");248});249250it("useRedux keeps selector-backed project fields stable across updates", async () => {251const storeName = trackStore(project_redux_name(PROJECT_ID));252redux.removeStore(storeName);253const store = redux.createStore<SelectorProjectState, SelectorProjectStore>(254storeName,255SelectorProjectStore,256);257store.setState({ base: 2, other: 1 });258const onRender = jest.fn();259260function ProjectSelectorValue() {261const doubled = useRedux(["doubled"], PROJECT_ID);262useEffect(() => {263onRender(doubled);264});265return <div>{doubled}</div>;266}267268render(<ProjectSelectorValue />);269await waitFor(() => expect(onRender).toHaveBeenCalledTimes(1));270expect(onRender).toHaveBeenLastCalledWith(4);271272act(() => {273store.setState({ other: 2 });274});275expect(onRender).toHaveBeenCalledTimes(1);276expect(onRender).toHaveBeenLastCalledWith(4);277278act(() => {279store.setState({ base: 3 });280});281await waitFor(() => expect(onRender).toHaveBeenCalledTimes(2));282expect(onRender).toHaveBeenLastCalledWith(6);283});284285it("useRedux with an empty store name returns undefined", async () => {286const onRender = jest.fn();287288function UnknownStore() {289const value = useRedux(["", "whatever"]);290useEffect(() => {291onRender(value);292});293return <div>{value ?? "none"}</div>;294}295296render(<UnknownStore />);297await waitFor(() => expect(onRender).toHaveBeenCalledTimes(1));298expect(onRender).toHaveBeenLastCalledWith(undefined);299});300301it("useRedux removes change listeners on unmount", async () => {302const storeName = trackStore(createStoreName("redux-hooks-test"));303const store = redux.createStore<{ foo: number }>(storeName);304store.setState({ foo: 1 });305306function Foo() {307const foo = useRedux([storeName, "foo"]);308return <div>{foo}</div>;309}310311const { unmount } = render(<Foo />);312await waitFor(() => expect(store.listeners("change").length).toBe(1));313314unmount();315expect(store.listeners("change").length).toBe(0);316});317318it("useEditorRedux tracks fields per-render and avoids unrelated updates", async () => {319const storeName = trackStore(redux_name(PROJECT_ID, NOTEBOOK_PATH));320const store = redux.createStore<EditorState>(storeName);321store.setState({ tasks: 1, pages: 1 });322const onRender = jest.fn();323324function EditorTasks() {325const useEditor = useEditorRedux<EditorState>({326project_id: PROJECT_ID,327path: NOTEBOOK_PATH,328});329const tasks = useEditor("tasks");330useEffect(() => {331onRender(tasks);332});333return <div>{tasks}</div>;334}335336render(<EditorTasks />);337await waitFor(() => expect(onRender).toHaveBeenCalledTimes(1));338339// Setting the tracked field to the same primitive value should not re-render.340act(() => {341store.setState({ tasks: 1 });342});343expect(onRender).toHaveBeenCalledTimes(1);344345// Update an unrelated field; useEditorRedux should not re-render.346act(() => {347store.setState({ pages: 2 });348});349expect(onRender).toHaveBeenCalledTimes(1);350351// Update the tracked field; useEditorRedux should re-render once.352act(() => {353store.setState({ tasks: 2 });354});355await waitFor(() => expect(onRender).toHaveBeenCalledTimes(2));356});357358it("useEditorRedux updates tracked fields when selected field changes across renders", async () => {359const storeName = trackStore(redux_name(PROJECT_ID, NOTEBOOK_PATH));360const store = redux.createStore<EditorState>(storeName);361store.setState({ tasks: 1, pages: 10 });362const onRender = jest.fn();363364function DynamicField({ usePages }: { usePages: boolean }) {365const useEditor = useEditorRedux<EditorState>({366project_id: PROJECT_ID,367path: NOTEBOOK_PATH,368});369const value = useEditor(usePages ? "pages" : "tasks");370useEffect(() => {371onRender(value);372});373return <div>{value}</div>;374}375376const rendered = render(<DynamicField usePages={false} />);377await waitFor(() => expect(onRender).toHaveBeenCalledTimes(1));378expect(onRender).toHaveBeenLastCalledWith(1);379380act(() => {381store.setState({ pages: 11 });382});383expect(onRender).toHaveBeenCalledTimes(1);384385rendered.rerender(<DynamicField usePages />);386await waitFor(() => expect(onRender).toHaveBeenCalledTimes(2));387expect(onRender).toHaveBeenLastCalledWith(11);388389act(() => {390store.setState({ tasks: 2 });391});392expect(onRender).toHaveBeenCalledTimes(2);393expect(onRender).toHaveBeenLastCalledWith(11); // selects pages394395act(() => {396store.setState({ pages: 12 });397});398await waitFor(() => expect(onRender).toHaveBeenCalledTimes(3));399expect(onRender).toHaveBeenLastCalledWith(12);400401act(() => {402store.setState({ tasks: 3 });403});404expect(onRender).toHaveBeenCalledTimes(3); // not called again405expect(onRender).toHaveBeenLastCalledWith(12); // still selects pages406});407408it("useEditorRedux re-renders for immutable values when the reference changes", async () => {409const storeName = trackStore(redux_name(PROJECT_ID, NOTEBOOK_PATH));410const store = redux.createStore<{411tasks: List<number>;412pages: List<number>;413}>(storeName);414store.setState({ tasks: List([1]), pages: List([1, 2]) });415const onRender = jest.fn();416417function EditorTasks() {418const useEditor = useEditorRedux<{ tasks: List<number> }>({419project_id: PROJECT_ID,420path: NOTEBOOK_PATH,421});422const tasks = useEditor("tasks");423useEffect(() => {424onRender(tasks);425});426return <div>{tasks?.size ?? 0}</div>;427}428429render(<EditorTasks />);430await waitFor(() => expect(onRender).toHaveBeenCalledTimes(1));431432// Update unrelated field; should not re-render.433act(() => {434store.setState({ pages: List([3]) });435});436expect(onRender).toHaveBeenCalledTimes(1);437438// New List reference (even with same contents) should re-render.439act(() => {440store.setState({ tasks: List([1]) });441});442await waitFor(() => expect(onRender).toHaveBeenCalledTimes(2));443});444445it("useEditorRedux handles editor store being created after initial render", async () => {446const missingEditorPath = "notebooks/missing-editor-useeditor.ipynb";447redux.removeStore(redux_name(PROJECT_ID, missingEditorPath));448expect(redux.getEditorStore(PROJECT_ID, missingEditorPath)).toBeUndefined();449const onRender = jest.fn();450451function WaitingForEditorStore() {452const useEditor = useEditorRedux<{ tasks: number }>({453project_id: PROJECT_ID,454path: missingEditorPath,455});456const tasks = useEditor("tasks");457useEffect(() => {458onRender(tasks);459});460return <div>{tasks ?? "none"}</div>;461}462463render(<WaitingForEditorStore />);464await waitFor(() => expect(onRender).toHaveBeenCalledTimes(1));465expect(onRender).toHaveBeenLastCalledWith(undefined);466467const storeName = trackStore(redux_name(PROJECT_ID, missingEditorPath));468const store = redux.createStore<{ tasks: number }>(storeName);469act(() => {470store.setState({ tasks: 7 });471});472await waitFor(() => expect(onRender).toHaveBeenCalledTimes(2));473expect(onRender).toHaveBeenLastCalledWith(7);474});475476it("useRedux handles editor store being created after initial render", async () => {477const missingEditorPath = "notebooks/missing-editor.ipynb";478redux.removeStore(redux_name(PROJECT_ID, missingEditorPath));479expect(redux.getEditorStore(PROJECT_ID, missingEditorPath)).toBeUndefined();480const onRender = jest.fn();481482function WaitingForEditorStore() {483const tasks = useRedux(["tasks"], PROJECT_ID, missingEditorPath);484useEffect(() => {485onRender(tasks);486});487return <div>{tasks ?? "none"}</div>;488}489490render(<WaitingForEditorStore />);491await waitFor(() => expect(onRender).toHaveBeenCalledTimes(1));492expect(onRender).toHaveBeenLastCalledWith(undefined);493494const storeName = trackStore(redux_name(PROJECT_ID, missingEditorPath));495const store = redux.createStore<{ tasks: number }>(storeName);496act(() => {497store.setState({ tasks: 7 });498});499await waitFor(() => expect(onRender).toHaveBeenCalledTimes(2));500expect(onRender).toHaveBeenLastCalledWith(7);501});502503it("useTypedRedux preserves types and only re-renders when the field changes", async () => {504const storeName = "account";505redux.removeStore(storeName);506trackStore(storeName);507const store = redux.createStore<AccountState>(storeName);508store.setState({509editor_settings: { theme: "light" },510other_settings: { dark_mode: false },511});512const onRender = jest.fn();513514function AccountSettings() {515const editorSettings = useTypedRedux("account", "editor_settings");516const typedSettings: AccountState["editor_settings"] = editorSettings;517const theme = typedSettings.get("theme");518useEffect(() => {519onRender(theme);520});521return <div>{theme}</div>;522}523524render(<AccountSettings />);525await waitFor(() => expect(onRender).toHaveBeenCalledTimes(1));526527// Update an unrelated field; useTypedRedux should not re-render.528act(() => {529store.setState({ other_settings: { dark_mode: true } });530});531expect(onRender).toHaveBeenCalledTimes(1);532533// Update the typed field; useTypedRedux should re-render once.534act(() => {535store.setState({ editor_settings: { theme: "dark" } });536});537await waitFor(() => expect(onRender).toHaveBeenCalledTimes(2));538});539540it("useTypedRedux re-renders for immutable values when the reference changes", async () => {541const storeName = "account";542redux.removeStore(storeName);543trackStore(storeName);544const store = redux.createStore<AccountState>(storeName);545store.setState({546editor_settings: Map({ theme: "light" }),547other_settings: Map({ dark_mode: false }),548});549const onRender = jest.fn();550551function AccountSettings() {552const editorSettings = useTypedRedux("account", "editor_settings");553const typedSettings: AccountState["editor_settings"] = editorSettings;554const theme = typedSettings.get("theme");555useEffect(() => {556onRender(theme);557});558return <div>{theme}</div>;559}560561render(<AccountSettings />);562await waitFor(() => expect(onRender).toHaveBeenCalledTimes(1));563564// Update unrelated field; should not re-render.565act(() => {566store.setState({ other_settings: Map({ dark_mode: true }) });567});568expect(onRender).toHaveBeenCalledTimes(1);569570// Update the typed field to a new immutable value; should re-render.571act(() => {572store.setState({ editor_settings: Map({ theme: "dark" }) });573});574await waitFor(() => expect(onRender).toHaveBeenCalledTimes(2));575});576});577578579