Browse Source

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 <noreply@anthropic.com>
handoff-20260429-1057
shrimbly 4 months ago
parent
commit
3a60d15260
  1. 48
      src/components/WorkflowCanvas.tsx
  2. 64
      src/components/nodes/FloatingNodeHeader.tsx

48
src/components/WorkflowCanvas.tsx

@ -57,7 +57,6 @@ import { GroupBackgroundsPortal, GroupControlsOverlay } from "./GroupsOverlay";
import { NodeType, NanoBananaNodeData, HandleType } from "@/types"; import { NodeType, NanoBananaNodeData, HandleType } from "@/types";
import { defaultNodeDimensions } from "@/store/utils/nodeDefaults"; import { defaultNodeDimensions } from "@/store/utils/nodeDefaults";
import { FloatingNodeHeader } from "./nodes/FloatingNodeHeader"; import { FloatingNodeHeader } from "./nodes/FloatingNodeHeader";
import { ProviderBadge } from "./nodes/ProviderBadge";
import { ControlPanel } from "./nodes/ControlPanel"; import { ControlPanel } from "./nodes/ControlPanel";
import { detectAndSplitGrid } from "@/utils/gridSplitter"; import { detectAndSplitGrid } from "@/utils/gridSplitter";
import { logger } from "@/utils/logger"; import { logger } from "@/utils/logger";
@ -366,14 +365,6 @@ export function WorkflowCanvas() {
return NODE_TITLES[node.type || ""] || "Node"; 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 <ProviderBadge provider={provider} />;
}
return null;
}, []);
// Wire comment/title change callbacks for FloatingNodeHeaders // Wire comment/title change callbacks for FloatingNodeHeaders
const handleCustomTitleChange = useCallback((nodeId: string, title: string) => { const handleCustomTitleChange = useCallback((nodeId: string, title: string) => {
@ -384,33 +375,22 @@ export function WorkflowCanvas() {
updateNodeData(nodeId, { comment: comment || undefined }); updateNodeData(nodeId, { comment: comment || undefined });
}, [updateNodeData]); }, [updateNodeData]);
// Create onRun callback for runnable nodes // Stable callback for running a node from its header
const getOnRun = useCallback((nodeId: string, nodeType: string) => { const handleRunNode = useCallback((nodeId: string) => {
const runnableTypes = ['nanoBanana', 'generateVideo', 'generate3d', 'generateAudio', 'llmGenerate']; regenerateNode(nodeId);
if (runnableTypes.includes(nodeType)) {
return () => regenerateNode(nodeId);
}
return undefined;
}, [regenerateNode]); }, [regenerateNode]);
// Create onExpand callback for expandable nodes // Stable callback for expanding a node from its header
const getOnExpand = useCallback((nodeId: string, nodeType: string) => { const handleExpandNode = useCallback((nodeId: string, nodeType: string) => {
if (nodeType === 'annotation') { if (nodeType === 'annotation') {
// Annotation uses annotationStore's openModal
return () => {
const node = getNodeById(nodeId); const node = getNodeById(nodeId);
if (!node) return; if (!node) return;
const imageToEdit = (node.data as any)?.outputImage || (node.data as any)?.image; const imageToEdit = (node.data as any)?.outputImage || (node.data as any)?.image;
if (!imageToEdit) return; if (!imageToEdit) return;
openAnnotationModal(nodeId, imageToEdit, (node.data as any)?.annotations); openAnnotationModal(nodeId, imageToEdit, (node.data as any)?.annotations);
}; } else {
} setExpandingNode({ id: nodeId, type: nodeType });
const expandableTypes = ['prompt', 'promptConstructor', 'splitGrid'];
if (expandableTypes.includes(nodeType)) {
return () => setExpandingNode({ id: nodeId, type: nodeType });
} }
return undefined;
}, [getNodeById, openAnnotationModal]); }, [getNodeById, openAnnotationModal]);
@ -2072,18 +2052,20 @@ export function WorkflowCanvas() {
key={`header-${node.id}`} key={`header-${node.id}`}
id={node.id} id={node.id}
type={node.type as NodeType} 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} position={node.position}
width={headerWidth} width={headerWidth}
selected={!!node.selected} selected={!!node.selected}
title={getNodeTitle(node)} title={getNodeTitle(node)}
customTitle={node.data?.customTitle} customTitle={node.data?.customTitle}
comment={node.data?.comment} comment={node.data?.comment}
titlePrefix={getNodeTitlePrefix(node)} provider={(node.data as any)?.selectedModel?.provider}
onCustomTitleChange={(title) => handleCustomTitleChange(node.id, title)} onCustomTitleChange={handleCustomTitleChange}
onCommentChange={(comment) => handleCommentChange(node.id, comment)} onCommentChange={handleCommentChange}
onRun={getOnRun(node.id, node.type as string)} onRunNode={handleRunNode}
onExpand={getOnExpand(node.id, node.type as string)} onExpandNode={handleExpandNode}
/> />
); );
})} })}

64
src/components/nodes/FloatingNodeHeader.tsx

@ -1,11 +1,12 @@
"use client"; "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 { createPortal } from "react-dom";
import { useReactFlow } from "@xyflow/react"; import { useReactFlow } from "@xyflow/react";
import { NodeType } from "@/types"; import { NodeType, ProviderType } from "@/types";
import { useWorkflowStore } from "@/store/workflowStore"; import { useWorkflowStore } from "@/store/workflowStore";
import { defaultNodeDimensions } from "@/store/utils/nodeDefaults"; import { defaultNodeDimensions } from "@/store/utils/nodeDefaults";
import { ProviderBadge } from "./ProviderBadge";
export interface CommentNavigationProps { export interface CommentNavigationProps {
currentIndex: number; currentIndex: number;
@ -14,38 +15,45 @@ export interface CommentNavigationProps {
onNext: () => void; onNext: () => void;
} }
const RUNNABLE_TYPES = new Set(['nanoBanana', 'generateVideo', 'generate3d', 'generateAudio', 'llmGenerate']);
const EXPANDABLE_TYPES = new Set(['prompt', 'promptConstructor', 'splitGrid', 'annotation']);
interface FloatingNodeHeaderProps { interface FloatingNodeHeaderProps {
id: string; id: string;
type: NodeType; type: NodeType;
data: any; isInLockedGroup?: boolean;
isExecuting?: boolean;
focusedCommentNodeId?: string | null;
position: { x: number; y: number }; position: { x: number; y: number };
width: number; width: number;
selected: boolean; selected: boolean;
onExpand?: () => void; onExpandNode?: (nodeId: string, nodeType: string) => void;
onRun?: () => void; onRunNode?: (nodeId: string) => void;
headerAction?: ReactNode; headerAction?: ReactNode;
headerButtons?: ReactNode; headerButtons?: ReactNode;
titlePrefix?: ReactNode; provider?: ProviderType;
title: string; title: string;
customTitle?: string; customTitle?: string;
comment?: string; comment?: string;
onCustomTitleChange?: (title: string) => void; onCustomTitleChange?: (nodeId: string, title: string) => void;
onCommentChange?: (comment: string) => void; onCommentChange?: (nodeId: string, comment: string) => void;
commentNavigation?: CommentNavigationProps; commentNavigation?: CommentNavigationProps;
} }
export function FloatingNodeHeader({ export const FloatingNodeHeader = memo(function FloatingNodeHeader({
id, id,
type, type,
data, isInLockedGroup = false,
isExecuting = false,
focusedCommentNodeId,
position, position,
width, width,
selected, selected,
onExpand, onExpandNode,
onRun, onRunNode,
headerAction, headerAction,
headerButtons, headerButtons,
titlePrefix, provider,
title, title,
customTitle, customTitle,
comment, comment,
@ -53,6 +61,8 @@ export function FloatingNodeHeader({
onCommentChange, onCommentChange,
commentNavigation, commentNavigation,
}: FloatingNodeHeaderProps) { }: FloatingNodeHeaderProps) {
const canRun = RUNNABLE_TYPES.has(type);
const canExpand = EXPANDABLE_TYPES.has(type);
const [isHeaderHovered, setIsHeaderHovered] = useState(false); const [isHeaderHovered, setIsHeaderHovered] = useState(false);
const isBodyHovered = useWorkflowStore((state) => state.hoveredNodeId === id); const isBodyHovered = useWorkflowStore((state) => state.hoveredNodeId === id);
const isHovered = isHeaderHovered || isBodyHovered; const isHovered = isHeaderHovered || isBodyHovered;
@ -68,14 +78,8 @@ export function FloatingNodeHeader({
const commentButtonRef = useRef<HTMLButtonElement>(null); const commentButtonRef = useRef<HTMLButtonElement>(null);
const tooltipRef = useRef<HTMLDivElement>(null); const tooltipRef = useRef<HTMLDivElement>(null);
// Check if this node is in a locked group
const isInLockedGroup = data?.isInLockedGroup || false;
// Check if comment is focused for navigation // Check if comment is focused for navigation
const isCommentFocused = data?.focusedCommentNodeId === id; const isCommentFocused = focusedCommentNodeId === id;
// Check if node is executing
const isExecuting = data?.isExecuting || false;
// Sync state with props // Sync state with props
useEffect(() => { useEffect(() => {
@ -133,10 +137,10 @@ export function FloatingNodeHeader({
const handleTitleSubmit = useCallback(() => { const handleTitleSubmit = useCallback(() => {
const trimmed = editTitleValue.trim(); const trimmed = editTitleValue.trim();
if (trimmed !== (customTitle || "")) { if (trimmed !== (customTitle || "")) {
onCustomTitleChange?.(trimmed); onCustomTitleChange?.(id, trimmed);
} }
setIsEditingTitle(false); setIsEditingTitle(false);
}, [editTitleValue, customTitle, onCustomTitleChange]); }, [editTitleValue, customTitle, onCustomTitleChange, id]);
const handleTitleKeyDown = useCallback( const handleTitleKeyDown = useCallback(
(e: React.KeyboardEvent) => { (e: React.KeyboardEvent) => {
@ -154,10 +158,10 @@ export function FloatingNodeHeader({
const handleCommentSubmit = useCallback(() => { const handleCommentSubmit = useCallback(() => {
const trimmed = editCommentValue.trim(); const trimmed = editCommentValue.trim();
if (trimmed !== (comment || "")) { if (trimmed !== (comment || "")) {
onCommentChange?.(trimmed); onCommentChange?.(id, trimmed);
} }
setIsEditingComment(false); setIsEditingComment(false);
}, [editCommentValue, comment, onCommentChange]); }, [editCommentValue, comment, onCommentChange, id]);
const handleCommentKeyDown = useCallback( const handleCommentKeyDown = useCallback(
(e: React.KeyboardEvent) => { (e: React.KeyboardEvent) => {
@ -342,7 +346,7 @@ export function FloatingNodeHeader({
> >
{/* Title Section */} {/* Title Section */}
<div className="flex-1 min-w-0 flex items-center gap-1.5 pl-2"> <div className="flex-1 min-w-0 flex items-center gap-1.5 pl-2">
{titlePrefix} {provider && <ProviderBadge provider={provider} />}
{isEditingTitle ? ( {isEditingTitle ? (
<input <input
ref={titleInputRef} ref={titleInputRef}
@ -487,10 +491,10 @@ export function FloatingNodeHeader({
</div> </div>
{/* Expand Button */} {/* Expand Button */}
{onExpand && ( {canExpand && onExpandNode && (
<div className="relative shrink-0 group"> <div className="relative shrink-0 group">
<button <button
onClick={onExpand} onClick={() => onExpandNode(id, type)}
className="nodrag nopan p-0.5 rounded transition-all duration-200 ease-in-out text-neutral-500 group-hover:text-neutral-200 border border-neutral-600 flex items-center overflow-hidden group-hover:pr-2" className="nodrag nopan p-0.5 rounded transition-all duration-200 ease-in-out text-neutral-500 group-hover:text-neutral-200 border border-neutral-600 flex items-center overflow-hidden group-hover:pr-2"
title="Expand editor" title="Expand editor"
> >
@ -516,10 +520,10 @@ export function FloatingNodeHeader({
)} )}
{/* Run Button */} {/* Run Button */}
{onRun && ( {canRun && onRunNode && (
<div className="relative shrink-0 group"> <div className="relative shrink-0 group">
<button <button
onClick={onRun} onClick={() => onRunNode(id)}
disabled={isExecuting} disabled={isExecuting}
className="nodrag nopan p-0.5 rounded transition-all duration-200 ease-in-out text-neutral-500 group-hover:text-neutral-200 border border-neutral-600 flex items-center overflow-hidden group-hover:pr-2 disabled:opacity-50 disabled:cursor-not-allowed" className="nodrag nopan p-0.5 rounded transition-all duration-200 ease-in-out text-neutral-500 group-hover:text-neutral-200 border border-neutral-600 flex items-center overflow-hidden group-hover:pr-2 disabled:opacity-50 disabled:cursor-not-allowed"
title="Run this node" title="Run this node"
@ -537,4 +541,4 @@ export function FloatingNodeHeader({
</div> </div>
</div> </div>
); );
} });

Loading…
Cancel
Save