Files
PaperClipAI/rules/ui-render-cost.mdc
T
Nicky LeachandClaude Opus 5.5 832ede93ed fix(apps): stop the Connectors page from pegging the CPU on managed instances (#19380)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The Connectors page (`/apps`) merges native connectors with about
1,600 apps from the Composio and Arcade catalogs
> - #19328 made some native connectors yield to aggregator sign-in on
managed instances. To decide, it checks whether any aggregator serves
each native row
> - That check scans the whole catalog. It runs once per native row for
every catalog app, so one pass makes about 1,600 × 110 × 1,600 name
comparisons
> - The pass also re-runs on every render, because a memo dependency is
a `Map` rebuilt in the component body. Aggregator polling keeps renders
coming
> - On a managed instance, the page pegs a CPU core and does not become
usable. Self-hosted instances skip the branch, and the tests used one or
two catalog entries, so nothing caught it
> - This pull request makes the check a set lookup, keeps the memo
inputs stable, and adds a test at shipped catalog scale plus a general
review rule for rendering large collections
> - The benefit is that `/apps` loads normally on managed instances
again, and similar regressions fail a test or get flagged in review

## Linked Issues or Issue Description

Refs #19328
Refs #19247

**What happened?**
On a managed instance, opening `/apps` pegs the CPU at 100%, and the
page is very slow or unresponsive.

**Steps to reproduce**
1. Open a managed instance (`useCloudInstance()?.managed === true`).
2. Go to `/apps`.
3. The tab holds a CPU core at 100%.

**Expected behavior**
The Connectors page renders in well under a second, and it does not
re-run catalog work on unrelated renders.

**Root cause**
In `ui/src/pages/apps/Browse.tsx`, `yieldsToAggregators()` calls
`hasAggregatorRoute()`. That function scans every aggregator catalog
entry with regex normalization. The `managedCatalog` loop calls
`yieldsToAggregators()` for every native row, for each of about 1,600
catalog apps. A Node benchmark with the shipped catalog data measured
about 214 s per pass on a managed instance and 40 ms self-hosted. The
`rows` memo depended on `upstreamAppsBySlug` and `gallery`, which were
rebuilt on every render, so the memo never reused its result.

## What Changed

- Index every aggregator identity once (`managedCatalogIdentities`), so
the aggregator route check is one `Set` lookup.
- Cache each slug's yield decision for the pass. Rows hold their final
connections and chat endpoints by then, so the answer cannot change.
- Normalize native row identities once before matching them against
catalog apps.
- Memoize `gallery`, the aggregator snapshots, `upstreamAppsBySlug` and
`managedCatalog`. A small `useShallowStableItems` helper keeps the
per-gateway `useQueries` data stable, so the `rows` memo runs only when
connector data changes.
- Match catalog apps to native rows through an identity-to-row index
instead of scanning the native rows for each catalog app. The earliest
row among an app's alias lookups is the row the old ordered scan found.
- Add a test that loads the shipped aggregator catalog and every native
connector in managed-instance mode, with a saved Composio gateway and
account. It counts `aggregatorAppIdentity` calls. The full load must
stay under 20× the catalog name count (today about 5×). An unchanged
refetch and a search must not rebuild the rows, and a changed account
must still appear. Above the limit the counter throws, so a regression
fails in milliseconds instead of blocking the test worker.
- Add `rules/ui-render-cost.mdc`, a general rule for `ui/src/**` code
that renders large collections: no nested scans, stable memo
dependencies, and a realistic-size test with a deterministic work
counter when a change touches such a path. Register it in
`.greptile/files.json`.

## Verification

- `cd ui && npx vitest run src/pages/apps/Browse.test.tsx`: 100 of 100
pass.
- With the previous `Browse.tsx` and the new test, the new test fails in
70 ms with "Connector catalog work exceeded its budget".
- Measured on the new code: a full managed-instance load makes 25,810
identity calls for 5,095 catalog names. A search makes 0.
- `node scripts/run-tsc.mjs -b` in `ui/`: no new errors. The only errors
are in `PluginOrganizationSwitcher`, because `@paperclipai/plugin-sdk`
is not built in a fresh worktree. They are unrelated to this change.
- `pnpm check:token-gates`: all gates clean.
- I did not check this in a browser on a managed instance. A local
self-hosted instance does not take that branch.

## Risks

- Low. The row-building logic gives the same results. The existing
managed-instance Slack, Asana and Google tests still pass.
- `useShallowStableItems` writes a ref during render. In the worst case,
a concurrent render does one extra memo pass. It cannot return stale
data, because it compares every item by identity.
- The new rule applies to all `ui/src/**` reviews. Its body limits it to
large or unbounded collections, to keep review noise low.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- Anthropic Claude Opus 5.5 (`claude-opus-5-5`) in Claude Code, with
tool use (shell, file editing, test execution).

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 06:00:31 -06:00

61 lines
2.9 KiB
Plaintext

---
description: Keep UI render work proportional to collection size, keep memo inputs stable, and test large-collection paths at realistic size.
globs: "ui/src/**/*.tsx,ui/src/**/use*.ts"
alwaysApply: false
---
# Render work must stay proportional to its data
Applies to components (`ui/src/**/*.tsx`) and hooks (`ui/src/**/use*.ts`) that
iterate over a collection during render, in a `useMemo`, or in a selector, when
the collection can be large. Large means hundreds of items or more. Examples are
catalogs shipped with the app and lists that grow with a company's data, such
as tasks, runs, agents, connections and activity.
## Why
Render code runs on the main thread, often many times per second while queries
poll or the user types. Work that is quadratic or worse in collection size
looks free with test fixtures of a few items. At real size it can freeze the
tab. Two patterns cause most of these problems:
- **A hidden inner scan.** A loop over one collection calls a helper that scans
another collection with `some`, `find`, `filter` or `includes`. Each line looks
cheap, but the cost is the product of both sizes, and nesting multiplies again.
- **A memo that never hits.** A `useMemo` depends on an object, array or `Map`
that the component body builds again on every render. The memo then
recomputes on every render, so expensive work runs on each keystroke and
each poll.
## Requirements
- Do not scan a large collection inside a loop over another large collection.
Build a `Set` or `Map` index once, outside the loop, and look items up in it.
- Normalize keys (slugs, lowercase names, identities) once per item, not inside
comparison callbacks.
- Keep every `useMemo` and `useCallback` dependency referentially stable across
renders that change no data. Memoize derived objects, or derive them from
query `data`, which TanStack Query keeps structurally shared. `useQueries`
returns a new array on every render, so do not use it directly as a
dependency.
- When a change adds or reorders work over a large collection, test that path
with realistic data size. Load shipped data with `vi.importActual` instead of
a few hand-written items, and turn on every flag or mode that gates the path.
Bound the work with a deterministic counter, such as calls to a key
normalizer, not with wall-clock time. Make the counter throw above its limit,
so a runaway render fails at once instead of stalling the test worker.
## Exceptions
- Small fixed lists, such as status options, tabs or a few providers.
- A nested loop where both sides are bounded and small, such as one task's
comments matched against its attachments.
## Review criteria
- Flag nested iteration where both collections can be large, including an
inner call that hides a scan.
- Flag `useMemo` or `useCallback` dependencies that are rebuilt every render.
- Flag large-collection paths behind a flag or mode whose only tests use a few
fixture items.