mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-02 03:55:55 +00:00
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:
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
@@ -102,9 +102,30 @@ export default function N8nAuthFormComponent({
|
||||
}, {} 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 变化时更新表单值
|
||||
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(
|
||||
(acc, item) => {
|
||||
@@ -120,11 +141,28 @@ export default function N8nAuthFormComponent({
|
||||
|
||||
// 更新认证类型
|
||||
setAuthType((mergedValues['auth-type'] as string) || 'none');
|
||||
previousInitialValues.current = initialValues;
|
||||
}
|
||||
}, [initialValues, form, itemConfigList]);
|
||||
|
||||
// 监听表单值变化
|
||||
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 }) => {
|
||||
// 如果认证类型变化,更新状态
|
||||
if (name === 'auth-type') {
|
||||
@@ -141,10 +179,11 @@ export default function N8nAuthFormComponent({
|
||||
{} as Record<string, string>,
|
||||
);
|
||||
|
||||
onSubmit?.(finalValues);
|
||||
onSubmitRef.current?.(finalValues);
|
||||
previousInitialValues.current = finalValues as Record<string, string>;
|
||||
});
|
||||
return () => subscription.unsubscribe();
|
||||
}, [form, onSubmit, itemConfigList]);
|
||||
}, [form, itemConfigList]);
|
||||
|
||||
// 根据认证类型过滤表单项
|
||||
const filteredConfigList = itemConfigList.filter((config) => {
|
||||
|
||||
@@ -185,6 +185,10 @@ export default function PipelineFormComponent({
|
||||
if (!isEditMode || !savedSnapshotRef.current) return false;
|
||||
return JSON.stringify(watchedValues) !== savedSnapshotRef.current;
|
||||
}, [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
|
||||
useEffect(() => {
|
||||
@@ -304,6 +308,9 @@ export default function PipelineFormComponent({
|
||||
// Called from DynamicFormComponent/N8nAuthFormComponent onSubmit callbacks.
|
||||
// On the first emission for a stage (mount-time default filling), the
|
||||
// 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(
|
||||
formName: keyof FormValues,
|
||||
stageName: string,
|
||||
@@ -322,9 +329,14 @@ export default function PipelineFormComponent({
|
||||
|
||||
if (isFirstEmission) {
|
||||
initializedStagesRef.current.add(stageKey);
|
||||
// Synchronously re-capture snapshot so that the useMemo comparison
|
||||
// in the same render cycle still returns false.
|
||||
savedSnapshotRef.current = JSON.stringify(form.getValues());
|
||||
// Only re-capture the snapshot when the form has no other pending
|
||||
// changes. If the user already modified something (e.g. switched
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user