Skip to content

Commit a881839

Browse files
authored
fix(frontend): drop stale refresh after code save (#14460)
1 parent 3e5692b commit a881839

2 files changed

Lines changed: 109 additions & 12 deletions

File tree

src/frontend/src/CustomNodes/helpers/__tests__/mutate-template.test.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,19 @@
1+
import useFlowStore from "@/stores/flowStore";
12
import type { APIClassType } from "@/types/api";
23
import { mutateTemplate } from "../mutate-template";
34

5+
const setStoreNodeCode = (nodeId: string, code: string) => {
6+
jest.spyOn(useFlowStore, "getState").mockReturnValue({
7+
nodes: [
8+
{ id: nodeId, data: { node: { template: { code: { value: code } } } } },
9+
],
10+
} as never);
11+
};
12+
413
describe("mutateTemplate", () => {
514
afterEach(() => {
615
jest.useRealTimers();
16+
jest.restoreAllMocks();
717
});
818

919
it("sends Tool Mode changes immediately", async () => {
@@ -89,4 +99,76 @@ describe("mutateTemplate", () => {
8999
);
90100
expect(metadataMutateAsync).not.toHaveBeenCalled();
91101
});
102+
103+
it("drops a refresh whose component code was replaced while it was in flight", async () => {
104+
const node = {
105+
template: {
106+
code: { value: "original source" },
107+
base_url: { value: "http://localhost:11434" },
108+
},
109+
outputs: [],
110+
} as unknown as APIClassType;
111+
const setNodeClass = jest.fn();
112+
const callback = jest.fn();
113+
const mutateAsync = jest.fn().mockImplementation(async () => {
114+
setStoreNodeCode("ollama-node", "edited source");
115+
return {
116+
template: { code: { value: "original source" } },
117+
outputs: [],
118+
} as unknown as APIClassType;
119+
});
120+
setStoreNodeCode("ollama-node", "original source");
121+
122+
await mutateTemplate(
123+
node.template.base_url.value,
124+
"ollama-node",
125+
node,
126+
setNodeClass,
127+
{ mutateAsync } as never,
128+
jest.fn(),
129+
"base_url",
130+
callback,
131+
);
132+
await new Promise((resolve) => setTimeout(resolve, 600));
133+
134+
expect(mutateAsync).toHaveBeenCalled();
135+
expect(setNodeClass).not.toHaveBeenCalled();
136+
expect(callback).toHaveBeenCalled();
137+
});
138+
139+
it("applies a refresh while the component code is unchanged", async () => {
140+
const node = {
141+
template: {
142+
code: { value: "original source" },
143+
base_url: { value: "http://localhost:11434" },
144+
},
145+
outputs: [],
146+
} as unknown as APIClassType;
147+
const setNodeClass = jest.fn();
148+
const mutateAsync = jest.fn().mockResolvedValue({
149+
template: {
150+
code: { value: "original source" },
151+
model_name: { value: "" },
152+
},
153+
outputs: [],
154+
} as unknown as APIClassType);
155+
setStoreNodeCode("unchanged-node", "original source");
156+
157+
await mutateTemplate(
158+
node.template.base_url.value,
159+
"unchanged-node",
160+
node,
161+
setNodeClass,
162+
{ mutateAsync } as never,
163+
jest.fn(),
164+
"base_url",
165+
);
166+
await new Promise((resolve) => setTimeout(resolve, 600));
167+
168+
expect(setNodeClass).toHaveBeenCalledWith(
169+
expect.objectContaining({
170+
template: expect.objectContaining({ model_name: expect.anything() }),
171+
}),
172+
);
173+
});
92174
});

src/frontend/src/CustomNodes/helpers/mutate-template.ts

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,33 @@
11
import type { UseMutationResult } from "@tanstack/react-query";
22
import { cloneDeep, debounce } from "lodash";
33
import { SAVE_DEBOUNCE_TIME } from "@/constants/constants";
4+
import useFlowStore from "@/stores/flowStore";
45
import type { APIClassType, ResponseErrorDetailAPI } from "@/types/api";
56
import i18n from "../../i18n";
67
import { updateHiddenOutputs } from "./update-hidden-outputs";
78

8-
// Map to store debounced functions for each node ID + parameter combination
99
const debouncedFunctions = new Map<string, ReturnType<typeof debounce>>();
1010

11+
const getNodeCode = (nodeId: string): unknown => {
12+
const currentNode = useFlowStore
13+
.getState()
14+
.nodes.find((flowNode) => flowNode.id === nodeId);
15+
return currentNode?.data?.node?.template?.code?.value;
16+
};
17+
18+
// A refresh answers for the code that was current when it left, and applying it
19+
// replaces the whole template — a code save landing meanwhile would be reverted.
20+
const isStaleForNode = (
21+
nodeId: string,
22+
requestedNode: APIClassType,
23+
): boolean => {
24+
const requestedCode = requestedNode.template?.code?.value;
25+
if (requestedCode === undefined) return false;
26+
const currentCode = getNodeCode(nodeId);
27+
if (currentCode === undefined) return false;
28+
return currentCode !== requestedCode;
29+
};
30+
1131
export const mutateTemplate = async (
1232
newValue,
1333
nodeId: string,
@@ -25,8 +45,7 @@ export const mutateTemplate = async (
2545
toolMode?: boolean,
2646
isRefresh?: boolean,
2747
) => {
28-
// Different parameters must debounce independently to avoid one field's
29-
// refresh cancelling another's during concurrent mount calls.
48+
// Per-parameter keys keep one field's refresh from cancelling another's on mount.
3049
const debounceKey = parameterName ? `${nodeId}-${parameterName}` : nodeId;
3150
if (!debouncedFunctions.has(debounceKey)) {
3251
debouncedFunctions.set(
@@ -56,7 +75,7 @@ export const mutateTemplate = async (
5675
tool_mode: toolMode ?? node.tool_mode,
5776
is_refresh: isRefresh ?? false,
5877
});
59-
if (newTemplate) {
78+
if (newTemplate && !isStaleForNode(nodeId, node)) {
6079
newNode.template = newTemplate.template;
6180
newNode.outputs = updateHiddenOutputs(
6281
newNode.outputs ?? [],
@@ -97,11 +116,8 @@ export const mutateTemplate = async (
97116
);
98117
}
99118

100-
// Enabling Tool Mode mounts the tools_metadata field, which queues its own
101-
// debounced refresh. If the user turns Tool Mode off before that refresh
102-
// runs, the queued request still carries tool_mode=true and can restore the
103-
// Toolset output after the off response. The explicit toggle supersedes that
104-
// pending metadata refresh.
119+
// A queued tools_metadata refresh still carries tool_mode=true and would restore
120+
// the Toolset output after an off response, so the explicit toggle supersedes it.
105121
if (parameterName === "tool_mode") {
106122
debouncedFunctions.get(`${nodeId}-tools_metadata`)?.cancel();
107123
}
@@ -119,9 +135,8 @@ export const mutateTemplate = async (
119135
isRefresh,
120136
);
121137

122-
// Tool Mode is a discrete toggle, so delaying it like a text input leaves
123-
// the node in its previous output shape and gives slower refresh responses
124-
// a chance to repaint the toggle with stale state.
138+
// Debouncing a discrete toggle like a text input lets slower refresh responses
139+
// repaint it with stale state, so Tool Mode is flushed immediately.
125140
if (parameterName === "tool_mode") {
126141
await debouncedFunction?.flush();
127142
}

0 commit comments

Comments
 (0)