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",
): UseMutationResult<void, Error, string> {
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<ShellInfo, Error, void> {
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<void, Error, { shellId: string }> {
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<ShellInfo, Error, { shellId: string }> {
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<void, Error, { cols: number; rows: number }> {
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 =