fix: make useLiveData a context that is pausable

This commit is contained in:
Aarnav Tale 2025-03-22 16:58:11 -04:00
parent 98d02bb595
commit c066b3064d
5 changed files with 89 additions and 61 deletions

View File

@ -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<typeof loader>();
return (

View File

@ -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,7 +30,11 @@ 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 (
<LiveDataProvider>
<html lang="en">
<head>
<meta charSet="utf-8" />
@ -44,6 +49,7 @@ export function Layout({ children }: { readonly children: React.ReactNode }) {
<Scripts />
</body>
</html>
</LiveDataProvider>
);
}

67
app/utils/live-data.tsx Normal file
View File

@ -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 (
<LiveDataPausedContext.Provider value={{ paused, setPaused }}>
{children}
</LiveDataPausedContext.Provider>
);
}
export function useLiveData() {
const context = useContext(LiveDataPausedContext);
return {
pause: () => context.setPaused(true),
resume: () => context.setPaused(false),
};
}

View File

@ -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<Record<string, HostInfo>>();
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,

View File

@ -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;
}