LCOV - code coverage report
Current view: top level - pkg/lib - hooks.ts Coverage Total Hit
Test: cockpit Lines: 100.0 % 90 90
Test Date: 2026-07-02 14:11:36

            Line data    Source code
       1              : /*
       2              :  * Copyright (C) 2020 Red Hat, Inc.
       3              :  * SPDX-License-Identifier: LGPL-2.1-or-later
       4              :  */
       5              : 
       6          240 : import cockpit from 'cockpit';
       7              : import { EventEmitter } from 'cockpit/event';
       8          240 : import { useState, useEffect, useRef, useReducer } from 'react';
       9              : import { dequal } from 'dequal/lite';
      10              : 
      11              : /* HOOKS
      12              :  *
      13              :  * These are some custom React hooks for Cockpit specific things.
      14              :  *
      15              :  * Overview:
      16              :  *
      17              :  * - usePageLocation: For following along with cockpit.location.
      18              :  *
      19              :  * - useLoggedInUser: For accessing information about the currently
      20              :  * logged in user.
      21              :  *
      22              :  * - useFile: For reading and watching files.
      23              :  *
      24              :  * - useObject: For maintaining arbitrary stateful objects that get
      25              :  * created from the properties of a component.
      26              :  *
      27              :  * - useEvent: For reacting to events emitted by arbitrary objects.
      28              :  *
      29              :  * - useInit: For running a function once.
      30              :  *
      31              :  * - useDeepEqualMemo: A utility hook that can help with things that
      32              :  * need deep equal comparisons in places where React only offers
      33              :  * Object identity comparisons, such as with useEffect.
      34              :  */
      35              : 
      36              : /* - usePageLocation()
      37              :  *
      38              :  * function Component() {
      39              :  *   const location = usePageLocation();
      40              :  *   const { path, options } = usePageLocation();
      41              :  *
      42              :  *   ...
      43              :  * }
      44              :  *
      45              :  * This returns the current value of cockpit.location and the
      46              :  * component is re-rendered when it changes. "location" is always a
      47              :  * valid object and never null.
      48              :  *
      49              :  * See https://cockpit-project.org/guide/latest/cockpit-location.html
      50              :  */
      51              : 
      52           61 : export function usePageLocation() {
      53           61 :     const [location, setLocation] = useState(cockpit.location);
      54              : 
      55           61 :     useEffect(() => {
      56           42 :         function update() { setLocation(cockpit.location) }
      57           61 :         cockpit.addEventListener("locationchanged", update);
      58            5 :         return () => cockpit.removeEventListener("locationchanged", update);
      59           61 :     }, []);
      60              : 
      61           61 :     return location;
      62           61 : }
      63              : 
      64              : /* - useLoggedInUser()
      65              :  *
      66              :  * function Component() {
      67              :  *   const user_info = useLoggedInUser();
      68              :  *
      69              :  *   ...
      70              :  * }
      71              :  *
      72              :  * "user_info" is the object delivered by cockpit.user(), or null
      73              :  * while that object is not yet available.
      74              :  */
      75              : 
      76          240 : const cockpit_user_promise = cockpit.user();
      77          240 : let cockpit_user: cockpit.UserInfo | null = null;
      78            3 : cockpit_user_promise.then(user => { cockpit_user = user }).catch(err => console.error(err));
      79              : 
      80          164 : export function useLoggedInUser() {
      81          164 :     const [user, setUser] = useState<cockpit.UserInfo | null>(cockpit_user);
      82          142 :     useEffect(() => { if (!cockpit_user) cockpit_user_promise.then(setUser); }, []);
      83          164 :     return user;
      84          164 : }
      85              : 
      86              : /* - useDeepEqualMemo(value)
      87              :  *
      88              :  * function Component(options) {
      89              :  *   const memo_options = useDeepEqualMemo(options);
      90              :  *   useEffect(() => {
      91              :  *       const channel = cockpit.channel(..., memo_options);
      92              :  *       ...
      93              :  *       return () => channel.close();
      94              :  *   }, [memo_options]);
      95              :  *
      96              :  *   ...
      97              :  * }
      98              :  *
      99              :  * function ParentComponent() {
     100              :  *     const options = { superuser: "require", host: "localhost" };
     101              :  *     return <Component options={options}/>
     102              :  * }
     103              :  *
     104              :  * Sometimes a useEffect hook has a deeply nested object as one of its
     105              :  * dependencies, such as options for a Cockpit channel.  However,
     106              :  * React will compare dependency values with Object.is, and would run
     107              :  * the effect hook too often.  In the example above, the "options"
     108              :  * variable of Component is a different object on each render
     109              :  * according to Object.is, but we only want to open a new channel when
     110              :  * the value of a field such as "superuser" or "host" has actually
     111              :  * changed.
     112              :  *
     113              :  * A call to useDeepEqualMemo will return some object that is deeply
     114              :  * equal to its argument, and it will continue to return the same
     115              :  * object (according to Object.is) until the parameter is not deeply
     116              :  * equal to it anymore.
     117              :  *
     118              :  * For the example, this means that "memo_options" will always be the
     119              :  * very same object, and the effect hook is only run once.  If we
     120              :  * would use "options" directly as a dependency of the effect hook,
     121              :  * the channel would be closed and opened on every render. This is
     122              :  * very inefficient, doesn't give the asynchronous channel time to do
     123              :  * its job, and will also lead to infinite loops when events on the
     124              :  * channel cause re-renders (which in turn will run the effect hook
     125              :  * again, which will cause a new event, ...).
     126              :  */
     127              : 
     128           15 : export function useDeepEqualMemo<T>(value: T): T {
     129           15 :     const ref = useRef(value);
     130           15 :     if (!dequal(ref.current, value))
     131            3 :         ref.current = value;
     132           15 :     return ref.current;
     133           15 : }
     134              : 
     135              : /* - useFile(path, options)
     136              :  * - useFileWithError(path, options)
     137              :  *
     138              :  * function Component() {
     139              :  *   const content = useFile("/etc/hostname", { superuser: "try" });
     140              :  *   const [content, error] = useFileWithError("/etc/hostname", { superuser: "try" });
     141              :  *
     142              :  *   ...
     143              :  * }
     144              :  *
     145              :  * The "path" and "options" parameters are passed unchanged to
     146              :  * cockpit.file().  Thus, if you need to parse the content of the
     147              :  * file, the best way to do that is via the "syntax" option.
     148              :  *
     149              :  * The "content" variable will reflect the content of the file
     150              :  * "/etc/hostname". When the file changes on disk, the component will
     151              :  * be re-rendered with the new content.
     152              :  *
     153              :  * When the file does not exist or there has been some error reading
     154              :  * it, "content" will be false.
     155              :  *
     156              :  * The "error" variable will contain any errors encountered while
     157              :  * reading the file.  It is false when there are no errors.
     158              :  *
     159              :  * When the file does not exist, "error" will be false.
     160              :  *
     161              :  * The "content" and "error" variables will be null until the file has
     162              :  * been read for the first time.
     163              :  *
     164              :  * useFile and useFileWithError are pretty much the same. useFile will
     165              :  * hide the exact error from the caller, which makes it slightly
     166              :  * cleaner to use when the exact error is not part of the UI. In the
     167              :  * case of error, useFile will log that error to the console and
     168              :  * return false.
     169              :  */
     170              : 
     171              : type UseFileWithErrorOptions = {
     172              :     log_errors?: boolean;
     173              : };
     174              : 
     175           15 : export function useFileWithError(path: string, options: cockpit.JsonObject, hook_options: UseFileWithErrorOptions) {
     176           15 :     const [content_and_error, setContentAndError] = useState<[string | false | null, cockpit.BasicError | false | null]>([null, null]);
     177           15 :     const memo_options = useDeepEqualMemo(options);
     178           15 :     const memo_hook_options = useDeepEqualMemo(hook_options);
     179              : 
     180           15 :     useEffect(() => {
     181           15 :         const handle = cockpit.file(path, memo_options);
     182           15 :         handle.watch((data, _tag, error) => {
     183            3 :             setContentAndError([data || false, error || false]);
     184            3 :             if (!data && memo_hook_options?.log_errors)
     185            3 :                 console.warn("Can't read " + path + ": " + (error ? error.toString() : "not found"));
     186           15 :         });
     187           15 :         return handle.close;
     188           15 :     }, [path, memo_options, memo_hook_options]);
     189              : 
     190           15 :     return content_and_error;
     191           15 : }
     192              : 
     193           15 : export function useFile(path: string, options: cockpit.JsonObject) {
     194           15 :     const [content] = useFileWithError(path, options, { log_errors: true });
     195           15 :     return content;
     196           15 : }
     197              : 
     198              : /* - useObject(create, destroy, dependencies, comparators)
     199              :  *
     200              :  * function Component(param) {
     201              :  *   const obj = useObject(() => create_object(param),
     202              :  *                         obj => obj.close(),
     203              :  *                         [param] as const, [dequal])
     204              :  *
     205              :  *   ...
     206              :  * }
     207              :  *
     208              :  * This will call "create_object(param)" before the first render of
     209              :  * the component, and will call "obj.close()" after the last render.
     210              :  *
     211              :  * More precisely, create_object will be called as part of the first
     212              :  * call to useObject, i.e., at the very beginning of the first render.
     213              :  *
     214              :  * When "param" changes compared to the previous call to useObject
     215              :  * (according to the dequal function in the example above), the
     216              :  * object will also be destroyed and a new one will be created for the
     217              :  * new value of "param" (as part of the call to useObject).
     218              :  *
     219              :  * There is no time when the "obj" variable is null in the example
     220              :  * above; the first render already has a fully created object.  This
     221              :  * is an advantage that useObject has over useEffect, which you might
     222              :  * otherwise use to only create objects when dependencies have
     223              :  * changed.
     224              :  *
     225              :  * And unlike useMemo, useObject will run a cleanup function when a
     226              :  * component is removed.  Also unlike useMemo, useObject guarantees
     227              :  * that it will not ignore the dependencies.
     228              :  *
     229              :  * The dependencies are an array of values that are by default
     230              :  * compared with Object.is.  If you need to use a custom comparator
     231              :  * function instead of Object.is, you can provide a second
     232              :  * "comparators" array that parallels the "dependencies" array.  The
     233              :  * values at a given index in the old and new "dependencies" arrays
     234              :  * are compared with the function at the same index in "comparators".
     235              :  */
     236              : 
     237              : type Tuple = readonly [...unknown[]];
     238              : type Comparator<T> = (a: T, b: T) => boolean;
     239              : type Comparators<T extends Tuple> = {[ t in keyof T ]?: Comparator<T[t]>};
     240              : 
     241          230 : function deps_changed<T extends Tuple>(old_deps: T | null, new_deps: T, comps: Comparators<T>): boolean {
     242          230 :     return (!old_deps || old_deps.length != new_deps.length ||
     243          223 :             old_deps.findIndex((o, i) => !(comps[i] || Object.is)(o, new_deps[i])) >= 0);
     244          230 : }
     245              : 
     246          230 : export function useObject<T, D extends Tuple>(create: () => T, destroy: ((value: T) => void) | null, deps: D, comps?: Comparators<D>): T {
     247          230 :     const ref = useRef<T | null>(null);
     248          230 :     const deps_ref = useRef<D | null>(null);
     249          230 :     const destroy_ref = useRef<((value: T) => void) | null>(destroy);
     250              : 
     251              :     /* Since each item in Comparators<> is optional, `[]` should be valid here
     252              :      * but for some reason it doesn't work — but `{}` does.
     253              :      */
     254          230 :     if (deps_changed(deps_ref.current, deps, comps || {})) {
     255          166 :         if (ref.current && destroy)
     256          166 :             destroy(ref.current);
     257          230 :         ref.current = create();
     258          230 :         deps_ref.current = deps;
     259          230 :     }
     260              : 
     261          230 :     destroy_ref.current = destroy;
     262          230 :     useEffect(() => {
     263           26 :         return () => { destroy_ref.current?.(ref.current!) };
     264          230 :     }, []);
     265              : 
     266          230 :     return ref.current!;
     267          230 : }
     268              : 
     269              : /* - useEvent(obj, event, handler)
     270              :  *
     271              :  * function Component(proxy) {
     272              :  *   useEvent(proxy, "changed");
     273              :  *
     274              :  *   ...
     275              :  * }
     276              :  *
     277              :  * The component will be re-rendered whenever "proxy" emits the
     278              :  * "changed" signal.  The "proxy" parameter can be null.
     279              :  *
     280              :  * When the optional "handler" is given, it will be called with the
     281              :  * arguments of the event.
     282              :  */
     283              : 
     284          221 : export function useEvent<EM extends cockpit.EventMap, E extends keyof EM>(obj: cockpit.EventSource<EM> | null, event: E, handler?: cockpit.EventListener<EM[E]>) {
     285              :     // We increase a (otherwise unused) state variable whenever the event
     286              :     // happens.  That reliably triggers a re-render.
     287              : 
     288          106 :     const [, forceUpdate] = useReducer(x => x + 1, 0);
     289              : 
     290          221 :     function addListener() {
     291          106 :         function update(...args: Parameters<cockpit.EventListener<EM[E]>>) {
     292          106 :             if (handler)
     293           46 :                 handler(...args);
     294          106 :             forceUpdate();
     295          106 :         }
     296              : 
     297          221 :         obj?.addEventListener(event, update);
     298          162 :         return () => obj?.removeEventListener(event, update);
     299          221 :     }
     300              : 
     301          221 :     useObject(
     302          221 :         addListener,
     303          162 :         removeListener => removeListener(),
     304          221 :         [obj, event, handler]);
     305          221 : }
     306              : 
     307              : /* Same as useEvent, but for our own EventEmitter.
     308              :  */
     309          125 : export function useOn<EM extends { [E in keyof EM]: (...args: never[]) => void }, E extends keyof EM>(object: EventEmitter<EM> | null, event: E): void {
     310          125 :     const [, forceUpdate] = useReducer(x => x + 1, 0);
     311              : 
     312          125 :     useObject(
     313          125 :         () => object?.on(event, forceUpdate as EM[E]),
     314            2 :         off => off && off(),
     315          125 :         [object, event]);
     316          125 : }
     317              : 
     318              : /* - useInit(func, deps, comps)
     319              :  *
     320              :  * function Component(arg) {
     321              :  *   useInit(() => {
     322              :  *     cockpit.spawn([ ..., arg ]);
     323              :  *   }, [arg]);
     324              :  *
     325              :  *   ...
     326              :  * }
     327              :  *
     328              :  * The function will be called once during the first render, and
     329              :  * whenever "arg" changes.
     330              :  *
     331              :  * "useInit(func, deps, comps)" is the same as "useObject(func, null,
     332              :  * deps, comps)" but if you want to emphasize that you just want to
     333              :  * run a function (instead of creating a object), it is clearer to use
     334              :  * the "useInit" name for that.  Also, "deps" are optional for
     335              :  * "useInit" and default to "[]".
     336              :  */
     337              : 
     338          186 : export function useInit<T, D extends Tuple>(func: () => T, deps?: D, comps?: Comparators<D>, destroy: ((value: T) => void) | null = null): T {
     339          174 :     return useObject(func, destroy, deps || [], comps);
     340          186 : }
        

Generated by: LCOV version 2.0-1