Skip to content

feat(middleware): aggregate React render reports and surface component errors - #472

Merged
V3RON merged 2 commits into
mainfrom
feat/react-agent-render-reports
Aug 31, 2026
Merged

feat(middleware): aggregate React render reports and surface component errors#472
V3RON merged 2 commits into
mainfrom
feat/react-agent-render-reports

Conversation

@V3RON

@V3RON V3RON commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Description

Makes the react agent domain answer render-performance questions in one call, and surfaces the error/warning counts React already reports.

  • getComponentRenders aggregates a whole profiling session into one row per component — render count, total/average/max render time, self time, and why it rendered — sortable five ways and filterable to a single component. Ports the useful part of agent-react-devtools' slow, rerenders and report commands as one tool.
  • getProfileTimeline lists every commit with its duration and rendered-fiber count, chronologically or slowest first, and stays queryable for the life of the session.
  • getErrors lists the components React logged errors or warnings against. TREE_OPERATION_UPDATE_ERRORS_OR_WARNINGS was previously parsed over and discarded; the counts now also appear on ordinary tree and node reads.
  • noHost (opt-in) on getTree / getChildren / searchNodes hides plain host components, promoting their children to the nearest visible ancestor.
  • maxValueLength (default 512) on getComponent / getProps / getState / getHooks caps serialized string width.

Related Issue

Closes #473

It implements the profiling and errors/warnings briefs already in docs/react-agent-features/ (04-errors-warnings.md, 05-profile-convenience-tools.md, 06-host-filtering.md).

Context

Why the aggregation tool. Getting from stopProfiling to "which component is slow" previously meant reading topSlowCommits, calling getRenderData once per commit at 20 items a page, and aggregating across commits in the agent's head. On a 40-commit session that is 40+ round trips and tens of thousands of tokens, and cross-commit aggregation done by a model is exactly the kind of thing that silently double-counts or drops fibers. getComponentRenders does it server-side in one call. getRenderData is unchanged and remains the way to drill into a single commit.

Aggregates are keyed by (rootId, fiberId), not fiberId alone — fiber IDs are only unique within a renderer, so merging on ID would invent a component that rendered under two roots. Roots whose data arrived from more than one renderer are excluded and reported in summary.skippedRootIds rather than dropped silently.

Why noHost is opt-in. It was built default-on, on the assumption that host components dominate a React Native tree. They do not reach us at all: getDefaultComponentFilters() in react-devtools-core hides host components at the backend, and Rozenite never overrides that, so host fibers are filtered before they hit the wire. Verified on a live session — getTree returns View as elementType: "function" with no host fibers present. The filter is kept as a safety net for the one case where it fires (a DevTools frontend on the same backend having re-enabled host components), defaults to off so existing behaviour is unchanged, and the tool description and docs say plainly that it is usually a no-op. Making it genuinely useful would mean sending updateComponentFilters, which re-sends the whole tree and invalidates node IDs mid-session — worth its own change, not this one.

Why maxValueLength. valueDepth bounded nesting but nothing bounded width, so a base64 image or serialized blob in a single prop passed through whole.

Refactors, kept to what the feature touched. The cursor encode/decode/validate/slice block was duplicated six times in store.ts; it is now paginateReactList in a new react/pagination.ts, which the three new tools reuse. ReactCommitData and ReactChangeDescription were defined identically in both store.ts and profiling-store.ts, and ReactProfilingCursorPayload duplicated the local cursor type — all deduplicated. Typing getProfilingDataSnapshot / getCommitData on the bridge removed a hand-written structural cast in stopProfiling and the dead Number(x) || 0 coercion around values getNumberMap already normalizes at ingest. Test bridge stubs were three divergent inline objects and are now one builder.

Testing

Automated, from the repository root after git fetch origin main:

  • pnpm checks:affected — typecheck, lint, format all pass
  • pnpm test:affected — 50 tasks pass; 60 tests in the React agent domain, 36 of them new

New unit coverage: operations parsing for errors/warnings (including a malformed update that must not abort the batch), getErrors ordering/pagination/scoping, host filtering (promotion, significant hosts, cursor scoping), the profiling aggregates (cross-commit unioning of change descriptions, per-root fiber-ID isolation, unmounted-fiber fallback, sorting, pagination, missing-data errors), and value truncation.

Manual, against the playground on an iPhone 17 Pro simulator driven through the rozenite CLI:

  • Verified all 15 tools register and that getComponentRenders' schema is correct.
  • Verified the live tree returns errorCount/warningCount in the default projection, and that paginated next commands carry their cursor inside --args.
  • Verified against a live session that host fibers never reach the store, which is what prompted noHost becoming opt-in.

End-to-end verification against a real React Native backend is complete and reported in full in a comment below. The headline: for Forget(HomeScreen) over a 4-commit session, per-commit actualDurationMs of [19.034, 16.407, 16.989, 16.873] sums to 69.30300000000001 — bit-for-bit equal to the aggregate's totalDurationMs — and slowestCommitIndex resolves to the commit reporting the aggregate's maxDurationMs. getErrors, pagination to exhaustion, truncation, and timeline totals were all confirmed live too.

One gap worth naming: the noHost filtering path is covered by unit tests only. It cannot execute on a real device, because React DevTools filters host components at the backend before they reach Rozenite — which is why the flag ships defaulting to off.

V3RON added 2 commits August 31, 2026 18:04
…ter host nodes

Add getComponentRenders, getProfileTimeline and getErrors to the React agent
domain, hide plain host components from tree reads by default, and cap the
width of serialized inspected values.
@V3RON

V3RON commented Aug 31, 2026

Copy link
Copy Markdown
Contributor Author

End-to-end verification: complete

Ran against the playground on an iPhone 17 Pro simulator, driven through the rozenite CLI. Everything passes, including the cross-check the PR description flagged as outstanding.

The check that mattered

getComponentRenders aggregates were cross-checked against per-commit getRenderData on a live React Native backend.

Session: root 2417, 4 commits, 69.303ms total.

Aggregate row for Forget(HomeScreen):

{
  "rootId": 2417, "fiberId": 2498, "label": "@c82",
  "displayName": "Forget(HomeScreen)",
  "renderCount": 4,
  "totalDurationMs": 69.30300000000001,
  "avgDurationMs": 17.326,
  "maxDurationMs": 19.034,
  "totalSelfDurationMs": 14.353000000000002,
  "slowRenderCount": 4,
  "slowestCommitIndex": 0
}
  • maxDurationMs vs the commit it points atgetRenderData {"rootId":2417,"commitIndex":0} reports fiber 2498 at actualDurationMs: 19.034, exactly the aggregate's maxDurationMs, confirming slowestCommitIndex resolves to the right commit.
  • totalDurationMs vs the sum of every commit — per-commit values [19.034, 16.407, 16.989, 16.873] sum to 69.30300000000001, bit-for-bit identical to the aggregate.
  • avgDurationMs — 69.303 / 4 = 17.32575, reported as 17.326; the rounding does what it claims.
  • getProfileTimelinesummary.totalCommits: 4 and totalRenderDurationMs: 69.30300000000001 match stopProfiling exactly, and sort: "duration-desc" returns commits in the same order as stopProfiling's topSlowCommits (0, 2, 3, 1).

Other results

Area Result
getErrors 16 Forget(PerfItem) rows at warningCount: 1 each after a probe console.warn, summary.totalWarnings: 16; getNode and searchNodes agree on the count; counts reset to zero on remount
getComponentRenders filtered to one component {"id":"@c82"} → exactly 1 row, numbers identical to the unfiltered aggregate
Sorting render-count-desc reorders without changing the row set; an invalid sort returns the full enum in its error
Pagination 79 rows paged to exhaustion at limit: 40 → 2 pages, 79 unique rows, no duplicates or gaps; next commands run verbatim
maxValueLength "Controls""Cont[+4 chars]" and "Controls plugin""Cont[+11 chars]" at maxValueLength: 4, in both getProps and getComponent; 0 rejected with the documented range error
Registration All 15 tools listed; getComponentRenders schema exposes the five documented sorts
CLI contract Columnar output with the trimmed default projection, summary metadata alongside, full field set under -v

Two findings unrelated to this PR

  1. Playground deep links are broken. apps/playground/app.json declares "scheme": "rozenite" while apps/playground/src/app/App.tsx:136 sets prefixes: ['playground://']. They disagree, so neither form works: playground:// is registered by a different app (com.aitwar.playground, "Cordierite") that is commonly installed on the same simulator and intercepts it, while rozenite:// is delivered to the playground but ignored by React Navigation. docs/agents/playground-testing.md documents the playground:// form throughout. Filed separately.

  2. A Rozenite agent session does not survive an app relaunch. After terminating and relaunching the app, session create returns status: "connected" and inbound-fed tools (getTree, searchNodes, getNode, getErrors) keep working, but everything needing the outbound channel fails — getProps/getComponent with "React DevTools outbound channel is unavailable for this device", and stopProfiling returning totalCommits: 0 with isProcessingData: true stuck indefinitely. Stopping and recreating the session fixes both. The half-working state is misleading enough to be worth a look, but it predates this PR — nothing here touches session or device registration.

@V3RON
V3RON merged commit 312fd97 into main Aug 31, 2026
4 checks passed
@V3RON
V3RON deleted the feat/react-agent-render-reports branch August 31, 2026 16:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

React agent domain: aggregate render reports, queryable commit timeline, and error/warning counts

1 participant