From 271019006e1755c1ea2135c7fc6377b7518433be Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Sat, 9 May 2026 09:29:20 +0800 Subject: [PATCH] fix: prevent workflow preview resize observer loop (#35936) --- web/app/components/workflow/index.tsx | 24 +++++++- .../operator/__tests__/index.spec.tsx | 10 ++-- .../components/workflow/operator/index.tsx | 24 +++++++- .../workflow/panel/__tests__/index.spec.tsx | 58 +++++++++++++++---- web/app/components/workflow/panel/index.tsx | 51 +++++++++------- .../workflow/store/workflow/layout-slice.ts | 30 ++++++---- 6 files changed, 148 insertions(+), 49 deletions(-) diff --git a/web/app/components/workflow/index.tsx b/web/app/components/workflow/index.tsx index 0707ba8b3b..d946ad4a97 100644 --- a/web/app/components/workflow/index.tsx +++ b/web/app/components/workflow/index.tsx @@ -213,6 +213,8 @@ export const Workflow: FC = memo(({ const bottomPanelHeight = useStore(s => s.bottomPanelHeight) const setWorkflowCanvasWidth = useStore(s => s.setWorkflowCanvasWidth) const setWorkflowCanvasHeight = useStore(s => s.setWorkflowCanvasHeight) + const workflowCanvasSizeRef = useRef<{ width?: number, height?: number }>({}) + const workflowCanvasResizeFrameRef = useRef(undefined) const controlHeight = useMemo(() => { if (!workflowCanvasHeight) return '100%' @@ -222,15 +224,33 @@ export const Workflow: FC = memo(({ // update workflow Canvas width and height useEffect(() => { if (workflowContainerRef.current) { + const updateWorkflowCanvasSize = (width: number, height: number) => { + if (workflowCanvasSizeRef.current.width === width && workflowCanvasSizeRef.current.height === height) + return + + workflowCanvasSizeRef.current = { width, height } + if (workflowCanvasResizeFrameRef.current) + cancelAnimationFrame(workflowCanvasResizeFrameRef.current) + + workflowCanvasResizeFrameRef.current = requestAnimationFrame(() => { + workflowCanvasResizeFrameRef.current = undefined + setWorkflowCanvasWidth(width) + setWorkflowCanvasHeight(height) + }) + } + const resizeContainerObserver = new ResizeObserver((entries) => { for (const entry of entries) { const { inlineSize, blockSize } = entry.borderBoxSize[0]! - setWorkflowCanvasWidth(inlineSize) - setWorkflowCanvasHeight(blockSize) + updateWorkflowCanvasSize(inlineSize, blockSize) } }) resizeContainerObserver.observe(workflowContainerRef.current) return () => { + if (workflowCanvasResizeFrameRef.current) { + cancelAnimationFrame(workflowCanvasResizeFrameRef.current) + workflowCanvasResizeFrameRef.current = undefined + } resizeContainerObserver.disconnect() } } diff --git a/web/app/components/workflow/operator/__tests__/index.spec.tsx b/web/app/components/workflow/operator/__tests__/index.spec.tsx index 455f3aa0b5..49f077341d 100644 --- a/web/app/components/workflow/operator/__tests__/index.spec.tsx +++ b/web/app/components/workflow/operator/__tests__/index.spec.tsx @@ -1,4 +1,4 @@ -import { act, screen } from '@testing-library/react' +import { act, screen, waitFor } from '@testing-library/react' import { createNode } from '../../__tests__/fixtures' import { renderWorkflowFlowComponent } from '../../__tests__/workflow-test-env' import { BlockEnum } from '../../types' @@ -110,7 +110,7 @@ describe('Operator', () => { expect(container.querySelector('div[style*="width: auto"]')).toBeInTheDocument() }) - it('should sync the observed panel size back into the workflow store and disconnect on unmount', () => { + it('should sync the observed panel size back into the workflow store and disconnect on unmount', async () => { const { store, unmount } = renderOperator({ workflowCanvasWidth: 900, rightPanelWidth: 260, @@ -126,8 +126,10 @@ describe('Operator', () => { ], {} as ResizeObserver) }) - expect(store.getState().bottomPanelWidth).toBe(512) - expect(store.getState().bottomPanelHeight).toBe(188) + await waitFor(() => { + expect(store.getState().bottomPanelWidth).toBe(512) + expect(store.getState().bottomPanelHeight).toBe(188) + }) unmount() diff --git a/web/app/components/workflow/operator/index.tsx b/web/app/components/workflow/operator/index.tsx index 052953cecf..5797983e44 100644 --- a/web/app/components/workflow/operator/index.tsx +++ b/web/app/components/workflow/operator/index.tsx @@ -15,6 +15,8 @@ type OperatorProps = { const Operator = ({ handleUndo, handleRedo }: OperatorProps) => { const bottomPanelRef = useRef(null) + const bottomPanelSizeRef = useRef<{ width?: number, height?: number }>({}) + const bottomPanelResizeFrameRef = useRef(undefined) const [showMiniMap, setShowMiniMap] = useState(true) const showUserCursors = useStore(s => s.showUserCursors) const setShowUserCursors = useStore(s => s.setShowUserCursors) @@ -55,15 +57,33 @@ const Operator = ({ handleUndo, handleRedo }: OperatorProps) => { // update bottom panel height useEffect(() => { if (bottomPanelRef.current) { + const updateBottomPanelSize = (width: number, height: number) => { + if (bottomPanelSizeRef.current.width === width && bottomPanelSizeRef.current.height === height) + return + + bottomPanelSizeRef.current = { width, height } + if (bottomPanelResizeFrameRef.current) + cancelAnimationFrame(bottomPanelResizeFrameRef.current) + + bottomPanelResizeFrameRef.current = requestAnimationFrame(() => { + bottomPanelResizeFrameRef.current = undefined + setBottomPanelWidth(width) + setBottomPanelHeight(height) + }) + } + const resizeContainerObserver = new ResizeObserver((entries) => { for (const entry of entries) { const { inlineSize, blockSize } = entry.borderBoxSize[0]! - setBottomPanelWidth(inlineSize) - setBottomPanelHeight(blockSize) + updateBottomPanelSize(inlineSize, blockSize) } }) resizeContainerObserver.observe(bottomPanelRef.current) return () => { + if (bottomPanelResizeFrameRef.current) { + cancelAnimationFrame(bottomPanelResizeFrameRef.current) + bottomPanelResizeFrameRef.current = undefined + } resizeContainerObserver.disconnect() } } diff --git a/web/app/components/workflow/panel/__tests__/index.spec.tsx b/web/app/components/workflow/panel/__tests__/index.spec.tsx index 5da08dd832..4a813f392d 100644 --- a/web/app/components/workflow/panel/__tests__/index.spec.tsx +++ b/web/app/components/workflow/panel/__tests__/index.spec.tsx @@ -1,4 +1,4 @@ -import { render, screen } from '@testing-library/react' +import { act, render, screen, waitFor } from '@testing-library/react' import * as React from 'react' import Panel from '../index' @@ -232,17 +232,55 @@ describe('Panel', () => { expect(mockPanelStoreState.setPreviewPanelWidth).not.toHaveBeenCalled() }) - it('should derive observer widths from border-box, content-rect, and fallback values and disconnect on unmount', () => { - mockResizeModes = ['borderBox', 'contentRect', 'fallback'] - + it('should derive observer widths from border-box, content-rect, and fallback values and disconnect on unmount', async () => { const { unmount } = render() - expect(mockPanelStoreState.setRightPanelWidth).toHaveBeenCalledWith(720) - expect(mockPanelStoreState.setRightPanelWidth).toHaveBeenCalledWith(530) - expect(mockPanelStoreState.setRightPanelWidth).toHaveBeenCalledWith(640) - expect(mockPanelStoreState.setOtherPanelWidth).toHaveBeenCalledWith(720) - expect(mockPanelStoreState.setOtherPanelWidth).toHaveBeenCalledWith(530) - expect(mockPanelStoreState.setOtherPanelWidth).toHaveBeenCalledWith(640) + await waitFor(() => { + expect(mockPanelStoreState.setRightPanelWidth).toHaveBeenCalledWith(640) + expect(mockPanelStoreState.setOtherPanelWidth).toHaveBeenCalledWith(640) + }) + + vi.mocked(mockPanelStoreState.setRightPanelWidth).mockClear() + vi.mocked(mockPanelStoreState.setOtherPanelWidth).mockClear() + + act(() => { + mockResizeObservers.forEach((observer) => { + observer.callback([createResizeEntry('borderBox')], observer as unknown as ResizeObserver) + }) + }) + + await waitFor(() => { + expect(mockPanelStoreState.setRightPanelWidth).toHaveBeenCalledWith(720) + expect(mockPanelStoreState.setOtherPanelWidth).toHaveBeenCalledWith(720) + }) + + vi.mocked(mockPanelStoreState.setRightPanelWidth).mockClear() + vi.mocked(mockPanelStoreState.setOtherPanelWidth).mockClear() + + act(() => { + mockResizeObservers.forEach((observer) => { + observer.callback([createResizeEntry('contentRect')], observer as unknown as ResizeObserver) + }) + }) + + await waitFor(() => { + expect(mockPanelStoreState.setRightPanelWidth).toHaveBeenCalledWith(530) + expect(mockPanelStoreState.setOtherPanelWidth).toHaveBeenCalledWith(530) + }) + + vi.mocked(mockPanelStoreState.setRightPanelWidth).mockClear() + vi.mocked(mockPanelStoreState.setOtherPanelWidth).mockClear() + + act(() => { + mockResizeObservers.forEach((observer) => { + observer.callback([createResizeEntry('fallback')], observer as unknown as ResizeObserver) + }) + }) + + await waitFor(() => { + expect(mockPanelStoreState.setRightPanelWidth).toHaveBeenCalledWith(640) + expect(mockPanelStoreState.setOtherPanelWidth).toHaveBeenCalledWith(640) + }) unmount() diff --git a/web/app/components/workflow/panel/index.tsx b/web/app/components/workflow/panel/index.tsx index 89e8419b5f..3b54583d1c 100644 --- a/web/app/components/workflow/panel/index.tsx +++ b/web/app/components/workflow/panel/index.tsx @@ -1,7 +1,7 @@ import type { FC } from 'react' import type { VersionHistoryPanelProps } from '@/app/components/workflow/panel/version-history-panel' import { cn } from '@langgenius/dify-ui/cn' -import { memo, useCallback, useEffect, useRef } from 'react' +import { memo, useEffect, useRef } from 'react' import { useStore as useReactflow } from 'reactflow' import { useShallow } from 'zustand/react/shallow' import dynamic from '@/next/dynamic' @@ -34,35 +34,50 @@ const getEntryWidth = (entry: ResizeObserverEntry, element: HTMLElement): number return element.getBoundingClientRect().width } -const useResizeObserver = ( - callback: (width: number) => void, - dependencies: React.DependencyList = [], -) => { +const useResizeObserver = (callback: (width: number) => void) => { const elementRef = useRef(null) - - const stableCallback = useCallback(callback, [callback]) + const widthRef = useRef(undefined) + const animationFrameRef = useRef(undefined) useEffect(() => { const element = elementRef.current if (!element) return + widthRef.current = undefined + + const updateWidth = (width: number) => { + if (widthRef.current === width) + return + + widthRef.current = width + if (animationFrameRef.current) + cancelAnimationFrame(animationFrameRef.current) + + animationFrameRef.current = requestAnimationFrame(() => { + animationFrameRef.current = undefined + callback(width) + }) + } + const resizeObserver = new ResizeObserver((entries) => { - for (const entry of entries) { - const width = getEntryWidth(entry, element) - stableCallback(width) - } + for (const entry of entries) + updateWidth(getEntryWidth(entry, element)) }) resizeObserver.observe(element) const initialWidth = element.getBoundingClientRect().width - stableCallback(initialWidth) + updateWidth(initialWidth) return () => { + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current) + animationFrameRef.current = undefined + } resizeObserver.disconnect() } - }, [stableCallback, ...dependencies]) + }, [callback]) return elementRef } @@ -113,15 +128,9 @@ const Panel: FC = ({ const setRightPanelWidth = useStore(s => s.setRightPanelWidth) const setOtherPanelWidth = useStore(s => s.setOtherPanelWidth) - const rightPanelRef = useResizeObserver( - setRightPanelWidth, - [setRightPanelWidth, selectedNode, showEnvPanel, showWorkflowVersionHistoryPanel], - ) + const rightPanelRef = useResizeObserver(setRightPanelWidth) - const otherPanelRef = useResizeObserver( - setOtherPanelWidth, - [setOtherPanelWidth, showEnvPanel, showWorkflowVersionHistoryPanel], - ) + const otherPanelRef = useResizeObserver(setOtherPanelWidth) return (
= set => ({ workflowCanvasWidth: undefined, workflowCanvasHeight: undefined, - setWorkflowCanvasWidth: width => set(() => ({ workflowCanvasWidth: width })), - setWorkflowCanvasHeight: height => set(() => ({ workflowCanvasHeight: height })), + setWorkflowCanvasWidth: width => set(state => + state.workflowCanvasWidth === width ? state : ({ workflowCanvasWidth: width })), + setWorkflowCanvasHeight: height => set(state => + state.workflowCanvasHeight === height ? state : ({ workflowCanvasHeight: height })), rightPanelWidth: undefined, - setRightPanelWidth: width => set(() => ({ rightPanelWidth: width })), + setRightPanelWidth: width => set(state => + state.rightPanelWidth === width ? state : ({ rightPanelWidth: width })), nodePanelWidth: localStorage.getItem('workflow-node-panel-width') ? Number.parseFloat(localStorage.getItem('workflow-node-panel-width')!) : 400, - setNodePanelWidth: width => set(() => ({ nodePanelWidth: width })), + setNodePanelWidth: width => set(state => + state.nodePanelWidth === width ? state : ({ nodePanelWidth: width })), previewPanelWidth: localStorage.getItem('debug-and-preview-panel-width') ? Number.parseFloat(localStorage.getItem('debug-and-preview-panel-width')!) : 400, - setPreviewPanelWidth: width => set(() => ({ previewPanelWidth: width })), + setPreviewPanelWidth: width => set(state => + state.previewPanelWidth === width ? state : ({ previewPanelWidth: width })), otherPanelWidth: 400, - setOtherPanelWidth: width => set(() => ({ otherPanelWidth: width })), + setOtherPanelWidth: width => set(state => + state.otherPanelWidth === width ? state : ({ otherPanelWidth: width })), bottomPanelWidth: 480, - setBottomPanelWidth: width => set(() => ({ bottomPanelWidth: width })), + setBottomPanelWidth: width => set(state => + state.bottomPanelWidth === width ? state : ({ bottomPanelWidth: width })), bottomPanelHeight: 324, - setBottomPanelHeight: height => set(() => ({ bottomPanelHeight: height })), + setBottomPanelHeight: height => set(state => + state.bottomPanelHeight === height ? state : ({ bottomPanelHeight: height })), variableInspectPanelHeight: localStorage.getItem('workflow-variable-inpsect-panel-height') ? Number.parseFloat(localStorage.getItem('workflow-variable-inpsect-panel-height')!) : 320, - setVariableInspectPanelHeight: height => set(() => ({ variableInspectPanelHeight: height })), + setVariableInspectPanelHeight: height => set(state => + state.variableInspectPanelHeight === height ? state : ({ variableInspectPanelHeight: height })), maximizeCanvas: localStorage.getItem('workflow-canvas-maximize') === 'true', - setMaximizeCanvas: maximize => set(() => ({ maximizeCanvas: maximize })), + setMaximizeCanvas: maximize => set(state => + state.maximizeCanvas === maximize ? state : ({ maximizeCanvas: maximize })), })