From c066b3064d73cb98f876c212ed134bed992b6e3c Mon Sep 17 00:00:00 2001 From: Aarnav Tale Date: Sat, 22 Mar 2025 16:58:11 -0400 Subject: [PATCH] fix: make useLiveData a context that is pausable --- app/layouts/dashboard.tsx | 2 -- app/root.tsx | 34 ++++++++++++-------- app/utils/live-data.tsx | 67 +++++++++++++++++++++++++++++++++++++++ app/utils/useAgent.ts | 12 ++----- app/utils/useLiveData.ts | 35 -------------------- 5 files changed, 89 insertions(+), 61 deletions(-) create mode 100644 app/utils/live-data.tsx delete mode 100644 app/utils/useLiveData.ts diff --git a/app/layouts/dashboard.tsx b/app/layouts/dashboard.tsx index 6aa97e6..bd405f1 100644 --- a/app/layouts/dashboard.tsx +++ b/app/layouts/dashboard.tsx @@ -5,7 +5,6 @@ import type { LoadContext } from '~/server'; import { ResponseError } from '~/server/headscale/api-client'; import cn from '~/utils/cn'; import log from '~/utils/log'; -import { useLiveData } from '~/utils/useLiveData'; export async function loader({ request, @@ -36,7 +35,6 @@ export async function loader({ } export default function Layout() { - useLiveData({ interval: 3000 }); const { healthy } = useLoaderData(); return ( diff --git a/app/root.tsx b/app/root.tsx index c83477d..71ebcad 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -12,6 +12,7 @@ import { ErrorPopup } from '~/components/Error'; import ProgressBar from '~/components/ProgressBar'; import ToastProvider from '~/components/ToastProvider'; import stylesheet from '~/tailwind.css?url'; +import { LiveDataProvider } from '~/utils/live-data'; import { useToastQueue } from '~/utils/toast'; export const meta: MetaFunction = () => [ @@ -29,21 +30,26 @@ export const links: LinksFunction = () => [ export function Layout({ children }: { readonly children: React.ReactNode }) { const toastQueue = useToastQueue(); + // LiveDataProvider is wrapped at the top level since dialogs and things + // that control its state are usually open in portal containers which + // are not a part of the normal React tree. return ( - - - - - - - - - {children} - - - - - + + + + + + + + + + {children} + + + + + + ); } diff --git a/app/utils/live-data.tsx b/app/utils/live-data.tsx new file mode 100644 index 0000000..910966c --- /dev/null +++ b/app/utils/live-data.tsx @@ -0,0 +1,67 @@ +import { createContext, useContext, useEffect, useState } from 'react'; +import { useRevalidator } from 'react-router'; +import { useInterval } from 'usehooks-ts'; + +const LiveDataPausedContext = createContext({ + paused: false, + setPaused: (_: boolean) => {}, +}); + +interface LiveDataProps { + children: React.ReactNode; +} + +export function LiveDataProvider({ children }: LiveDataProps) { + const [paused, setPaused] = useState(false); + const revalidator = useRevalidator(); + + // Document is marked as optional here because it's not available in SSR + // The optional chain means if document is not defined, visible is false + const [visible, setVisible] = useState( + () => + typeof document !== 'undefined' && document.visibilityState === 'visible', + ); + + // Function to revalidate safely + const revalidateIfIdle = () => { + if (revalidator.state === 'idle') { + revalidator.revalidate(); + } + }; + + useEffect(() => { + const handleVisibilityChange = () => { + setVisible(document.visibilityState === 'visible'); + if (!paused && document.visibilityState === 'visible') { + revalidateIfIdle(); + } + }; + + window.addEventListener('online', revalidateIfIdle); + document.addEventListener('focus', revalidateIfIdle); + document.addEventListener('visibilitychange', handleVisibilityChange); + + return () => { + window.removeEventListener('online', revalidateIfIdle); + document.removeEventListener('focus', revalidateIfIdle); + document.removeEventListener('visibilitychange', handleVisibilityChange); + }; + }, [paused, revalidator]); + + // Poll only when visible and not paused + useInterval(revalidateIfIdle, visible && !paused ? 3000 : null); + + return ( + + {children} + + ); +} + +export function useLiveData() { + const context = useContext(LiveDataPausedContext); + return { + pause: () => context.setPaused(true), + resume: () => context.setPaused(false), + }; +} diff --git a/app/utils/useAgent.ts b/app/utils/useAgent.ts index 808c609..a879313 100644 --- a/app/utils/useAgent.ts +++ b/app/utils/useAgent.ts @@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef } from 'react'; import { useFetcher } from 'react-router'; import { HostInfo } from '~/types'; -export default function useAgent(nodeIds: string[], interval = 3000) { +export default function useAgent(nodeIds: string[]) { const fetcher = useFetcher>(); const qp = useMemo( () => new URLSearchParams({ node_ids: nodeIds.join(',') }), @@ -15,15 +15,7 @@ export default function useAgent(nodeIds: string[], interval = 3000) { fetcher.load(`/api/agent?${qp.toString()}`); idRef.current = nodeIds; } - - const intervalID = setInterval(() => { - fetcher.load(`/api/agent?${qp.toString()}`); - }, interval); - - return () => { - clearInterval(intervalID); - }; - }, [interval, qp]); + }, [qp.toString()]); return { data: fetcher.data, diff --git a/app/utils/useLiveData.ts b/app/utils/useLiveData.ts deleted file mode 100644 index 68c2cb9..0000000 --- a/app/utils/useLiveData.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { useRevalidator } from 'react-router'; -import { useEffect } from 'react'; -import { useInterval } from 'usehooks-ts'; - -interface Props { - interval: number; -} - -export function useLiveData({ interval }: Props) { - const revalidator = useRevalidator(); - - // Handle normal stale-while-revalidate behavior - useInterval(() => { - if (revalidator.state === 'idle') { - revalidator.revalidate(); - } - }, interval); - - useEffect(() => { - const handler = () => { - if (revalidator.state === 'idle') { - revalidator.revalidate(); - } - }; - - window.addEventListener('online', handler); - document.addEventListener('focus', handler); - - return () => { - window.removeEventListener('online', handler); - document.removeEventListener('focus', handler); - }; - }, [revalidator]); - return revalidator; -}