mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-02 12:05:54 +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 { 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) => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user