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
This commit is contained in:
Junyan Qin
2026-04-14 22:06:12 +08:00
committed by WangCham
parent 2697d82286
commit fad69c70b6
2 changed files with 58 additions and 7 deletions

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod'; import { z } from 'zod';
@@ -102,9 +102,30 @@ export default function N8nAuthFormComponent({
}, {} as FormValues), }, {} as FormValues),
}); });
const isInitialMount = useRef(true);
const previousInitialValues = useRef(initialValues);
// Stable ref for onSubmit to avoid re-triggering the effect when the
// parent passes a new closure on every render (matches DynamicFormComponent pattern).
const onSubmitRef = useRef(onSubmit);
onSubmitRef.current = onSubmit;
// 当 initialValues 变化时更新表单值 // 当 initialValues 变化时更新表单值
useEffect(() => { useEffect(() => {
if (initialValues) { // Skip the first mount — defaultValues already handles it
if (isInitialMount.current) {
isInitialMount.current = false;
previousInitialValues.current = initialValues;
return;
}
// Deep compare to avoid reacting to parent re-renders that pass
// the same values back (e.g. after our own onSubmit emission).
const hasRealChange =
JSON.stringify(previousInitialValues.current) !==
JSON.stringify(initialValues);
if (initialValues && hasRealChange) {
// 合并默认值和初始值 // 合并默认值和初始值
const mergedValues = itemConfigList.reduce( const mergedValues = itemConfigList.reduce(
(acc, item) => { (acc, item) => {
@@ -120,11 +141,28 @@ export default function N8nAuthFormComponent({
// 更新认证类型 // 更新认证类型
setAuthType((mergedValues['auth-type'] as string) || 'none'); setAuthType((mergedValues['auth-type'] as string) || 'none');
previousInitialValues.current = initialValues;
} }
}, [initialValues, form, itemConfigList]); }, [initialValues, form, itemConfigList]);
// 监听表单值变化 // 监听表单值变化
useEffect(() => { useEffect(() => {
// Emit initial form values on mount so the parent form's
// initializedStagesRef registers this stage (matches DynamicFormComponent).
const formValues = form.getValues();
const initialFinalValues = itemConfigList.reduce(
(acc, item) => {
acc[item.name] = formValues[item.name] ?? item.default;
return acc;
},
{} as Record<string, string>,
);
onSubmitRef.current?.(initialFinalValues);
previousInitialValues.current = initialFinalValues as Record<
string,
string
>;
const subscription = form.watch((value, { name }) => { const subscription = form.watch((value, { name }) => {
// 如果认证类型变化,更新状态 // 如果认证类型变化,更新状态
if (name === 'auth-type') { if (name === 'auth-type') {
@@ -141,10 +179,11 @@ export default function N8nAuthFormComponent({
{} as Record<string, string>, {} as Record<string, string>,
); );
onSubmit?.(finalValues); onSubmitRef.current?.(finalValues);
previousInitialValues.current = finalValues as Record<string, string>;
}); });
return () => subscription.unsubscribe(); return () => subscription.unsubscribe();
}, [form, onSubmit, itemConfigList]); }, [form, itemConfigList]);
// 根据认证类型过滤表单项 // 根据认证类型过滤表单项
const filteredConfigList = itemConfigList.filter((config) => { const filteredConfigList = itemConfigList.filter((config) => {

View File

@@ -185,6 +185,10 @@ export default function PipelineFormComponent({
if (!isEditMode || !savedSnapshotRef.current) return false; if (!isEditMode || !savedSnapshotRef.current) return false;
return JSON.stringify(watchedValues) !== savedSnapshotRef.current; return JSON.stringify(watchedValues) !== savedSnapshotRef.current;
}, [isEditMode, watchedValues]); }, [isEditMode, watchedValues]);
// Keep a ref so that non-reactive callbacks (handleDynamicFormEmit) can
// read the latest dirty state without stale closures.
const hasUnsavedChangesRef = useRef(hasUnsavedChanges);
hasUnsavedChangesRef.current = hasUnsavedChanges;
// Notify parent when dirty state changes // Notify parent when dirty state changes
useEffect(() => { useEffect(() => {
@@ -304,6 +308,9 @@ export default function PipelineFormComponent({
// Called from DynamicFormComponent/N8nAuthFormComponent onSubmit callbacks. // Called from DynamicFormComponent/N8nAuthFormComponent onSubmit callbacks.
// On the first emission for a stage (mount-time default filling), the // On the first emission for a stage (mount-time default filling), the
// snapshot is synchronously re-captured so that hasUnsavedChanges stays false. // snapshot is synchronously re-captured so that hasUnsavedChanges stays false.
// However, if the form is already dirty (the user has made real changes),
// we must NOT re-capture the snapshot — otherwise we would silently absorb
// those real changes and flip hasUnsavedChanges back to false.
function handleDynamicFormEmit( function handleDynamicFormEmit(
formName: keyof FormValues, formName: keyof FormValues,
stageName: string, stageName: string,
@@ -322,9 +329,14 @@ export default function PipelineFormComponent({
if (isFirstEmission) { if (isFirstEmission) {
initializedStagesRef.current.add(stageKey); initializedStagesRef.current.add(stageKey);
// Synchronously re-capture snapshot so that the useMemo comparison // Only re-capture the snapshot when the form has no other pending
// in the same render cycle still returns false. // changes. If the user already modified something (e.g. switched
savedSnapshotRef.current = JSON.stringify(form.getValues()); // runner), the snapshot must remain at the last-saved state so that
// hasUnsavedChanges stays true.
const currentSnapshot = JSON.stringify(form.getValues());
if (savedSnapshotRef.current === '' || !hasUnsavedChangesRef.current) {
savedSnapshotRef.current = currentSnapshot;
}
} }
} }