fix(web): stabilize useMutation results so TerminalPanel stops flooding /shell/resize

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.
This commit is contained in:
tao.chen
2026-07-06 18:19:57 +08:00
parent fc4bed67f4
commit 3df9f42626
+63 -48
View File
@@ -188,14 +188,19 @@ function useProcessActionMutation(
action: "start" | "stop" | "restart", action: "start" | "stop" | "restart",
): UseMutationResult<void, Error, string> { ): UseMutationResult<void, Error, string> {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ const mutationFn = useCallback(
mutationFn: (workspaceId: string) => postProcessAction(workspaceId, action), (workspaceId: string) => postProcessAction(workspaceId, action),
onSuccess: (_, workspaceId) => { [action],
);
const onSuccess = useCallback(
(_: void, workspaceId: string) => {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: processStatusQueryKey(workspaceId), queryKey: processStatusQueryKey(workspaceId),
}); });
}, },
}); [queryClient],
);
return useMutation({ mutationFn, onSuccess });
} }
@@ -215,69 +220,72 @@ export function useShellStart(
workspaceId: string | null, workspaceId: string | null,
): UseMutationResult<ShellInfo, Error, void> { ): UseMutationResult<ShellInfo, Error, void> {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ const mutationFn = useCallback(async () => {
mutationFn: async () => { if (!workspaceId) {
if (!workspaceId) { throw new Error("workspace id required");
throw new Error("workspace id required"); }
} return postShellStart(workspaceId);
return postShellStart(workspaceId); }, [workspaceId]);
}, const onSuccess = useCallback(() => {
onSuccess: () => { if (!workspaceId) return;
if (!workspaceId) return; queryClient.invalidateQueries({
queryClient.invalidateQueries({ queryKey: shellListQueryKey(workspaceId),
queryKey: shellListQueryKey(workspaceId), });
}); queryClient.invalidateQueries({
queryClient.invalidateQueries({ queryKey: ["workspaces", workspaceId, "shell", "status"],
queryKey: ["workspaces", workspaceId, "shell", "status"], });
}); }, [queryClient, workspaceId]);
}, return useMutation({ mutationFn, onSuccess });
});
} }
export function useShellStop( export function useShellStop(
workspaceId: string | null, workspaceId: string | null,
): UseMutationResult<void, Error, { shellId: string }> { ): UseMutationResult<void, Error, { shellId: string }> {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ const mutationFn = useCallback(
mutationFn: async ({ shellId }) => { async ({ shellId }: { shellId: string }) => {
if (!workspaceId) { if (!workspaceId) {
throw new Error("workspace id required"); throw new Error("workspace id required");
} }
return postShellStop(workspaceId, shellId); return postShellStop(workspaceId, shellId);
}, },
onSuccess: () => { [workspaceId],
if (!workspaceId) return; );
queryClient.invalidateQueries({ const onSuccess = useCallback(() => {
queryKey: shellListQueryKey(workspaceId), if (!workspaceId) return;
}); queryClient.invalidateQueries({
queryClient.invalidateQueries({ queryKey: shellListQueryKey(workspaceId),
queryKey: ["workspaces", workspaceId, "shell", "status"], });
}); queryClient.invalidateQueries({
}, queryKey: ["workspaces", workspaceId, "shell", "status"],
}); });
}, [queryClient, workspaceId]);
return useMutation({ mutationFn, onSuccess });
} }
export function useShellRestart( export function useShellRestart(
workspaceId: string | null, workspaceId: string | null,
): UseMutationResult<ShellInfo, Error, { shellId: string }> { ): UseMutationResult<ShellInfo, Error, { shellId: string }> {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ const mutationFn = useCallback(
mutationFn: async ({ shellId }) => { async ({ shellId }: { shellId: string }) => {
if (!workspaceId) { if (!workspaceId) {
throw new Error("workspace id required"); throw new Error("workspace id required");
} }
return postShellRestart(workspaceId, shellId); return postShellRestart(workspaceId, shellId);
}, },
onSuccess: () => { [workspaceId],
if (!workspaceId) return; );
queryClient.invalidateQueries({ const onSuccess = useCallback(() => {
queryKey: shellListQueryKey(workspaceId), if (!workspaceId) return;
}); queryClient.invalidateQueries({
queryClient.invalidateQueries({ queryKey: shellListQueryKey(workspaceId),
queryKey: ["workspaces", workspaceId, "shell", "status"], });
}); queryClient.invalidateQueries({
}, queryKey: ["workspaces", workspaceId, "shell", "status"],
}); });
}, [queryClient, workspaceId]);
return useMutation({ mutationFn, onSuccess });
} }
async function postShellResize( async function postShellResize(
@@ -303,14 +311,21 @@ export function useShellResize(
workspaceId: string | null, workspaceId: string | null,
shellId: string | null, shellId: string | null,
): UseMutationResult<void, Error, { cols: number; rows: number }> { ): UseMutationResult<void, Error, { cols: number; rows: number }> {
return useMutation({ // Stable mutationFn: without useCallback, a fresh closure is created on
mutationFn: async ({ cols, rows }) => { // 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) { if (!workspaceId || !shellId) {
throw new Error("workspace id and shell id required"); throw new Error("workspace id and shell id required");
} }
return postShellResize(workspaceId, shellId, cols, rows); return postShellResize(workspaceId, shellId, cols, rows);
}, },
}); [workspaceId, shellId],
);
return useMutation({ mutationFn });
} }
export type ProcessWebSocketStatus = export type ProcessWebSocketStatus =