fix(ui): eliminate loading flicker on page refresh

Root cause: ws object is a stable ref — useCallback deps never changed
when WS connected, so useEffect never re-fired to fetch data.

Fix: subscribe to reactive `connected` state from auth store in all
data-loading hooks. For manual hooks, add connected to useCallback deps
and initialize loading=true. For TanStack Query hooks, add
enabled: connected and use isPending (true while disabled with no cache).

Detail pages now use DetailPageSkeleton matching final layout shape
instead of DeferredSpinner to prevent layout shift.
This commit is contained in:
viettranx committed 2026-03-07 19:14:47 +07:00
1 parent 2bebe76439
commit d850467175
16 files changed
+92 -44

No files matched your search

@@ -39,6 +39,35 @@ export function DetailSkeleton() {
);
}
/** Skeleton matching detail page layout (header + tabs card) to prevent layout shift on load. */
export function DetailPageSkeleton({ tabs = 4 }: { tabs?: number }) {
return (
<div className="p-4 sm:p-6">
<div className="mb-6 flex items-start gap-4">
<Skeleton className="h-9 w-9 shrink-0 rounded-md" />
<Skeleton className="h-12 w-12 shrink-0 rounded-xl" />
<div className="min-w-0 flex-1 space-y-2">
<Skeleton className="h-6 w-48" />
<Skeleton className="h-4 w-32" />
</div>
</div>
<div className="max-w-4xl rounded-xl border bg-card p-3 shadow-sm sm:p-4">
<div className="flex gap-2 border-b pb-2">
{Array.from({ length: tabs }).map((_, i) => (
<Skeleton key={i} className="h-8 w-20 rounded-md" />
))}
</div>
<div className="mt-4 space-y-3">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
</div>
</div>
</div>
);
}
/** Spinner that only appears after a delay (default 500ms) to avoid flicker on fast loads. */
export function DeferredSpinner({ delay = 500 }: { delay?: number }) {
const [show, setShow] = useState(false);
@@ -1,5 +1,6 @@
import { useState, useEffect, useCallback } from "react";
import { useWs } from "./use-ws";
import { useAuthStore } from "@/stores/use-auth-store";
import { useWsEvent } from "./use-ws-event";
import { Methods, Events } from "@/api/protocol";
import { toast } from "@/stores/use-toast-store";
@@ -11,10 +12,11 @@ interface Options {
export function usePendingPairingsCount({ showToast }: Options = {}) {
const ws = useWs();
const connected = useAuthStore((s) => s.connected);
const [pendingCount, setPendingCount] = useState(0);
const fetchCount = useCallback(async () => {
if (!ws.isConnected) return;
if (!connected) return;
try {
const res = await ws.call<{ pending: { code: string }[] }>(
Methods.PAIRING_LIST,
@@ -23,7 +25,7 @@ export function usePendingPairingsCount({ showToast }: Options = {}) {
} catch {
// ignore
}
}, [ws]);
}, [ws, connected]);
useEffect(() => {
fetchCount();
@@ -1,15 +1,17 @@
import { useState, useCallback } from "react";
import { useWs } from "@/hooks/use-ws";
import { useAuthStore } from "@/stores/use-auth-store";
import { Methods } from "@/api/protocol";
import type { AgentLinkData, AgentLinkSettings } from "@/types/agent";
export function useAgentLinks(agentId: string) {
const ws = useWs();
const connected = useAuthStore((s) => s.connected);
const [links, setLinks] = useState<AgentLinkData[]>([]);
const [loading, setLoading] = useState(false);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
if (!ws.isConnected) return;
if (!connected) return;
setLoading(true);
try {
const res = await ws.call<{ links: AgentLinkData[]; count: number }>(
@@ -22,7 +24,7 @@ export function useAgentLinks(agentId: string) {
} finally {
setLoading(false);
}
}, [ws, agentId]);
}, [ws, agentId, connected]);
const createLink = useCallback(
async (params: {
+1 -1
View File
@@ -19,7 +19,7 @@ export function useAgents() {
const connected = useAuthStore((s) => s.connected);
const queryClient = useQueryClient();
const { data: agents = [], isLoading: loading, error: queryError } = useQuery({
const { data: agents = [], isPending: loading, error: queryError } = useQuery({
queryKey: queryKeys.agents.all,
queryFn: async () => {
// Try HTTP first (returns full agent data, filtered by user access)
@@ -1,5 +1,6 @@
import { useState, useEffect, useCallback } from "react";
import { useWs } from "@/hooks/use-ws";
import { useAuthStore } from "@/stores/use-auth-store";
import { useWsEvent } from "@/hooks/use-ws-event";
import { Methods, Events } from "@/api/protocol";
import { toast } from "@/stores/use-toast-store";
@@ -13,12 +14,13 @@ export interface PendingApproval {
export function useApprovals() {
const ws = useWs();
const connected = useAuthStore((s) => s.connected);
const [pending, setPending] = useState<PendingApproval[]>([]);
const [loading, setLoading] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
if (!ws.isConnected) return;
if (!connected) return;
setLoading(true);
setError(null);
try {
@@ -29,7 +31,7 @@ export function useApprovals() {
} finally {
setLoading(false);
}
}, [ws]);
}, [ws, connected]);
useEffect(() => {
load();
@@ -10,7 +10,7 @@ import { ChannelCredentialsTab } from "./channel-credentials-tab";
import { ChannelConfigTab } from "./channel-config-tab";
import { ChannelGroupsTab } from "./channel-groups-tab";
import { ChannelWritersTab } from "./channel-writers-tab";
import { DeferredSpinner } from "@/components/shared/loading-skeleton";
import { DetailPageSkeleton } from "@/components/shared/loading-skeleton";
import { channelTypeLabels } from "../channels-status-view";
import { useChannels } from "../hooks/use-channels";
@@ -34,14 +34,7 @@ export function ChannelDetailPage({ instanceId, onBack }: ChannelDetailPageProps
const [activeTab, setActiveTab] = useState("general");
if (loading || !instance) {
return (
<div className="p-4 sm:p-6">
<Button variant="ghost" onClick={onBack} className="mb-4 gap-1">
<ArrowLeft className="h-4 w-4" /> Back
</Button>
<DeferredSpinner />
</div>
);
return <DetailPageSkeleton tabs={4} />;
}
const status = channels[instance.name] ?? null;
@@ -1,5 +1,6 @@
import { useState, useEffect, useCallback } from "react";
import { useWs } from "@/hooks/use-ws";
import { useAuthStore } from "@/stores/use-auth-store";
import { Methods } from "@/api/protocol";
export interface ChannelStatus {
@@ -9,12 +10,13 @@ export interface ChannelStatus {
export function useChannels() {
const ws = useWs();
const connected = useAuthStore((s) => s.connected);
const [channels, setChannels] = useState<Record<string, ChannelStatus>>({});
const [loading, setLoading] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
if (!ws.isConnected) return;
if (!connected) return;
setLoading(true);
setError(null);
try {
@@ -27,7 +29,7 @@ export function useChannels() {
} finally {
setLoading(false);
}
}, [ws]);
}, [ws, connected]);
useEffect(() => {
load();
@@ -11,12 +11,13 @@ import { useAuthStore } from "@/stores/use-auth-store";
export function useChatSessions(agentId: string) {
const ws = useWs();
const userId = useAuthStore((s) => s.userId);
const connected = useAuthStore((s) => s.connected);
const [sessions, setSessions] = useState<SessionInfo[]>([]);
const [loading, setLoading] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const loadSessions = useCallback(async () => {
if (!ws.isConnected) return;
if (!connected) return;
setLoading(true);
setError(null);
try {
@@ -34,7 +35,7 @@ export function useChatSessions(agentId: string) {
} finally {
setLoading(false);
}
}, [ws, agentId]);
}, [ws, agentId, connected]);
useEffect(() => {
loadSessions();
+4 -2
View File
@@ -1,6 +1,7 @@
import { useState, useCallback, useRef } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useWs } from "@/hooks/use-ws";
import { useAuthStore } from "@/stores/use-auth-store";
import { Methods } from "@/api/protocol";
import { queryKeys } from "@/lib/query-keys";
@@ -12,20 +13,21 @@ interface ConfigData {
export function useConfig() {
const ws = useWs();
const connected = useAuthStore((s) => s.connected);
const queryClient = useQueryClient();
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const hashRef = useRef("");
const { data, isLoading: loading } = useQuery({
const { data, isPending: loading } = useQuery({
queryKey: queryKeys.config.all,
queryFn: async (): Promise<ConfigData> => {
if (!ws.isConnected) return { config: {}, hash: "", path: "" };
const res = await ws.call<ConfigData>(Methods.CONFIG_GET);
hashRef.current = res.hash;
return res;
},
staleTime: 5 * 60_000,
enabled: connected,
});
const config = data?.config ?? null;
+4 -2
View File
@@ -1,6 +1,7 @@
import { useCallback } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useWs } from "@/hooks/use-ws";
import { useAuthStore } from "@/stores/use-auth-store";
import { Methods } from "@/api/protocol";
import { queryKeys } from "@/lib/query-keys";
import { toast } from "@/stores/use-toast-store";
@@ -49,17 +50,18 @@ export interface CronRunLogEntry {
export function useCron() {
const ws = useWs();
const connected = useAuthStore((s) => s.connected);
const queryClient = useQueryClient();
const { data: jobs = [], isLoading: loading } = useQuery({
const { data: jobs = [], isPending: loading } = useQuery({
queryKey: queryKeys.cron.all,
queryFn: async () => {
if (!ws.isConnected) return [];
const res = await ws.call<{ jobs: CronJob[] }>(Methods.CRON_LIST, {
includeDisabled: true,
});
return res.jobs ?? [];
},
enabled: connected,
});
const invalidate = useCallback(
+5 -3
View File
@@ -1,5 +1,6 @@
import { useState, useEffect, useCallback } from "react";
import { useWs } from "@/hooks/use-ws";
import { useAuthStore } from "@/stores/use-auth-store";
import { useWsEvent } from "@/hooks/use-ws-event";
import { Methods, Events } from "@/api/protocol";
@@ -23,12 +24,13 @@ export interface PairedDevice {
export function useNodes() {
const ws = useWs();
const connected = useAuthStore((s) => s.connected);
const [pendingPairings, setPendingPairings] = useState<PendingPairing[]>([]);
const [pairedDevices, setPairedDevices] = useState<PairedDevice[]>([]);
const [loading, setLoading] = useState(false);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
if (!ws.isConnected) return;
if (!connected) return;
setLoading(true);
try {
const res = await ws.call<{
@@ -42,7 +44,7 @@ export function useNodes() {
} finally {
setLoading(false);
}
}, [ws]);
}, [ws, connected]);
useEffect(() => {
load();
@@ -1,6 +1,7 @@
import { useCallback } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useWs } from "@/hooks/use-ws";
import { useAuthStore } from "@/stores/use-auth-store";
import { Methods } from "@/api/protocol";
import { queryKeys } from "@/lib/query-keys";
import type { SessionInfo, SessionPreview, Message } from "@/types/session";
@@ -13,12 +14,13 @@ interface UseSessionsOptions {
export function useSessions(opts: UseSessionsOptions = {}) {
const ws = useWs();
const connected = useAuthStore((s) => s.connected);
const queryClient = useQueryClient();
const { agentFilter, limit, offset } = opts;
const queryKey = queryKeys.sessions.list({ agentFilter, limit, offset });
const { data, isLoading: loading } = useQuery({
const { data, isPending: loading } = useQuery({
queryKey,
queryFn: async () => {
if (!ws.isConnected) return { sessions: [] as SessionInfo[], total: 0 };
@@ -30,6 +32,7 @@ export function useSessions(opts: UseSessionsOptions = {}) {
return { sessions: res.sessions ?? [], total: res.total ?? 0 };
},
placeholderData: (prev) => prev,
enabled: connected,
});
const sessions = data?.sessions ?? [];
+4 -2
View File
@@ -1,6 +1,7 @@
import { useCallback } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useWs, useHttp } from "@/hooks/use-ws";
import { useAuthStore } from "@/stores/use-auth-store";
import { Methods } from "@/api/protocol";
import { queryKeys } from "@/lib/query-keys";
import type { SkillInfo } from "@/types/skill";
@@ -10,16 +11,17 @@ export type { SkillInfo };
export function useSkills() {
const ws = useWs();
const http = useHttp();
const connected = useAuthStore((s) => s.connected);
const queryClient = useQueryClient();
const { data: skills = [], isLoading: loading } = useQuery({
const { data: skills = [], isPending: loading } = useQuery({
queryKey: queryKeys.skills.all,
queryFn: async () => {
if (!ws.isConnected) return [];
const res = await ws.call<{ skills: SkillInfo[] }>(Methods.SKILLS_LIST);
return res.skills ?? [];
},
staleTime: 60_000,
enabled: connected,
});
const invalidate = useCallback(
+5 -3
View File
@@ -1,15 +1,17 @@
import { useState, useCallback } from "react";
import { useWs } from "@/hooks/use-ws";
import { useAuthStore } from "@/stores/use-auth-store";
import { Methods } from "@/api/protocol";
import type { TeamData, TeamMemberData, TeamTaskData, TeamAccessSettings } from "@/types/team";
export function useTeams() {
const ws = useWs();
const connected = useAuthStore((s) => s.connected);
const [teams, setTeams] = useState<TeamData[]>([]);
const [loading, setLoading] = useState(false);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
if (!ws.isConnected) return;
if (!connected) return;
setLoading(true);
try {
const res = await ws.call<{ teams: TeamData[]; count: number }>(
@@ -21,7 +23,7 @@ export function useTeams() {
} finally {
setLoading(false);
}
}, [ws]);
}, [ws, connected]);
const createTeam = useCallback(
async (params: {
+4 -2
View File
@@ -1,6 +1,7 @@
import { useState, useCallback } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useWs } from "@/hooks/use-ws";
import { useAuthStore } from "@/stores/use-auth-store";
import { Methods } from "@/api/protocol";
import { queryKeys } from "@/lib/query-keys";
@@ -43,18 +44,19 @@ const DEFAULT_TTS: TtsConfig = {
export function useTtsConfig() {
const ws = useWs();
const connected = useAuthStore((s) => s.connected);
const queryClient = useQueryClient();
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const { data: tts = DEFAULT_TTS, isLoading: loading } = useQuery({
const { data: tts = DEFAULT_TTS, isPending: loading } = useQuery({
queryKey: queryKeys.tts.all,
queryFn: async () => {
if (!ws.isConnected) return DEFAULT_TTS;
const res = await ws.call<{ config: Record<string, unknown> }>(Methods.CONFIG_GET);
const ttsConfig = (res.config?.tts as TtsConfig) ?? DEFAULT_TTS;
return { ...DEFAULT_TTS, ...ttsConfig };
},
enabled: connected,
});
const invalidate = useCallback(
+7 -5
View File
@@ -1,5 +1,6 @@
import { useState, useEffect, useCallback } from "react";
import { useWs } from "@/hooks/use-ws";
import { useAuthStore } from "@/stores/use-auth-store";
import { Methods } from "@/api/protocol";
export interface UsageRecord {
@@ -28,14 +29,15 @@ export interface UsageSummary {
export function useUsage() {
const ws = useWs();
const connected = useAuthStore((s) => s.connected);
const [records, setRecords] = useState<UsageRecord[]>([]);
const [total, setTotal] = useState(0);
const [summary, setSummary] = useState<UsageSummary | null>(null);
const [loading, setLoading] = useState(false);
const [loading, setLoading] = useState(true);
const loadRecords = useCallback(
async (opts?: { agentId?: string; limit?: number; offset?: number }) => {
if (!ws.isConnected) return;
if (!connected) return;
setLoading(true);
try {
const res = await ws.call<{ records: UsageRecord[]; total?: number }>(Methods.USAGE_GET, {
@@ -51,18 +53,18 @@ export function useUsage() {
setLoading(false);
}
},
[ws],
[ws, connected],
);
const loadSummary = useCallback(async () => {
if (!ws.isConnected) return;
if (!connected) return;
try {
const res = await ws.call<UsageSummary>(Methods.USAGE_SUMMARY);
setSummary(res);
} catch {
// ignore
}
}, [ws]);
}, [ws, connected]);
useEffect(() => {
loadRecords();