Line data Source code
1 : /*
2 : * Copyright (C) 2020 Red Hat, Inc.
3 : * SPDX-License-Identifier: LGPL-2.1-or-later
4 : */
5 :
6 699 : import cockpit from 'cockpit';
7 : import { EventEmitter } from 'cockpit/event';
8 699 : 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://docs.cockpit-project.org/cockpit-guide/latest/guide/cockpit-location.html
50 : */
51 :
52 107 : export function usePageLocation() {
53 107 : const [location, setLocation] = useState(cockpit.location);
54 :
55 107 : useEffect(() => {
56 73 : function update() { setLocation(cockpit.location) }
57 107 : cockpit.addEventListener("locationchanged", update);
58 5 : return () => cockpit.removeEventListener("locationchanged", update);
59 107 : }, []);
60 :
61 107 : return location;
62 107 : }
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 699 : const cockpit_user_promise = cockpit.user();
77 699 : let cockpit_user: cockpit.UserInfo | null = null;
78 3 : cockpit_user_promise.then(user => { cockpit_user = user }).catch(err => console.error(err));
79 :
80 433 : export function useLoggedInUser() {
81 433 : const [user, setUser] = useState<cockpit.UserInfo | null>(cockpit_user);
82 371 : useEffect(() => { if (!cockpit_user) cockpit_user_promise.then(setUser); }, []);
83 433 : return user;
84 433 : }
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 19 : export function useDeepEqualMemo<T>(value: T): T {
129 19 : const ref = useRef(value);
130 19 : if (!dequal(ref.current, value))
131 4 : ref.current = value;
132 19 : return ref.current;
133 19 : }
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 19 : export function useFileWithError(path: string, options: cockpit.JsonObject, hook_options: UseFileWithErrorOptions) {
176 19 : const [content_and_error, setContentAndError] = useState<[string | false | null, cockpit.BasicError | false | null]>([null, null]);
177 19 : const memo_options = useDeepEqualMemo(options);
178 19 : const memo_hook_options = useDeepEqualMemo(hook_options);
179 :
180 19 : useEffect(() => {
181 19 : const handle = cockpit.file(path, memo_options);
182 19 : handle.watch((data, _tag, error) => {
183 4 : setContentAndError([data || false, error || false]);
184 4 : if (!data && memo_hook_options?.log_errors)
185 4 : console.warn("Can't read " + path + ": " + (error ? error.toString() : "not found"));
186 19 : });
187 19 : return handle.close;
188 19 : }, [path, memo_options, memo_hook_options]);
189 :
190 19 : return content_and_error;
191 19 : }
192 :
193 19 : export function useFile(path: string, options: cockpit.JsonObject) {
194 19 : const [content] = useFileWithError(path, options, { log_errors: true });
195 19 : return content;
196 19 : }
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 664 : function deps_changed<T extends Tuple>(old_deps: T | null, new_deps: T, comps: Comparators<T>): boolean {
242 662 : return (!old_deps || old_deps.length != new_deps.length ||
243 648 : old_deps.findIndex((o, i) => !(comps[i] || Object.is)(o, new_deps[i])) >= 0);
244 664 : }
245 :
246 664 : export function useObject<T, D extends Tuple>(create: () => T, destroy: ((value: T) => void) | null, deps: D, comps?: Comparators<D>): T {
247 664 : const ref = useRef<T | null>(null);
248 664 : const deps_ref = useRef<D | null>(null);
249 664 : 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 664 : if (deps_changed(deps_ref.current, deps, comps || {})) {
255 579 : if (ref.current && destroy)
256 579 : destroy(ref.current);
257 664 : ref.current = create();
258 664 : deps_ref.current = deps;
259 664 : }
260 :
261 664 : destroy_ref.current = destroy;
262 664 : useEffect(() => {
263 169 : return () => { destroy_ref.current?.(ref.current!) };
264 664 : }, []);
265 :
266 664 : return ref.current!;
267 664 : }
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 652 : 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 343 : const [, forceUpdate] = useReducer(x => x + 1, 0);
289 :
290 652 : function addListener() {
291 344 : function update(...args: Parameters<cockpit.EventListener<EM[E]>>) {
292 344 : if (handler)
293 182 : handler(...args);
294 344 : forceUpdate();
295 344 : }
296 :
297 652 : obj?.addEventListener(event, update);
298 580 : return () => obj?.removeEventListener(event, update);
299 652 : }
300 :
301 652 : useObject(
302 652 : addListener,
303 580 : removeListener => removeListener(),
304 652 : [obj, event, handler]);
305 652 : }
306 :
307 : /* Same as useEvent, but for our own EventEmitter.
308 : */
309 345 : export function useOn<EM extends { [E in keyof EM]: (...args: never[]) => void }, E extends keyof EM>(object: EventEmitter<EM> | null, event: E): void {
310 345 : const [, forceUpdate] = useReducer(x => x + 1, 0);
311 :
312 345 : useObject(
313 345 : () => object?.on(event, forceUpdate as EM[E]),
314 4 : off => off && off(),
315 345 : [object, event]);
316 345 : }
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 498 : export function useInit<T, D extends Tuple>(func: () => T, deps?: D, comps?: Comparators<D>, destroy: ((value: T) => void) | null = null): T {
339 483 : return useObject(func, destroy, deps || [], comps);
340 498 : }
|