Files
software-mansion__argent/packages/tool-server/test/react-profiler/render-annotations.test.ts
Paweł Fornagiel 62d0fe0c73 refactor: overhaul React profiler - DevTools backend, session ownership, richer data (#156)
## What this PR does

This refactor:
- replaces the DFS fiber-walking hook inside the app's JS runtime with
the **React DevTools backend** as the source of profiling data
- adds a session-ownership system so two tool-server instances can't
silently clobber each other
- gives agent a new status tool for recovery after interruptions
- improves what the profiling analysis report shows the agent

I realise this a big pull request, but changes began to affect each
other when it comes to registered scripts on the react-native side.
This, all-in-all, should however cover our needs when it comes to the
react profiler and if it proves sufficient - we will only have to
iterate on small fixes afterwards.

If I were you, I would skip over the implementation of scripts on the
native-side and focus on what the tool-server does when it comes to
profiling right now.

---

## Profiling workflow

The overall flow has not changed from the agent's perspective - agent
calls `react-profiler-start`, do something in the app, call
`react-profiler-stop`.

What changed is how the profiling data is collected.

**Before:** `react-profiler-start` injected a custom `onCommitFiberRoot`
hook that walked the live fiber tree at every React commit in DFS-like
way and appended records to a global `__ARGENT_DEVTOOLS_COMMITS__` array
in the app's JS heap. On stop, the array was fetched in chunks over CDP.

**Now:** `react-profiler-start` calls `ri.startProfiling(true)` on the
React DevTools backend's `rendererInterface`. React's own profiler
accumulates commit data internally. On stop, a single
`STOP_AND_READ_SCRIPT` call stops the backend and reads
`ri.getProfilingData()` in one round-trip. The DevTools backend data is
richer, more accurate, and doesn't require maintaining a bespoke
fiber-walking implementation.

---

## Session ownership

The profiler runs inside the app's JS heap. There's only one "slot": it
used to be that when two tool-server instances (or the same instance
after a process restart) both try to start profiling, the second call
silently overwrites the first and all data from the first session is
gone.

Right now, there is an approach which blocks silent overtake of the
profiling session.

When `react-profiler-start` runs, it:

1. Reads the current state from the runtime using `READ_STATE_SCRIPT` (a
pure, side-effect-free read).

2. If a session is already running, it reads the
`__ARGENT_PROFILER_OWNER__` object written into `globalThis`, which
contains `sessionId`, `startedAtEpochMs`, and `lastHeartbeatEpochMs`.

3. Classifies the existing session as **fresh** or **stale** using
`classifyStaleness()` (stale = no heartbeat for > 5 minutes).

If the session is fresh and you don't pass `{ force: true }`, start
refuses and returns an `already_running` payload instead of overwriting.
 
4. If the session is stale or `force: true`, it calls
`STOP_FOR_TAKEOVER_SCRIPT` to cleanly stop the prior session, then
starts a new one.

### Heartbeats

Each `sessionId` UUID is written to `__ARGENT_PROFILER_OWNER__` in the
runtime at start. While profiling, any side query tool
(`react-profiler-renders`, `react-profiler-fiber-tree`) that this same
tool-server process calls will bump `lastHeartbeatEpochMs` via
`HEARTBEAT_SCRIPT`. This keeps the session from being classified as
stale as long as the owning process is still making calls.

### What the agent sees when something goes wrong

Previously, if you called `react-profiler-start` into an already-running
session you'd get a silent clobber or a confusing Hermes error. Now:

- **Running session detected, still fresh:** `{ already_running: true,
owner: { sessionId, startedAtEpochMs, lastHeartbeatEpochMs },
age_seconds, how_to_reclaim }` — the agent can read the owner timestamp
and decide whether to tell the user to wait or force-reclaim.
- **Running session detected, stale:** automatic takeover with a log
note.
- **No owner record (e.g. another DevTools client started the
session):** also automatic takeover.

### New `react-profiler-status` tool

A read-only tool that reports the current state without starting or
stopping anything. Returns `session_status` as one of `"active"` /
`"taken_over"` / `"stopped"` / `"no_react_runtime"`, plus the full owner
record. Use this after a debugger disconnect, an unexpected error, or
when the agent restarts mid-session to decide whether to continue,
reclaim, or give up.

The agent has instructions to use the tool whenever it is interrupted
during the profiling by the user or otherwise. Will have to look into
how much this is actually used.

---

## What changed in the data we collect

The old hook read fiber fields directly (`fiber.type.displayName`,
`fiber.memoizedProps`, `fiber.selfBaseDuration`, etc.) at commit time.
This meant:

- Names for **transient components** (modals, tooltips, navigation
screens) that unmounted before stop were already lost - the fiber was
gone.
- Change-detection logic (which props changed, which hooks fired) was
reimplemented manually against the fiber internals.

The new approach reads `ri.getProfilingData()` which gives us the
DevTools backend's own commit log. Each commit entry has
`fiberActualDurations`, `fiberSelfDurations`, `changeDescriptions`,
`timestamp`, and `duration` - the same data React DevTools Profiler
shows in its browser panel.

### Fiber name cache for transient components

To address the lost-name problem, `FIBER_ROOT_TRACKER_SCRIPT` now hooks
`onCommitFiberRoot` and, immediately after the DevTools backend finishes
processing each commit (synchronously, while the fiber is still in
memory), populates `globalThis.__argent_fiberNames__` with `fiberID →
displayName` entries. Because fiber IDs are monotonically increasing and
never reused, this cache never goes stale. `STOP_AND_READ_SCRIPT` uses
this cache as a fallback for any fiber whose name
`getDisplayNameForElementID` can no longer resolve.

### Unattributed fiber tracking

When a transient fiber renders during the session but still can't be
named at stop time (not in the name cache, not in the DevTools instance
map), its work is now **surfaced rather than silently dropped**. The
stop tool computes `unattributedByCommit`: a per-commit tuple of
`[commitIndex, droppedFiberCount, droppedSelfMs]`. This flows through
to:

- The session metadata file on disk (`unattributedByCommit` array).
- The pipeline (`buildHotCommitSummaries` receives and indexes it).
- The `HotCommitSummary` type gains `unattributedMs` and
`unattributedFiberCount`.
- The generated report shows a warning line per commit with unattributed
work, e.g. `⚠️ 12ms unattributed — 3 fibers unmounted before stop`.
- `react-profiler-stop` returns top-level `unattributed_ms`,
`unattributed_fiber_count`, `unattributed_commit_count` in its response
so the agent immediately knows if there's a gap.

### Actual vs. self duration now both surfaced

The old report showed only `selfDuration` (exclusive render time). Both
are now captured in `HotCommitComponentEntry` and rendered in the report
as:

```
- `ComponentName` — 4.2ms self, 18.7ms w/children
```

A legend at the top of the report explains when to sum self and when not
to sum inclusive time. The summary table also shows both columns.

### Component name unwrapping

Display names from the DevTools backend can be wrapped:
`Forget(Memo(MyButton))`, `ForwardRef(Icon)`, etc. The pipeline's render
stage now strips these wrappers using `annotateComponentName()` and
appends a human-readable tag, so the report shows `\`MyButton\`
[React.memo + React Compiler]` instead of the raw internal string. The
raw DevTools name is still used in all tool-call suggestions (since
every pipeline stage keys on the original string).

---

## What scripts do what

All injected JS scripts now live in a single file —
`packages/tool-server/src/utils/react-profiler/scripts.ts` — grouped by
responsibility:

| Script | When injected | What it does |
|---|---|---|
| `REACT_NATIVE_PROFILER_SETUP_SCRIPT` | Every `react-profiler-start`
(idempotent) | Wraps `ri.startProfiling` / `ri.stopProfiling` to track
`isProfiling` state and `startedAtEpochMs` without clock skew; installs
the `__argent_profilerHeartbeat` helper |
| `FIBER_ROOT_TRACKER_SCRIPT` | On CDP connect (idempotent) | Intercepts
`onCommitFiberRoot` to populate the `__argent_fiberNames__` commit-time
name cache |
| `READ_STATE_SCRIPT` | Every `react-profiler-start`,
`react-profiler-status` | Pure read: returns `{ hookExists,
rendererInterfaceFound, isRunning, owner, nowEpochMs }` |
| `buildStartScript(ownerJson)` | `react-profiler-start` on the happy
path | Calls `ri.startProfiling`, writes owner record, returns
post-start verification flags |
| `STOP_FOR_TAKEOVER_SCRIPT` | `react-profiler-start` on the takeover
path | Stops the prior session cleanly before starting a new one |
| `HEARTBEAT_SCRIPT` | `react-profiler-renders`,
`react-profiler-fiber-tree` | Bumps `lastHeartbeatEpochMs` while the
owning process is still active |
| `STOP_AND_READ_SCRIPT` | `react-profiler-stop` | Stops the backend
profiler and reads `getProfilingData()` + name cache in one round-trip |
| `RESOLVE_FIBER_META_SCRIPT` | `react-profiler-stop` (if commits > 0) |
Walks the live fiber tree to collect `hookTypes`, `parentName`, and
`isCompilerOptimized` metadata per component |
2026-04-29 19:20:50 +02:00

123 lines
4.2 KiB
TypeScript

import { describe, it, expect } from "vitest";
import { promises as fs } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { renderProfilingReport } from "../../src/utils/react-profiler/pipeline/05-render";
import type { HotCommitSummary } from "../../src/utils/react-profiler/types/output";
import type { SessionContext } from "../../src/utils/react-profiler/types/pipeline";
// User-reported scenario (profiler-react19-investigation.md):
// commit #50 happens 216ms after the "tap post" annotation, but the legacy
// renderer subtracted `minTs` (the first hot commit's offset) from each
// commit's timestamp — shifting the commit side out of the annotation's
// reference frame and matching commit #50 to "scroll feed" 19s earlier.
//
// `BackendCommitData.timestamp` is already "ms since startProfiling"
// (set by React DevTools as `performance.now() - profilingStartTime`),
// and `annotation.offsetMs` is `tapTimestampMs - startedAtEpochMs`. Both
// share the "ms since profile-start" frame, so the correct relativeMs is
// just `summary.timestampMs` — no anchor needed.
const COMMIT_0_OFFSET = 17_587;
const COMMIT_50_OFFSET = 192_287;
const SUMMARIES: HotCommitSummary[] = [
{
commitIndex: 0,
timestampMs: COMMIT_0_OFFSET,
totalRenderMs: 100,
isMargin: false,
tier: "hot",
components: [],
totalComponentCount: 0,
},
{
commitIndex: 50,
timestampMs: COMMIT_50_OFFSET,
totalRenderMs: 100,
isMargin: false,
tier: "hot",
components: [],
totalComponentCount: 0,
},
];
const ANNOTATIONS = [
{ offsetMs: 173_110, label: "scroll feed" },
{ offsetMs: 192_071, label: "tap post" },
];
const SESSION_CONTEXT: SessionContext = {
reactCompilerEnabled: false,
strictModeEnabled: false,
buildMode: "dev",
rnArchitecture: "bridgeless",
projectRoot: "/tmp/fake-project",
platform: "ios",
};
async function makeDebugDir(): Promise<string> {
const dir = join(
tmpdir(),
`argent-render-annotations-${Date.now()}-${Math.random().toString(36).slice(2)}`
);
await fs.mkdir(dir, { recursive: true });
return dir;
}
describe("renderProfilingReport — annotation reference frame", () => {
it("matches the annotation immediately preceding a commit (regression for the user's scenario)", async () => {
const debugDir = await makeDebugDir();
const { report } = await renderProfilingReport({
hotCommitSummaries: SUMMARIES,
componentFindings: [],
sessionContext: SESSION_CONTEXT,
recordingMs: 200_000,
anyRuntimeCompilerDetected: false,
reactCommits: 51,
annotations: ANNOTATIONS,
debugDir,
});
// commit #50 occurred 216ms after "tap post" — must surface as the prior annotation.
const commit50Idx = report.indexOf("### Commit #50");
expect(commit50Idx).toBeGreaterThanOrEqual(0);
const after50 = report.slice(commit50Idx, commit50Idx + 200);
expect(after50).toMatch(/> After: "tap post" \(0\.2s prior\)/);
// Sanity: not the buggy assignment.
expect(after50).not.toContain('"scroll feed"');
// commit #0 (t=17.6s, before any annotation) gets no `> After:` line at all.
const commit0Idx = report.indexOf("### Commit #0");
expect(commit0Idx).toBeGreaterThanOrEqual(0);
const between = report.slice(commit0Idx, commit50Idx);
expect(between).not.toMatch(/> After:/);
// Smoking-gun headers: both commits show their actual "ms since profile-start"
// value, not the legacy minTs-shifted value (which would put commit #0 at t=0.0s).
expect(between).toContain("(t=17.6s)");
expect(after50).toContain("(t=192.3s)");
await fs.rm(debugDir, { recursive: true, force: true });
});
it("renders without annotations when none are provided", async () => {
const debugDir = await makeDebugDir();
const { report } = await renderProfilingReport({
hotCommitSummaries: SUMMARIES,
componentFindings: [],
sessionContext: SESSION_CONTEXT,
recordingMs: 200_000,
anyRuntimeCompilerDetected: false,
reactCommits: 51,
debugDir,
});
expect(report).toContain("### Commit #50");
expect(report).toContain("### Commit #0");
expect(report).not.toMatch(/> After:/);
await fs.rm(debugDir, { recursive: true, force: true });
});
});