From 7a82176ab25e3e5ad0a32367cfc56cf0044100a8 Mon Sep 17 00:00:00 2001 From: Celal Zamanoglu <95054566+celalzamanoglu@users.noreply.github.com> Date: Fri, 6 Feb 2026 01:39:25 +0300 Subject: [PATCH] Fix React error #185 when copy-pasting content between blocks (#SKY-7638) (#4646) --- .../AutoResizingTextarea.tsx | 19 ++++- .../routes/workflows/editor/FlowRenderer.tsx | 69 +++++++++++++++---- 2 files changed, 71 insertions(+), 17 deletions(-) diff --git a/skyvern-frontend/src/components/AutoResizingTextarea/AutoResizingTextarea.tsx b/skyvern-frontend/src/components/AutoResizingTextarea/AutoResizingTextarea.tsx index 286798fd..f3e202a6 100644 --- a/skyvern-frontend/src/components/AutoResizingTextarea/AutoResizingTextarea.tsx +++ b/skyvern-frontend/src/components/AutoResizingTextarea/AutoResizingTextarea.tsx @@ -1,6 +1,6 @@ import { Textarea } from "@/components/ui/textarea"; import type { ChangeEventHandler, HTMLAttributes } from "react"; -import { forwardRef, useEffect, useRef, useCallback } from "react"; +import { forwardRef, useRef, useCallback, useLayoutEffect } from "react"; import { cn } from "@/util/utils"; type Props = { @@ -30,6 +30,7 @@ const AutoResizingTextarea = forwardRef( forwardedRef, ) => { const innerRef = useRef(null); + const lastHeightRef = useRef(""); const getTextarea = useCallback(() => innerRef.current, []); const setRefs = (element: HTMLTextAreaElement | null) => { @@ -43,13 +44,25 @@ const AutoResizingTextarea = forwardRef( } }; - useEffect(() => { + useLayoutEffect(() => { const textareaElement = getTextarea(); if (!textareaElement) { return; } + + // Temporarily set to auto to measure scrollHeight accurately textareaElement.style.height = "auto"; - textareaElement.style.height = `${textareaElement.scrollHeight + 2}px`; + const newHeight = `${textareaElement.scrollHeight + 2}px`; + + // Only apply the final height if it differs from the last applied height + // This prevents unnecessary dimension change events in React Flow + if (lastHeightRef.current !== newHeight) { + lastHeightRef.current = newHeight; + textareaElement.style.height = newHeight; + } else { + // Restore the previous height since nothing changed + textareaElement.style.height = lastHeightRef.current; + } }, [getTextarea, value]); return ( diff --git a/skyvern-frontend/src/routes/workflows/editor/FlowRenderer.tsx b/skyvern-frontend/src/routes/workflows/editor/FlowRenderer.tsx index 8a3024c6..c0e3fd16 100644 --- a/skyvern-frontend/src/routes/workflows/editor/FlowRenderer.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/FlowRenderer.tsx @@ -36,6 +36,7 @@ import { import "@xyflow/react/dist/style.css"; import { nanoid } from "nanoid"; import { useCallback, useEffect, useRef, useState } from "react"; +import { useDebouncedCallback } from "use-debounce"; import { useBlocker, useParams } from "react-router-dom"; import { AWSSecretParameter, @@ -321,6 +322,9 @@ function FlowRenderer({ // Track if this is the initial load to prevent false "unsaved changes" detection const isInitialLoadRef = useRef(true); + // Track if we're currently in a layout operation to prevent infinite loops + const isLayoutingRef = useRef(false); + useEffect(() => { if (nodesInitialized) { setShouldConstrainPan(true); @@ -364,6 +368,27 @@ function FlowRenderer({ [setNodes, setEdges, targettedBlockLabel], ); + // Debounced layout for dimension changes to prevent infinite loops (React error #185) + // when copy-pasting triggers rapid successive dimension changes + const debouncedLayoutForDimensions = useDebouncedCallback( + (tempNodes: Array, currentEdges: Array) => { + if (isLayoutingRef.current) { + return; + } + isLayoutingRef.current = true; + try { + doLayout(tempNodes, currentEdges); + } finally { + // Reset the flag after a short delay to allow React to flush updates + requestAnimationFrame(() => { + isLayoutingRef.current = false; + }); + } + }, + 50, + { leading: true, trailing: true, maxWait: 200 }, + ); + useEffect(() => { if (nodesInitialized) { doLayout(nodes, edges); @@ -850,21 +875,37 @@ function FlowRenderer({ const dimensionChanges = changes.filter( (change) => change.type === "dimensions", ); - const tempNodes = [...nodes]; - dimensionChanges.forEach((change) => { - const node = tempNodes.find((node) => node.id === change.id); - if (node) { - if (node.measured?.width) { - node.measured.width = change.dimensions?.width; - } - if (node.measured?.height) { - node.measured.height = change.dimensions?.height; - } - } - }); - if (dimensionChanges.length > 0) { - doLayout(tempNodes, edges); + // Only process dimension changes if we're not already in a layout operation + // This prevents infinite loops (React error #185) during copy-paste + if (dimensionChanges.length > 0 && !isLayoutingRef.current) { + const tempNodes = [...nodes]; + let hasActualChanges = false; + + dimensionChanges.forEach((change) => { + const node = tempNodes.find((node) => node.id === change.id); + if (node) { + const newWidth = change.dimensions?.width; + const newHeight = change.dimensions?.height; + + // Only update if dimensions actually changed + if ( + node.measured?.width !== newWidth || + node.measured?.height !== newHeight + ) { + hasActualChanges = true; + if (node.measured) { + node.measured.width = newWidth; + node.measured.height = newHeight; + } + } + } + }); + + // Only trigger layout if there were actual dimension changes + if (hasActualChanges) { + debouncedLayoutForDimensions(tempNodes, edges); + } } // Only track changes after initial load is complete and not during internal updates