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:
+63
-48
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user