From 3df9f42626aff2bc279d518730a8dc3c054cd5b8 Mon Sep 17 00:00:00 2001 From: "tao.chen" <93983997+taochen-ct@users.noreply.github.com> Date: Mon, 6 Jul 2026 18:19:57 +0800 Subject: [PATCH] fix(web): stabilize useMutation results so TerminalPanel stops flooding /shell/resize MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mutationFn / onSuccess closures inside every useMutation in process.ts were created fresh on every render, which made the mutation result object unstable across renders. TerminalPanel's terminal-init useEffect has [resize, workspaceId] as deps; with "resize" changing on every render, the effect re-ran on every render, which: - disposed and recreated the xterm Terminal - re-attached onResize (which fires fit() → onResize → mutate) - 100ms debounce kept getting reset - but the effect re-creating itself with new closures every ~16ms kept fit()'s pre-resize state oscillating, eventually letting the debounce expire and firing the API Result: /api/workspaces/:id/shell/resize was being spammed. Fix: wrap every useMutation's mutationFn (and onSuccess where present) in useCallback with explicit deps. useMutation now sees a stable config and returns a stable result object. The terminal effect's dep [resize, workspaceId] no longer changes per render unless workspaceId actually changes. Affects: useProcessStart/Stop/Restart, useShellStart/Stop/ Restart/Resize. All same pattern, all same risk. --- web/src/lib/api/process.ts | 111 +++++++++++++++++++++---------------- 1 file changed, 63 insertions(+), 48 deletions(-) diff --git a/web/src/lib/api/process.ts b/web/src/lib/api/process.ts index 21cd750..c341574 100644 --- a/web/src/lib/api/process.ts +++ b/web/src/lib/api/process.ts @@ -188,14 +188,19 @@ function useProcessActionMutation( action: "start" | "stop" | "restart", ): UseMutationResult { const queryClient = useQueryClient(); - return useMutation({ - mutationFn: (workspaceId: string) => postProcessAction(workspaceId, action), - onSuccess: (_, workspaceId) => { + const mutationFn = useCallback( + (workspaceId: string) => postProcessAction(workspaceId, action), + [action], + ); + const onSuccess = useCallback( + (_: void, workspaceId: string) => { queryClient.invalidateQueries({ queryKey: processStatusQueryKey(workspaceId), }); }, - }); + [queryClient], + ); + return useMutation({ mutationFn, onSuccess }); } @@ -215,69 +220,72 @@ export function useShellStart( workspaceId: string | null, ): UseMutationResult { const queryClient = useQueryClient(); - return useMutation({ - mutationFn: async () => { - if (!workspaceId) { - throw new Error("workspace id required"); - } - return postShellStart(workspaceId); - }, - onSuccess: () => { - if (!workspaceId) return; - queryClient.invalidateQueries({ - queryKey: shellListQueryKey(workspaceId), - }); - queryClient.invalidateQueries({ - queryKey: ["workspaces", workspaceId, "shell", "status"], - }); - }, - }); + const mutationFn = useCallback(async () => { + if (!workspaceId) { + throw new Error("workspace id required"); + } + return postShellStart(workspaceId); + }, [workspaceId]); + const onSuccess = useCallback(() => { + if (!workspaceId) return; + queryClient.invalidateQueries({ + queryKey: shellListQueryKey(workspaceId), + }); + queryClient.invalidateQueries({ + queryKey: ["workspaces", workspaceId, "shell", "status"], + }); + }, [queryClient, workspaceId]); + return useMutation({ mutationFn, onSuccess }); } export function useShellStop( workspaceId: string | null, ): UseMutationResult { const queryClient = useQueryClient(); - return useMutation({ - mutationFn: async ({ shellId }) => { + const mutationFn = useCallback( + async ({ shellId }: { shellId: string }) => { if (!workspaceId) { throw new Error("workspace id required"); } return postShellStop(workspaceId, shellId); }, - onSuccess: () => { - if (!workspaceId) return; - queryClient.invalidateQueries({ - queryKey: shellListQueryKey(workspaceId), - }); - queryClient.invalidateQueries({ - queryKey: ["workspaces", workspaceId, "shell", "status"], - }); - }, - }); + [workspaceId], + ); + const onSuccess = useCallback(() => { + if (!workspaceId) return; + queryClient.invalidateQueries({ + queryKey: shellListQueryKey(workspaceId), + }); + queryClient.invalidateQueries({ + queryKey: ["workspaces", workspaceId, "shell", "status"], + }); + }, [queryClient, workspaceId]); + return useMutation({ mutationFn, onSuccess }); } export function useShellRestart( workspaceId: string | null, ): UseMutationResult { const queryClient = useQueryClient(); - return useMutation({ - mutationFn: async ({ shellId }) => { + const mutationFn = useCallback( + async ({ shellId }: { shellId: string }) => { if (!workspaceId) { throw new Error("workspace id required"); } return postShellRestart(workspaceId, shellId); }, - onSuccess: () => { - if (!workspaceId) return; - queryClient.invalidateQueries({ - queryKey: shellListQueryKey(workspaceId), - }); - queryClient.invalidateQueries({ - queryKey: ["workspaces", workspaceId, "shell", "status"], - }); - }, - }); + [workspaceId], + ); + const onSuccess = useCallback(() => { + if (!workspaceId) return; + queryClient.invalidateQueries({ + queryKey: shellListQueryKey(workspaceId), + }); + queryClient.invalidateQueries({ + queryKey: ["workspaces", workspaceId, "shell", "status"], + }); + }, [queryClient, workspaceId]); + return useMutation({ mutationFn, onSuccess }); } async function postShellResize( @@ -303,14 +311,21 @@ export function useShellResize( workspaceId: string | null, shellId: string | null, ): UseMutationResult { - return useMutation({ - mutationFn: async ({ cols, rows }) => { + // Stable mutationFn: without useCallback, a fresh closure is created on + // every render, which makes useMutation's returned object unstable. That + // ripples into useEffect dep arrays in TerminalPanel and causes the + // terminal to be torn down and recreated on every render — which fires + // onResize in a tight loop and floods /shell/resize. + const mutationFn = useCallback( + async ({ cols, rows }: { cols: number; rows: number }) => { if (!workspaceId || !shellId) { throw new Error("workspace id and shell id required"); } return postShellResize(workspaceId, shellId, cols, rows); }, - }); + [workspaceId, shellId], + ); + return useMutation({ mutationFn }); } export type ProcessWebSocketStatus =