Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState, type ReactNode } from 'react';
import { memo, useState, type ReactNode } from 'react';
import { Handle, Position, NodeProps } from 'reactflow';
import {
Shield,
Expand Down Expand Up @@ -72,7 +72,7 @@ function PanelButton({
);
}

export function CustomNode({ data }: NodeProps) {
function CustomNodeComponent({ data }: NodeProps) {
const [isHovered, setIsHovered] = useState(false);
const { onNavigateToDetailedArch } = useDiagramActions();

Expand Down Expand Up @@ -400,4 +400,6 @@ export function CustomNode({ data }: NodeProps) {
)}
</div>
);
};
}

export const CustomNode = memo(CustomNodeComponent);
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { useState, useCallback } from 'react';
import { memo, useState, useCallback } from 'react';
import { EdgeProps, getBezierPath, EdgeLabelRenderer, useStore } from 'reactflow';
import { getEdgeParams } from './utils/floatingEdges.js';
import { EdgeBadge, EdgeTooltip, getBadgeStyle } from './edge-components/index.js';
import type { EdgeData } from '../../contracts/contracts.js';

export function FloatingEdge({
function FloatingEdgeComponent({
id,
source,
target,
Expand Down Expand Up @@ -116,7 +116,9 @@ export function FloatingEdge({
)}
</>
);
};
}

export const FloatingEdge = memo(FloatingEdgeComponent);

/**
* Calculate offset positions for bidirectional edges
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { memo } from 'react';
import { NodeProps, Handle, Position } from 'reactflow';
import { THEME } from './theme';

export function SystemGroupNode({ data }: NodeProps) {
function SystemGroupNodeComponent({ data }: NodeProps) {
return (
<div
style={{
Expand Down Expand Up @@ -37,4 +38,6 @@ export function SystemGroupNode({ data }: NodeProps) {
</div>
</div>
);
};
}

export const SystemGroupNode = memo(SystemGroupNodeComponent);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(non-blocking nit) DecisionGroupNode is registered alongside these in PatternGraph.tsx and DiffGraph.tsx but isn't wrapped; worth memoising it too for consistency.

Original file line number Diff line number Diff line change
Expand Up @@ -88,29 +88,28 @@ export function useGraphInteractions({
const handleNodeMouseEnter = useCallback(
(_event: React.MouseEvent, node: Node) => {
setNodes((nds) =>
nds.map((n) => ({
...n,
style: {
...n.style,
zIndex: n.id === node.id && !isGroupType(n.type) ? 1000
: isGroupType(n.type) ? -1
: 1,
},
}))
nds.map((n) => {
const zIndex = n.id === node.id && !isGroupType(n.type) ? 1000
: isGroupType(n.type) ? -1
: 1;
// Only allocate a new node object when the value actually
// changes, so memoized node components (CustomNode,
// SystemGroupNode) don't re-render for untouched nodes.
Comment on lines +95 to +97

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(non-blocking nit) The mechanism is one layer up: ReactFlow's own memo(NodeWrapper) compares style by reference, so the identity return is what stops every node re-rendering, and the component memo()s are a second layer.

Suggested change
// Only allocate a new node object when the value actually
// changes, so memoized node components (CustomNode,
// SystemGroupNode) don't re-render for untouched nodes.
// Return the untouched node as-is: ReactFlow's memoised
// NodeWrapper compares `style` by reference, so a fresh
// object here re-renders every node on each hover.

if (n.style?.zIndex === zIndex) return n;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reference-stability of untouched nodes is the whole optimisation, but nothing pins it: the hook tests don't cover the hover handlers, and the checklist says tests were added but the diff has none. This block in useGraphInteractions.test.ts fails on main and passes on this branch:

describe('useGraphInteractions hover z-index', () => {
    const hoverNodes: Node[] = [
        { id: 'a', type: 'custom', position: { x: 0, y: 0 }, data: {}, style: { zIndex: 1 } },
        { id: 'b', type: 'custom', position: { x: 0, y: 0 }, data: {}, style: { zIndex: 1 } },
        { id: 'g', type: 'group', position: { x: 0, y: 0 }, data: {}, style: { zIndex: -1 } },
    ];

    function setupHover() {
        let updated: Node[] = [];
        const setNodes = vi.fn((updater: (nodes: Node[]) => Node[]) => {
            updated = updater(hoverNodes);
        });
        const { result } = renderHook(() =>
            useGraphInteractions({ setNodes, onNodesChangeBase: vi.fn(), groupNodeTypes: ['group'] })
        );
        return { result, getUpdated: () => updated };
    }

    it('elevates only the hovered node and keeps other node references stable', () => {
        const { result, getUpdated } = setupHover();
        result.current.handleNodeMouseEnter({} as React.MouseEvent, hoverNodes[0]);
        const [a, b, g] = getUpdated();
        expect(a.style?.zIndex).toBe(1000);
        expect(a).not.toBe(hoverNodes[0]);
        expect(b).toBe(hoverNodes[1]);
        expect(g).toBe(hoverNodes[2]);
    });

    it('resets only the elevated node on mouse leave', () => {
        const { result, getUpdated } = setupHover();
        result.current.handleNodeMouseEnter({} as React.MouseEvent, hoverNodes[0]);
        const elevated = getUpdated();
        const setNodes = vi.fn((updater: (nodes: Node[]) => Node[]) => updater(elevated));
        const { result: r2 } = renderHook(() =>
            useGraphInteractions({ setNodes, onNodesChangeBase: vi.fn(), groupNodeTypes: ['group'] })
        );
        r2.current.handleNodeMouseLeave();
        const [a, b, g] = setNodes.mock.results[0].value as Node[];
        expect(a.style?.zIndex).toBe(1);
        expect(b).toBe(elevated[1]);
        expect(g).toBe(elevated[2]);
    });
});

return { ...n, style: { ...n.style, zIndex } };
})
);
},
[setNodes, isGroupType]
);

const handleNodeMouseLeave = useCallback(() => {
setNodes((nds) =>
nds.map((n) => ({
...n,
style: {
...n.style,
zIndex: isGroupType(n.type) ? -1 : 1,
},
}))
nds.map((n) => {
const zIndex = isGroupType(n.type) ? -1 : 1;
if (n.style?.zIndex === zIndex) return n;
return { ...n, style: { ...n.style, zIndex } };
})
);
}, [setNodes, isGroupType]);

Expand Down
Loading