From fad69c70b6057b66761c11e8f7fa94de7e86dc09 Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Tue, 14 Apr 2026 22:06:12 +0800 Subject: [PATCH] fix(web): prevent first-emission snapshot from swallowing unsaved changes in pipeline editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../dynamic-form/N8nAuthFormComponent.tsx | 47 +++++++++++++++++-- .../pipeline-form/PipelineFormComponent.tsx | 18 +++++-- 2 files changed, 58 insertions(+), 7 deletions(-) diff --git a/web/src/app/home/components/dynamic-form/N8nAuthFormComponent.tsx b/web/src/app/home/components/dynamic-form/N8nAuthFormComponent.tsx index 5605cae2..f519d65f 100644 --- a/web/src/app/home/components/dynamic-form/N8nAuthFormComponent.tsx +++ b/web/src/app/home/components/dynamic-form/N8nAuthFormComponent.tsx @@ -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, + ); + 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, ); - onSubmit?.(finalValues); + onSubmitRef.current?.(finalValues); + previousInitialValues.current = finalValues as Record; }); return () => subscription.unsubscribe(); - }, [form, onSubmit, itemConfigList]); + }, [form, itemConfigList]); // 根据认证类型过滤表单项 const filteredConfigList = itemConfigList.filter((config) => { diff --git a/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx b/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx index a516a09d..2cd80c1c 100644 --- a/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx +++ b/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx @@ -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; + } } }