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. 58
      src/components/WorkflowCanvas.tsx
  2. 64
      src/components/nodes/FloatingNodeHeader.tsx

58
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 <ProviderBadge provider={provider} />;
}
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}
/>
);
})}

64
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<HTMLButtonElement>(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
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 */}
<div className="flex-1 min-w-0 flex items-center gap-1.5 pl-2">
{titlePrefix}
{provider && <ProviderBadge provider={provider} />}
{isEditingTitle ? (
<input
ref={titleInputRef}
@ -487,10 +491,10 @@ export function FloatingNodeHeader({
</div>
{/* Expand Button */}
{onExpand && (
{canExpand && onExpandNode && (
<div className="relative shrink-0 group">
<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"
title="Expand editor"
>
@ -516,10 +520,10 @@ export function FloatingNodeHeader({
)}
{/* Run Button */}
{onRun && (
{canRun && onRunNode && (
<div className="relative shrink-0 group">
<button
onClick={onRun}
onClick={() => onRunNode(id)}
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"
title="Run this node"
@ -537,4 +541,4 @@ export function FloatingNodeHeader({
</div>
</div>
);
}
});

Loading…
Cancel
Save