Audit BaseNode's render path at 500-node scale, fix the findings, and pin
the invariants that keep large canvases viable.
Audit and guards:
- PERFORMANCE.md: audit report with baseline numbers, ranked findings
F1-F6, fix status, and a before/after improvements table
- BaseNode.perf.test.tsx: deterministic render-count guards at N=500 (one
render per node on mount, position-only sweeps render zero bodies,
select/hover/data edits re-render exactly one node, height write-back
converges with at most one store write per node and zero when seeded)
- ConnectedHandlesContext.perf.test.tsx: granular notification guards (an
edge change notifies only its endpoint nodes; identical edge arrays and
untouched nodes keep stable Set snapshots)
- BaseNode.bench.tsx + canvas-scale.bench.ts: vitest benchmarks for the
component pipeline and per-node hot paths (resolveHandles, resolveDisplay,
memo comparator, resolveCollisions), run via the new `bench` script
Fixes:
- F1: resolve handle configurations once in BaseNode for every source
(context override, data override, manifest) and pass preResolved to
useButtonHandles, which skips its internal re-resolution and its
node-data memo dependency on that path (1000 -> 500 resolution passes
per 500-node mount). Other callers keep the previous behavior, and the
manifest-path field whitelist is preserved so previously-ignored
manifest group fields (customPositionAndOffsets, boundary) still do not
reach the handle renderers.
- F2: drop interaction state (isConnecting/isSelected/isDragging) from
statusContext in BaseNode and LoopNode; neither resolveToolbar nor
resolveAdornments reads it, so connect gestures no longer re-resolve
toolbars and adornments across all nodes (2000 -> 0 resolver runs per
gesture at 500 nodes). Interaction-driven toolbar behavior stays in
offsetToolbar and NodeToolbar props, and the now never-populated
NodeStatusContext interaction fields are deprecated.
- F3: execution/validation status hooks now read the context getter
during render (memoized) instead of setState-in-effect: state is
available on the first render and each published update costs one
render per node instead of two.
- F4: extract computeBaseNodeHeight to utils/node-height (exported) so
consumers can seed node.height at creation and skip the mount
write-back; BaseNode uses the same function so the rule cannot drift.
It accepts a resolutionContext so heights seeded from raw manifests
resolve repeat/string-visibility handles exactly like BaseNode.
- F5: add the missing useSmartHandles dependency to
toolbarSideHandleAffordances (stale toolbar offset under context
handle overrides).
- F6: keep the previous handleConfigurations identity when resolution
output is value-identical (areResolvedHandleGroupsEqual), so
label-only data edits no longer trigger updateNodeInternals DOM
re-measures or handle element rebuilds.
Bench: 500-node mount improved ~15% (mean ~488ms -> ~412ms, happy-dom,
component code only).
Build hygiene: the rslib entry glob excluded test and stories files but
not bench or perf fixtures, so those compiled into dist and shipped in the
published package requiring vitest and @testing-library/react; Vitest 4
also dropped `**/dist/**` from its default exclude, so the bench glob
matched that build output. Exclude bench and perf fixtures from the rslib
entry and tsconfig (which also stops empty declaration stubs) and exclude
dist from both the test and benchmark runners. Bench files are therefore
no longer covered by the dts type check, matching the existing treatment
of test files.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Summary
Comprehensive performance audit of
BaseNodeand its render path at 500-node scale, with deterministic regression tests and benchmarks to prevent future regressions. All identified issues are fixed and pinned by tests.Verdict: 500 nodes is comfortably supported. Mount costs ~370ms (one-time), and steady-state interactions are O(1): selecting/hovering a node re-renders exactly that node, position sweeps cost ~2.7ms.

Key Changes
New Test & Benchmark Files
BaseNode.perf.test.tsx(308 lines): Deterministic render-count regression guards at 500-node scaleConnectedHandlesContext.perf.test.tsx(133 lines): Subscription-isolation guardsBaseNode.bench.tsx(113 lines): Timing benchmarks (non-CI-gating)canvas-scale.bench.ts(142 lines): Pure-function benchmarks for hot pathsresolveHandles,resolveDisplay,areNodePropsEqualIgnoringPosition,resolveCollisionsPERFORMANCE.md(221 lines): Audit findings, current numbers, and design decisions that scale wellnode-height.test.ts(92 lines): Height computation tests with resolution-context parityExecutionStatusContext.test.tsx(88 lines): Render-efficiency regression tests for status hooksFixture & Utility Files
BaseNode.perf-fixtures.tsx: Shared test/bench data builders and provider stacknode-height.ts: Extracted height computation logic with public API for seeding nodesCore Fixes (All Pinned by Tests)
F1: Handles resolved twice per node — FIXED
BaseNodenow resolves handles once and passespreResolvedtouseButtonHandlesuseButtonHandlesskips internal resolution whenpreResolvedis providedcustomPositionAndOffsets,boundary)onAction, etc.)areResolvedHandleGroupsEqualcomparator to avoid cascading invalidationF2: Connect gestures re-resolved toolbars/adornments on all nodes — FIXED
statusContextnow carries onlynodeId,executionState,validationState,modeisConnecting,isSelected,isDragging(neither resolver reads them)offsetToolbarand NodeToolbar propsF3: Execution/validation hooks double-rendered per update — FIXED
useNodeExecutionStateanduseElementValidationStatusnow read state viauseMemoon context identityuseState-in-effect pattern; state available on first renderModified Files
BaseNode.tsx: RemovedisConnecting/isSelected/isDraggingfromstatusContext, integrated height computation, handle resolution refactoreduseButtonHandles.tsx: AddedpreResolvedpath with inert subscription sentinelExecutionStatusContext.tsx: Switched fromuseState-in-effect touseMemogetter patternValidationStatusContext.tsx: Same pattern as ExecutionStatusContexthttps://claude.ai/code/session_01UNiMAk1eE9HTe3N5RD1wHD