/*
* This file is part of Cockpit.
*
* Copyright (C) 2020 Red Hat, Inc.
*
* Cockpit is free software; you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation; either version 2.1 of the License, or
* (at your option) any later version.
*
* Cockpit is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Cockpit; If not, see .
*/
import cockpit from 'cockpit';
import { useState, useEffect, useRef, useReducer } from 'react';
import deep_equal from "deep-equal";
/* HOOKS
*
* These are some custom React hooks for Cockpit specific things.
*
* Overview:
*
* - usePageLocation: For following along with cockpit.location.
*
* - useLoggedInUser: For accessing information about the currently
* logged in user.
*
* - useFile: For reading and watching files.
*
* - useObject: For maintaining arbitrary stateful objects that get
* created from the properties of a component.
*
* - useEvent: For reacting to events emitted by arbitrary objects.
*
* - useInit: For running a function once.
*
* - useDeepEqualMemo: A utility hook that can help with things that
* need deep equal comparisons in places where React only offers
* Object identity comparisons, such as with useEffect.
*/
/* - usePageLocation()
*
* function Component() {
* const location = usePageLocation();
* const { path, options } = usePageLocation();
*
* ...
* }
*
* This returns the current value of cockpit.location and the
* component is re-rendered when it changes. "location" is always a
* valid object and never null.
*
* See https://cockpit-project.org/guide/latest/cockpit-location.html
*/
export function usePageLocation() {
const [location, setLocation] = useState(cockpit.location);
useEffect(() => {
function update() { setLocation(cockpit.location) }
cockpit.addEventListener("locationchanged", update);
return () => cockpit.removeEventListener("locationchanged", update);
}, []);
return location;
}
/* - useLoggedInUser()
*
* function Component() {
* const user_info = useLoggedInUser();
*
* ...
* }
*
* "user_info" is the object delivered by cockpit.user(), or null
* while that object is not yet available.
*/
const cockpit_user_promise = cockpit.user();
let cockpit_user = null;
cockpit_user_promise.then(user => { cockpit_user = user });
export function useLoggedInUser() {
const [user, setUser] = useState(cockpit_user);
useEffect(() => { if (!cockpit_user) cockpit_user_promise.then(setUser); }, []);
return user;
}
/* - useDeepEqualMemo(value)
*
* function Component(options) {
* const memo_options = useDeepEqualMemo(options);
* useEffect(() => {
* const channel = cockpit.channel(..., memo_options);
* ...
* return () => channel.close();
* }, [memo_options]);
*
* ...
* }
*
* function ParentComponent() {
* const options = { superuser: true, host: "localhost" };
* return
* }
*
* Sometimes a useEffect hook has a deeply nested object as one of its
* dependencies, such as options for a Cockpit channel. However,
* React will compare dependency values with Object.is, and would run
* the effect hook too often. In the example above, the "options"
* variable of Component is a different object on each render
* according to Object.is, but we only want to open a new channel when
* the value of a field such as "superuser" or "host" has actually
* changed.
*
* A call to useDeepEqualMemo will return some object that is deeply
* equal to its argument, and it will continue to return the same
* object (according to Object.is) until the parameter is not deeply
* equal to it anymore.
*
* For the example, this means that "memo_options" will always be the
* very same object, and the effect hook is only run once. If we
* would use "options" directly as a dependency of the effect hook,
* the channel would be closed and opened on every render. This is
* very inefficient, doesn't give the asynchronous channel time to do
* its job, and will also lead to infinite loops when events on the
* channel cause re-renders (which in turn will run the effect hook
* again, which will cause a new event, ...).
*/
export function useDeepEqualMemo(value) {
const ref = useRef(value);
if (!deep_equal(ref.current, value))
ref.current = value;
return ref.current;
}
/* - useFile(path, options)
* - useFileWithError(path, options)
*
* function Component() {
* const content = useFile("/etc/hostname", { superuser: "try" });
* const [content, error] = useFileWithError("/etc/hostname", { superuser: "try" });
*
* ...
* }
*
* The "path" and "options" parameters are passed unchanged to
* cockpit.file(). Thus, if you need to parse the content of the
* file, the best way to do that is via the "syntax" option.
*
* The "content" variable will reflect the content of the file
* "/etc/hostname". When the file changes on disk, the component will
* be re-rendered with the new content.
*
* When the file does not exist or there has been some error reading
* it, "content" will be false.
*
* The "error" variable will contain any errors encountered while
* reading the file. It is false when there are no errors.
*
* When the file does not exist, "error" will be false.
*
* The "content" and "error" variables will be null until the file has
* been read for the first time.
*
* useFile and useFileWithError are pretty much the same. useFile will
* hide the exact error from the caller, which makes it slightly
* cleaner to use when the exact error is not part of the UI. In the
* case of error, useFile will log that error to the console and
* return false.
*/
export function useFileWithError(path, options, hook_options) {
const [content_and_error, setContentAndError] = useState([null, null]);
const memo_options = useDeepEqualMemo(options);
const memo_hook_options = useDeepEqualMemo(hook_options);
useEffect(() => {
const handle = cockpit.file(path, memo_options);
handle.watch((data, tag, error) => {
setContentAndError([data || false, error || false]);
if (!data && memo_hook_options?.log_errors)
console.warn("Can't read " + path + ": " + (error ? error.toString() : "not found"));
});
return handle.close;
}, [path, memo_options, memo_hook_options]);
return content_and_error;
}
export function useFile(path, options) {
const [content] = useFileWithError(path, options, { log_errors: true });
return content;
}
/* - useObject(create, destroy, dependencies, comparators)
*
* function Component(param) {
* const obj = useObject(() => create_object(param),
* obj => obj.close(),
* [param], [deep_equal])
*
* ...
* }
*
* This will call "create_object(param)" before the first render of
* the component, and will call "obj.close()" after the last render.
*
* More precisely, create_object will be called as part of the first
* call to useObject, i.e., at the very beginning of the first render.
*
* When "param" changes compared to the previous call to useObject
* (according to the deep_equal function in the example above), the
* object will also be destroyed and a new one will be created for the
* new value of "param" (as part of the call to useObject).
*
* There is no time when the "obj" variable is null in the example
* above; the first render already has a fully created object. This
* is an advantage that useObject has over useEffect, which you might
* otherwise use to only create objects when dependencies have
* changed.
*
* And unlike useMemo, useObject will run a cleanup function when a
* component is removed. Also unlike useMemo, useObject guarantees
* that it will not ignore the dependencies.
*
* The dependencies are an array of values that are by default
* compared with Object.is. If you need to use a custom comparator
* function instead of Object.is, you can provide a second
* "comparators" array that parallels the "dependencies" array. The
* values at a given index in the old and new "dependencies" arrays
* are compared with the function at the same index in "comparators".
*/
function deps_changed(old_deps, new_deps, comps) {
return (!old_deps || old_deps.length != new_deps.length ||
old_deps.findIndex((o, i) => !(comps[i] || Object.is)(o, new_deps[i])) >= 0);
}
export function useObject(create, destroy, deps, comps) {
const ref = useRef(null);
const deps_ref = useRef(null);
const destroy_ref = useRef(null);
if (deps_changed(deps_ref.current, deps, comps || [])) {
if (ref.current && destroy)
destroy(ref.current);
ref.current = create();
deps_ref.current = deps;
}
destroy_ref.current = destroy;
useEffect(() => {
return () => destroy_ref.current?.(ref.current);
}, []);
return ref.current;
}
/* - useEvent(obj, event, handler)
*
* function Component(proxy) {
* useEvent(proxy, "changed");
*
* ...
* }
*
* The component will be re-rendered whenever "proxy" emits the
* "changed" signal. The "proxy" parameter can be null.
*
* When the optional "handler" is given, it will be called with the
* arguments of the event.
*/
export function useEvent(obj, event, handler) {
// We increase a (otherwise unused) state variable whenever the event
// happens. That reliably triggers a re-render.
const [, forceUpdate] = useReducer(x => x + 1, 0);
useEffect(() => {
function update() {
if (handler)
handler.apply(null, arguments);
forceUpdate();
}
obj?.addEventListener(event, update);
return () => obj?.removeEventListener(event, update);
}, [obj, event, handler]);
}
/* - useInit(func, deps, comps)
*
* function Component(arg) {
* useInit(() => {
* cockpit.spawn([ ..., arg ]);
* }, [arg]);
*
* ...
* }
*
* The function will be called once during the first render, and
* whenever "arg" changes.
*
* "useInit(func, deps, comps)" is the same as "useObject(func, null,
* deps, comps)" but if you want to emphasize that you just want to
* run a function (instead of creating a object), it is clearer to use
* the "useInit" name for that. Also, "deps" are optional for
* "useInit" and default to "[]".
*/
export function useInit(func, deps, comps, destroy = null) {
return useObject(func, destroy, deps || [], comps);
}