@faybl/react-utils 1.2.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,10 +6,11 @@ React hooks for Faybl web apps.
6
6
  npm install @faybl/react-utils
7
7
  ```
8
8
 
9
- Every hook takes the `@faybl/client` client as `client` and does nothing until
10
- it has one. `useConnection` and `useSession` need client 4.1 or later. A list
11
- of rids or ids is compared by its content, so a new array with the same ids
12
- does not resubscribe.
9
+ Every hook but `useSession` takes the `@faybl/client` client as `client` and
10
+ does nothing until it has one; `useSession` reads it from `SessionProvider`.
11
+ `useConnection` and `SessionProvider` need client 4.1 or later. A list of rids
12
+ or ids is compared by its content, so a new array with the same ids does not
13
+ resubscribe.
13
14
 
14
15
  ```js
15
16
  import {
@@ -24,16 +25,16 @@ const presenceById = usePresence({ client, identityIds });
24
25
  const latest = useChannelsLatest({ client, rids, commitMs: 1000, onError });
25
26
  const series = useChannelsSeries({ client, rids, windowSeconds: 3600 });
26
27
  const connected = useConnection({ client });
27
- const { expired, login } = useSession({ client });
28
+ const { expired, cause, user, signIn, signOut } = useSession();
28
29
  ```
29
30
 
30
- | Hook | Returns |
31
- | ------------------- | ------------------------------------------------------------------------- |
32
- | `usePresence` | `{ [identityId]: { ts, connected } }`, live |
33
- | `useChannelsLatest` | `{ [rid]: { ts, pl } }`, the last stored point, then live |
34
- | `useChannelsSeries` | `{ [rid]: points }`, the last `windowSeconds`, oldest first, then live |
35
- | `useConnection` | whether the MQTT connection is up |
36
- | `useSession` | `{ expired, login }`: whether the login session is gone, and the way back |
31
+ | Hook | Returns |
32
+ | ------------------- | ---------------------------------------------------------------------- |
33
+ | `usePresence` | `{ [identityId]: { ts, connected } }`, live |
34
+ | `useChannelsLatest` | `{ [rid]: { ts, pl } }`, the last stored point, then live |
35
+ | `useChannelsSeries` | `{ [rid]: points }`, the last `windowSeconds`, oldest first, then live |
36
+ | `useConnection` | whether the MQTT connection is up |
37
+ | `useSession` | the session, see [An expired session](#an-expired-session) |
37
38
 
38
39
  `connected` and `expired` are `undefined` until the client has reported them.
39
40
 
@@ -46,20 +47,57 @@ The two channel hooks take two options:
46
47
 
47
48
  ## An expired session
48
49
 
49
- `useSession` shows nothing and never navigates on its own; what happens when
50
- the session expires is the app's choice. `login` navigates the whole page to
51
- `/oauth2/start` and back to where the user was, which happens at once while the
52
- identity provider still has a session.
50
+ `SessionProvider` follows the session behind the client and reacts when
51
+ it is gone. Put it around the app once the client exists:
53
52
 
54
- A banner with a button that calls `login` keeps whatever the user was typing. A
55
- page with nothing to lose can call `login` from an effect once `expired` is
56
- true instead.
53
+ ```jsx
54
+ <SessionProvider client={client}>
55
+ <App />
56
+ </SessionProvider>
57
+ ```
58
+
59
+ It finds an expiry two ways, and each has its own prop:
60
+
61
+ | Prop | When | Default |
62
+ | ------------------ | --------------------------------------------------------------- | -------- |
63
+ | `expiredOnReturn` | the user comes back to the tab; a request is sent then to check | `reload` |
64
+ | `expiredOnRequest` | a request is refused while the user works on the page | `dialog` |
65
+
66
+ Each takes `reload`, `dialog` or `none`, also exported as `REACTION`:
67
+
68
+ - `reload` reloads the page. While the identity provider still has a session
69
+ the user is back on the same page at once; otherwise the sign-in page shows.
70
+ - `dialog` shows `SessionExpiredDialog`, which keeps the page, and whatever the
71
+ user was typing, until the user chooses to sign in again.
72
+ - `none` does nothing, for an app that shows the expiry its own way through
73
+ `useSession`.
74
+
75
+ An expiry found while the tab is hidden counts as a return and waits until the
76
+ user comes back. After an automatic reload, a tab reloads on its own again only
77
+ once `reloadCooldownMs` (default 30 000) has passed; an expiry before that shows
78
+ the dialog, so a 401 that is not about the session cannot reload in a loop.
79
+ `dialogLabels` sets the dialog's `{ title, action }`, which are English by
80
+ default.
81
+
82
+ `useSession()` returns:
83
+
84
+ - `expired`: undefined until the client has reported, then whether the
85
+ session is gone.
86
+ - `cause`: how the expiry was found, `returned` or `request`, also exported as
87
+ `CAUSE`.
88
+ - `user`: `{ id, firstName, lastName, email }` from `auth.getUserInfo`, fetched
89
+ the first time a component asks and kept. Undefined until it arrives.
90
+ - `signIn()`: reloads the page.
91
+ - `signOut()`: goes to the login proxy's `sign_out` on the app's own host, which
92
+ ends the session there and at the identity provider, and comes back to the
93
+ page the user left, which asks the user to sign in again. `signOutUrl` on the
94
+ provider sends it somewhere else instead.
95
+
96
+ An app that handles the expiry itself can still use the dialog:
57
97
 
58
98
  ```jsx
59
- const SessionBanner = ({ client }) => {
60
- const { expired, login } = useSession({ client });
61
- return expired ? (
62
- <Alert action={<Button onClick={login}>Log in</Button>}>Your session has expired.</Alert>
63
- ) : undefined;
99
+ const SessionNotice = () => {
100
+ const { expired, signIn } = useSession();
101
+ return <SessionExpiredDialog open={expired === true} onSignIn={signIn} />;
64
102
  };
65
103
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@faybl/react-utils",
3
- "version": "1.2.0",
3
+ "version": "2.0.0",
4
4
  "description": "React hooks for Faybl web apps",
5
5
  "license": "TBD",
6
6
  "author": "Arcturitech",
@@ -30,7 +30,7 @@
30
30
  }
31
31
  },
32
32
  "dependencies": {
33
- "@faybl/utils": "^1.2.0"
33
+ "@faybl/utils": "^2.0.0"
34
34
  },
35
35
  "engines": {
36
36
  "node": ">=22"
@@ -0,0 +1,50 @@
1
+ import { createElement, useEffect, useId, useRef } from 'react';
2
+
3
+ const DEFAULT_LABELS = { title: 'You have been signed out', action: 'Sign in again' };
4
+
5
+ // Light or dark after the user's system, as apps here are.
6
+ const dialogStyle = {
7
+ colorScheme: 'light dark',
8
+ border: 'none',
9
+ borderRadius: 8,
10
+ padding: 24,
11
+ minWidth: 280,
12
+ font: 'inherit',
13
+ boxShadow: '0 8px 32px rgba(0, 0, 0, 0.3)',
14
+ };
15
+ const titleStyle = { margin: '0 0 24px', fontSize: '1.25rem', fontWeight: 500 };
16
+ const actionStyle = { font: 'inherit', fontWeight: 500, padding: '6px 16px', cursor: 'pointer' };
17
+
18
+ // The only way out is to sign in again, so Escape does not close it.
19
+ const keepOpen = (event) => event.preventDefault();
20
+
21
+ const useModal = (open) => {
22
+ const ref = useRef(undefined);
23
+ useEffect(() => {
24
+ const dialog = ref.current;
25
+ if (open && !dialog.open) dialog.showModal();
26
+ if (!open && dialog.open) dialog.close();
27
+ }, [open]);
28
+ return ref;
29
+ };
30
+
31
+ /**
32
+ * A modal telling the user the session is gone, with one action that signs in
33
+ * again. The provider shows it for the `dialog` reaction; an app that handles
34
+ * expiry itself can render it with `open` from useSession.
35
+ * @param {object} params
36
+ * @param {boolean} params.open
37
+ * @param {() => void} params.onSignIn Usually useSession's signIn
38
+ * @param {{ title?: string, action?: string }} [params.labels] English by default
39
+ */
40
+ export const SessionExpiredDialog = ({ open, onSignIn, labels }) => {
41
+ const ref = useModal(open);
42
+ const titleId = useId();
43
+ const { title, action } = { ...DEFAULT_LABELS, ...labels };
44
+ return createElement(
45
+ 'dialog',
46
+ { ref, 'aria-labelledby': titleId, onCancel: keepOpen, style: dialogStyle },
47
+ createElement('h2', { id: titleId, style: titleStyle }, title),
48
+ createElement('button', { type: 'button', onClick: onSignIn, style: actionStyle }, action),
49
+ );
50
+ };
@@ -0,0 +1,117 @@
1
+ import { createElement, useCallback, useEffect, useMemo, useReducer } from 'react';
2
+
3
+ import { navigateTo, reloadPage } from './pageNavigation.js';
4
+ import { readReloadStamp, writeReloadStamp } from './reloadStamp.js';
5
+ import { SessionContext } from './sessionContext.js';
6
+ import { SessionExpiredDialog } from './SessionExpiredDialog.js';
7
+ import { CAUSE, DEFAULT_RELOAD_COOLDOWN_MS, REACTION } from './sessionReaction.js';
8
+ import { INITIAL_SESSION_STATE, SESSION_EVENT, sessionReducer } from './sessionState.js';
9
+ import { defaultSignOutUrl } from './signOutUrl.js';
10
+ import { useReturnCheck } from './useReturnCheck.js';
11
+ import { useUser } from './useUser.js';
12
+
13
+ const isVisible = () => document.visibilityState === 'visible';
14
+
15
+ const useSessionState = ({ client, reactions, reloadCooldownMs, probing }) => {
16
+ const [state, dispatch] = useReducer(sessionReducer, INITIAL_SESSION_STATE);
17
+ const policy = useCallback(
18
+ () => ({ reactions, reloadStamp: readReloadStamp(), now: Date.now(), reloadCooldownMs }),
19
+ [reactions, reloadCooldownMs],
20
+ );
21
+
22
+ useEffect(() => {
23
+ if (!client) return undefined;
24
+ return client.session.subscribe(({ expired }) =>
25
+ dispatch({
26
+ type: SESSION_EVENT.REPORTED,
27
+ client,
28
+ expired,
29
+ visible: isVisible(),
30
+ probing: probing(),
31
+ policy: policy(),
32
+ }),
33
+ );
34
+ }, [client, policy, probing]);
35
+
36
+ const returned = useCallback(
37
+ () => dispatch({ type: SESSION_EVENT.VISIBLE, client, visible: true, policy: policy() }),
38
+ [client, policy],
39
+ );
40
+
41
+ return { state: state.client === client ? state : INITIAL_SESSION_STATE, returned };
42
+ };
43
+
44
+ const useAutomaticReload = (reaction) =>
45
+ useEffect(() => {
46
+ if (reaction !== REACTION.RELOAD) return;
47
+ writeReloadStamp(Date.now());
48
+ reloadPage();
49
+ }, [reaction]);
50
+
51
+ /**
52
+ * Follows the session behind `client` and reacts when it expires:
53
+ * `expiredOnReturn` when the user comes back to the tab, `expiredOnRequest`
54
+ * when a request fails while the user works. Each is `reload`, `dialog` or
55
+ * `none`. After an automatic reload, none runs for `reloadCooldownMs`; an
56
+ * expiry then shows the dialog. Children read it through useSession.
57
+ * @param {object} params
58
+ * @param {object} params.client faybl-client 4.1 or later; nothing happens until it is set
59
+ * @param {'reload' | 'dialog' | 'none'} [params.expiredOnReturn] Default `reload`
60
+ * @param {'reload' | 'dialog' | 'none'} [params.expiredOnRequest] Default `dialog`
61
+ * @param {number} [params.reloadCooldownMs] Default 30 000
62
+ * @param {string} [params.signOutUrl] Where signOut goes; through the login proxy by default
63
+ * @param {{ title?: string, action?: string }} [params.dialogLabels]
64
+ * @param {import('react').ReactNode} params.children
65
+ */
66
+ export const SessionProvider = ({
67
+ client,
68
+ expiredOnReturn = REACTION.RELOAD,
69
+ expiredOnRequest = REACTION.DIALOG,
70
+ reloadCooldownMs = DEFAULT_RELOAD_COOLDOWN_MS,
71
+ signOutUrl,
72
+ dialogLabels,
73
+ children,
74
+ }) => {
75
+ const reactions = useMemo(
76
+ () => ({ [CAUSE.RETURNED]: expiredOnReturn, [CAUSE.REQUEST]: expiredOnRequest }),
77
+ [expiredOnReturn, expiredOnRequest],
78
+ );
79
+ const { user, loadUser, fetchUser } = useUser(client);
80
+ const { probing, check } = useReturnCheck(fetchUser);
81
+ const { state, returned } = useSessionState({ client, reactions, reloadCooldownMs, probing });
82
+ const { expired, cause, reaction } = state;
83
+
84
+ useEffect(() => {
85
+ if (!client) return undefined;
86
+ const onVisibilityChange = () => {
87
+ if (!isVisible()) return;
88
+ returned();
89
+ check();
90
+ };
91
+ document.addEventListener('visibilitychange', onVisibilityChange);
92
+ return () => document.removeEventListener('visibilitychange', onVisibilityChange);
93
+ }, [client, returned, check]);
94
+
95
+ useAutomaticReload(reaction);
96
+
97
+ const signOut = useCallback(
98
+ () => navigateTo(signOutUrl ?? defaultSignOutUrl(window.location)),
99
+ [signOutUrl],
100
+ );
101
+ const session = useMemo(
102
+ () => ({ expired, cause, user, signIn: reloadPage, signOut }),
103
+ [expired, cause, user, signOut],
104
+ );
105
+ const value = useMemo(() => ({ session, loadUser }), [session, loadUser]);
106
+
107
+ return createElement(
108
+ SessionContext,
109
+ { value },
110
+ children,
111
+ createElement(SessionExpiredDialog, {
112
+ open: reaction === REACTION.DIALOG,
113
+ onSignIn: reloadPage,
114
+ labels: dialogLabels,
115
+ }),
116
+ );
117
+ };
package/src/index.js CHANGED
@@ -1,3 +1,6 @@
1
+ export { CAUSE, REACTION } from './sessionReaction.js';
2
+ export { SessionExpiredDialog } from './SessionExpiredDialog.js';
3
+ export { SessionProvider } from './SessionProvider.js';
1
4
  export { useChannelsLatest } from './useChannelsLatest.js';
2
5
  export { useChannelsSeries } from './useChannelsSeries.js';
3
6
  export { useConnection } from './useConnection.js';
@@ -0,0 +1,5 @@
1
+ // Whole-page navigation, which ends the app's own state.
2
+
3
+ export const reloadPage = () => window.location.reload();
4
+
5
+ export const navigateTo = (url) => window.location.assign(url);
@@ -0,0 +1,26 @@
1
+ const RELOAD_STAMP_KEY = 'faybl.session.reloadedAt';
2
+
3
+ // sessionStorage is per tab and survives the reload it records. Reaching it
4
+ // throws where the browser blocks storage, so it is reached inside the guard.
5
+ const tabStorage = () => globalThis.sessionStorage;
6
+
7
+ /**
8
+ * When this tab last reloaded on its own: `{ ok: true, value }`, with `value`
9
+ * undefined for never, or `{ ok: false }` when storage cannot be read.
10
+ */
11
+ export const readReloadStamp = (storage = tabStorage) => {
12
+ try {
13
+ const stamp = Number(storage().getItem(RELOAD_STAMP_KEY));
14
+ return { ok: true, value: Number.isFinite(stamp) && stamp > 0 ? stamp : undefined };
15
+ } catch {
16
+ return { ok: false };
17
+ }
18
+ };
19
+
20
+ export const writeReloadStamp = (now, storage = tabStorage) => {
21
+ try {
22
+ storage().setItem(RELOAD_STAMP_KEY, String(now));
23
+ } catch {
24
+ // A failed write leaves the earlier stamp, if any.
25
+ }
26
+ };
@@ -0,0 +1,3 @@
1
+ import { createContext } from 'react';
2
+
3
+ export const SessionContext = createContext(undefined);
@@ -0,0 +1,37 @@
1
+ export const REACTION = { RELOAD: 'reload', DIALOG: 'dialog', NONE: 'none' };
2
+
3
+ export const CAUSE = { RETURNED: 'returned', REQUEST: 'request' };
4
+
5
+ export const DEFAULT_RELOAD_COOLDOWN_MS = 30_000;
6
+
7
+ /**
8
+ * Whether an automatic reload may run now: none until `reloadCooldownMs` have
9
+ * passed since the last one, so a 401 that a reload does not cure cannot loop.
10
+ * Without a readable stamp the last reload is unknown, and none runs.
11
+ * @param {object} params
12
+ * @param {{ ok: boolean, value?: number }} params.reloadStamp Epoch ms of the last automatic reload
13
+ * @param {number} params.now Epoch ms
14
+ * @param {number} params.reloadCooldownMs
15
+ * @returns {boolean}
16
+ */
17
+ export const reloadAllowed = ({ reloadStamp, now, reloadCooldownMs }) =>
18
+ reloadStamp.ok &&
19
+ (reloadStamp.value === undefined || now - reloadStamp.value >= reloadCooldownMs);
20
+
21
+ /**
22
+ * The reaction to an expired session: the one configured for its cause, with
23
+ * a reload turned into the dialog during the reload cooldown.
24
+ * @param {object} params
25
+ * @param {string} params.cause One of CAUSE
26
+ * @param {{ returned: string, request: string }} params.reactions One of REACTION per cause
27
+ * @param {{ ok: boolean, value?: number }} params.reloadStamp
28
+ * @param {number} params.now
29
+ * @param {number} params.reloadCooldownMs
30
+ * @returns {string} One of REACTION
31
+ */
32
+ export const chooseReaction = ({ cause, reactions, ...guard }) => {
33
+ const reaction = Object.values(REACTION).includes(reactions[cause])
34
+ ? reactions[cause]
35
+ : REACTION.DIALOG;
36
+ return reaction === REACTION.RELOAD && !reloadAllowed(guard) ? REACTION.DIALOG : reaction;
37
+ };
@@ -0,0 +1,42 @@
1
+ import { CAUSE, chooseReaction } from './sessionReaction.js';
2
+
3
+ export const SESSION_EVENT = { REPORTED: 'reported', VISIBLE: 'visible' };
4
+
5
+ export const INITIAL_SESSION_STATE = {
6
+ client: undefined,
7
+ expired: undefined,
8
+ cause: undefined,
9
+ reaction: undefined,
10
+ };
11
+
12
+ // The reaction is chosen only while the user can see the page; an expiry found
13
+ // in a hidden tab waits for the user to come back.
14
+ const decide = (state, { visible, policy }) =>
15
+ state.expired && visible && !state.reaction
16
+ ? { ...state, reaction: chooseReaction({ cause: state.cause, ...policy }) }
17
+ : state;
18
+
19
+ const reported = (state, { client, expired, visible, probing }) => {
20
+ const current = state.client === client ? state : { ...INITIAL_SESSION_STATE, client };
21
+ if (!expired) return { ...INITIAL_SESSION_STATE, client, expired };
22
+ if (current.expired) return current;
23
+ const cause = probing || !visible ? CAUSE.RETURNED : CAUSE.REQUEST;
24
+ return { client, expired, cause, reaction: undefined };
25
+ };
26
+
27
+ /**
28
+ * The provider's session state, for one client at a time. REPORTED is the
29
+ * client's `{ expired }`; VISIBLE is the tab becoming visible again. Both carry
30
+ * `client`, `visible` and `policy`, the input to chooseReaction apart from the
31
+ * cause, and REPORTED whether a check started by the user's return is running.
32
+ */
33
+ export const sessionReducer = (state, event) => {
34
+ switch (event.type) {
35
+ case SESSION_EVENT.REPORTED:
36
+ return decide(reported(state, event), event);
37
+ case SESSION_EVENT.VISIBLE:
38
+ return state.client === event.client ? decide(state, event) : state;
39
+ default:
40
+ return state;
41
+ }
42
+ };
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Where sign-out goes by default: the login proxy on the tenant's host, which
3
+ * clears its cookie, ends the identity provider's session and sends the user
4
+ * back to the page they left. That page has no session, so it asks the user to
5
+ * sign in.
6
+ * @param {{ host: string, origin: string, pathname: string }} location
7
+ * @returns {string}
8
+ */
9
+ export const defaultSignOutUrl = ({ host, origin, pathname }) =>
10
+ `https://oauth2.${host}/oauth2/sign_out?rd=${encodeURIComponent(`${origin}${pathname}`)}`;
@@ -0,0 +1,26 @@
1
+ import { useCallback, useRef } from 'react';
2
+
3
+ /**
4
+ * The request sent when the user returns to the tab, so a lost session is
5
+ * found at once. `probing()` is true while one is running, which tells an
6
+ * expiry found by it from one found while the user works.
7
+ * @param {() => Promise<void>} request A cheap request through the client that never rejects
8
+ */
9
+ export const useReturnCheck = (request) => {
10
+ const running = useRef(0);
11
+
12
+ const probing = useCallback(() => running.current > 0, []);
13
+
14
+ const check = useCallback(async () => {
15
+ /* eslint-disable functional/immutable-data -- Counts checks in flight across visibility events; it is read synchronously by the session callback the request triggers, before any render. */
16
+ running.current += 1;
17
+ try {
18
+ await request();
19
+ } finally {
20
+ running.current -= 1;
21
+ }
22
+ /* eslint-enable functional/immutable-data */
23
+ }, [request]);
24
+
25
+ return { probing, check };
26
+ };
package/src/useSession.js CHANGED
@@ -1,31 +1,37 @@
1
- import { useCallback, useEffect, useState } from 'react';
1
+ import { useContext, useEffect } from 'react';
2
2
 
3
- const LOGIN_PATH = '/oauth2/start';
3
+ import { navigateTo, reloadPage } from './pageNavigation.js';
4
+ import { SessionContext } from './sessionContext.js';
5
+ import { defaultSignOutUrl } from './signOutUrl.js';
4
6
 
5
- const navigateTo = (url) => window.location.assign(url);
6
-
7
- // The login proxy sends the user back to `rd`; while the identity provider
8
- // still has a session that happens at once.
9
- export const loginUrl = (href) => `${LOGIN_PATH}?rd=${encodeURIComponent(href)}`;
7
+ const OUTSIDE_PROVIDER = {
8
+ expired: undefined,
9
+ cause: undefined,
10
+ user: undefined,
11
+ signIn: reloadPage,
12
+ signOut: () => navigateTo(defaultSignOutUrl(window.location)),
13
+ };
10
14
 
11
15
  /**
12
- * Whether the login session behind the client has expired, and `login`, which
13
- * sends the user to log in and back to the current page. `expired` is
14
- * undefined until the client has reported it. Nothing navigates on its own.
15
- * @param {object} params
16
- * @param {object} params.client faybl-client
17
- * @param {(url: string) => void} [params.navigate] Top-level navigation
18
- * @returns {{ expired: boolean | undefined, login: () => void }}
16
+ * The session from the nearest SessionProvider. `expired` is undefined
17
+ * until the client has reported it, and `cause` says how an expiry was found:
18
+ * `returned` or `request`. `user` is fetched the first time a component asks
19
+ * and is undefined until it arrives.
20
+ * @returns {{
21
+ * expired: boolean | undefined,
22
+ * cause: 'returned' | 'request' | undefined,
23
+ * user: { id: string, firstName?: string, lastName?: string, email?: string } | undefined,
24
+ * signIn: () => void,
25
+ * signOut: () => void,
26
+ * }}
19
27
  */
20
- export const useSession = ({ client, navigate = navigateTo }) => {
21
- const [state, setState] = useState({ client: undefined, expired: undefined });
28
+ export const useSession = () => {
29
+ const context = useContext(SessionContext);
30
+ const loadUser = context?.loadUser;
22
31
 
23
32
  useEffect(() => {
24
- if (!client) return undefined;
25
- return client.session.subscribe(({ expired }) => setState({ client, expired }));
26
- }, [client]);
27
-
28
- const login = useCallback(() => navigate(loginUrl(window.location.href)), [navigate]);
33
+ loadUser?.();
34
+ }, [loadUser]);
29
35
 
30
- return { expired: state.client === client ? state.expired : undefined, login };
36
+ return context?.session ?? OUTSIDE_PROVIDER;
31
37
  };
package/src/useUser.js ADDED
@@ -0,0 +1,31 @@
1
+ import { useCallback, useRef, useState } from 'react';
2
+
3
+ /**
4
+ * Who is signed in, from the client's `auth.getUserInfo`. `loadUser` fetches
5
+ * once per client and keeps the answer; `fetchUser` fetches again. A failed
6
+ * fetch keeps the last answer: a 401 already reaches the session, and the next
7
+ * return to the tab fetches again.
8
+ */
9
+ export const useUser = (client) => {
10
+ const [cache, setCache] = useState({ client: undefined, user: undefined });
11
+ const loadedFor = useRef(undefined);
12
+
13
+ const fetchUser = useCallback(async () => {
14
+ if (!client) return;
15
+ try {
16
+ const user = await client.auth.getUserInfo();
17
+ setCache({ client, user });
18
+ } catch {
19
+ // Reported by the client: a 401 as `session`, anything else to its onError.
20
+ }
21
+ }, [client]);
22
+
23
+ const loadUser = useCallback(() => {
24
+ if (!client || loadedFor.current === client) return;
25
+ // eslint-disable-next-line functional/immutable-data -- Marks the client whose user is fetched, across the renders of every component that asks; state would refetch until the first answer lands.
26
+ loadedFor.current = client;
27
+ void fetchUser();
28
+ }, [client, fetchUser]);
29
+
30
+ return { user: cache.client === client ? cache.user : undefined, loadUser, fetchUser };
31
+ };