Skip to content
Closed
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
1 change: 1 addition & 0 deletions packages/apollo-react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions packages/apollo-react/rslib.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/**',
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, number>();
const snapshots = new Map<string, ReadonlySet<string>>();

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) => <Probe key={`node-${i}`} nodeId={`node-${i}`} />),
[]
);
return <ConnectedHandlesProvider edges={edges}>{probes}</ConnectedHandlesProvider>;
};

/** 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(<Harness edges={edges} />);
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(<Harness edges={added} />);

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(<Harness edges={edges} />);
const baseline = totalRenders();

rerender(<Harness edges={edges.map((e) => ({ ...e }))} />);

expect(totalRenders()).toBe(baseline);
});

it('removing one edge notifies only the nodes that lose a connection', () => {
const edges = makeChainEdges(N);
const { rerender } = render(<Harness edges={edges} />);
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(<Harness edges={edges.filter((e) => 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(<Harness edges={edges} />);
const before = snapshots.get('node-100');

rerender(
<Harness
edges={[
...edges,
{ id: 'x', source: 'node-1', sourceHandle: 's2', target: 'node-2', targetHandle: 't2' },
]}
/>
);

// 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);
});
});
Original file line number Diff line number Diff line change
@@ -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<string, { height?: number }>(),
rfState: { current: { connection: { inProgress: false } } },
}));

vi.mock('@uipath/apollo-react/canvas/xyflow/react', async (importOriginal) => {
const actual = await importOriginal<typeof import('@uipath/apollo-react/canvas/xyflow/react')>();
return {
...actual,
Handle: ({ id, type }: { id?: string; type?: string }) => (
<div data-handleid={id} data-handletype={type} />
),
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<typeof import('@xyflow/react')>()),
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(<NodeGrid nodes={baseNodes} />);
view.unmount();
},
{ time: 0, iterations: 5, warmupIterations: 1 }
);

bench(
'mount 1 BaseNode (per-node cost floor)',
() => {
const view = render(<NodeGrid nodes={baseNodes.slice(0, 1)} />);
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<typeof render> | null = null;
let offset = 0;
bench(
`position-only re-render sweep across ${PERF_NODE_COUNT} nodes (memo fast path)`,
() => {
if (!view) view = render(<NodeGrid nodes={baseNodes} />);
offset += 1;
view.rerender(
<NodeGrid
nodes={baseNodes.map((p) => ({
...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<typeof render> | null = null;
let selected = false;
bench(
`toggle selection of 1 node among ${PERF_NODE_COUNT}`,
() => {
if (!view) view = render(<NodeGrid nodes={baseNodes} />);
selected = !selected;
const next = [...baseNodes];
next[0] = { ...baseNodes[0]!, selected };
view.rerender(<NodeGrid nodes={next} />);
},
{ time: 1000 }
);
}
Original file line number Diff line number Diff line change
@@ -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<Node<BaseNodeData>>;

export const makeNodeProps = (
i: number,
overrides: Partial<PerfNodeProps> = {}
): 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;
}) => (
<NodeRegistryProvider manifest={MANIFEST_BUNDLE}>
<BaseCanvasModeProvider mode="design">
<BaseNodeOverrideConfigProvider value={overrides}>
{nodes.map((p) => (
<BaseNode key={p.id} {...p} />
))}
</BaseNodeOverrideConfigProvider>
</BaseCanvasModeProvider>
</NodeRegistryProvider>
);
Loading
Loading