From 95655405b8fefe6b8123e83a0723853f1870b368 Mon Sep 17 00:00:00 2001 From: Sonia Appasamy Date: Tue, 5 Dec 2023 18:03:05 -0500 Subject: [PATCH] client/web: start using swr for some fetching Adds swr to the web client, and starts by using it from the useNodeData hook. Updates #10261 Signed-off-by: Sonia Appasamy --- client/web/package.json | 1 + client/web/src/api.ts | 40 ++++++++++------ .../web/src/components/views/login-view.tsx | 3 +- client/web/src/hooks/auth.ts | 14 +++--- client/web/src/hooks/exit-nodes.ts | 3 +- client/web/src/hooks/node-data.ts | 47 +++++-------------- client/web/src/hooks/self-update.ts | 5 +- client/web/src/index.tsx | 10 ++-- client/web/yarn.lock | 15 +++++- 9 files changed, 70 insertions(+), 68 deletions(-) diff --git a/client/web/package.json b/client/web/package.json index fbce70dae..9433cb1d9 100644 --- a/client/web/package.json +++ b/client/web/package.json @@ -13,6 +13,7 @@ "classnames": "^2.3.1", "react": "^18.2.0", "react-dom": "^18.2.0", + "swr": "^2.2.4", "wouter": "^2.11.0", "zustand": "^4.4.7" }, diff --git a/client/web/src/api.ts b/client/web/src/api.ts index 7b19c7627..95d3afc91 100644 --- a/client/web/src/api.ts +++ b/client/web/src/api.ts @@ -1,6 +1,13 @@ // Copyright (c) Tailscale Inc & AUTHORS // SPDX-License-Identifier: BSD-3-Clause +import { SWRConfiguration } from "swr" + +export const swrConfig: SWRConfiguration = { + fetcher: (url: string) => apiFetch(url, "GET"), + onError: (err, _) => console.error(err), +} + let csrfToken: string let synoToken: string | undefined // required for synology API requests let unraidCsrfToken: string | undefined // required for unraid POST requests (#8062) @@ -11,14 +18,13 @@ let unraidCsrfToken: string | undefined // required for unraid POST requests (#8 // apiFetch adds the `api` prefix to the request URL, // so endpoint should be provided without the `api` prefix // (i.e. provide `/data` rather than `api/data`). -export function apiFetch( +export function apiFetch( endpoint: string, method: "GET" | "POST" | "PATCH", - body?: any, - params?: Record -): Promise { + body?: any +): Promise { const urlParams = new URLSearchParams(window.location.search) - const nextParams = new URLSearchParams(params) + const nextParams = new URLSearchParams() if (synoToken) { nextParams.set("SynoToken", synoToken) } else { @@ -51,16 +57,22 @@ export function apiFetch( "Content-Type": contentType, "X-CSRF-Token": csrfToken, }, - body, - }).then((r) => { - updateCsrfToken(r) - if (!r.ok) { - return r.text().then((err) => { - throw new Error(err) - }) - } - return r + body: body, }) + .then((r) => { + updateCsrfToken(r) + if (!r.ok) { + return r.text().then((err) => { + throw new Error(err) + }) + } + return r + }) + .then((r) => { + if (r.headers.get("Content-Type") === "application/json") { + return r.json() + } + }) } function updateCsrfToken(r: Response) { diff --git a/client/web/src/components/views/login-view.tsx b/client/web/src/components/views/login-view.tsx index 19b1fc6c2..e18f7b03c 100644 --- a/client/web/src/components/views/login-view.tsx +++ b/client/web/src/components/views/login-view.tsx @@ -146,8 +146,7 @@ type TailscaleUpOptions = { } function tailscaleUp(options: TailscaleUpOptions) { - return apiFetch("/up", "POST", options) - .then((r) => r.json()) + return apiFetch<{ url?: string }>("/up", "POST", options) .then((d) => { d.url && window.open(d.url, "_blank") }) diff --git a/client/web/src/hooks/auth.ts b/client/web/src/hooks/auth.ts index f8537681a..ba8c285d3 100644 --- a/client/web/src/hooks/auth.ts +++ b/client/web/src/hooks/auth.ts @@ -28,11 +28,10 @@ export default function useAuth() { const loadAuth = useCallback(() => { setLoading(true) - return apiFetch("/auth", "GET") - .then((r) => r.json()) + return apiFetch("/auth", "GET") .then((d) => { setData(d) - switch ((d as AuthResponse).authNeeded) { + switch (d.authNeeded) { case AuthType.synology: fetch("/webman/login.cgi") .then((r) => r.json()) @@ -53,15 +52,16 @@ export default function useAuth() { }, []) const newSession = useCallback(() => { - return apiFetch("/auth/session/new", "GET") - .then((r) => r.json()) + return apiFetch<{ authUrl?: string }>("/auth/session/new", "GET") .then((d) => { if (d.authUrl) { window.open(d.authUrl, "_blank") return apiFetch("/auth/session/wait", "GET") } }) - .then(() => loadAuth()) + .then(() => { + loadAuth() + }) .catch((error) => { console.error(error) }) @@ -70,7 +70,7 @@ export default function useAuth() { useEffect(() => { loadAuth().then((d) => { if ( - !d.canManageNode && + !d?.canManageNode && new URLSearchParams(window.location.search).get("check") === "now" ) { newSession() diff --git a/client/web/src/hooks/exit-nodes.ts b/client/web/src/hooks/exit-nodes.ts index 7d9fa4cf7..c488bf690 100644 --- a/client/web/src/hooks/exit-nodes.ts +++ b/client/web/src/hooks/exit-nodes.ts @@ -33,8 +33,7 @@ export default function useExitNodes(node: NodeData, filter?: string) { const [data, setData] = useState([]) useEffect(() => { - apiFetch("/exit-nodes", "GET") - .then((r) => r.json()) + apiFetch("/exit-nodes", "GET") .then((r) => setData(r)) .catch((err) => { alert("Failed operation: " + err.message) diff --git a/client/web/src/hooks/node-data.ts b/client/web/src/hooks/node-data.ts index 5cca13023..3ad1646f2 100644 --- a/client/web/src/hooks/node-data.ts +++ b/client/web/src/hooks/node-data.ts @@ -6,6 +6,7 @@ import { apiFetch, incrementMetric, setUnraidCsrfToken } from "src/api" import { ExitNode, noExitNode, runAsExitNode } from "src/hooks/exit-nodes" import { VersionInfo } from "src/hooks/self-update" import { assertNever } from "src/utils/util" +import useSWR from "swr" export type NodeData = { Profile: UserProfile @@ -120,19 +121,12 @@ type RoutesPOSTData = { // useNodeData returns basic data about the current node. export default function useNodeData() { - const [data, setData] = useState() + const { data, mutate } = useSWR("/data") const [isPosting, setIsPosting] = useState(false) - const refreshData = useCallback( - () => - apiFetch("/data", "GET") - .then((r) => r.json()) - .then((d: NodeData) => { - setData(d) - setUnraidCsrfToken(d.IsUnraid ? d.UnraidToken : undefined) - }) - .catch((error) => console.error(error)), - [setData] + useEffect( + () => setUnraidCsrfToken(data?.IsUnraid ? data.UnraidToken : undefined), + [data] ) const prefsPATCH = useCallback( @@ -147,12 +141,12 @@ export default function useNodeData() { // then make the prefs PATCH. If the request fails, // data will be updated to it's previous value in // onComplete below. - setData(optimisticUpdates) + mutate(optimisticUpdates, false) } const onComplete = () => { setIsPosting(false) - refreshData() // refresh data after PATCH finishes + mutate() // refresh data after PATCH finishes } return apiFetch("/local/v0/prefs", "PATCH", d) @@ -163,7 +157,7 @@ export default function useNodeData() { throw err }) }, - [setIsPosting, refreshData, setData, data] + [data, mutate] ) const routesPOST = useCallback( @@ -171,7 +165,7 @@ export default function useNodeData() { setIsPosting(true) const onComplete = () => { setIsPosting(false) - refreshData() // refresh data after POST finishes + mutate() // refresh data after POST finishes } const updateMetrics = () => { // only update metrics if values have changed @@ -195,26 +189,7 @@ export default function useNodeData() { throw err }) }, - [setIsPosting, refreshData, data?.AdvertisingExitNode] - ) - - useEffect( - () => { - // Initial data load. - refreshData() - - // Refresh on browser tab focus. - const onVisibilityChange = () => { - document.visibilityState === "visible" && refreshData() - } - window.addEventListener("visibilitychange", onVisibilityChange) - return () => { - // Cleanup browser tab listener. - window.removeEventListener("visibilitychange", onVisibilityChange) - } - }, - // Run once. - [refreshData] + [mutate, data?.AdvertisingExitNode] ) const nodeUpdaters: NodeUpdaters = useMemo( @@ -245,5 +220,5 @@ export default function useNodeData() { ] ) - return { data, refreshData, nodeUpdaters, isPosting } + return { data, refreshData: mutate, nodeUpdaters, isPosting } } diff --git a/client/web/src/hooks/self-update.ts b/client/web/src/hooks/self-update.ts index c40bb793c..6fd6ab44c 100644 --- a/client/web/src/hooks/self-update.ts +++ b/client/web/src/hooks/self-update.ts @@ -61,9 +61,8 @@ export function useInstallUpdate(currentVersion: string, cv?: VersionInfo) { let timer: NodeJS.Timeout | undefined function poll() { - apiFetch("/local/v0/update/progress", "GET") - .then((res) => res.json()) - .then((res: UpdateProgress[]) => { + apiFetch("/local/v0/update/progress", "GET") + .then((res) => { // res contains a list of UpdateProgresses that is strictly increasing // in size, so updateMessagesRead keeps track (across calls of poll()) // of how many of those we have already read. This is why it is not diff --git a/client/web/src/index.tsx b/client/web/src/index.tsx index cbab009b7..31ac7890f 100644 --- a/client/web/src/index.tsx +++ b/client/web/src/index.tsx @@ -10,8 +10,10 @@ import React from "react" import { createRoot } from "react-dom/client" +import { swrConfig } from "src/api" import App from "src/components/app" import ToastProvider from "src/ui/toaster" +import { SWRConfig } from "swr" declare var window: any // This is used to determine if the react client is built. @@ -26,8 +28,10 @@ const root = createRoot(rootEl) root.render( - - - + + + + + ) diff --git a/client/web/yarn.lock b/client/web/yarn.lock index 46ff121f1..a40697405 100644 --- a/client/web/yarn.lock +++ b/client/web/yarn.lock @@ -2599,6 +2599,11 @@ classnames@^2.3.1: resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.3.2.tgz#351d813bf0137fcc6a76a16b88208d2560a0d924" integrity sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw== +client-only@^0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/client-only/-/client-only-0.0.1.tgz#38bba5d403c41ab150bff64a95c85013cf73bca1" + integrity sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA== + color-convert@^1.9.0: version "1.9.3" resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" @@ -4872,6 +4877,14 @@ svg-parser@^2.0.4: resolved "https://registry.yarnpkg.com/svg-parser/-/svg-parser-2.0.4.tgz#fdc2e29e13951736140b76cb122c8ee6630eb6b5" integrity sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ== +swr@^2.2.4: + version "2.2.4" + resolved "https://registry.yarnpkg.com/swr/-/swr-2.2.4.tgz#03ec4c56019902fbdc904d78544bd7a9a6fa3f07" + integrity sha512-njiZ/4RiIhoOlAaLYDqwz5qH/KZXVilRLvomrx83HjzCWTfa+InyfAjv05PSFxnmLzZkNO9ZfvgoqzAaEI4sGQ== + dependencies: + client-only "^0.0.1" + use-sync-external-store "^1.2.0" + symbol-tree@^3.2.4: version "3.2.4" resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2" @@ -5159,7 +5172,7 @@ use-sidecar@^1.1.2: detect-node-es "^1.1.0" tslib "^2.0.0" -use-sync-external-store@1.2.0, use-sync-external-store@^1.0.0: +use-sync-external-store@1.2.0, use-sync-external-store@^1.0.0, use-sync-external-store@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a" integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==