Skip to content

Audit BaseNode render isolation at 500-node scale - #1063

Draft
snuziale wants to merge 4 commits into
mainfrom
claude/basenode-audit-performance-td8ca5
Draft

Audit BaseNode render isolation at 500-node scale#1063
snuziale wants to merge 4 commits into
mainfrom
claude/basenode-audit-performance-td8ca5

Conversation

@snuziale

@snuziale snuziale commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

Summary

Comprehensive performance audit of BaseNode and 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.
image

Key Changes

New Test & Benchmark Files

  • BaseNode.perf.test.tsx (308 lines): Deterministic render-count regression guards at 500-node scale

    • Mounts N nodes with exactly one body render per node (no cascades)
    • Height write-back performs at most one store write per node on first mount
    • Absolute-position-only changes never re-render node bodies (memo fast path)
    • Single-node interactions (select/hover/edit) re-render only that node
    • Handle resolution occurs exactly once per node on mount
  • ConnectedHandlesContext.perf.test.tsx (133 lines): Subscription-isolation guards

    • Edge changes notify only touched nodes
    • New edges array with identical content notifies nobody (set reuse)
  • BaseNode.bench.tsx (113 lines): Timing benchmarks (non-CI-gating)

    • Mount 500 nodes, per-node cost floor, position-only sweeps, single-node interactions
  • canvas-scale.bench.ts (142 lines): Pure-function benchmarks for hot paths

    • resolveHandles, resolveDisplay, areNodePropsEqualIgnoringPosition, resolveCollisions
  • PERFORMANCE.md (221 lines): Audit findings, current numbers, and design decisions that scale well

  • node-height.test.ts (92 lines): Height computation tests with resolution-context parity

  • ExecutionStatusContext.test.tsx (88 lines): Render-efficiency regression tests for status hooks

Fixture & Utility Files

  • BaseNode.perf-fixtures.tsx: Shared test/bench data builders and provider stack
  • node-height.ts: Extracted height computation logic with public API for seeding nodes

Core Fixes (All Pinned by Tests)

F1: Handles resolved twice per node — FIXED

  • BaseNode now resolves handles once and passes preResolved to useButtonHandles
  • useButtonHandles skips internal resolution when preResolved is provided
  • Manifest path applies field whitelist (strips customPositionAndOffsets, boundary)
  • Override configs keep all runtime fields (onAction, etc.)
  • Added areResolvedHandleGroupsEqual comparator to avoid cascading invalidation

F2: Connect gestures re-resolved toolbars/adornments on all nodes — FIXED

  • statusContext now carries only nodeId, executionState, validationState, mode
  • Removed isConnecting, isSelected, isDragging (neither resolver reads them)
  • Interaction-dependent toolbar behavior remains in offsetToolbar and NodeToolbar props

F3: Execution/validation hooks double-rendered per update — FIXED

  • useNodeExecutionState and useElementValidationStatus now read state via useMemo on context identity
  • Removed useState-in-effect pattern; state available on first render
  • Each update costs one render per node instead of two

Modified Files

  • BaseNode.tsx: Removed isConnecting/isSelected/isDragging from statusContext, integrated height computation, handle resolution refactored
  • useButtonHandles.tsx: Added preResolved path with inert subscription sentinel
  • ExecutionStatusContext.tsx: Switched from useState-in-effect to useMemo getter pattern
  • ValidationStatusContext.tsx: Same pattern as ExecutionStatusContext

https://claude.ai/code/session_01UNiMAk1eE9HTe3N5RD1wHD

Copilot AI lite review requested due to automatic review settings August 18, 2026 00:52

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@github-actions

github-actions Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Apollo Coded App preview deployments are ready.

Project Status Preview Updated (PT)
apollo-design Ready Preview · Logs Aug 20, 2026, 01:07:58 PM
apollo-docs Ready Preview · Logs Aug 20, 2026, 01:07:58 PM
apollo-landing Ready Preview · Logs Aug 20, 2026, 01:07:58 PM
apollo-vertex Ready Preview · Logs Aug 20, 2026, 01:07:58 PM

@github-actions github-actions Bot added the size:XXL 1,000+ changed lines. label Aug 18, 2026
@github-actions

github-actions Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Dependency License Review

  • 1948 package(s) scanned
  • ✅ No license issues found
  • ⚠️ 2 package(s) excluded (see details below)
License distribution
License Packages
MIT 1719
ISC 88
Apache-2.0 55
BSD-3-Clause 27
BSD-2-Clause 23
BlueOak-1.0.0 8
MPL-2.0 4
MIT-0 3
CC0-1.0 3
MIT OR Apache-2.0 2
(MIT OR Apache-2.0) 2
Unlicense 2
LGPL-3.0-or-later 1
Python-2.0 1
CC-BY-4.0 1
(MPL-2.0 OR Apache-2.0) 1
Unknown 1
Artistic-2.0 1
(WTFPL OR MIT) 1
(BSD-2-Clause OR MIT OR Apache-2.0) 1
CC-BY-3.0 1
0BSD 1
(MIT OR CC0-1.0) 1
MIT AND ISC 1
Excluded packages
Package Version License Reason
@img/sharp-libvips-linux-x64 1.3.2 LGPL-3.0-or-later LGPL pre-built binary, not linked
khroma 2.1.0 Unknown MIT per GitHub repo, missing license field in package.json

@github-actions

github-actions Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

📊 Coverage + size by package

Per-package coverage and bundle size on this PR. New-line coverage = of the source lines this PR adds or changes, the % hit by tests.

Package Coverage New-line coverage Packed (gzip) Unpacked vs main
@uipath/apollo-core 41.59 MB 49.70 MB ±0
@uipath/apollo-react 40.2% 52.6% (70/133) 7.54 MB 29.03 MB +1.9 KB
@uipath/apollo-ui-icons 2.85 MB 6.91 MB ±0
@uipath/apollo-wind 416.0 KB 2.66 MB +7 B
@uipath/ap-chat 85.8% 43.46 MB 56.09 MB ±0

"Coverage" is each package's own coverage.include scope (e.g. apollo-core instruments only scripts/). "Packed"/"Unpacked" come from npm pack --dry-run and only cover built packages — "—" means not measured this run (package not affected / not built). "vs main" is the packed (gzipped) delta against the last successful main build (the package-sizes artifact from the Release workflow); "—" there means no main baseline was available this run. The baseline is main's latest build, not this PR's exact merge-base, so it includes any drift since the branch diverged. Packages with no vitest config are omitted.

@github-actions

github-actions Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Storybook visual diff

⚠️ Visual changes detected: 10 changed (of 310 compared, 300 unchanged). View report

Baseline is the deployed main Storybook, so changes merged to main after this branch was last updated can also appear here. Logs

Updated (PT): Aug 20, 2026, 01:25:29 PM

Copilot AI review requested due to automatic review settings August 18, 2026 02:09

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

claude and others added 4 commits August 20, 2026 12:57
…egression guards

Audit BaseNode's render path at 500-node scale and pin the invariants that
make large canvases viable:

- 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
- PERFORMANCE.md: audit report with baseline numbers, ranked findings, and
  recommendations (double handle resolution, connect-gesture invalidation,
  execution-status context fan-out, mount write burst, stale memo dep)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UNiMAk1eE9HTe3N5RD1wHD
Implements the fixes for findings F1-F6 from the BaseNode performance
audit (PERFORMANCE.md), each pinned by a new regression test:

- 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.
- 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.
- 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.
- 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). PERFORMANCE.md updated with fix status and a
before/after improvements table.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UNiMAk1eE9HTe3N5RD1wHD
Apply the five findings from the code review of the BaseNode performance
fixes:

- restore the manifest-path field whitelist: single-pass resolution no longer
  lets previously-ignored manifest group fields (customPositionAndOffsets,
  boundary) reach the handle renderers; override configs keep their runtime
  fields exactly as before (pinned by new BaseNode tests)
- computeBaseNodeHeight accepts resolutionContext so heights seeded from raw
  manifests resolve repeat/string-visibility handles exactly like BaseNode
  (parity pinned by utils/node-height.test.ts)
- useButtonHandles points its node-data subscription at an inert sentinel on
  the preResolved path, so pre-resolved nodes carry no live store subscription
- deprecate the never-populated interaction fields on NodeStatusContext and
  document the mode field
- replace stale line references in PERFORMANCE.md with symbol references

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UNiMAk1eE9HTe3N5RD1wHD
…tion

Running `pnpm bench` failed both bench suites with "Vitest cannot be
imported in a CommonJS module using require()".

Two causes combined. The rslib entry glob excluded test and stories files
but not bench or perf fixtures, so those were compiled into dist as CJS
and ESM, and shipped in the published package requiring vitest and
@testing-library/react. Vitest 4 then dropped `**/dist/**` from its
default exclude, which is only `**/node_modules/**` and `**/.git/**`, so
the bench glob matched that build output alongside the sources it came
from.

Exclude bench and perf fixtures from the rslib entry and from tsconfig,
which also stops empty declaration stubs being emitted for them, and
exclude dist from both the test and benchmark runners so build output
cannot be collected again.

Bench files are no longer covered by the dts type check. They are type
clean today, and the package has no typecheck script, so this matches the
existing treatment of test files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings August 20, 2026 19:57
@snuziale
snuziale force-pushed the claude/basenode-audit-performance-td8ca5 branch from a3c1fe2 to 735fd1e Compare August 20, 2026 19:57

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pkg:apollo-react size:XXL 1,000+ changed lines.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants