Path: blob/master/src/packages/frontend/app-framework/redux-hooks.ts
5808 views
/*1* This file is part of CoCalc: Copyright © 2020-2026 Sagemath, Inc.2* License: MS-RSL – see LICENSE.md for details3*/45/*6Overview7--------8This file defines the core React hooks for reading Redux-like stores in the9frontend. There are three usage shapes:10111) Named/global store:12const accountId = useRedux(["account", "account_id"]);13142) Project store:15const title = useRedux(["settings", "title"], projectId);16173) Editor store in a project:18const cursor = useRedux(["cursor"], projectId, path);1920Typed hook wrapper:21const projectState = useTypedRedux({ project_id: projectId }, "status");22const pageState = useTypedRedux("page", "current_tab");2324Editor selector hook:25const useEditor = useEditorRedux<MyEditorState>({ project_id, path });26const tasks = useEditor("tasks");27const pages = useEditor("pages");2829If the store name is not yet known, you may use "" to get undefined:30useRedux(["", "whatever"]) === undefined3132Implementation Notes33--------------------34- All hooks are called unconditionally and keep a stable order to satisfy35react-hooks/rules-of-hooks.36- Subscriptions listen to the store "change" event and compare values by37reference. Immutable stores are expected to update references on changes.38- useRedux normalizes arguments into a tagged target and uses a single39subscription path.40- useEditorRedux returns a selector function that tracks which fields were41read during render and only re-renders when those fields change. This keeps42hook usage valid while preserving per-field change detection.43*/4445import React, { useCallback, useEffect, useLayoutEffect, useRef } from "react";4647import {48ProjectActions,49ProjectStore,50redux,51} from "@cocalc/frontend/app-framework";52import * as types from "@cocalc/frontend/app-framework/actions-and-stores";53import { ProjectStoreState } from "@cocalc/frontend/project_store";54import { is_valid_uuid_string } from "@cocalc/util/misc";5556export interface StoreStates {57account: types.AccountState;58"admin-site-licenses": types.SiteLicensesState;59"admin-users": types.AdminUsersState;60billing: types.BillingState;61compute_images: types.ComputeImagesState;62customize: types.CustomizeState;63file_use: types.FileUseState;64mentions: types.MentionsState;65messages: types.MessagesState;66page: types.PageState;67projects: types.ProjectsState;68users: types.UsersState;69news: types.NewsState;70}7172/**73* Typed wrapper around useRedux.74*75* Use this for safer typing when possible. The overloads enforce which76* store is being accessed and the field name within that store.77*78* Examples:79* const pageTab = useTypedRedux("page", "current_tab");80* const status = useTypedRedux({ project_id }, "status");81*/82export function useTypedRedux<83T extends keyof StoreStates,84S extends keyof StoreStates[T],85>(store: T, field: S): StoreStates[T][S];8687export function useTypedRedux<S extends keyof ProjectStoreState>(88project_id: { project_id: string },89field: S,90): ProjectStoreState[S];9192export function useTypedRedux(93a: keyof StoreStates | { project_id: string },94field: string,95) {96const path = typeof a === "string" ? a : a.project_id;97return useRedux(path, field);98}99100/**101* Read a field from an editor store regardless of the underlying store API.102*103* This supports Immutable-style stores that expose getIn/get, as well as104* plain object stores. It returns undefined for missing stores/fields.105*/106function getEditorFieldValue(store: any, field: string) {107if (store == null) return undefined;108if (typeof store.getIn === "function") {109return store.getIn([field]);110}111if (typeof store.get === "function") {112return store.get(field);113}114return store[field];115}116117/**118* Hook that returns a selector for editor store fields.119*120* The returned function is NOT a hook. Call it during render to read fields121* and to register which fields this component depends on.122*123* Example:124* const useEditor = useEditorRedux<MyEditorState>({ project_id, path });125* const tasks = useEditor("tasks");126* const pages = useEditor("pages");127*128* Implementation details:129* - Tracks fields read during render (renderFieldsRef).130* - After render (useLayoutEffect), snapshots those fields into131* trackedFieldsRef and caches their latest values.132* - A single store subscription compares only tracked fields and triggers133* a re-render when any of them changes.134* - Handles editor store creation being delayed by subscribing to the135* global redux store until the editor store exists.136*/137export function useEditorRedux<State>(editor: {138project_id: string;139path: string;140}) {141const [, forceRender] = React.useState(0);142const storeRef = useRef<any>(143redux.getEditorStore(editor.project_id, editor.path),144);145const trackedFieldsRef = useRef<Set<string>>(new Set());146const lastValuesRef = useRef<Map<string, any>>(new Map());147const renderFieldsRef = useRef<Set<string>>(new Set());148const editorKeyRef = useRef<string>("");149150const editorKey = `${editor.project_id}:${editor.path}`;151if (editorKeyRef.current !== editorKey) {152editorKeyRef.current = editorKey;153trackedFieldsRef.current = new Set();154lastValuesRef.current = new Map();155}156157storeRef.current = redux.getEditorStore(editor.project_id, editor.path);158renderFieldsRef.current = new Set();159160const selectField = useCallback(<S extends keyof State>(field: S) => {161renderFieldsRef.current.add(field as string);162return getEditorFieldValue(storeRef.current, field as string) as State[S];163}, []);164165useLayoutEffect(() => {166const fields = renderFieldsRef.current;167trackedFieldsRef.current = fields;168const store = storeRef.current;169const lastValues = lastValuesRef.current;170for (const field of Array.from(lastValues.keys())) {171if (!fields.has(field)) {172lastValues.delete(field);173}174}175if (store != null) {176for (const field of fields) {177lastValues.set(field, getEditorFieldValue(store, field));178}179}180});181182useEffect(() => {183let store = redux.getEditorStore(editor.project_id, editor.path);184storeRef.current = store;185let is_mounted = true;186let unsubscribe: (() => void) | undefined;187188const update = (obj) => {189if (obj == null || !is_mounted) return;190storeRef.current = obj;191const fields = trackedFieldsRef.current;192if (fields.size === 0) return;193let changed = false;194const lastValues = lastValuesRef.current;195for (const field of fields) {196const newValue = getEditorFieldValue(obj, field);197if (lastValues.get(field) !== newValue) {198lastValues.set(field, newValue);199changed = true;200}201}202if (changed) {203forceRender((version) => version + 1);204}205};206207if (store != null) {208store.on("change", update);209update(store);210} else {211const g = () => {212if (!is_mounted) {213unsubscribe?.();214return;215}216store = redux.getEditorStore(editor.project_id, editor.path);217if (store != null) {218unsubscribe?.();219storeRef.current = store;220update(store); // may have missed an initial change221store.on("change", update);222}223};224unsubscribe = redux.reduxStore.subscribe(g);225}226227return () => {228is_mounted = false;229store?.removeListener("change", update);230unsubscribe?.();231};232}, [editor.project_id, editor.path]);233234return selectField;235}236237type ReduxTarget =238| { kind: "named"; path: string[] }239| { kind: "project"; path: string[]; project_id: string }240| { kind: "editor"; path: string[]; project_id: string; filename: string };241242/**243* Normalize useRedux arguments into a tagged target.244*245* Rules:246* - String path + string project_id => named store or project store247* - Array path + project_id => project store (if uuid) or named store248* - Array path + project_id + filename => editor store249*/250function normalizeReduxArgs(251path: string | string[],252project_id?: string,253filename?: string,254): ReduxTarget {255if (typeof path === "string") {256// good typed version!! -- path specifies store257if (typeof project_id !== "string" || typeof filename !== "undefined") {258throw Error(259"if first argument of useRedux is a string then second argument must also be and no other arguments can be specified",260);261}262if (is_valid_uuid_string(path)) {263return { kind: "project", path: [project_id], project_id: path };264}265return { kind: "named", path: [path, project_id] };266}267if (project_id == null) {268return { kind: "named", path };269}270if (filename == null) {271if (!is_valid_uuid_string(project_id)) {272// this is used a lot by frame-tree editors right now.273return { kind: "named", path: [project_id].concat(path) };274}275return { kind: "project", path, project_id };276}277return { kind: "editor", path, project_id, filename };278}279280/**281* Read the current snapshot for a normalized target.282*283* This does not subscribe; it is used for initial state and for comparing284* store updates inside the subscription.285*/286function getReduxValue(target: ReduxTarget) {287if (target.kind === "named") {288if (target.path[0] === "") {289return undefined;290}291return redux.getStore(target.path[0])?.getIn(target.path.slice(1) as any);292}293if (target.kind === "project") {294return redux295.getProjectStore(target.project_id)296.getIn(target.path as [string, string, string, string, string]);297}298return redux299.getEditorStore(target.project_id, target.filename)300?.getIn(target.path as [string, string, string, string, string]);301}302303/**304* General-purpose hook to read values from named stores, project stores, or305* editor stores. The hook decides which store to subscribe to based on the306* argument shape (see examples below).307*308* Examples:309* const userName = useRedux(["account", "full_name"]);310* const status = useRedux(["status"], projectId);311* const cursor = useRedux(["cursor"], projectId, path);312* const maybe = useRedux(["", "unknown"]) // => undefined313*314* Implementation details:315* - Arguments are normalized to a target so hooks are not called conditionally.316* - A single useEffect subscribes to the correct store based on target.kind.317* - Updates compare by reference; immutable stores should update references.318*/319export function useRedux(320path: string | string[],321project_id?: string,322filename?: string,323) {324const target = normalizeReduxArgs(path, project_id, filename);325// Stable key: normalizeReduxArgs creates a deterministic shape for JSON.stringify.326const targetKey = JSON.stringify(target);327const [value, set_value] = React.useState(() => getReduxValue(target));328329useEffect(() => {330let store: any;331let last_value = getReduxValue(target);332let is_mounted = true;333set_value(() => last_value);334335const update = (obj) => {336if (obj == null || !is_mounted) return;337const subpath =338target.kind === "named" ? target.path.slice(1) : target.path;339const new_value = obj.getIn(subpath as any);340if (last_value !== new_value) {341last_value = new_value;342set_value(() => new_value);343}344};345346if (target.kind === "named") {347if (target.path[0] === "") {348return () => {349is_mounted = false;350};351}352store = redux.getStore(target.path[0]);353if (store == null) {354console.warn(355`store "${target.path[0]}" must exist; path=`,356target.path,357);358return () => {359is_mounted = false;360};361}362store.on("change", update);363update(store);364return () => {365is_mounted = false;366store?.removeListener("change", update);367};368}369370if (target.kind === "project") {371store = redux.getProjectStore(target.project_id);372store.on("change", update);373update(store);374return () => {375is_mounted = false;376store?.removeListener("change", update);377};378}379380let editorStore = redux.getEditorStore(target.project_id, target.filename);381let unsubscribe: (() => void) | undefined;382const f = (obj) => {383if (obj == null || !is_mounted) return;384const new_value = obj.getIn(target.path);385if (last_value !== new_value) {386last_value = new_value;387set_value(() => new_value);388}389};390f(editorStore);391if (editorStore != null) {392editorStore.on("change", f);393} else {394const g = () => {395if (!is_mounted) {396unsubscribe?.();397return;398}399editorStore = redux.getEditorStore(target.project_id, target.filename);400if (editorStore != null) {401unsubscribe?.();402f(editorStore); // may have missed an initial change403editorStore.on("change", f);404}405};406unsubscribe = redux.reduxStore.subscribe(g);407}408409return () => {410is_mounted = false;411editorStore?.removeListener("change", f);412unsubscribe?.();413};414}, [targetKey]);415416return value;417}418419/**420* Hook to get actions for a named store, a project, or an editor.421*422* Examples:423* const actions = useActions("projects");424* const actions = useActions({ project_id });425* const editorActions = useActions(projectId, path);426*427* Notes:428* - Named actions must exist; missing named actions throw an error.429* - Project actions can be undefined while a project is closing.430*/431432export function useActions(name: "account"): types.AccountActions;433export function useActions(434name: "admin-site-licenses",435): types.SiteLicensesActions;436export function useActions(name: "admin-users"): types.AdminUsersActions;437export function useActions(name: "billing"): types.BillingActions;438export function useActions(name: "file_use"): types.FileUseActions;439export function useActions(name: "mentions"): types.MentionsActions;440export function useActions(name: "messages"): types.MessagesActions;441export function useActions(name: "page"): types.PageActions;442export function useActions(name: "projects"): types.ProjectsActions;443export function useActions(name: "users"): types.UsersActions;444export function useActions(name: "news"): types.NewsActions;445export function useActions(name: "customize"): types.CustomizeActions;446447// If it is none of the explicitly named ones... it's a project or just some general actions.448// That said *always* use {project_id} as below to get the actions for a project, so you449// get proper typing.450export function useActions(x: string): any;451452export function useActions<T>(x: { name: string }): T;453454// Return type includes undefined because the actions for a project *do* get455// destroyed when closing a project, and rendering can still happen during this456// time, so client code must account for this.457export function useActions(x: {458project_id: string;459}): ProjectActions | undefined;460461// Or an editor actions (any for now)462export function useActions(x: string, path: string): any;463464export function useActions(x, path?: string) {465return React.useMemo(() => {466let actions;467if (path != null) {468actions = redux.getEditorActions(x, path);469} else {470if (x?.name != null) {471actions = redux.getActions(x.name);472} else if (x?.project_id != null) {473// return here to avoid null check below; it can be null474return redux.getProjectActions(x.project_id);475} else if (is_valid_uuid_string(x)) {476// return here to avoid null check below; it can be null477return redux.getProjectActions(x);478} else {479actions = redux.getActions(x);480}481}482if (actions == null) {483throw Error(`BUG: actions for "${path}" must be defined but is not`);484}485return actions;486}, [x, path]);487}488489// WARNING: I tried to define this Stores interface490// in actions-and-stores.ts but it did NOT work. All491// the types just became any or didn't match. Don't492// move this unless you also fully test it!!493import { Store } from "@cocalc/util/redux/Store";494import { isEqual } from "lodash";495export interface Stores {496account: types.AccountStore;497"admin-site-licenses": types.SiteLicensesStore;498"admin-users": types.AdminUsersStore;499billing: types.BillingStore;500compute_images: types.ComputeImagesStore;501customize: types.CustomizeStore;502file_use: types.FileUseStore;503mentions: types.MentionsStore;504messages: types.MessagesStore;505page: types.PageStore;506projects: types.ProjectsStore;507users: types.UsersStore;508news: types.NewsStore;509}510511// If it is none of the explicitly named ones... it's a project.512//export function useStore(name: "projects"): types.ProjectsStore;513/**514* Hook to get a store instance (named or project).515*516* Examples:517* const store = useStore("projects");518* const store = useStore({ project_id });519*520* Throws if the store is not defined.521*/522export function useStore<T extends keyof Stores>(name: T): Stores[T];523export function useStore(x: { project_id: string }): ProjectStore;524export function useStore<T>(x: { name: string }): T;525// Or an editor store (any for now):526//export function useStore(project_id: string, path: string): Store<any>;527export function useStore(x): any {528return React.useMemo(() => {529let store;530if (x?.project_id != null) {531store = redux.getProjectStore(x.project_id);532} else if (x?.name != null) {533store = redux.getStore(x.name);534} else if (is_valid_uuid_string(x)) {535store = redux.getProjectStore(x);536} else {537store = redux.getStore(x);538}539if (store == null) {540throw Error("store must be defined");541}542return store;543}, [x]) as Store<any>;544}545546/**547* Debug hook that logs which props changed between renders.548*549* Uses deep equality (lodash isEqual) to detect changes and logs a map of550* keys to [previous, next] values.551*/552export function useTraceUpdate(props) {553const prev = useRef(props);554useEffect(() => {555const changedProps = Object.entries(props).reduce((ps, [k, v]) => {556if (!isEqual(prev.current[k], v)) {557ps[k] = [prev.current[k], v];558}559return ps;560}, {});561if (Object.keys(changedProps).length > 0) {562console.log("Changed props:", changedProps);563}564prev.current = props;565});566}567568569