Path: blob/master/src/packages/frontend/app-framework/redux-hooks.ts
1503 views
/*1* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.2* License: MS-RSL – see LICENSE.md for details3*/45/*67**IMPORTANT:** TYPED REDUX HOOKS -- If you use89useTypedRedux('name' | {project_id:'the project id'}, 'one field')1011then you will get good guaranteed typing (unless, of course, the global store12hasn't been converted to typescript yet!). If you use plain useRedux, you13get a dangerous "any" type out!1415---1617Hook for getting anything from our global redux store, and this should18also work fine with computed properties.1920Use it is as follows:2122With a named store, such as "projects", "account", "page", etc.:2324useRedux(['name-of-store', 'path', 'in', 'store'])2526With a specific project:2728useRedux(['path', 'in', 'project store'], 'project-id')2930Or with an editor in a project:3132useRedux(['path', 'in', 'project store'], 'project-id', 'path')3334If you don't know the name of the store initially, you can use a name of '',35and you'll always get back undefined.3637useRedux(['', 'other', 'stuff']) === undefined38*/3940import { is_valid_uuid_string } from "@cocalc/util/misc";41import { redux, ProjectActions, ProjectStore } from "../app-framework";42import { ProjectStoreState } from "../project_store";43import React, { useEffect, useRef } from "react";44import * as types from "./actions-and-stores";4546export function useReduxNamedStore(path: string[]) {47const [value, set_value] = React.useState(() => {48return redux.getStore(path[0])?.getIn(path.slice(1) as any) as any;49});5051useEffect(() => {52if (path[0] == "") {53// Special case -- we allow passing "" for the name of the store and get out undefined.54// This is useful when using the useRedux hook but when the name of the store isn't known initially.55return undefined;56}57const store = redux.getStore(path[0]);58if (store == null) {59// This could happen if some input is invalid, e.g., trying to create one of these60// redux hooks with an invalid project_id. There will be other warnings in the logs61// about that. It's better at this point to warn once in the logs, rather than completely62// crash the client.63console.warn(`store "${path[0]}" must exist; path=`, path);64return undefined;65}66const subpath = path.slice(1);67let last_value = value;68const f = () => {69if (!f.is_mounted) {70// CRITICAL: even after removing the change listener, sometimes f gets called;71// I don't know why EventEmitter has those semantics, but it definitely does.72// That's why we *also* maintain this is_mounted flag.73return;74}75const new_value = store.getIn(subpath as any);76if (last_value !== new_value) {77/*78console.log("useReduxNamedStore change ", {79name: path[0],80path: JSON.stringify(path),81new_value,82last_value,83});84*/85last_value = new_value;86set_value(new_value);87}88};89f.is_mounted = true;90store.on("change", f);91f();92return () => {93f.is_mounted = false;94store.removeListener("change", f);95};96}, path);9798return value;99}100101function useReduxProjectStore(path: string[], project_id: string) {102const [value, set_value] = React.useState(() =>103redux104.getProjectStore(project_id)105.getIn(path as [string, string, string, string, string]),106);107108useEffect(() => {109const store = redux.getProjectStore(project_id);110let last_value = value;111const f = (obj) => {112if (obj == null || !f.is_mounted) return; // see comment for useReduxNamedStore113const new_value = obj.getIn(path);114if (last_value !== new_value) {115/*116console.log("useReduxProjectStore change ", {117path: JSON.stringify(path),118new_value,119last_value,120});121*/122last_value = new_value;123set_value(new_value);124}125};126f.is_mounted = true;127store.on("change", f);128f(store);129return () => {130f.is_mounted = false;131store.removeListener("change", f);132};133}, [...path, project_id]);134135return value;136}137138function useReduxEditorStore(139path: string[],140project_id: string,141filename: string,142) {143const [value, set_value] = React.useState(() =>144// the editor itself might not be defined hence the ?. below:145redux146.getEditorStore(project_id, filename)147?.getIn(path as [string, string, string, string, string]),148);149150useEffect(() => {151let store = redux.getEditorStore(project_id, filename);152let last_value = value;153const f = (obj) => {154if (obj == null || !f.is_mounted) return; // see comment for useReduxNamedStore155const new_value = obj.getIn(path);156if (last_value !== new_value) {157last_value = new_value;158set_value(new_value);159}160};161f.is_mounted = true;162f(store);163if (store != null) {164store.on("change", f);165} else {166/* This code is extra complicated since we account for the case167when getEditorStore is undefined then becomes defined.168Very rarely there are components that useRedux and somehow169manage to do so before the editor store gets created.170NOTE: I might be able to solve this same problem with171simpler code with useAsyncEffect...172*/173const g = () => {174if (!f.is_mounted) {175unsubscribe();176return;177}178store = redux.getEditorStore(project_id, filename);179if (store != null) {180unsubscribe();181f(store); // may have missed an initial change182store.on("change", f);183}184};185const unsubscribe = redux.reduxStore.subscribe(g);186}187188return () => {189f.is_mounted = false;190store?.removeListener("change", f);191};192}, [...path, project_id, filename]);193194return value;195}196197export interface StoreStates {198account: types.AccountState;199"admin-site-licenses": types.SiteLicensesState;200"admin-users": types.AdminUsersState;201billing: types.BillingState;202compute_images: types.ComputeImagesState;203customize: types.CustomizeState;204file_use: types.FileUseState;205mentions: types.MentionsState;206messages: types.MessagesState;207page: types.PageState;208projects: types.ProjectsState;209users: types.UsersState;210news: types.NewsState;211}212213export function useTypedRedux<214T extends keyof StoreStates,215S extends keyof StoreStates[T],216>(store: T, field: S): StoreStates[T][S];217218export function useTypedRedux<S extends keyof ProjectStoreState>(219project_id: { project_id: string },220field: S,221): ProjectStoreState[S];222223export function useTypedRedux(224a: keyof StoreStates | { project_id: string },225field: string,226) {227if (typeof a == "string") {228return useRedux(a, field);229}230return useRedux(a.project_id, field);231}232233export function useEditorRedux<State>(editor: {234project_id: string;235path: string;236}) {237function f<S extends keyof State>(field: S): State[S] {238return useReduxEditorStore(239[field as string],240editor.project_id,241editor.path,242) as any;243}244return f;245}246247/*248export function useEditorRedux<State, S extends keyof State>(editor: {249project_id: string;250path: string;251}): State[S] {252return useReduxEditorStore(253[S as string],254editor.project_id,255editor.path256) as any;257}258*/259/*260export function useEditorRedux(261editor: { project_id: string; path: string },262field263): any {264return useReduxEditorStore(265[field as string],266editor.project_id,267editor.path268) as any;269}270*/271272export function useRedux(273path: string | string[],274project_id?: string,275filename?: string,276) {277if (typeof path == "string") {278// good typed version!! -- path specifies store279if (typeof project_id != "string" || typeof filename != "undefined") {280throw Error(281"if first argument of useRedux is a string then second argument must also be and no other arguments can be specified",282);283}284if (is_valid_uuid_string(path)) {285return useRedux([project_id], path);286} else {287return useRedux([path, project_id]);288}289}290if (project_id == null) {291return useReduxNamedStore(path);292}293if (filename == null) {294if (!is_valid_uuid_string(project_id)) {295// this is used a lot by frame-tree editors right now.296return useReduxNamedStore([project_id].concat(path));297} else {298return useReduxProjectStore(path, project_id);299}300}301return useReduxEditorStore(path, project_id, filename);302}303304/*305Hook to get the actions associated to a named actions/store,306a project, or an editor. If the first argument is a uuid,307then it's the project actions or editor actions; otherwise,308it's one of the other named actions or undefined.309*/310311export function useActions(name: "account"): types.AccountActions;312export function useActions(313name: "admin-site-licenses",314): types.SiteLicensesActions;315export function useActions(name: "admin-users"): types.AdminUsersActions;316export function useActions(name: "billing"): types.BillingActions;317export function useActions(name: "file_use"): types.FileUseActions;318export function useActions(name: "mentions"): types.MentionsActions;319export function useActions(name: "messages"): types.MessagesActions;320export function useActions(name: "page"): types.PageActions;321export function useActions(name: "projects"): types.ProjectsActions;322export function useActions(name: "users"): types.UsersActions;323export function useActions(name: "news"): types.NewsActions;324export function useActions(name: "customize"): types.CustomizeActions;325326// If it is none of the explicitly named ones... it's a project or just some general actions.327// That said *always* use {project_id} as below to get the actions for a project, so you328// get proper typing.329export function useActions(x: string): any;330331export function useActions<T>(x: { name: string }): T;332333// Return type includes undefined because the actions for a project *do* get334// destroyed when closing a project, and rendering can still happen during this335// time, so client code must account for this.336export function useActions(x: {337project_id: string;338}): ProjectActions | undefined;339340// Or an editor actions (any for now)341export function useActions(x: string, path: string): any;342343export function useActions(x, path?: string) {344return React.useMemo(() => {345let actions;346if (path != null) {347actions = redux.getEditorActions(x, path);348} else {349if (x?.name != null) {350actions = redux.getActions(x.name);351} else if (x?.project_id != null) {352// return here to avoid null check below; it can be null353return redux.getProjectActions(x.project_id);354} else if (is_valid_uuid_string(x)) {355// return here to avoid null check below; it can be null356return redux.getProjectActions(x);357} else {358actions = redux.getActions(x);359}360}361if (actions == null) {362throw Error(`BUG: actions for "${path}" must be defined but is not`);363}364return actions;365}, [x, path]);366}367368// WARNING: I tried to define this Stores interface369// in actions-and-stores.ts but it did NOT work. All370// the types just became any or didn't match. Don't371// move this unless you also fully test it!!372import { Store } from "@cocalc/util/redux/Store";373import { isEqual } from "lodash";374export interface Stores {375account: types.AccountStore;376"admin-site-licenses": types.SiteLicensesStore;377"admin-users": types.AdminUsersStore;378billing: types.BillingStore;379compute_images: types.ComputeImagesStore;380customize: types.CustomizeStore;381file_use: types.FileUseStore;382mentions: types.MentionsStore;383messages: types.MessagesStore;384page: types.PageStore;385projects: types.ProjectsStore;386users: types.UsersStore;387news: types.NewsStore;388}389390// If it is none of the explicitly named ones... it's a project.391//export function useStore(name: "projects"): types.ProjectsStore;392export function useStore<T extends keyof Stores>(name: T): Stores[T];393export function useStore(x: { project_id: string }): ProjectStore;394export function useStore<T>(x: { name: string }): T;395// Or an editor store (any for now):396//export function useStore(project_id: string, path: string): Store<any>;397export function useStore(x): any {398return React.useMemo(() => {399let store;400if (x?.project_id != null) {401store = redux.getProjectStore(x.project_id);402} else if (x?.name != null) {403store = redux.getStore(x.name);404} else if (is_valid_uuid_string(x)) {405store = redux.getProjectStore(x);406} else {407store = redux.getStore(x);408}409if (store == null) {410throw Error("store must be defined");411}412return store;413}, [x]) as Store<any>;414}415416// Debug which props changed in a component417export function useTraceUpdate(props) {418const prev = useRef(props);419useEffect(() => {420const changedProps = Object.entries(props).reduce((ps, [k, v]) => {421if (!isEqual(prev.current[k], v)) {422ps[k] = [prev.current[k], v];423}424return ps;425}, {});426if (Object.keys(changedProps).length > 0) {427console.log("Changed props:", changedProps);428}429prev.current = props;430});431}432433434