Skip to content

Commit f10891b

Browse files
committed
fix(web): prevent first-emission snapshot from swallowing unsaved changes in pipeline editor
When switching runner (e.g. local-agent → n8n), the newly mounted stage's first emit would re-capture the saved snapshot, erasing the dirty state caused by the runner change. The save button would incorrectly go dim. - Skip snapshot re-capture in handleDynamicFormEmit when form is already dirty - Add mount-time emit to N8nAuthFormComponent (matching DynamicFormComponent) - Use stable onSubmitRef to prevent useEffect subscription churn - Add previousInitialValues guard to prevent initialValues echo loops
1 parent 715da8e commit f10891b

2 files changed

Lines changed: 58 additions & 7 deletions

File tree

web/src/app/home/components/dynamic-form/N8nAuthFormComponent.tsx

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect, useState } from 'react';
1+
import { useEffect, useRef, useState } from 'react';
22
import { useForm } from 'react-hook-form';
33
import { zodResolver } from '@hookform/resolvers/zod';
44
import { z } from 'zod';
@@ -102,9 +102,30 @@ export default function N8nAuthFormComponent({
102102
}, {} as FormValues),
103103
});
104104

105+
const isInitialMount = useRef(true);
106+
const previousInitialValues = useRef(initialValues);
107+
108+
// Stable ref for onSubmit to avoid re-triggering the effect when the
109+
// parent passes a new closure on every render (matches DynamicFormComponent pattern).
110+
const onSubmitRef = useRef(onSubmit);
111+
onSubmitRef.current = onSubmit;
112+
105113
// 当 initialValues 变化时更新表单值
106114
useEffect(() => {
107-
if (initialValues) {
115+
// Skip the first mount — defaultValues already handles it
116+
if (isInitialMount.current) {
117+
isInitialMount.current = false;
118+
previousInitialValues.current = initialValues;
119+
return;
120+
}
121+
122+
// Deep compare to avoid reacting to parent re-renders that pass
123+
// the same values back (e.g. after our own onSubmit emission).
124+
const hasRealChange =
125+
JSON.stringify(previousInitialValues.current) !==
126+
JSON.stringify(initialValues);
127+
128+
if (initialValues && hasRealChange) {
108129
// 合并默认值和初始值
109130
const mergedValues = itemConfigList.reduce(
110131
(acc, item) => {
@@ -120,11 +141,28 @@ export default function N8nAuthFormComponent({
120141

121142
// 更新认证类型
122143
setAuthType((mergedValues['auth-type'] as string) || 'none');
144+
previousInitialValues.current = initialValues;
123145
}
124146
}, [initialValues, form, itemConfigList]);
125147

126148
// 监听表单值变化
127149
useEffect(() => {
150+
// Emit initial form values on mount so the parent form's
151+
// initializedStagesRef registers this stage (matches DynamicFormComponent).
152+
const formValues = form.getValues();
153+
const initialFinalValues = itemConfigList.reduce(
154+
(acc, item) => {
155+
acc[item.name] = formValues[item.name] ?? item.default;
156+
return acc;
157+
},
158+
{} as Record<string, string>,
159+
);
160+
onSubmitRef.current?.(initialFinalValues);
161+
previousInitialValues.current = initialFinalValues as Record<
162+
string,
163+
string
164+
>;
165+
128166
const subscription = form.watch((value, { name }) => {
129167
// 如果认证类型变化,更新状态
130168
if (name === 'auth-type') {
@@ -141,10 +179,11 @@ export default function N8nAuthFormComponent({
141179
{} as Record<string, string>,
142180
);
143181

144-
onSubmit?.(finalValues);
182+
onSubmitRef.current?.(finalValues);
183+
previousInitialValues.current = finalValues as Record<string, string>;
145184
});
146185
return () => subscription.unsubscribe();
147-
}, [form, onSubmit, itemConfigList]);
186+
}, [form, itemConfigList]);
148187

149188
// 根据认证类型过滤表单项
150189
const filteredConfigList = itemConfigList.filter((config) => {

web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,10 @@ export default function PipelineFormComponent({
185185
if (!isEditMode || !savedSnapshotRef.current) return false;
186186
return JSON.stringify(watchedValues) !== savedSnapshotRef.current;
187187
}, [isEditMode, watchedValues]);
188+
// Keep a ref so that non-reactive callbacks (handleDynamicFormEmit) can
189+
// read the latest dirty state without stale closures.
190+
const hasUnsavedChangesRef = useRef(hasUnsavedChanges);
191+
hasUnsavedChangesRef.current = hasUnsavedChanges;
188192

189193
// Notify parent when dirty state changes
190194
useEffect(() => {
@@ -304,6 +308,9 @@ export default function PipelineFormComponent({
304308
// Called from DynamicFormComponent/N8nAuthFormComponent onSubmit callbacks.
305309
// On the first emission for a stage (mount-time default filling), the
306310
// snapshot is synchronously re-captured so that hasUnsavedChanges stays false.
311+
// However, if the form is already dirty (the user has made real changes),
312+
// we must NOT re-capture the snapshot — otherwise we would silently absorb
313+
// those real changes and flip hasUnsavedChanges back to false.
307314
function handleDynamicFormEmit(
308315
formName: keyof FormValues,
309316
stageName: string,
@@ -322,9 +329,14 @@ export default function PipelineFormComponent({
322329

323330
if (isFirstEmission) {
324331
initializedStagesRef.current.add(stageKey);
325-
// Synchronously re-capture snapshot so that the useMemo comparison
326-
// in the same render cycle still returns false.
327-
savedSnapshotRef.current = JSON.stringify(form.getValues());
332+
// Only re-capture the snapshot when the form has no other pending
333+
// changes. If the user already modified something (e.g. switched
334+
// runner), the snapshot must remain at the last-saved state so that
335+
// hasUnsavedChanges stays true.
336+
const currentSnapshot = JSON.stringify(form.getValues());
337+
if (savedSnapshotRef.current === '' || !hasUnsavedChangesRef.current) {
338+
savedSnapshotRef.current = currentSnapshot;
339+
}
328340
}
329341
}
330342

0 commit comments

Comments
 (0)