perf(clients): de-duplicate options + paged list fetches

Two issues caused each clients-page load to fire its requests twice:

1. setQuery in the hook took whatever object the consumer passed and
   stored it as-is. The consumer (ClientsPage) constructs a new object
   literal in an effect, so even when nothing actually changed the ref
   was new — the hook's useEffect saw a new query and re-fetched.
   Wrapped setQuery with a shallow value compare so identical params
   are a no-op.

2. The picker /inbounds/options fetch was bundled into refresh() with a
   length==0 guard, but the two back-to-back refreshes both saw an
   empty inbounds array (the first hadn't resolved yet) so both fired
   the request. Moved the options fetch into its own one-shot effect.
This commit is contained in:
MHSanaei 2026-05-23 17:29:47 +02:00
parent b74465e869
commit 4242f8c881
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A

View file

@ -95,7 +95,24 @@ export function useClients() {
const [onlines, setOnlines] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [fetched, setFetched] = useState(false);
const [query, setQuery] = useState<ClientQueryParams>(DEFAULT_QUERY);
const [query, setQueryState] = useState<ClientQueryParams>(DEFAULT_QUERY);
// Shallow-compare against the previous query so callers can pass a fresh
// object on every render (the common React pattern) without triggering a
// re-fetch when nothing actually changed.
const setQuery = useCallback((next: ClientQueryParams) => {
setQueryState((prev) => {
if (
prev.page === next.page
&& prev.pageSize === next.pageSize
&& (prev.search ?? '') === (next.search ?? '')
&& (prev.filter ?? '') === (next.filter ?? '')
&& (prev.protocol ?? '') === (next.protocol ?? '')
&& (prev.sort ?? '') === (next.sort ?? '')
&& (prev.order ?? '') === (next.order ?? '')
) return prev;
return next;
});
}, []);
const [subSettings, setSubSettings] = useState<SubSettings>({
enable: false, subURI: '', subJsonURI: '', subJsonEnable: false,
});
@ -129,26 +146,30 @@ export function useClients() {
try {
const params = override ?? queryRef.current;
const qs = buildQS(params);
const [clientsMsg, inboundsMsg] = await Promise.all([
HttpUtil.get(`/panel/api/clients/list/paged?${qs}`) as Promise<ApiMsg<ClientPageResponse>>,
inbounds.length === 0
? HttpUtil.get('/panel/api/inbounds/options') as Promise<ApiMsg<InboundOption[]>>
: Promise.resolve(null as ApiMsg<InboundOption[]> | null),
]);
if (clientsMsg?.success && clientsMsg.obj) {
setClients(Array.isArray(clientsMsg.obj.items) ? clientsMsg.obj.items : []);
setTotal(clientsMsg.obj.total ?? 0);
setFiltered(clientsMsg.obj.filtered ?? 0);
if (clientsMsg.obj.summary) setSummary(clientsMsg.obj.summary);
}
if (inboundsMsg?.success) {
setInbounds(Array.isArray(inboundsMsg.obj) ? inboundsMsg.obj : []);
const msg = await HttpUtil.get(`/panel/api/clients/list/paged?${qs}`) as ApiMsg<ClientPageResponse>;
if (msg?.success && msg.obj) {
setClients(Array.isArray(msg.obj.items) ? msg.obj.items : []);
setTotal(msg.obj.total ?? 0);
setFiltered(msg.obj.filtered ?? 0);
if (msg.obj.summary) setSummary(msg.obj.summary);
}
setFetched(true);
} finally {
setLoading(false);
}
}, [inbounds.length]);
}, []);
// Inbound options are picker-shaped and don't depend on the clients query —
// fetch them once on mount instead of every refresh.
useEffect(() => {
let cancelled = false;
(async () => {
const msg = await HttpUtil.get('/panel/api/inbounds/options') as ApiMsg<InboundOption[]>;
if (cancelled) return;
if (msg?.success) setInbounds(Array.isArray(msg.obj) ? msg.obj : []);
})();
return () => { cancelled = true; };
}, []);
const fetchSubSettings = useCallback(async () => {
const msg = await HttpUtil.post('/panel/setting/defaultSettings') as ApiMsg<Record<string, unknown>>;