From 3a60d152605389ec28ccabbee5de73e97511803c Mon Sep 17 00:00:00 2001 From: shrimbly Date: Tue, 10 Mar 2026 21:31:08 +1300 Subject: [PATCH] perf: wrap FloatingNodeHeader in React.memo with stable callbacks Replace inline closure callbacks (onCustomTitleChange, onCommentChange, onRun, onExpand) with stable handler patterns that pass nodeId. Replace titlePrefix ReactNode prop with primitive provider string. Replace data object prop with individual primitive fields (isInLockedGroup, isExecuting, focusedCommentNodeId). This prevents N unnecessary re-renders per frame during canvas panning. Co-Authored-By: Claude Opus 4.6 --- src/components/WorkflowCanvas.tsx | 58 +++++++------------ src/components/nodes/FloatingNodeHeader.tsx | 64 +++++++++++---------- 2 files changed, 54 insertions(+), 68 deletions(-) diff --git a/src/components/WorkflowCanvas.tsx b/src/components/WorkflowCanvas.tsx index 520bbf70..8706e915 100644 --- a/src/components/WorkflowCanvas.tsx +++ b/src/components/WorkflowCanvas.tsx @@ -57,7 +57,6 @@ import { GroupBackgroundsPortal, GroupControlsOverlay } from "./GroupsOverlay"; import { NodeType, NanoBananaNodeData, HandleType } from "@/types"; import { defaultNodeDimensions } from "@/store/utils/nodeDefaults"; import { FloatingNodeHeader } from "./nodes/FloatingNodeHeader"; -import { ProviderBadge } from "./nodes/ProviderBadge"; import { ControlPanel } from "./nodes/ControlPanel"; import { detectAndSplitGrid } from "@/utils/gridSplitter"; import { logger } from "@/utils/logger"; @@ -366,14 +365,6 @@ export function WorkflowCanvas() { return NODE_TITLES[node.type || ""] || "Node"; }, []); - // Helper to get title prefix (provider badge for generate/LLM nodes) - const getNodeTitlePrefix = useCallback((node: Node): React.ReactNode => { - const provider = (node.data as any)?.selectedModel?.provider; - if (provider) { - return ; - } - return null; - }, []); // Wire comment/title change callbacks for FloatingNodeHeaders const handleCustomTitleChange = useCallback((nodeId: string, title: string) => { @@ -384,33 +375,22 @@ export function WorkflowCanvas() { updateNodeData(nodeId, { comment: comment || undefined }); }, [updateNodeData]); - // Create onRun callback for runnable nodes - const getOnRun = useCallback((nodeId: string, nodeType: string) => { - const runnableTypes = ['nanoBanana', 'generateVideo', 'generate3d', 'generateAudio', 'llmGenerate']; - if (runnableTypes.includes(nodeType)) { - return () => regenerateNode(nodeId); - } - return undefined; + // Stable callback for running a node from its header + const handleRunNode = useCallback((nodeId: string) => { + regenerateNode(nodeId); }, [regenerateNode]); - // Create onExpand callback for expandable nodes - const getOnExpand = useCallback((nodeId: string, nodeType: string) => { + // Stable callback for expanding a node from its header + const handleExpandNode = useCallback((nodeId: string, nodeType: string) => { if (nodeType === 'annotation') { - // Annotation uses annotationStore's openModal - return () => { - const node = getNodeById(nodeId); - if (!node) return; - const imageToEdit = (node.data as any)?.outputImage || (node.data as any)?.image; - if (!imageToEdit) return; - openAnnotationModal(nodeId, imageToEdit, (node.data as any)?.annotations); - }; - } - - const expandableTypes = ['prompt', 'promptConstructor', 'splitGrid']; - if (expandableTypes.includes(nodeType)) { - return () => setExpandingNode({ id: nodeId, type: nodeType }); + const node = getNodeById(nodeId); + if (!node) return; + const imageToEdit = (node.data as any)?.outputImage || (node.data as any)?.image; + if (!imageToEdit) return; + openAnnotationModal(nodeId, imageToEdit, (node.data as any)?.annotations); + } else { + setExpandingNode({ id: nodeId, type: nodeType }); } - return undefined; }, [getNodeById, openAnnotationModal]); @@ -2072,18 +2052,20 @@ export function WorkflowCanvas() { key={`header-${node.id}`} id={node.id} type={node.type as NodeType} - data={node.data} + isInLockedGroup={!!(node.data as any)?.isInLockedGroup} + isExecuting={!!(node.data as any)?.isExecuting} + focusedCommentNodeId={(node.data as any)?.focusedCommentNodeId} position={node.position} width={headerWidth} selected={!!node.selected} title={getNodeTitle(node)} customTitle={node.data?.customTitle} comment={node.data?.comment} - titlePrefix={getNodeTitlePrefix(node)} - onCustomTitleChange={(title) => handleCustomTitleChange(node.id, title)} - onCommentChange={(comment) => handleCommentChange(node.id, comment)} - onRun={getOnRun(node.id, node.type as string)} - onExpand={getOnExpand(node.id, node.type as string)} + provider={(node.data as any)?.selectedModel?.provider} + onCustomTitleChange={handleCustomTitleChange} + onCommentChange={handleCommentChange} + onRunNode={handleRunNode} + onExpandNode={handleExpandNode} /> ); })} diff --git a/src/components/nodes/FloatingNodeHeader.tsx b/src/components/nodes/FloatingNodeHeader.tsx index acb41238..bd5cb5d6 100644 --- a/src/components/nodes/FloatingNodeHeader.tsx +++ b/src/components/nodes/FloatingNodeHeader.tsx @@ -1,11 +1,12 @@ "use client"; -import { ReactNode, useState, useEffect, useRef, useCallback } from "react"; +import { ReactNode, useState, useEffect, useRef, useCallback, memo } from "react"; import { createPortal } from "react-dom"; import { useReactFlow } from "@xyflow/react"; -import { NodeType } from "@/types"; +import { NodeType, ProviderType } from "@/types"; import { useWorkflowStore } from "@/store/workflowStore"; import { defaultNodeDimensions } from "@/store/utils/nodeDefaults"; +import { ProviderBadge } from "./ProviderBadge"; export interface CommentNavigationProps { currentIndex: number; @@ -14,38 +15,45 @@ export interface CommentNavigationProps { onNext: () => void; } +const RUNNABLE_TYPES = new Set(['nanoBanana', 'generateVideo', 'generate3d', 'generateAudio', 'llmGenerate']); +const EXPANDABLE_TYPES = new Set(['prompt', 'promptConstructor', 'splitGrid', 'annotation']); + interface FloatingNodeHeaderProps { id: string; type: NodeType; - data: any; + isInLockedGroup?: boolean; + isExecuting?: boolean; + focusedCommentNodeId?: string | null; position: { x: number; y: number }; width: number; selected: boolean; - onExpand?: () => void; - onRun?: () => void; + onExpandNode?: (nodeId: string, nodeType: string) => void; + onRunNode?: (nodeId: string) => void; headerAction?: ReactNode; headerButtons?: ReactNode; - titlePrefix?: ReactNode; + provider?: ProviderType; title: string; customTitle?: string; comment?: string; - onCustomTitleChange?: (title: string) => void; - onCommentChange?: (comment: string) => void; + onCustomTitleChange?: (nodeId: string, title: string) => void; + onCommentChange?: (nodeId: string, comment: string) => void; commentNavigation?: CommentNavigationProps; } -export function FloatingNodeHeader({ +export const FloatingNodeHeader = memo(function FloatingNodeHeader({ id, type, - data, + isInLockedGroup = false, + isExecuting = false, + focusedCommentNodeId, position, width, selected, - onExpand, - onRun, + onExpandNode, + onRunNode, headerAction, headerButtons, - titlePrefix, + provider, title, customTitle, comment, @@ -53,6 +61,8 @@ export function FloatingNodeHeader({ onCommentChange, commentNavigation, }: FloatingNodeHeaderProps) { + const canRun = RUNNABLE_TYPES.has(type); + const canExpand = EXPANDABLE_TYPES.has(type); const [isHeaderHovered, setIsHeaderHovered] = useState(false); const isBodyHovered = useWorkflowStore((state) => state.hoveredNodeId === id); const isHovered = isHeaderHovered || isBodyHovered; @@ -68,14 +78,8 @@ export function FloatingNodeHeader({ const commentButtonRef = useRef(null); const tooltipRef = useRef(null); - // Check if this node is in a locked group - const isInLockedGroup = data?.isInLockedGroup || false; - // Check if comment is focused for navigation - const isCommentFocused = data?.focusedCommentNodeId === id; - - // Check if node is executing - const isExecuting = data?.isExecuting || false; + const isCommentFocused = focusedCommentNodeId === id; // Sync state with props useEffect(() => { @@ -133,10 +137,10 @@ export function FloatingNodeHeader({ const handleTitleSubmit = useCallback(() => { const trimmed = editTitleValue.trim(); if (trimmed !== (customTitle || "")) { - onCustomTitleChange?.(trimmed); + onCustomTitleChange?.(id, trimmed); } setIsEditingTitle(false); - }, [editTitleValue, customTitle, onCustomTitleChange]); + }, [editTitleValue, customTitle, onCustomTitleChange, id]); const handleTitleKeyDown = useCallback( (e: React.KeyboardEvent) => { @@ -154,10 +158,10 @@ export function FloatingNodeHeader({ const handleCommentSubmit = useCallback(() => { const trimmed = editCommentValue.trim(); if (trimmed !== (comment || "")) { - onCommentChange?.(trimmed); + onCommentChange?.(id, trimmed); } setIsEditingComment(false); - }, [editCommentValue, comment, onCommentChange]); + }, [editCommentValue, comment, onCommentChange, id]); const handleCommentKeyDown = useCallback( (e: React.KeyboardEvent) => { @@ -342,7 +346,7 @@ export function FloatingNodeHeader({ > {/* Title Section */}
- {titlePrefix} + {provider && } {isEditingTitle ? ( {/* Expand Button */} - {onExpand && ( + {canExpand && onExpandNode && (
); -} +});