diff --git a/packages/apollo-react/package.json b/packages/apollo-react/package.json index e4354e9ac8..882a632207 100644 --- a/packages/apollo-react/package.json +++ b/packages/apollo-react/package.json @@ -149,6 +149,7 @@ "test:watch": "vitest", "test:ui": "vitest --ui", "test:coverage": "pnpm run i18n:compile && vitest --run --coverage", + "bench": "pnpm run i18n:compile && vitest bench --run", "clean": "rm -rf dist", "i18n:extract": "lingui extract", "i18n:extract:watch": "lingui extract --watch", diff --git a/packages/apollo-react/rslib.config.ts b/packages/apollo-react/rslib.config.ts index d1999eae4c..7f662d0a92 100644 --- a/packages/apollo-react/rslib.config.ts +++ b/packages/apollo-react/rslib.config.ts @@ -53,6 +53,8 @@ export default defineConfig({ index: [ './src/**', '!./src/**/*.test.{ts,tsx}', + '!./src/**/*.bench.{ts,tsx}', + '!./src/**/*.perf-fixtures.{ts,tsx}', '!./src/**/*.stories.{ts,tsx}', '!./src/**/storybook-utils/**', '!./src/test/**', diff --git a/packages/apollo-react/src/canvas/components/BaseCanvas/ConnectedHandlesContext.perf.test.tsx b/packages/apollo-react/src/canvas/components/BaseCanvas/ConnectedHandlesContext.perf.test.tsx new file mode 100644 index 0000000000..2b6e77f6aa --- /dev/null +++ b/packages/apollo-react/src/canvas/components/BaseCanvas/ConnectedHandlesContext.perf.test.tsx @@ -0,0 +1,133 @@ +/** + * ConnectedHandlesContext subscription-isolation regression tests at 500-node scale. + * + * BaseNode relies on this store for O(1) connected-handle lookups with granular + * per-node notifications. If the store ever regressed to notifying all + * subscribers (or the provider re-rendered its subtree on every edge change), + * every edge edit would re-render all 500 nodes. These tests pin the contract: + * + * 1. An edge change notifies ONLY the nodes it touches. + * 2. A new edges array with identical content notifies nobody (set reuse). + * 3. Removing an edge notifies only the previously-connected nodes. + */ +import { render } from '@testing-library/react'; +import type { Edge } from '@uipath/apollo-react/canvas/xyflow/react'; +import { memo, useMemo } from 'react'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { ConnectedHandlesProvider, useConnectedHandles } from './ConnectedHandlesContext'; + +const N = 500; + +const renderCounts = new Map(); +const snapshots = new Map>(); + +const Probe = memo(({ nodeId }: { nodeId: string }) => { + const handles = useConnectedHandles(nodeId); + renderCounts.set(nodeId, (renderCounts.get(nodeId) ?? 0) + 1); + snapshots.set(nodeId, handles); + return null; +}); + +// The probes subtree is memoized so provider re-renders (new edges prop) reach +// subscribers only through the store, mirroring how memoized BaseNodes behave +// inside ReactFlow. +const Harness = ({ edges }: { edges: Edge[] }) => { + const probes = useMemo( + () => Array.from({ length: N }, (_, i) => ), + [] + ); + return {probes}; +}; + +/** Chain edges: node-i.out → node-(i+1).in for the first `count` nodes. */ +const makeChainEdges = (count: number): Edge[] => + Array.from({ length: count - 1 }, (_, i) => ({ + id: `e${i}`, + source: `node-${i}`, + sourceHandle: 'out', + target: `node-${i + 1}`, + targetHandle: 'in', + })); + +const countOf = (i: number) => renderCounts.get(`node-${i}`) ?? 0; +const totalRenders = () => [...renderCounts.values()].reduce((a, b) => a + b, 0); + +describe('ConnectedHandlesContext @ 500 nodes: granular notification guards', () => { + beforeEach(() => { + renderCounts.clear(); + snapshots.clear(); + }); + + it('adding one edge notifies only the two nodes it connects', () => { + const edges = makeChainEdges(N); + const { rerender } = render(); + const baseline = totalRenders(); + const node0Before = countOf(0); + const node499Before = countOf(499); + + // Connect node-0 and node-499 with fresh handle ids so both sets change. + const added: Edge[] = [ + ...edges, + { + id: 'e-new', + source: 'node-0', + sourceHandle: 'out-secondary', + target: 'node-499', + targetHandle: 'in-secondary', + }, + ]; + rerender(); + + expect(countOf(0)).toBe(node0Before + 1); + expect(countOf(499)).toBe(node499Before + 1); + // Exactly two subscribers re-rendered; the other 498 were untouched. + expect(totalRenders()).toBe(baseline + 2); + }); + + it('a new edges array with identical content notifies nobody', () => { + const edges = makeChainEdges(N); + const { rerender } = render(); + const baseline = totalRenders(); + + rerender( ({ ...e }))} />); + + expect(totalRenders()).toBe(baseline); + }); + + it('removing one edge notifies only the nodes that lose a connection', () => { + const edges = makeChainEdges(N); + const { rerender } = render(); + const baseline = totalRenders(); + const node250Before = countOf(250); + const node251Before = countOf(251); + + // Drop e250 (node-250.out → node-251.in). Both endpoints lose a handle. + rerender( e.id !== 'e250')} />); + + expect(countOf(250)).toBe(node250Before + 1); + expect(countOf(251)).toBe(node251Before + 1); + expect(totalRenders()).toBe(baseline + 2); + expect(snapshots.get('node-250')?.has('out')).toBe(false); + // node-250 still receives via its `in` handle from e249. + expect(snapshots.get('node-250')?.has('in')).toBe(true); + }); + + it('snapshot identity is stable for untouched nodes across edge updates', () => { + const edges = makeChainEdges(N); + const { rerender } = render(); + const before = snapshots.get('node-100'); + + rerender( + + ); + + // Even if node-100 re-rendered for some other reason, its snapshot must be + // the exact same Set instance (useSyncExternalStore identity guarantee). + expect(snapshots.get('node-100')).toBe(before); + }); +}); diff --git a/packages/apollo-react/src/canvas/components/BaseNode/BaseNode.bench.tsx b/packages/apollo-react/src/canvas/components/BaseNode/BaseNode.bench.tsx new file mode 100644 index 0000000000..95de68f6dc --- /dev/null +++ b/packages/apollo-react/src/canvas/components/BaseNode/BaseNode.bench.tsx @@ -0,0 +1,113 @@ +/** + * BaseNode component benchmarks at 500-node scale. + * + * Run with: pnpm --filter @uipath/apollo-react bench + * (or from packages/apollo-react: pnpm bench) + * + * Measures the BaseNode render pipeline itself (manifest resolution, memo + * comparison, geometry vars, handle elements) with xyflow store hooks stubbed, + * so numbers isolate OUR per-node cost from xyflow's internals. Baselines on a + * dev container (happy-dom, 2025-class CI hardware): + * + * - mount of 500 nodes should stay well under ~2s + * - a position-only re-render sweep across 500 nodes should stay in the + * low-millisecond range (memo comparator fast path, no body renders) + * - single-node interaction updates must not scale with node count + * + * These are benchmarks, not CI-gating tests; the deterministic render-count + * guards live in BaseNode.perf.test.tsx. + */ +import { render } from '@testing-library/react'; +import { bench, vi } from 'vitest'; + +const { rfNodeStore, rfState } = vi.hoisted(() => ({ + rfNodeStore: new Map(), + rfState: { current: { connection: { inProgress: false } } }, +})); + +vi.mock('@uipath/apollo-react/canvas/xyflow/react', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + Handle: ({ id, type }: { id?: string; type?: string }) => ( +
+ ), + useStore: (selector: (s: unknown) => unknown) => selector(rfState.current), + useUpdateNodeInternals: () => () => {}, + useReactFlow: () => ({ + updateNodeData: () => {}, + updateNode: (id: string, patch: { height?: number }) => { + rfNodeStore.set(id, { ...rfNodeStore.get(id), ...patch }); + }, + getNode: (id: string) => rfNodeStore.get(id), + }), + }; +}); + +vi.mock('@xyflow/react', async (importOriginal) => ({ + ...(await importOriginal()), + useNodesData: () => null, +})); + +import { makeNodes, NodeGrid, PERF_NODE_COUNT } from './BaseNode.perf-fixtures'; + +const baseNodes = makeNodes(PERF_NODE_COUNT); + +bench( + `mount ${PERF_NODE_COUNT} BaseNodes`, + () => { + const view = render(); + view.unmount(); + }, + { time: 0, iterations: 5, warmupIterations: 1 } +); + +bench( + 'mount 1 BaseNode (per-node cost floor)', + () => { + const view = render(); + view.unmount(); + }, + { time: 500 } +); + +{ + // Container-drag frame: all 500 nodes get new absolute positions. The memo + // comparator must swallow the sweep without rendering a single node body. + let view: ReturnType | null = null; + let offset = 0; + bench( + `position-only re-render sweep across ${PERF_NODE_COUNT} nodes (memo fast path)`, + () => { + if (!view) view = render(); + offset += 1; + view.rerender( + ({ + ...p, + positionAbsoluteX: p.positionAbsoluteX + offset, + }))} + /> + ); + }, + { time: 1000 } + ); +} + +{ + // Single-node interaction: toggle selection on one node in a 500-node canvas. + // Cost must track the one changed node, not the canvas size. + let view: ReturnType | null = null; + let selected = false; + bench( + `toggle selection of 1 node among ${PERF_NODE_COUNT}`, + () => { + if (!view) view = render(); + selected = !selected; + const next = [...baseNodes]; + next[0] = { ...baseNodes[0]!, selected }; + view.rerender(); + }, + { time: 1000 } + ); +} diff --git a/packages/apollo-react/src/canvas/components/BaseNode/BaseNode.perf-fixtures.tsx b/packages/apollo-react/src/canvas/components/BaseNode/BaseNode.perf-fixtures.tsx new file mode 100644 index 0000000000..9b8812cf4e --- /dev/null +++ b/packages/apollo-react/src/canvas/components/BaseNode/BaseNode.perf-fixtures.tsx @@ -0,0 +1,100 @@ +/** + * Shared fixtures for BaseNode performance tests and benchmarks. + * + * Builds a realistic 500-node scenario: a registered manifest with left/right + * handles, per-node instance data, and the real provider stack (registry, mode, + * override config). xyflow hooks are NOT mocked here — each test/bench file + * declares its own module mocks (vi.mock is per-file); these fixtures only + * provide data builders and the provider wrapper. + */ +import type { Node, NodeProps } from '@uipath/apollo-react/canvas/xyflow/react'; +import { NodeRegistryProvider } from '../../core'; +import type { NodeManifest } from '../../schema/node-definition'; +import { BaseCanvasModeProvider } from '../BaseCanvas/BaseCanvasModeProvider'; +import { BaseNode } from './BaseNode'; +import type { BaseNodeData } from './BaseNode.types'; +import { + type BaseNodeOverrideConfig, + BaseNodeOverrideConfigProvider, +} from './BaseNodeConfigContext'; + +export const PERF_NODE_TYPE = 'perf.task'; + +/** Default node count for scale scenarios: the canvas must support 500 nodes easily. */ +export const PERF_NODE_COUNT = 500; + +/** computedHeight for this manifest: 1 handle per side → floor 64 < default 96. */ +export const PERF_NODE_HEIGHT = 96; + +export const PERF_MANIFEST: NodeManifest = { + nodeType: PERF_NODE_TYPE, + version: '1.0.0', + tags: [], + sortOrder: 0, + display: { label: 'Task', shape: 'square' }, + handleConfiguration: [ + { + position: 'left', + handles: [{ id: 'in', type: 'target', handleType: 'input' }], + }, + { + position: 'right', + handles: [{ id: 'out', type: 'source', handleType: 'output', showButton: true }], + }, + ], +} as NodeManifest; + +const MANIFEST_BUNDLE = { nodes: [PERF_MANIFEST], categories: [] }; + +// Suppress the toolbar: the default design-mode toolbar would pull in xyflow's +// NodeToolbar (needs a real store). Toolbar resolution cost is still covered by +// unit benches; here we isolate the node body render path. +const OVERRIDES: BaseNodeOverrideConfig = { toolbarConfig: null }; + +export type PerfNodeProps = NodeProps>; + +export const makeNodeProps = ( + i: number, + overrides: Partial = {} +): PerfNodeProps => ({ + id: `node-${i}`, + type: PERF_NODE_TYPE, + data: { nodeType: PERF_NODE_TYPE, display: { label: `Node ${i}` } } as BaseNodeData, + selected: false, + dragging: false, + draggable: true, + zIndex: 0, + isConnectable: true, + positionAbsoluteX: (i % 25) * 160, + positionAbsoluteY: Math.floor(i / 25) * 160, + selectable: true, + deletable: true, + ...overrides, +}); + +export const makeNodes = (count: number): PerfNodeProps[] => + Array.from({ length: count }, (_, i) => makeNodeProps(i)); + +/** + * Renders the given nodes under the real provider stack. + * Pass `overrides` to exercise other config paths (e.g. `{}` to let the + * manifest-default toolbar resolve); it must be reference-stable across + * rerenders, exactly like production provider values. + */ +export const NodeGrid = ({ + nodes, + overrides = OVERRIDES, +}: { + nodes: PerfNodeProps[]; + overrides?: BaseNodeOverrideConfig; +}) => ( + + + + {nodes.map((p) => ( + + ))} + + + +); diff --git a/packages/apollo-react/src/canvas/components/BaseNode/BaseNode.perf.test.tsx b/packages/apollo-react/src/canvas/components/BaseNode/BaseNode.perf.test.tsx new file mode 100644 index 0000000000..f38fac66a6 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/BaseNode/BaseNode.perf.test.tsx @@ -0,0 +1,308 @@ +/** + * BaseNode render-count regression tests at 500-node scale. + * + * These tests guard the render-isolation invariants that make a 500-node + * canvas viable. They intentionally assert render COUNTS (deterministic) + * rather than wall-clock time (flaky in CI); timing lives in BaseNode.bench.tsx. + * + * Invariants guarded: + * 1. Mounting N nodes renders each node body exactly once (no cascades). + * 2. Absolute-position-only prop changes never re-render the node body + * (areNodePropsEqualIgnoringPosition wiring on the memo export). + * 3. Selecting / hovering / editing one node re-renders only that node. + * 4. The height write-back effect performs at most one store write per node + * and none when node.height is already correct (no write storms, no loops). + */ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const { + rfNodeStore, + rfState, + mockUpdateNode, + mockGetNode, + mockUpdateNodeInternals, + labelRenders, + resolveHandlesSpy, + toolbarCalls, +} = vi.hoisted(() => { + const rfNodeStore = new Map(); + return { + rfNodeStore, + rfState: { current: { connection: { inProgress: false } } }, + mockUpdateNode: vi.fn((id: string, patch: { height?: number }) => { + rfNodeStore.set(id, { ...rfNodeStore.get(id), ...patch }); + }), + mockGetNode: vi.fn((id: string) => rfNodeStore.get(id)), + mockUpdateNodeInternals: vi.fn(), + /** Render counter per node label — bumped by the NodeLabel stub below. */ + labelRenders: new Map(), + /** Holds the spy wrapping the REAL resolveHandles (installed by the mock below). */ + resolveHandlesSpy: { current: undefined as ReturnType | undefined }, + /** Props captured from every NodeToolbar render. */ + // biome-ignore lint/suspicious/noExplicitAny: captured toolbar props for assertions + toolbarCalls: [] as any[], + }; +}); + +// Selector-aware xyflow mock: BaseNode reads `useStore(selectIsConnecting)` and +// the store node via `getNode`/`updateNode`. The global canvas-mocks version is +// not selector-aware, so override it here. +vi.mock('@uipath/apollo-react/canvas/xyflow/react', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + // Real xyflow Handle requires a zustand store; a stub keeps ButtonHandles real + // while avoiding a full ReactFlow instance. + Handle: ({ id, type }: { id?: string; type?: string }) => ( +
+ ), + useStore: (selector: (s: unknown) => unknown) => selector(rfState.current), + useUpdateNodeInternals: () => mockUpdateNodeInternals, + useReactFlow: () => ({ + updateNodeData: vi.fn(), + updateNode: mockUpdateNode, + getNode: mockGetNode, + }), + }; +}); + +// useButtonHandles reads node data through the real @xyflow/react store hook; +// there is no ReactFlow store in these tests, so return null (hook falls back to {}). +vi.mock('@xyflow/react', async (importOriginal) => ({ + ...(await importOriginal()), + useNodesData: () => null, +})); + +// Replace the memoized NodeLabel with a counting stub: it renders exactly once +// per BaseNode body render, giving a per-node render counter keyed by label. +vi.mock('./NodeLabel', () => ({ + NodeLabel: ({ label }: { label?: string }) => { + const key = label ?? ''; + labelRenders.set(key, (labelRenders.get(key) ?? 0) + 1); + return
{label}
; + }, +})); + +// Wrap the REAL resolveHandles with a counting spy so tests can assert how many +// resolution passes a mount performs (guards against double resolution). +vi.mock('../../utils/manifest-resolver', async (importOriginal) => { + const actual = await importOriginal(); + const spy = vi.fn(actual.resolveHandles); + resolveHandlesSpy.current = spy; + return { ...actual, resolveHandles: spy }; +}); + +// Capture NodeToolbar props to assert toolbar-config identity stability. +vi.mock('../Toolbar', () => ({ + // biome-ignore lint/suspicious/noExplicitAny: captured toolbar props for assertions + NodeToolbar: (props: any) => { + toolbarCalls.push(props); + return null; + }, +})); + +import { makeNodeProps, makeNodes, NodeGrid, PERF_NODE_HEIGHT } from './BaseNode.perf-fixtures'; + +const N = 500; + +// Reference-stable empty overrides: lets the manifest-default toolbar resolve. +const EMPTY_OVERRIDES = {}; + +const rendersOf = (i: number) => labelRenders.get(`Node ${i}`) ?? 0; +const totalRenders = () => [...labelRenders.values()].reduce((a, b) => a + b, 0); + +describe('BaseNode @ 500 nodes: render isolation regression guards', () => { + beforeEach(() => { + labelRenders.clear(); + rfNodeStore.clear(); + rfState.current = { connection: { inProgress: false } }; + toolbarCalls.length = 0; + resolveHandlesSpy.current?.mockClear(); + }); + + afterEach(() => { + mockUpdateNode.mockClear(); + mockGetNode.mockClear(); + mockUpdateNodeInternals.mockClear(); + }); + + it('mounts 500 nodes with exactly one body render per node (no render cascades)', () => { + render(); + + expect(totalRenders()).toBe(N); + expect(screen.getAllByTestId('base-container')).toHaveLength(N); + }); + + it('height write-back performs exactly one store write per node on first mount', () => { + render(); + + // One write per node (height undefined → computed), never more. A second + // write for the same node would indicate the measure→write loop regressed. + expect(mockUpdateNode).toHaveBeenCalledTimes(N); + const writesPerNode = new Map(); + for (const [id] of mockUpdateNode.mock.calls) { + writesPerNode.set(id as string, (writesPerNode.get(id as string) ?? 0) + 1); + } + for (const [id, count] of writesPerNode) { + expect({ id, count }).toEqual({ id, count: 1 }); + } + // One internals recalculation per node on mount. + expect(mockUpdateNodeInternals).toHaveBeenCalledTimes(N); + }); + + it('performs zero height writes when node.height is already correct (seeded nodes)', () => { + for (let i = 0; i < N; i++) { + rfNodeStore.set(`node-${i}`, { height: PERF_NODE_HEIGHT }); + } + render(); + + expect(mockUpdateNode).not.toHaveBeenCalled(); + }); + + it('does not re-render any node body on absolute-position-only changes (container drag)', () => { + const nodes = makeNodes(N); + const { rerender } = render(); + expect(totalRenders()).toBe(N); + + // Simulate a container drag frame: every node gets a new absolute position. + const moved = nodes.map((p) => ({ + ...p, + positionAbsoluteX: p.positionAbsoluteX + 40, + positionAbsoluteY: p.positionAbsoluteY + 24, + })); + rerender(); + + // areNodePropsEqualIgnoringPosition must swallow all 500 updates. + expect(totalRenders()).toBe(N); + }); + + it('does not re-render any node body when props are value-identical', () => { + const nodes = makeNodes(N); + const { rerender } = render(); + + rerender(); + + expect(totalRenders()).toBe(N); + }); + + it('selecting one node re-renders exactly that node', () => { + const nodes = makeNodes(N); + const { rerender } = render(); + + const next = [...nodes]; + next[7] = { ...nodes[7]!, selected: true }; + rerender(); + + expect(rendersOf(7)).toBe(2); + expect(totalRenders()).toBe(N + 1); + }); + + it('updating one node’s data re-renders exactly that node', () => { + const nodes = makeNodes(N); + const { rerender } = render(); + + const next = [...nodes]; + next[3] = makeNodeProps(3, { + data: { nodeType: nodes[3]!.type, display: { label: 'Node 3', subLabel: 'edited' } }, + }); + rerender(); + + expect(rendersOf(3)).toBe(2); + expect(totalRenders()).toBe(N + 1); + }); + + it('hovering one node re-renders exactly that node', () => { + render(); + + const wrapper = screen.getAllByTestId('base-container')[11]!.parentElement!; + act(() => { + fireEvent.mouseEnter(wrapper); + }); + + expect(rendersOf(11)).toBe(2); + expect(totalRenders()).toBe(N + 1); + + act(() => { + fireEvent.mouseLeave(wrapper); + }); + expect(rendersOf(11)).toBe(3); + expect(totalRenders()).toBe(N + 2); + }); + + it('resolves handle configurations exactly once per node on mount (no double resolution)', () => { + render(); + + // One resolveHandles pass per node: BaseNode resolves and useButtonHandles + // consumes the pre-resolved output. 2N here means resolution regressed to + // running twice per node. + expect(resolveHandlesSpy.current).toHaveBeenCalledTimes(N); + }); + + it('starting a connect gesture does not re-resolve the toolbar config', () => { + // Manifest-default toolbar (design mode): pass empty overrides instead of + // the default toolbar-suppressing ones. + const nodes = [makeNodeProps(0, { selected: true })]; + render(); + + const configBefore = toolbarCalls.at(-1)?.config; + expect(configBefore).toBeTruthy(); + + // Connect gesture starts; the hover forces the node to re-render and read + // the new store state. + rfState.current = { connection: { inProgress: true } }; + act(() => { + fireEvent.mouseEnter(screen.getByTestId('base-container').parentElement!); + }); + + expect(toolbarCalls.length).toBeGreaterThan(1); + // Same resolved config object: resolveToolbar must not re-run (it allocates + // fresh action objects and icon elements per node per call). + expect(toolbarCalls.at(-1)?.config).toBe(configBefore); + }); + + it('a label-only data edit keeps handle config identity: no re-measure, no height write', () => { + const nodes = makeNodes(N); + const { rerender } = render(); + mockUpdateNode.mockClear(); + mockUpdateNodeInternals.mockClear(); + + const next = [...nodes]; + next[3] = makeNodeProps(3, { + data: { nodeType: nodes[3]!.type, display: { label: 'Renamed' } }, + }); + rerender(); + + // The node re-rendered with its new label... + expect(labelRenders.get('Renamed')).toBe(1); + // ...but resolution output was value-identical, so the stable-identity + // guard must prevent the DOM re-measure and any height write. + expect(mockUpdateNodeInternals).not.toHaveBeenCalled(); + expect(mockUpdateNode).not.toHaveBeenCalled(); + }); + + it('dragging one node re-renders only that node (dragging prop) and never loops', () => { + const nodes = makeNodes(N); + const { rerender } = render(); + mockUpdateNode.mockClear(); + + // Drag start: dragging=true on one node; every frame moves absolute positions. + let current = [...nodes]; + current[42] = { ...nodes[42]!, dragging: true }; + rerender(); + + for (let frame = 1; frame <= 5; frame++) { + current = current.map((p) => ({ + ...p, + positionAbsoluteX: p.positionAbsoluteX + frame, + })); + rerender(); + } + + // Only the drag-start transition renders (1), position frames are swallowed. + expect(rendersOf(42)).toBe(2); + expect(totalRenders()).toBe(N + 1); + // Height is a pure function of handles/footer: dragging must not write. + expect(mockUpdateNode).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/apollo-react/src/canvas/components/BaseNode/BaseNode.test.tsx b/packages/apollo-react/src/canvas/components/BaseNode/BaseNode.test.tsx index 63d8d32744..4df5a23269 100644 --- a/packages/apollo-react/src/canvas/components/BaseNode/BaseNode.test.tsx +++ b/packages/apollo-react/src/canvas/components/BaseNode/BaseNode.test.tsx @@ -134,7 +134,8 @@ vi.mock('../../utils/icon-registry', () => ({ getIcon: () => () =>
Icon
, CanvasIcon: ({ icon }: { icon: string }) => , })); -vi.mock('../../utils/manifest-resolver', () => ({ +vi.mock('../../utils/manifest-resolver', async (importOriginal) => ({ + ...(await importOriginal()), resolveDisplay: (display: Record | undefined) => ({ label: 'Test Node', subLabel: 'Test SubLabel', @@ -142,7 +143,14 @@ vi.mock('../../utils/manifest-resolver', () => ({ icon: 'test-icon', ...display, }), - resolveHandles: () => [], + // Pass-through resolution: BaseNode now resolves every handle source + // (context override included), so the mock must preserve the input groups + // while normalizing `visible` to a boolean like the real resolver. + resolveHandles: (groups: HandleGroupManifest[]) => + groups.map((group) => ({ + ...group, + handles: group.handles.map((handle) => ({ ...handle, visible: handle.visible !== false })), + })), })); vi.mock('./NodeLabel', () => ({ @@ -304,6 +312,60 @@ describe('BaseNode', () => { }); }); + // The manifest path strips resolved groups to a field whitelist before they + // reach the handle renderers: manifest-declared extras (customPositionAndOffsets, + // boundary) were never honored, and honoring them is a separate feature + // decision. Runtime override configs keep every field (onAction, offsets, ...). + describe('Manifest handle field whitelist', () => { + type CapturedOpts = { handleConfigurations?: Array> }; + const capturedConfigs = () => + (mockUseButtonHandles.mock.calls.at(-1)?.[0] as CapturedOpts).handleConfigurations; + + it('manifest handle groups pass only whitelisted fields to renderers', () => { + mockManifest.current = { + ...DEFAULT_MANIFEST, + handleConfiguration: [ + { + position: Position.Right, + customPositionAndOffsets: { top: 12 }, + boundary: 'inner', + handles: [{ id: 'out', type: 'source', handleType: 'output', unlistedExtra: 'x' }], + }, + ], + }; + render(); + + const groups = capturedConfigs()!; + expect(groups).toHaveLength(1); + expect(groups[0]).not.toHaveProperty('customPositionAndOffsets'); + expect(groups[0]).not.toHaveProperty('boundary'); + expect((groups[0]!.handles as Array>)[0]).not.toHaveProperty( + 'unlistedExtra' + ); + expect((groups[0]!.handles as Array>)[0]).toMatchObject({ + id: 'out', + type: 'source', + handleType: 'output', + }); + }); + + it('override handle groups keep their runtime fields', () => { + const onAction = vi.fn(); + mockHandleConfigs.current = [ + { + position: Position.Right, + customPositionAndOffsets: { top: 12 }, + handles: [{ id: 'out', type: 'source', handleType: 'output', onAction }], + }, + ] as unknown as HandleGroupManifest[]; + render(); + + const groups = capturedConfigs()!; + expect(groups[0]).toHaveProperty('customPositionAndOffsets', { top: 12 }); + expect((groups[0]!.handles as Array>)[0]!.onAction).toBe(onAction); + }); + }); + describe('Loading state', () => { it.each([ { loading: true, expectSkeleton: true }, diff --git a/packages/apollo-react/src/canvas/components/BaseNode/BaseNode.tsx b/packages/apollo-react/src/canvas/components/BaseNode/BaseNode.tsx index e62db57abc..774a85d9fe 100644 --- a/packages/apollo-react/src/canvas/components/BaseNode/BaseNode.tsx +++ b/packages/apollo-react/src/canvas/components/BaseNode/BaseNode.tsx @@ -15,10 +15,6 @@ import { NODE_BADGE_SIZE, NODE_BORDER_SIZE, NODE_CONTAINER_RADIUS_RATIO, - NODE_HEIGHT_DEFAULT, - NODE_HEIGHT_FOOTER_BUTTON, - NODE_HEIGHT_FOOTER_DOUBLE, - NODE_HEIGHT_FOOTER_SINGLE, NODE_INNER_ICON_RATIO, NODE_INNER_RADIUS_RATIO, NODE_INNER_SHAPE_RATIO, @@ -29,8 +25,14 @@ import type { NodeShape } from '../../schema'; import type { HandleGroupManifest } from '../../schema/node-definition'; import { resolveAdornments } from '../../utils/adornment-resolver'; import { CanvasIcon, getIcon } from '../../utils/icon-registry'; -import { resolveDisplay, resolveHandles } from '../../utils/manifest-resolver'; +import { + areResolvedHandleGroupsEqual, + type ResolvedHandleGroup, + resolveDisplay, + resolveHandles, +} from '../../utils/manifest-resolver'; import { selectIsConnecting } from '../../utils/NodeUtils'; +import { computeBaseNodeHeight } from '../../utils/node-height'; import { areNodePropsEqualIgnoringPosition } from '../../utils/nodePropsEqual'; import { resolveToolbar } from '../../utils/toolbar-resolver'; import { useBaseCanvasMode } from '../BaseCanvas/BaseCanvasModeProvider'; @@ -45,12 +47,7 @@ import { InitialsBadge } from '../shared/InitialsBadge'; import type { NodeToolbarConfig } from '../Toolbar'; import { NodeToolbar } from '../Toolbar'; import { lockToolbarConfig } from '../Toolbar/NodeToolbar/NodeToolbar.utils'; -import type { - BaseNodeData, - FooterVariant, - NodeAdornments, - NodeStatusContext, -} from './BaseNode.types'; +import type { BaseNodeData, NodeAdornments, NodeStatusContext } from './BaseNode.types'; import { BaseBadgeSlot } from './BaseNodeBadgeSlot'; import { useBaseNodeOverrideConfig } from './BaseNodeConfigContext'; import { BaseContainer } from './BaseNodeContainer'; @@ -69,24 +66,6 @@ const getContainerWidth = (shape: NodeShape | undefined, width: number | undefin return defaultWidth; }; -// Intrinsic height: the fixed footer height when a footer is present, else the default. -const getIntrinsicHeight = ( - hasFooter: boolean, - footerVariant: FooterVariant | undefined -): number => { - if (hasFooter) { - switch (footerVariant) { - case 'button': - return NODE_HEIGHT_FOOTER_BUTTON; - case 'single': - return NODE_HEIGHT_FOOTER_SINGLE; - case 'double': - return NODE_HEIGHT_FOOTER_DOUBLE; - } - } - return NODE_HEIGHT_DEFAULT; -}; - const BaseNodeComponent = (props: NodeProps>) => { const { type, data, selected, id, dragging, width, parentId } = props; @@ -144,26 +123,19 @@ const BaseNodeComponent = (props: NodeProps>) => { // Get manifest and resolve with instance data const manifest = useMemo(() => nodeTypeRegistry.getManifest(type), [type, nodeTypeRegistry]); + // Toolbar/adornment resolution reads only identity, status, and mode. + // Interaction state (isConnecting/isSelected/isDragging) is deliberately + // omitted: neither resolver reads it, and including it re-resolved toolbars + // and adornments for every node on each connect gesture and selection change. + // Interaction-dependent toolbar behavior lives in `offsetToolbar` below. const statusContext: NodeStatusContext = useMemo( () => ({ nodeId: id, executionState: executionStatusOverride ?? executionState, validationState, - isConnecting, - isSelected: selected, - isDragging: dragging, mode, }), - [ - id, - executionStatusOverride, - executionState, - validationState, - isConnecting, - selected, - dragging, - mode, - ] + [id, executionStatusOverride, executionState, validationState, mode] ); // Callbacks: Use props only (no longer in data) @@ -200,29 +172,34 @@ const BaseNodeComponent = (props: NodeProps>) => { return ; }, [iconComponent, display.icon, display.label]); - // Resolve handles: context override > data override > manifest default - const handleConfigurations = useMemo((): HandleGroupManifest[] => { - // Priority 1: Context override (runtime configuration from parent wrapper components) - if (handleConfigurationsProp && Array.isArray(handleConfigurationsProp)) { - return handleConfigurationsProp; - } - - // Priority 2: Per-instance override via node data + // Resolve handles ONCE for every source (context override > data override > + // manifest default). The resolved output is handed to useButtonHandles with + // `preResolved`, so templates/repeat/visibility never resolve twice per render. + const resolvedHandleConfigurations = useMemo((): ResolvedHandleGroup[] => { const dataHandleConfigs = (data as Record)?.handleConfigurations as | HandleGroupManifest[] | undefined; - if (dataHandleConfigs && Array.isArray(dataHandleConfigs)) { - return dataHandleConfigs; + + // Priority 1/2: runtime override configs. resolveHandles spreads each + // handle, preserving the runtime-only fields overrides may carry + // (onAction, labelBackgroundColor, customPositionAndOffsets, ...). + const override = + (Array.isArray(handleConfigurationsProp) && handleConfigurationsProp) || + (Array.isArray(dataHandleConfigs) && dataHandleConfigs); + if (override) { + // Pass nodeId and collapsed for collapse state lookup + return resolveHandles(override, { ...data, nodeId: id }); } - // Priority 3: Manifest default + // Priority 3: manifest default. Only whitelisted fields reach the handle + // renderers — group-level extras (customPositionAndOffsets, boundary) from + // manifests were never honored here, and honoring them is a feature + // decision, not part of the single-resolution change. if (!manifest) return []; - // Pass nodeId and collapsed for collapse state lookup const resolved = resolveHandles(manifest.handleConfiguration, { ...data, nodeId: id }); - - // Convert resolved handles to HandleGroupManifest format for ButtonHandle return resolved.map((group) => ({ position: group.position, + visible: group.visible, handles: group.handles.map((h) => ({ id: h.id, type: h.type, @@ -233,10 +210,22 @@ const BaseNodeComponent = (props: NodeProps>) => { labelVisibility: h.labelVisibility, constraints: h.constraints, })), - visible: group.visible, })); }, [handleConfigurationsProp, manifest, data, id]); + // Keep the previous array identity when resolution output is value-identical. + // Resolution allocates fresh objects on every `data` change (e.g. a label + // rename), and a new identity would cascade into updateNodeInternals (a DOM + // re-measure) and handle-element rebuilds even though no handle changed. + const stableHandleConfigsRef = useRef(resolvedHandleConfigurations); + if ( + stableHandleConfigsRef.current !== resolvedHandleConfigurations && + !areResolvedHandleGroupsEqual(stableHandleConfigsRef.current, resolvedHandleConfigurations) + ) { + stableHandleConfigsRef.current = resolvedHandleConfigurations; + } + const handleConfigurations = stableHandleConfigsRef.current as HandleGroupManifest[]; + // Toolbar config resolution with priority: props > manifest const toolbarConfig = useMemo(() => { // Priority 1: Prop override (runtime callbacks) @@ -273,28 +262,16 @@ const BaseNodeComponent = (props: NodeProps>) => { // Computed node height: max of the handle-count floor and the intrinsic default/footer // height. Pure (never reads the measured `height`); written to node.height below as // the authoritative size, so node content is expected to fit within it. - const computedHeight = useMemo(() => { - const leftHandles = handleConfigurations - .filter((config) => config.position === Position.Left && config.visible !== false) - .reduce( - (count, config) => count + config.handles.filter((h) => h.visible !== false).length, - 0 - ); - - const rightHandles = handleConfigurations - .filter((config) => config.position === Position.Right && config.visible !== false) - .reduce( - (count, config) => count + config.handles.filter((h) => h.visible !== false).length, - 0 - ); - - const leftRightHandles = Math.max(leftHandles, rightHandles); - - // Each handle gets a 2-grid-space lane (32px), plus 2-grid-space padding at top + bottom of node. - const handleFloor = (leftRightHandles * 2 + 2) * GRID_SPACING; - - return Math.max(getIntrinsicHeight(!!footerComponent, footerVariant), handleFloor); - }, [handleConfigurations, footerComponent, footerVariant]); + // Consumers can seed node.height at creation with the same computeBaseNodeHeight + // (utils/node-height) to skip the write-back entirely on mount. + const computedHeight = useMemo( + () => + computeBaseNodeHeight(handleConfigurations, { + hasFooter: !!footerComponent, + footerVariant, + }), + [handleConfigurations, footerComponent, footerVariant] + ); // Write computedHeight to node.height and recalculate handle positions. Compare // against node.height (not the measured `height` prop) so a lagging measurement @@ -487,7 +464,7 @@ const BaseNodeComponent = (props: NodeProps>) => { } } return { hasButton, hasLabel }; - }, [toolbarPosition, handleConfigurations]); + }, [toolbarPosition, handleConfigurations, useSmartHandles]); // Offset the toolbar to clear whichever handle affordance is actually rendered // at its side — not merely configured. A shown add button stacks button + label @@ -547,6 +524,8 @@ const BaseNodeComponent = (props: NodeProps>) => { nodeHeight: computedHeight, shouldShowAddButtonFn, portalActions: !!parentId, + // Handles were already resolved above; skip the hook's internal resolution. + preResolved: true, }); // Generate SmartHandle elements from handle configurations (opt-in) diff --git a/packages/apollo-react/src/canvas/components/BaseNode/BaseNode.types.ts b/packages/apollo-react/src/canvas/components/BaseNode/BaseNode.types.ts index 82984cd05c..25ff13afd2 100644 --- a/packages/apollo-react/src/canvas/components/BaseNode/BaseNode.types.ts +++ b/packages/apollo-react/src/canvas/components/BaseNode/BaseNode.types.ts @@ -55,8 +55,20 @@ export interface NodeStatusContext { nodeId: string; executionState?: ExecutionState; validationState?: ValidationState; + /** Canvas mode ('design' | 'view' | ...); populated so toolbar resolution refreshes on mode change. */ + mode?: string; + /** + * @deprecated Never populated. BaseNode/LoopNode no longer feed interaction + * state into toolbar/adornment resolution (it forced a full re-resolve on + * every connect gesture and selection change); interaction-dependent toolbar + * behavior lives in the toolbar offset/visibility props instead. Will be + * removed in a future major. + */ isHovered?: boolean; + /** @deprecated Never populated; see `isHovered`. */ isConnecting?: boolean; + /** @deprecated Never populated; see `isHovered`. */ isSelected?: boolean; + /** @deprecated Never populated; see `isHovered`. */ isDragging?: boolean; } diff --git a/packages/apollo-react/src/canvas/components/BaseNode/PERFORMANCE.md b/packages/apollo-react/src/canvas/components/BaseNode/PERFORMANCE.md new file mode 100644 index 0000000000..1447f31fe7 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/BaseNode/PERFORMANCE.md @@ -0,0 +1,221 @@ +# BaseNode Performance Audit + +Audit of `BaseNode` and its render path at 500-node scale, with benchmarks and +render-count regression tests to prevent regressions. + +- **Verdict: 500 nodes is comfortably supported today.** Mounting 500 BaseNodes + costs ~370ms of component code (one-time, in a headless DOM; real browsers are + faster), and steady-state interactions are O(1) in canvas size: a position + sweep across all 500 nodes costs ~2.7ms, selecting or hovering a node + re-renders exactly one node body. +- The invariants that make this true are now pinned by deterministic tests + (`BaseNode.perf.test.tsx`, `ConnectedHandlesContext.perf.test.tsx`) and + tracked by benchmarks (`BaseNode.bench.tsx`, `utils/canvas-scale.bench.ts`). + +## How to run + +```bash +# Regression guards (run in CI as part of the normal test task) +pnpm --filter @uipath/apollo-react test -- BaseNode.perf +pnpm --filter @uipath/apollo-react test -- ConnectedHandlesContext.perf + +# Benchmarks (timing, not CI-gating) +pnpm --filter @uipath/apollo-react bench +``` + +## Current numbers (post-fix) + +Vitest bench, happy-dom, dev container (2026-08). Means, xyflow store hooks +stubbed so numbers isolate our per-node code from xyflow internals. + +| Scenario | Mean | +| --- | --- | +| Mount 500 BaseNodes | ~412 ms | +| Mount 1 BaseNode (per-node floor) | ~1.7 ms | +| Position-only re-render sweep across 500 nodes (memo fast path) | ~2.7 ms | +| Toggle selection of 1 node among 500 | ~3.9 ms | +| `resolveHandles` static manifest x 500 | ~0.49 ms | +| `resolveHandles` repeat expansion (5 items) x 500 | ~4.8 ms | +| `resolveDisplay` x 500 | ~0.02 ms | +| `areNodePropsEqualIgnoringPosition` sweep x 500 | ~0.17 ms | +| `resolveCollisions` 500 overlapping nodes | ~10.7 ms | + +## What already scales well + +These are the load-bearing design decisions; the new tests exist to keep them. + +1. **Memoized node body with position-ignoring comparator** + (the `memo` export at the bottom of `BaseNode.tsx`, comparator in + `utils/nodePropsEqual.ts`). XYFlow passes + `positionAbsoluteX/Y` to every node on every render; during a container drag + every descendant gets new values at 60fps. The comparator swallows these, so + drags re-render only the transform (applied by XYFlow's NodeWrapper), not + 500 node bodies. +2. **Granular connected-handles store** (`BaseCanvas/ConnectedHandlesContext.tsx`). + O(1) per-node lookup via `useSyncExternalStore` with per-node listeners and + Set-identity reuse. An edge edit notifies only the touched nodes, not all 500. +3. **Selection state computed once** (`BaseCanvas/SelectionStateContext.tsx`). + `multipleNodesSelected` is O(n) once per nodes-array change with an + early-exit, and the context value only changes identity when the boolean + flips, so it does not fan out re-renders. +4. **CSS-variable geometry** (`nodeVars` in `BaseNode.tsx`). All geometry is + set once as custom properties on the wrapper; children use static Tailwind + class strings, so React skips their DOM updates entirely. +5. **Pure, convergent height** (`computedHeight` + the height-sync effect in + `BaseNode.tsx`, rule in `utils/node-height.ts`). `computedHeight` is a + pure function of handle count/footer, never the measured height, and the + write-back is guarded by `getNode(id)?.height !== computedHeight`. No + measure-write oscillation. Verified at 500 nodes: exactly one write per node + on first mount, zero when heights are pre-seeded. +6. **O(1) manifest lookup** (`core/NodeTypeRegistry.ts`): all registry queries + are Map lookups; caches are precomputed at registration. +7. **Viewport virtualization**: `BaseCanvas` defaults + `onlyRenderVisibleElements` to true, so off-screen nodes are not mounted at + all. A 500-node graph zoomed to fit is the worst case; panned in close, the + working set is much smaller. +8. **Memoized leaf components**: `NodeLabel`, `BaseInnerShape`, + `ExecutionStatusIndicator` are `memo`-wrapped. + +## Findings (ranked) and fixes + +All code findings from the audit are FIXED (2026-08); each fix is pinned by a +regression test. F7 remains a consumer contract to be aware of. + +### F1. Handles were resolved twice per node — FIXED + +`BaseNode` resolved manifest handles and passed the output to +`useButtonHandles`, which called `resolveHandles` **again** on the +already-resolved configuration, re-running template replacement (regex over +every handle id/label) and re-allocating every group/handle object per node +per invalidation. + +*Fix:* `BaseNode` now resolves ONCE for every handle source (context override, +data override, manifest default) and passes `preResolved` to +`useButtonHandles`, which skips its internal resolution and swaps its node-data +subscription for an inert sentinel on that path (hook order unchanged, no live +store subscription). Other callers (TriggerNode, StageNodeHandles) keep the old +behavior by default. The manifest path keeps its pre-existing field whitelist, +so manifest-declared extras (`customPositionAndOffsets`, `boundary`) still do +not reach the handle renderers; override configs keep their runtime fields +(`onAction`, ...) exactly as before. Side benefit: override configs with +`repeat`/template handles are now resolved before height computation, so +dynamic handles from overrides count correctly toward the handle floor. +*Guard:* "resolves handle configurations exactly once per node on mount" and +"manifest handle groups pass only whitelisted fields to renderers". + +### F2. Connect gestures re-resolved toolbars/adornments on all nodes — FIXED + +`isConnecting`, `isSelected`, and `isDragging` were folded into +`statusContext`, whose identity change re-ran `resolveToolbar` (fresh action +objects, closures, and icon React elements per action per node) and +`resolveAdornments` for all 500 nodes, twice per connect gesture. Neither +resolver reads interaction state. + +*Fix:* `statusContext` now carries only identity + status + mode +(`BaseNode.tsx`, and the same pattern in `LoopNode.tsx`). +Interaction-dependent toolbar behavior (offsets, visibility) already lived in +`offsetToolbar` and the NodeToolbar props, which remain fully reactive. +*Guard:* "starting a connect gesture does not re-resolve the toolbar config". + +### F3. Execution/validation hooks double-rendered per update — FIXED + +`useNodeExecutionState` / `useElementValidationStatus` read state via +setState-in-effect: every published update cost a context render plus a second +setState render, and the state was unavailable on the first render. Two +`useState`+`useEffect` pairs per node besides. + +*Fix:* the hooks now read the getter during render, memoized on context +identity. Same provider contract (publish by swapping the context value), half +the renders per update, state available on first render, no per-node effects. +*Guard:* `hooks/ExecutionStatusContext.test.tsx`. +*Still recommended (API change, not done):* a store+selector +(`ConnectedHandlesStore` pattern) so an update renders only the affected node +instead of all N; requires changing the provider contract consumers inject. + +### F4. Mount write burst — MITIGATED (seeding API added) + +Each node writes its computed height (`updateNode`) + `updateNodeInternals` +on first mount: 500 store writes for a fresh 500-node graph. The write-back is +guarded, so a node whose `height` is already correct writes nothing. + +*Fix:* the height rule is extracted to `computeBaseNodeHeight` +(`utils/node-height.ts`, exported from canvas utils). Consumers can seed +`node.height` at creation and skip the mount write entirely; BaseNode uses the +same function, so the two can never drift. When seeding from a raw manifest, +pass `resolutionContext` (the node's data) so `repeat` and string-visibility +handles resolve exactly as BaseNode resolves them. +*Guard:* "performs zero height writes when node.height is already correct" and +the parity cases in `utils/node-height.test.ts`. + +### F5. Stale memo: `toolbarSideHandleAffordances` omitted `useSmartHandles` — FIXED + +The memo read `useSmartHandles` without listing it, serving a stale toolbar +offset when handle configs came from the context override and +`data.useSmartHandles` flipped. The dependency is now listed. + +### F6. Any `data` change invalidated handle configs — FIXED + +A label rename produced a new `handleConfigurations` identity even when no +handle changed, re-triggering `updateNodeInternals` (a DOM re-measure) and +handle-element rebuilds. + +*Fix:* resolution output is value-compared (`areResolvedHandleGroupsEqual`, +shallow per group/handle, functions and nested objects by reference) and the +previous identity is kept when nothing resolved differently. Conservative by +construction: a false negative costs one old-style re-render, never staleness. +*Guard:* "a label-only data edit keeps handle config identity". + +### F7. Consumer contract: `data` must be reference-stable (informational) + +All of the memoization relies on consumers not recreating `node.data` (or +`BaseNodeOverrideConfig` values) on every parent render. A consumer that maps +`nodes` to fresh `data` objects per render silently disables every guard above. +The perf tests document the expected pattern (stable arrays, spread-per-change). + +## Measured improvements (before → after fixes) + +Counted invariants are exact and test-pinned; wall-clock rows are vitest bench +means on an idle dev container (happy-dom), same machine, sequential runs. + +| Scenario (500 nodes) | Before | After | +| --- | --- | --- | +| `resolveHandles` passes on mount | 1,000 (2/node) | 500 (1/node) | +| Toolbar + adornment resolver runs per connect gesture (start+end) | 2,000 | 0 | +| Renders per node per execution/validation update | 2 | 1 | +| Execution state available on first render | no (undefined until 2nd) | yes | +| DOM re-measures (`updateNodeInternals`) after a label-only edit | 1 | 0 | +| Height store writes on mount, heights seeded via `computeBaseNodeHeight` | no seeding API | 0 | +| Stale toolbar offset when `useSmartHandles` flips under context override | possible | fixed | +| Mount 500 BaseNodes (bench mean) | ~488 ms | ~412 ms (−15%) | +| Mount 500 BaseNodes (bench min) | ~418 ms | ~365 ms (−13%) | + +Steady-state numbers that were already flat stayed flat (position-only sweep +~2.6 ms, single-node selection ~3.4 ms, pure resolvers unchanged). + +## Regression guards + +`BaseNode.perf.test.tsx` (all at N=500, deterministic render counts, no timing): + +- mount renders each node body exactly once, no cascades +- height write-back: exactly one `updateNode` per node on mount; zero when + heights are seeded; zero during drags +- position-only prop sweeps (container drag frames) render zero node bodies +- selecting / hovering / editing data on one node re-renders exactly that node +- `updateNodeInternals` called exactly once per node on mount +- `resolveHandles` runs exactly once per node on mount (no double resolution) +- a connect gesture never re-resolves toolbar configs (identity-stable) +- a label-only data edit triggers no re-measure and no height write + +`hooks/ExecutionStatusContext.test.tsx`: + +- execution/validation state is available on the FIRST render +- each published update costs exactly one render per subscriber + +`ConnectedHandlesContext.perf.test.tsx` (N=500): + +- an added/removed edge notifies only its endpoint nodes +- a rebuilt-but-identical edges array notifies nobody +- snapshot Set identity is stable for untouched nodes + +If a change breaks one of these, it will show up as a hard test failure with +the exact invariant named, rather than as a slow canvas in production. diff --git a/packages/apollo-react/src/canvas/components/ButtonHandle/useButtonHandles.tsx b/packages/apollo-react/src/canvas/components/ButtonHandle/useButtonHandles.tsx index 463f4a38bb..d77de344c3 100644 --- a/packages/apollo-react/src/canvas/components/ButtonHandle/useButtonHandles.tsx +++ b/packages/apollo-react/src/canvas/components/ButtonHandle/useButtonHandles.tsx @@ -2,11 +2,19 @@ import { useNodesData } from '@xyflow/react'; import type { Position } from '@xyflow/system'; import { useMemo } from 'react'; import type { HandleGroupManifest } from '../../schema/node-definition'; -import { resolveHandles } from '../../utils/manifest-resolver'; +import { type ResolvedHandleGroup, resolveHandles } from '../../utils/manifest-resolver'; import { useConnectedHandles } from '../BaseCanvas/ConnectedHandlesContext'; import type { HandleActionEvent, HandleMouseEvent } from '../ButtonHandle'; import { ButtonHandles } from '../ButtonHandle'; +const EMPTY_DATA: Record = {}; + +// Sentinel id for the pre-resolved path: hook order must not change, so +// useNodesData is still called, but pointing it at a never-existing id makes +// the subscription inert (a failed map lookup, a stable null result, and no +// re-render on any node-data change). +const NO_SUBSCRIPTION_NODE_ID = '__apollo_pre_resolved_no_subscription__'; + export const useButtonHandles = ({ handleConfigurations, shouldShowHandles, @@ -22,6 +30,7 @@ export const useButtonHandles = ({ nodeWidth, nodeHeight, portalActions, + preResolved, }: { handleConfigurations: HandleGroupManifest[]; shouldShowHandles: boolean; @@ -37,6 +46,14 @@ export const useButtonHandles = ({ nodeHeight?: number; portalActions?: boolean; + /** + * Set when `handleConfigurations` is already the output of `resolveHandles` + * (templates replaced, repeats expanded, visibility booleans resolved). + * Skips the hook's internal resolution pass and its node-data dependency, + * so the same configuration is never resolved twice per render. + */ + preResolved?: boolean; + /** * Allows for consumers to control the predicate for showing the add button from the props that's passed in * @@ -56,7 +73,13 @@ export const useButtonHandles = ({ }) => boolean; }) => { const connectedHandleIds = useConnectedHandles(nodeId); - const node = useNodesData(nodeId); + // Node data is only needed to resolve raw configurations; the pre-resolved + // path swaps in the sentinel so it carries no live data subscription. + const node = useNodesData(preResolved ? NO_SUBSCRIPTION_NODE_ID : nodeId); + + // When the input is pre-resolved, node data is not read for resolution; a + // stable empty object keeps data changes from invalidating the memo below. + const dataForResolution = preResolved ? EMPTY_DATA : (node?.data ?? EMPTY_DATA); const handleElements = useMemo(() => { if ( @@ -66,7 +89,9 @@ export const useButtonHandles = ({ ) return <>; - const resolvedHandles = resolveHandles(handleConfigurations, node?.data ?? {}); + const resolvedHandles = preResolved + ? (handleConfigurations as unknown as ResolvedHandleGroup[]) + : resolveHandles(handleConfigurations, dataForResolution); const elements = resolvedHandles.map((config, i) => { const groupVisible = shouldShowHandles && (config.visible ?? true); @@ -120,7 +145,8 @@ export const useButtonHandles = ({ nodeWidth, nodeHeight, portalActions, - node?.data, + dataForResolution, + preResolved, ]); return handleElements; diff --git a/packages/apollo-react/src/canvas/components/LoopNode/LoopNode.tsx b/packages/apollo-react/src/canvas/components/LoopNode/LoopNode.tsx index 48fb5a25f0..53f5ab8be4 100644 --- a/packages/apollo-react/src/canvas/components/LoopNode/LoopNode.tsx +++ b/packages/apollo-react/src/canvas/components/LoopNode/LoopNode.tsx @@ -217,26 +217,18 @@ function LoopNodeComponent(props: LoopNodeProps) { const executionState = useNodeExecutionState(id); const validationState = useElementValidationStatus(id); + // Toolbar/adornment resolution reads only identity, status, and mode. + // Interaction state (isConnecting/isSelected/isDragging) is deliberately + // omitted: neither resolver reads it, and including it re-resolved toolbars + // and adornments on every connect gesture and selection change (see BaseNode). const statusContext: NodeStatusContext = useMemo( () => ({ nodeId: id, executionState: executionStatusOverride ?? executionState, validationState, - isConnecting, - isSelected: selected, - isDragging: dragging, mode, }), - [ - dragging, - executionStatusOverride, - executionState, - id, - isConnecting, - mode, - selected, - validationState, - ] + [executionStatusOverride, executionState, id, mode, validationState] ); const executionStatus = diff --git a/packages/apollo-react/src/canvas/hooks/ExecutionStatusContext.test.tsx b/packages/apollo-react/src/canvas/hooks/ExecutionStatusContext.test.tsx new file mode 100644 index 0000000000..7dbfbaf9f8 --- /dev/null +++ b/packages/apollo-react/src/canvas/hooks/ExecutionStatusContext.test.tsx @@ -0,0 +1,88 @@ +/** + * Render-efficiency regression tests for the execution/validation status hooks. + * + * These hooks run in EVERY canvas node, so their render behavior multiplies by + * canvas size. The contract pinned here: + * - the state is available on the FIRST render (no setState-in-effect second render) + * - publishing a new context value costs exactly one render per subscriber + */ +import { render } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import type { ExecutionState } from '../types/execution'; +import { ValidationErrorSeverity, type ValidationState } from '../types/validation'; +import { ExecutionStatusContext, useNodeExecutionState } from './ExecutionStatusContext'; +import { useElementValidationStatus, ValidationStatusContext } from './ValidationStatusContext'; + +describe('useNodeExecutionState', () => { + let renders = 0; + let observed: ExecutionState | undefined; + + const Probe = ({ nodeId }: { nodeId: string }) => { + observed = useNodeExecutionState(nodeId); + renders++; + return null; + }; + + const makeValue = (state: ExecutionState | undefined) => ({ + getNodeExecutionState: () => state, + getEdgeExecutionState: () => undefined, + }); + + it('returns the state on the first render, in a single render', () => { + renders = 0; + render( + + + + ); + + expect(observed).toBe('Completed'); + expect(renders).toBe(1); + }); + + it('costs exactly one render per published update', () => { + renders = 0; + const { rerender } = render( + + + + ); + expect(renders).toBe(1); + + rerender( + + + + ); + + expect(observed).toBe('Failed'); + expect(renders).toBe(2); + }); +}); + +describe('useElementValidationStatus', () => { + let renders = 0; + let observed: ValidationState | undefined; + + const Probe = ({ elementId }: { elementId: string }) => { + observed = useElementValidationStatus(elementId); + renders++; + return null; + }; + + it('returns the state on the first render, in a single render', () => { + renders = 0; + const state: ValidationState = { + validationStatus: ValidationErrorSeverity.ERROR, + validationError: undefined, + }; + render( + state }}> + + + ); + + expect(observed).toBe(state); + expect(renders).toBe(1); + }); +}); diff --git a/packages/apollo-react/src/canvas/hooks/ExecutionStatusContext.tsx b/packages/apollo-react/src/canvas/hooks/ExecutionStatusContext.tsx index fe0fc1b056..ad2f10d88d 100644 --- a/packages/apollo-react/src/canvas/hooks/ExecutionStatusContext.tsx +++ b/packages/apollo-react/src/canvas/hooks/ExecutionStatusContext.tsx @@ -1,4 +1,4 @@ -import React, { useContext, useEffect, useState } from 'react'; +import React, { useContext, useMemo } from 'react'; import type { ExecutionState } from '../types/execution'; export interface ExecutionStateContextValue { @@ -11,15 +11,16 @@ export const ExecutionStatusContext = React.createContext undefined, }); +// These hooks read the getter during render (memoized on context identity) +// instead of the previous setState-in-effect pattern. Same update semantics — +// providers publish changes by swapping the context value — but the state is +// available on the FIRST render and each update costs one render per node +// instead of two (context render + setState re-render). At 500 nodes that +// halves the render work per execution tick. + export const useNodeExecutionState = (nodeId: string): ExecutionState | undefined => { const context = useContext(ExecutionStatusContext); - const [state, setState] = useState(); - - useEffect(() => { - setState(context.getNodeExecutionState(nodeId)); - }, [nodeId, context]); - - return state; + return useMemo(() => context.getNodeExecutionState(nodeId), [nodeId, context]); }; export const useEdgeExecutionState = ( @@ -27,12 +28,8 @@ export const useEdgeExecutionState = ( targetNodeId: string ): ExecutionState | undefined => { const context = useContext(ExecutionStatusContext); - const [state, setState] = useState(); - - useEffect(() => { - const executionState = context.getEdgeExecutionState(edgeId, targetNodeId); - setState(executionState); - }, [edgeId, targetNodeId, context]); - - return state; + return useMemo( + () => context.getEdgeExecutionState(edgeId, targetNodeId), + [edgeId, targetNodeId, context] + ); }; diff --git a/packages/apollo-react/src/canvas/hooks/ValidationStatusContext.tsx b/packages/apollo-react/src/canvas/hooks/ValidationStatusContext.tsx index 3137f79972..acba715d0e 100644 --- a/packages/apollo-react/src/canvas/hooks/ValidationStatusContext.tsx +++ b/packages/apollo-react/src/canvas/hooks/ValidationStatusContext.tsx @@ -1,4 +1,4 @@ -import React, { useContext, useEffect, useState } from 'react'; +import React, { useContext, useMemo } from 'react'; import type { ValidationState } from '../types/validation'; export interface ValidationStateContextValue { @@ -9,14 +9,10 @@ export const ValidationStatusContext = React.createContext undefined, }); +// Read during render (memoized on context identity) rather than setState in an +// effect: value available on first render, one render per update instead of +// two. See ExecutionStatusContext for the full rationale. export const useElementValidationStatus = (elementId: string): ValidationState | undefined => { const context = useContext(ValidationStatusContext); - const [validationState, setValidationState] = useState(); - - useEffect(() => { - const state = context.getElementValidationState(elementId); - setValidationState(state); - }, [elementId, context]); - - return validationState; + return useMemo(() => context.getElementValidationState(elementId), [elementId, context]); }; diff --git a/packages/apollo-react/src/canvas/utils/canvas-scale.bench.ts b/packages/apollo-react/src/canvas/utils/canvas-scale.bench.ts new file mode 100644 index 0000000000..455ff56e6a --- /dev/null +++ b/packages/apollo-react/src/canvas/utils/canvas-scale.bench.ts @@ -0,0 +1,142 @@ +/** + * Pure-function benchmarks for the canvas hot paths that run per node. + * + * Run with: pnpm --filter @uipath/apollo-react bench + * + * Every function here executes once per node per invalidation on a live + * canvas, so at 500 nodes a 0.1ms regression per call costs 50ms per sweep. + */ +import { bench } from 'vitest'; +import type { Node, NodeProps } from '@uipath/apollo-react/canvas/xyflow/react'; +import type { HandleGroupManifest } from '../schema/node-definition'; +import { resolveDisplay, resolveHandles } from './manifest-resolver'; +import { resolveCollisions } from './NodeUtils'; +import { areNodePropsEqualIgnoringPosition } from './nodePropsEqual'; + +const NODE_COUNT = 500; + +// --------------------------------------------------------------------------- +// manifest-resolver: runs inside BaseNode's memos (and again in useButtonHandles) +// --------------------------------------------------------------------------- + +const STATIC_HANDLE_GROUPS: HandleGroupManifest[] = [ + { + position: 'left', + handles: [{ id: 'in', type: 'target', handleType: 'input' }], + }, + { + position: 'right', + handles: [ + { id: 'out', type: 'source', handleType: 'output', showButton: true }, + { id: 'error', type: 'source', handleType: 'output', visible: 'hasErrorBranch' }, + ], + }, +] as HandleGroupManifest[]; + +const REPEAT_HANDLE_GROUPS: HandleGroupManifest[] = [ + { + position: 'bottom', + handles: [ + { + id: 'case-{index}', + type: 'source', + handleType: 'output', + label: 'Case {index}: {item.label}', + repeat: 'cases', + itemVar: 'item', + indexVar: 'index', + }, + ], + }, +] as HandleGroupManifest[]; + +const contexts = Array.from({ length: NODE_COUNT }, (_, i) => ({ + nodeId: `node-${i}`, + display: { label: `Node ${i}` }, + inputs: { hasErrorBranch: i % 2 === 0 }, + cases: Array.from({ length: 5 }, (_, c) => ({ label: `Case ${c}` })), +})); + +bench(`resolveHandles: static manifest x ${NODE_COUNT} nodes`, () => { + for (const ctx of contexts) { + resolveHandles(STATIC_HANDLE_GROUPS, ctx); + } +}); + +bench(`resolveHandles: repeat expansion (5 items) x ${NODE_COUNT} nodes`, () => { + for (const ctx of contexts) { + resolveHandles(REPEAT_HANDLE_GROUPS, ctx); + } +}); + +const MANIFEST_DISPLAY = { + label: 'Task', + canvasLabel: 'Task', + icon: 'timer', + shape: 'square' as const, + color: '#333', +}; + +bench(`resolveDisplay x ${NODE_COUNT} nodes`, () => { + for (const ctx of contexts) { + resolveDisplay(MANIFEST_DISPLAY, ctx); + } +}); + +// --------------------------------------------------------------------------- +// nodePropsEqual: the memo comparator runs for EVERY node on EVERY parent sweep +// (e.g. 60x/second for all nodes inside a dragged container) +// --------------------------------------------------------------------------- + +type AnyNodeProps = NodeProps>>; + +const makeProps = (i: number): AnyNodeProps => + ({ + id: `node-${i}`, + type: 'task', + data: { display: { label: `Node ${i}` } }, + selected: false, + dragging: false, + draggable: true, + zIndex: 0, + isConnectable: true, + positionAbsoluteX: i * 10, + positionAbsoluteY: i * 10, + selectable: true, + deletable: true, + }) as AnyNodeProps; + +const prevProps = Array.from({ length: NODE_COUNT }, (_, i) => makeProps(i)); +const movedProps = prevProps.map((p) => ({ + ...p, + positionAbsoluteX: (p.positionAbsoluteX as number) + 5, + positionAbsoluteY: (p.positionAbsoluteY as number) + 5, +})); + +bench(`areNodePropsEqualIgnoringPosition: position-only sweep x ${NODE_COUNT}`, () => { + for (let i = 0; i < NODE_COUNT; i++) { + areNodePropsEqualIgnoringPosition(prevProps[i]!, movedProps[i]!); + } +}); + +// --------------------------------------------------------------------------- +// resolveCollisions: bulk layout pass over the whole canvas (O(n^2) per sweep) +// --------------------------------------------------------------------------- + +const overlappingNodes: Node[] = Array.from({ length: NODE_COUNT }, (_, i) => ({ + id: `node-${i}`, + type: 'task', + // 20-column grid at 80px pitch with 96px nodes → neighbors overlap slightly. + position: { x: (i % 20) * 80, y: Math.floor(i / 20) * 80 }, + width: 96, + height: 96, + data: {}, +})); + +bench( + `resolveCollisions: ${NODE_COUNT} overlapping nodes`, + () => { + resolveCollisions(overlappingNodes, { maxIterations: 10 }); + }, + { time: 1000 } +); diff --git a/packages/apollo-react/src/canvas/utils/index.ts b/packages/apollo-react/src/canvas/utils/index.ts index b565838e7d..19fa51597a 100644 --- a/packages/apollo-react/src/canvas/utils/index.ts +++ b/packages/apollo-react/src/canvas/utils/index.ts @@ -4,6 +4,7 @@ export * from './CanvasEventBus'; export * from './CssUtil'; export * from './coded-agents/d3-layout'; export * from './coded-agents/mermaid-parser'; +export * from './node-height'; export * from './node-size'; export * from './constraint-validator'; export * from './container'; diff --git a/packages/apollo-react/src/canvas/utils/manifest-resolver.ts b/packages/apollo-react/src/canvas/utils/manifest-resolver.ts index 112e9530d2..c9471995a5 100644 --- a/packages/apollo-react/src/canvas/utils/manifest-resolver.ts +++ b/packages/apollo-react/src/canvas/utils/manifest-resolver.ts @@ -343,6 +343,51 @@ export function resolveHandles( }); } +const shallowEqual = (a: object, b: object): boolean => { + if (a === b) return true; + const recordA = a as Record; + const recordB = b as Record; + const aKeys = Object.keys(recordA); + if (aKeys.length !== Object.keys(recordB).length) return false; + for (const key of aKeys) { + if (!Object.is(recordA[key], recordB[key])) return false; + } + return true; +}; + +/** + * Value-compare two resolved handle-group arrays. + * + * Resolution allocates fresh group/handle objects every run, so output + * identity changes even when nothing resolved differently (e.g. a node label + * edit re-runs resolution with identical handle inputs). Callers can use this + * to keep the previous array identity and avoid cascading invalidation + * (handle re-measures, element rebuilds). + * + * Comparison is shallow per group and per handle: nested objects (constraints, + * customPositionAndOffsets) and callbacks compare by reference, which is + * conservative — a false negative only costs the old re-render, never staleness. + */ +export function areResolvedHandleGroupsEqual( + a: readonly ResolvedHandleGroup[], + b: readonly ResolvedHandleGroup[] +): boolean { + if (a === b) return true; + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + const groupA = a[i]!; + const groupB = b[i]!; + if (groupA.handles.length !== groupB.handles.length) return false; + const { handles: handlesA, ...restA } = groupA; + const { handles: handlesB, ...restB } = groupB; + if (!shallowEqual(restA, restB)) return false; + for (let j = 0; j < handlesA.length; j++) { + if (!shallowEqual(handlesA[j]!, handlesB[j]!)) return false; + } + } + return true; +} + /** * Get a property value by dot-notation path. * diff --git a/packages/apollo-react/src/canvas/utils/node-height.test.ts b/packages/apollo-react/src/canvas/utils/node-height.test.ts new file mode 100644 index 0000000000..25ba91290f --- /dev/null +++ b/packages/apollo-react/src/canvas/utils/node-height.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest'; +import type { HandleGroupManifest } from '../schema/node-definition'; +import { resolveHandles } from './manifest-resolver'; +import { computeBaseNodeHeight, getIntrinsicNodeHeight } from './node-height'; + +const handles = (position: string, count: number): HandleGroupManifest => + ({ + position, + handles: Array.from({ length: count }, (_, i) => ({ + id: `h${i}`, + type: 'target', + handleType: 'input', + })), + }) as HandleGroupManifest; + +describe('computeBaseNodeHeight', () => { + it('returns the 96px default when handles fit within it', () => { + expect(computeBaseNodeHeight([handles('left', 2)])).toBe(96); + expect(computeBaseNodeHeight([])).toBe(96); + }); + + it('expands with the densest left/right rail', () => { + // 4 handles → (4*2+2)*16 = 160 + expect(computeBaseNodeHeight([handles('left', 4)])).toBe(160); + expect(computeBaseNodeHeight([handles('left', 2), handles('right', 4)])).toBe(160); + // Top/bottom rails never contribute to the floor. + expect(computeBaseNodeHeight([handles('top', 8)])).toBe(96); + }); + + it('uses the fixed footer height as the floor when it exceeds the handle floor', () => { + expect( + computeBaseNodeHeight([handles('left', 2)], { hasFooter: true, footerVariant: 'single' }) + ).toBe(160); + expect(getIntrinsicNodeHeight(true, 'double')).toBe(176); + expect(getIntrinsicNodeHeight(false, undefined)).toBe(96); + }); + + it('skips groups and handles hidden by boolean visibility', () => { + const hiddenGroup = { ...handles('left', 4), visible: false }; + expect(computeBaseNodeHeight([hiddenGroup])).toBe(96); + + const partiallyHidden = { + position: 'left', + handles: [ + { id: 'a', type: 'target', handleType: 'input' }, + { id: 'b', type: 'target', handleType: 'input', visible: false }, + ], + } as HandleGroupManifest; + expect(computeBaseNodeHeight([partiallyHidden, handles('right', 4)])).toBe(160); + }); + + // The seeding contract: a raw manifest counted WITH resolutionContext must + // agree exactly with counting the resolveHandles output, because BaseNode + // computes from the resolved set. A drifting seed would re-introduce the + // mount height write the seeding API exists to eliminate. + describe('resolutionContext parity with BaseNode', () => { + const dynamicManifest: HandleGroupManifest[] = [ + { + position: 'right', + handles: [ + { + id: 'case-{index}', + type: 'source', + handleType: 'output', + repeat: 'cases', + }, + { id: 'error', type: 'source', handleType: 'output', visible: 'hasErrorBranch' }, + ], + }, + ] as HandleGroupManifest[]; + + const data = { + cases: [{}, {}, {}, {}], + hasErrorBranch: false, + }; + + it('expands repeat handles and evaluates string visibility', () => { + // 4 repeat-expanded + error hidden → 4 right handles → 160px. + expect(computeBaseNodeHeight(dynamicManifest, { resolutionContext: data })).toBe(160); + // Raw counting (no context) would see 2 configured handles → 96px floor, + // which is exactly the drift the option exists to prevent. + expect(computeBaseNodeHeight(dynamicManifest)).toBe(96); + }); + + it('matches counting the resolveHandles output exactly', () => { + const resolved = resolveHandles(dynamicManifest, data); + expect(computeBaseNodeHeight(dynamicManifest, { resolutionContext: data })).toBe( + computeBaseNodeHeight(resolved) + ); + }); + }); +}); diff --git a/packages/apollo-react/src/canvas/utils/node-height.ts b/packages/apollo-react/src/canvas/utils/node-height.ts new file mode 100644 index 0000000000..7731995cb1 --- /dev/null +++ b/packages/apollo-react/src/canvas/utils/node-height.ts @@ -0,0 +1,95 @@ +/** + * Node height computation. + * + * The canonical height rule for BaseNode-style nodes: the maximum of the + * intrinsic height (default, or the fixed footer-variant height) and the + * handle floor derived from the densest left/right handle rail. + * + * BaseNode uses this internally and writes the result to `node.height`. + * Consumers creating nodes can call `computeBaseNodeHeight` up front to seed + * `height` on the node object; a correctly seeded node skips the height + * write-back entirely on mount (no store write, at any canvas size). + * + * IMPORTANT: when seeding from a manifest, pass `resolutionContext` (the + * node's data) so `repeat` handles and string-visibility expressions resolve + * exactly as BaseNode resolves them. Counting raw manifest groups would treat + * a repeat handle as one handle and a string `visible` as visible, producing + * a seed that disagrees with BaseNode's computed height. + */ +import { Position } from '@uipath/apollo-react/canvas/xyflow/react'; +import type { FooterVariant } from '../components/BaseNode/BaseNode.types'; +import { + GRID_SPACING, + NODE_HEIGHT_DEFAULT, + NODE_HEIGHT_FOOTER_BUTTON, + NODE_HEIGHT_FOOTER_DOUBLE, + NODE_HEIGHT_FOOTER_SINGLE, +} from '../constants'; +import type { HandleGroupManifest } from '../schema/node-definition'; +import { type ResolutionContext, resolveHandles } from './manifest-resolver'; + +/** Intrinsic height: the fixed footer height when a footer is present, else the default. */ +export const getIntrinsicNodeHeight = ( + hasFooter: boolean, + footerVariant: FooterVariant | undefined +): number => { + if (hasFooter) { + switch (footerVariant) { + case 'button': + return NODE_HEIGHT_FOOTER_BUTTON; + case 'single': + return NODE_HEIGHT_FOOTER_SINGLE; + case 'double': + return NODE_HEIGHT_FOOTER_DOUBLE; + } + } + return NODE_HEIGHT_DEFAULT; +}; + +const countVisibleSideHandles = ( + handleGroups: readonly HandleGroupManifest[], + side: Position +): number => { + let count = 0; + for (const group of handleGroups) { + if (group.position !== side || group.visible === false) continue; + for (const handle of group.handles) { + if (handle.visible !== false) count++; + } + } + return count; +}; + +export interface ComputeBaseNodeHeightOptions { + hasFooter?: boolean; + footerVariant?: FooterVariant; + /** + * Resolution context (typically the node's `data`) for UNRESOLVED manifest + * groups: `repeat` handles are expanded and string-visibility expressions + * evaluated against it before counting, matching BaseNode exactly. Omit only + * when `handleGroups` is already the output of `resolveHandles`. + */ + resolutionContext?: ResolutionContext; +} + +/** + * Computed node height: max of the handle-count floor and the intrinsic + * default/footer height. Pure function of handle configuration and footer; + * it never reads a measured height, so it is stable across renders. + */ +export function computeBaseNodeHeight( + handleGroups: readonly HandleGroupManifest[], + { hasFooter = false, footerVariant, resolutionContext }: ComputeBaseNodeHeightOptions = {} +): number { + const groups = resolutionContext + ? resolveHandles(handleGroups as HandleGroupManifest[], resolutionContext) + : handleGroups; + const leftHandles = countVisibleSideHandles(groups, Position.Left); + const rightHandles = countVisibleSideHandles(groups, Position.Right); + const leftRightHandles = Math.max(leftHandles, rightHandles); + + // Each handle gets a 2-grid-space lane (32px), plus 2-grid-space padding at top + bottom of node. + const handleFloor = (leftRightHandles * 2 + 2) * GRID_SPACING; + + return Math.max(getIntrinsicNodeHeight(hasFooter, footerVariant), handleFloor); +} diff --git a/packages/apollo-react/tsconfig.json b/packages/apollo-react/tsconfig.json index 1c117b932e..940998230f 100644 --- a/packages/apollo-react/tsconfig.json +++ b/packages/apollo-react/tsconfig.json @@ -35,6 +35,10 @@ "**/*.test.tsx", "**/*.spec.ts", "**/*.spec.tsx", + "**/*.bench.ts", + "**/*.bench.tsx", + "**/*.perf-fixtures.ts", + "**/*.perf-fixtures.tsx", "**/*.stories.ts", "**/*.stories.tsx" ], diff --git a/packages/apollo-react/vitest.config.ts b/packages/apollo-react/vitest.config.ts index 133673ec3b..1ee356fb41 100644 --- a/packages/apollo-react/vitest.config.ts +++ b/packages/apollo-react/vitest.config.ts @@ -1,7 +1,12 @@ import { fileURLToPath } from 'node:url'; import react from '@vitejs/plugin-react'; -import { defineConfig } from 'vitest/config'; +import { defaultExclude, defineConfig } from 'vitest/config'; + +// Vitest 4 dropped `**/dist/**` from its default exclude, so build output that +// matches a test or bench glob gets picked up alongside the sources it came +// from. Keep dist out of both runners explicitly. +const exclude = [...defaultExclude, '**/dist/**']; export default defineConfig({ plugins: [ @@ -16,6 +21,10 @@ export default defineConfig({ environment: 'happy-dom', setupFiles: ['./src/test/setup.ts'], passWithNoTests: true, + exclude, + benchmark: { + exclude, + }, coverage: { provider: 'v8', reporter: ['text', 'json', 'json-summary', 'html', 'lcov'],