@agent-scope/runtime 1.17.1 → 1.17.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +596 -0
- package/package.json +4 -3
package/README.md
ADDED
|
@@ -0,0 +1,596 @@
|
|
|
1
|
+
# @agent-scope/runtime
|
|
2
|
+
|
|
3
|
+
Browser-side React instrumentation for the Scope platform.
|
|
4
|
+
|
|
5
|
+
`@agent-scope/runtime` captures a complete snapshot of a running React application: the full component tree with serialized props, state, and context; render timing; console output; JavaScript errors; and Suspense boundary status. It does this by installing a DevTools hook before React loads and walking React's internal fiber tree at capture time.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install @agent-scope/runtime
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Requires `@agent-scope/core` as a peer dependency (automatically installed as a direct dependency).
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## What it does / when to use it
|
|
20
|
+
|
|
21
|
+
Use `@agent-scope/runtime` whenever you need a programmatic snapshot of a React application at runtime. Typical use-cases:
|
|
22
|
+
|
|
23
|
+
- **AI agents / LLM tools** — capture the current UI state so a language model can reason about it.
|
|
24
|
+
- **Automated tests** — assert on the rendered component tree without touching the DOM.
|
|
25
|
+
- **Performance monitoring** — surface re-render counts and render durations per component.
|
|
26
|
+
- **DevTools / debugging dashboards** — build custom UI inspectors that augment React DevTools.
|
|
27
|
+
|
|
28
|
+
The package is entirely passive — it does not modify React or your components. The DevTools hook intercepts React's internal renderer registration, and the fiber walker reads fiber fields at capture time without writing back.
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## Quick Start
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
import { capture } from "@agent-scope/runtime";
|
|
36
|
+
|
|
37
|
+
// capture() must be called from the browser, after React has rendered
|
|
38
|
+
const result = await capture();
|
|
39
|
+
|
|
40
|
+
console.log(result.tree.name); // "App"
|
|
41
|
+
console.log(result.tree.children[0]?.name); // "Router"
|
|
42
|
+
console.log(result.capturedIn); // e.g. 12 (ms)
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
For large trees (2 500+ components), use the lightweight mode to reduce payload size by ~99%:
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
const result = await capture({ lightweight: true });
|
|
49
|
+
// result.tree is a LightweightComponentNode — no props/state/context/source
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## API Reference
|
|
55
|
+
|
|
56
|
+
### `capture(options?)`
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
// Full capture
|
|
60
|
+
function capture(options?: CaptureOptions): Promise<CaptureResult>;
|
|
61
|
+
|
|
62
|
+
// Lightweight overload (explicit discriminant)
|
|
63
|
+
function capture(options: CaptureOptions & { lightweight: true }): Promise<LightweightCaptureResult>;
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
#### `CaptureOptions`
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
interface CaptureOptions {
|
|
70
|
+
/**
|
|
71
|
+
* URL to record in the result. Defaults to window.location.href.
|
|
72
|
+
*/
|
|
73
|
+
url?: string;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Milliseconds to wait for React to register a renderer before timing out.
|
|
77
|
+
* Default: 10_000.
|
|
78
|
+
*/
|
|
79
|
+
reactTimeout?: number;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* When true, skip serialising props/state/context/source/renderCount/renderDuration.
|
|
83
|
+
* Returns a LightweightCaptureResult with a LightweightComponentNode tree.
|
|
84
|
+
* Expected size reduction: ~99%.
|
|
85
|
+
* Default: false.
|
|
86
|
+
*/
|
|
87
|
+
lightweight?: boolean;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* When true, include host (DOM) elements (div, span, etc.) in the tree.
|
|
91
|
+
* Default: false — host elements are skipped and their children are
|
|
92
|
+
* promoted up to the nearest React component.
|
|
93
|
+
*/
|
|
94
|
+
includeHostElements?: boolean;
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
#### `CaptureResult`
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
type CaptureResult = {
|
|
102
|
+
url: string;
|
|
103
|
+
timestamp: number; // Unix ms when capture started
|
|
104
|
+
capturedIn: number; // wall-clock ms for the full capture
|
|
105
|
+
tree: ComponentNode; // full component tree (from @agent-scope/core)
|
|
106
|
+
consoleEntries: ConsoleEntry[];
|
|
107
|
+
errors: CapturedError[];
|
|
108
|
+
suspenseBoundaries: SuspenseBoundaryInfo[];
|
|
109
|
+
};
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
#### `LightweightCaptureResult`
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
interface LightweightCaptureResult {
|
|
116
|
+
url: string;
|
|
117
|
+
timestamp: number;
|
|
118
|
+
capturedIn: number;
|
|
119
|
+
tree: LightweightComponentNode; // no props/state/context/source/renderCount/renderDuration
|
|
120
|
+
consoleEntries: ConsoleEntry[];
|
|
121
|
+
errors: CapturedError[];
|
|
122
|
+
suspenseBoundaries: SuspenseBoundaryInfo[];
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
**Throws** when:
|
|
127
|
+
- No React renderer registers within `reactTimeout` ms.
|
|
128
|
+
- React has rendered no components (empty fiber tree).
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
### DevTools Hook
|
|
133
|
+
|
|
134
|
+
```ts
|
|
135
|
+
import { installHook, getHook, getRenderers, waitForReact } from "@agent-scope/runtime";
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
The hook must be installed **before React is loaded** to intercept renderer registration. `capture()` calls `installHook()` automatically; you only need these APIs when integrating at a lower level.
|
|
139
|
+
|
|
140
|
+
```ts
|
|
141
|
+
/**
|
|
142
|
+
* Install the Scope DevTools hook at window.__REACT_DEVTOOLS_GLOBAL_HOOK__.
|
|
143
|
+
*
|
|
144
|
+
* - If no hook exists: installs a fresh Scope hook.
|
|
145
|
+
* - If a Scope hook is already there: returns the existing instance (idempotent).
|
|
146
|
+
* - If real React DevTools are already installed: patches inject() to forward
|
|
147
|
+
* renderer registrations to Scope as well, without displacing the existing hook.
|
|
148
|
+
*/
|
|
149
|
+
function installHook(): ScopeDevToolsHook;
|
|
150
|
+
|
|
151
|
+
/** Returns the installed hook, or null if installHook() has not been called. */
|
|
152
|
+
function getHook(): ScopeDevToolsHook | null;
|
|
153
|
+
|
|
154
|
+
/** Returns all React renderers registered since the hook was installed. */
|
|
155
|
+
function getRenderers(): ReactRenderer[];
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Wait until at least one React renderer registers.
|
|
159
|
+
* @param timeout Maximum wait in ms (default: 10_000).
|
|
160
|
+
* @throws If timeout elapses with no renderer.
|
|
161
|
+
*/
|
|
162
|
+
function waitForReact(timeout?: number): Promise<ReactRenderer>;
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
**`ScopeDevToolsHook` shape** (installed at `window.__REACT_DEVTOOLS_GLOBAL_HOOK__`):
|
|
166
|
+
|
|
167
|
+
```ts
|
|
168
|
+
interface ScopeDevToolsHook {
|
|
169
|
+
isDisabled: false; // tells React that DevTools are present
|
|
170
|
+
supportsFiber: true; // signals React fiber-mode support
|
|
171
|
+
inject(renderer: unknown): number;
|
|
172
|
+
onCommitFiberRoot(rendererID: number, root: unknown, priorityLevel: unknown): void;
|
|
173
|
+
onCommitFiberUnmount(rendererID: number, fiber: unknown): void;
|
|
174
|
+
onPostCommitFiberRoot(rendererID: number, root: unknown): void;
|
|
175
|
+
_renderers: Map<number, ReactRenderer>;
|
|
176
|
+
_isScopeHook: true; // marker so we can detect our own hook
|
|
177
|
+
}
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
React calls `hook.inject(renderer)` once per renderer when it initialises. Scope records the renderer and its `fiberRoots` set; `capture()` uses `fiberRoots` to locate the entry-point fiber.
|
|
181
|
+
|
|
182
|
+
---
|
|
183
|
+
|
|
184
|
+
### Fiber Walker
|
|
185
|
+
|
|
186
|
+
```ts
|
|
187
|
+
import { walkFiber, walkFiberRoot, walkFiberLightweight, walkFiberRootLightweight } from "@agent-scope/runtime";
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
The walker converts a React fiber (an internal React object) into a `ComponentNode` tree. You rarely call these directly — `capture()` wraps them — but they are exported for custom instrumentation.
|
|
191
|
+
|
|
192
|
+
```ts
|
|
193
|
+
/**
|
|
194
|
+
* Walk a fiber and its descendants, returning a ComponentNode tree.
|
|
195
|
+
* Returns null if the root fiber should be skipped (HostRoot, Fragment, etc.).
|
|
196
|
+
*/
|
|
197
|
+
function walkFiber(fiber: Fiber | null, options?: WalkOptions): ComponentNode | null;
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Walk from a fiber root object (e.g. from ReactRenderer.fiberRoots).
|
|
201
|
+
* Skips the HostRoot wrapper; returns the first meaningful ComponentNode.
|
|
202
|
+
*/
|
|
203
|
+
function walkFiberRoot(fiberRoot: any, options?: WalkOptions): ComponentNode | null;
|
|
204
|
+
|
|
205
|
+
/** Same as walkFiber but emits LightweightComponentNode (no props/state/etc.). */
|
|
206
|
+
function walkFiberLightweight(fiber: Fiber | null, options?: WalkOptions): LightweightComponentNode | null;
|
|
207
|
+
|
|
208
|
+
/** Same as walkFiberRoot but lightweight. */
|
|
209
|
+
function walkFiberRootLightweight(fiberRoot: any, options?: WalkOptions): LightweightComponentNode | null;
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
**Walking algorithm:**
|
|
213
|
+
|
|
214
|
+
1. Start at the given fiber.
|
|
215
|
+
2. If the fiber should be skipped (`HostRoot`, `HostText`, `Fragment`, `SuspenseComponent`, or `HostComponent` when `includeHostElements` is false), perform a **transparent skip** — the fiber is invisible in the output but its children are promoted up to the nearest visible ancestor.
|
|
216
|
+
3. Otherwise: extract name, type, source, props, hooks, context, and profiling data, then recurse into `fiber.child` and `fiber.sibling`.
|
|
217
|
+
4. A `WeakSet` cycle guard prevents infinite loops in corrupt fiber trees.
|
|
218
|
+
|
|
219
|
+
**Name resolution order** (mirrors `extractName()`):
|
|
220
|
+
1. `type.displayName`
|
|
221
|
+
2. `type.name` (function/class name; inferred property names like `"render"` are rejected)
|
|
222
|
+
3. Inner `render.displayName` / `render.name` for `forwardRef` wrappers
|
|
223
|
+
4. Inner `type.displayName` / `type.name` for `memo` wrappers
|
|
224
|
+
5. String tag for host elements (`"div"`, `"span"`, …)
|
|
225
|
+
6. `"Anonymous"` as last resort
|
|
226
|
+
|
|
227
|
+
**Type classification** (the `ComponentType` field):
|
|
228
|
+
|
|
229
|
+
| React fiber tag | `type` result |
|
|
230
|
+
|---|---|
|
|
231
|
+
| `FunctionComponent`, `IndeterminateComponent` | `"function"` |
|
|
232
|
+
| `ClassComponent` | `"class"` |
|
|
233
|
+
| `ForwardRef` | `"forward_ref"` |
|
|
234
|
+
| `MemoComponent`, `SimpleMemoComponent` | `"memo"` |
|
|
235
|
+
| `HostComponent`, `HostText` | `"host"` |
|
|
236
|
+
|
|
237
|
+
**Example** (from `src/__tests__/fiber-walker.test.ts`):
|
|
238
|
+
|
|
239
|
+
```ts
|
|
240
|
+
// Tree: App → [Header, Footer]
|
|
241
|
+
// fiber structure:
|
|
242
|
+
const footer = makeFiber({ tag: FunctionComponent, type: Footer, index: 1 });
|
|
243
|
+
const header = makeFiber({ tag: FunctionComponent, type: Header, index: 0, sibling: footer });
|
|
244
|
+
const app = makeFiber({ tag: FunctionComponent, type: App, child: header });
|
|
245
|
+
|
|
246
|
+
const node = walkFiber(app);
|
|
247
|
+
// node.name → "App"
|
|
248
|
+
// node.children[0].name → "Header"
|
|
249
|
+
// node.children[1].name → "Footer"
|
|
250
|
+
|
|
251
|
+
// Host element transparent skip:
|
|
252
|
+
// App → div → Button (div is skipped, Button is promoted to App's child)
|
|
253
|
+
const button = makeFiber({ tag: FunctionComponent, type: Button });
|
|
254
|
+
const div = makeFiber({ tag: HostComponent, type: "div", child: button });
|
|
255
|
+
const app2 = makeFiber({ tag: FunctionComponent, type: App, child: div });
|
|
256
|
+
|
|
257
|
+
walkFiber(app2).children[0].name // → "Button" (div is invisible)
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
---
|
|
261
|
+
|
|
262
|
+
### Hooks Extractor
|
|
263
|
+
|
|
264
|
+
```ts
|
|
265
|
+
import { extractHooks } from "@agent-scope/runtime";
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
Extracts `HookState[]` from a fiber's `memoizedState` linked list by duck-typing each node's shape (React does not tag hooks internally).
|
|
269
|
+
|
|
270
|
+
```ts
|
|
271
|
+
function extractHooks(fiber: any): HookState[];
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
**Detection heuristics** per hook type:
|
|
275
|
+
|
|
276
|
+
| Hook | Detection pattern |
|
|
277
|
+
|---|---|
|
|
278
|
+
| `useEffect` | `memoizedState.create` is a function AND `"deps"` key exists AND `tag & 0b01000` (Passive flag) |
|
|
279
|
+
| `useLayoutEffect` | Same as useEffect but `tag & 0b00100` (Layout flag) |
|
|
280
|
+
| `useRef` | `queue === null`, `memoizedState` is `{ current: value }` (exactly one key named `"current"`) |
|
|
281
|
+
| `useMemo` | `queue === null`, `memoizedState` is `[value, deps]` tuple (array of length 2, deps is array or null), value is NOT a function |
|
|
282
|
+
| `useCallback` | Same tuple as useMemo, but value IS a function |
|
|
283
|
+
| `useState` | `queue.dispatch` is a function AND `queue.lastRenderedReducer.name === "basicStateReducer"` |
|
|
284
|
+
| `useReducer` | `queue.dispatch` is a function AND reducer name is NOT `"basicStateReducer"` |
|
|
285
|
+
| `custom` | None of the above |
|
|
286
|
+
|
|
287
|
+
**Example outputs** (from `src/__tests__/hooks-extractor.test.ts`):
|
|
288
|
+
|
|
289
|
+
```ts
|
|
290
|
+
// useState(42)
|
|
291
|
+
{ type: "useState", name: null, value: { type: "number", value: 42 }, deps: null, hasCleanup: null }
|
|
292
|
+
|
|
293
|
+
// useEffect(() => cleanup, [dep])
|
|
294
|
+
{ type: "useEffect", name: null, value: { type: "undefined" }, deps: [{ type: "string", value: "abc" }], hasCleanup: true }
|
|
295
|
+
|
|
296
|
+
// useLayoutEffect(() => {}, ["dep"])
|
|
297
|
+
{ type: "useLayoutEffect", name: null, value: { type: "undefined" }, deps: [{ type: "string", value: "dep" }], hasCleanup: true }
|
|
298
|
+
|
|
299
|
+
// useMemo(() => 99, [1, 2])
|
|
300
|
+
{ type: "useMemo", name: null, value: { type: "number", value: 99 }, deps: [{ type: "number", value: 1 }, { type: "number", value: 2 }], hasCleanup: null }
|
|
301
|
+
|
|
302
|
+
// useCallback(fn, ["dep"])
|
|
303
|
+
{ type: "useCallback", name: null, value: { type: "function", preview: "..." }, deps: [{ type: "string", value: "dep" }], hasCleanup: null }
|
|
304
|
+
|
|
305
|
+
// useRef(domEl)
|
|
306
|
+
{ type: "useRef", name: null, value: { type: "object", ... }, deps: null, hasCleanup: null }
|
|
307
|
+
|
|
308
|
+
// useReducer (user-supplied reducer)
|
|
309
|
+
{ type: "useReducer", name: null, value: { type: "object", ... }, deps: null, hasCleanup: null }
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
---
|
|
313
|
+
|
|
314
|
+
### Profiler
|
|
315
|
+
|
|
316
|
+
```ts
|
|
317
|
+
import { installProfiler, getProfilingData, resetProfilingData } from "@agent-scope/runtime";
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
Accumulates per-component render counts and self-durations across React commits.
|
|
321
|
+
|
|
322
|
+
```ts
|
|
323
|
+
/**
|
|
324
|
+
* Wrap hook.onCommitFiberRoot to intercept every React commit.
|
|
325
|
+
* Safe to call multiple times (idempotent).
|
|
326
|
+
*/
|
|
327
|
+
function installProfiler(hook: ScopeDevToolsHook): void;
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Get accumulated profiling data for a fiber by its numeric ID.
|
|
331
|
+
* Returns null when no data has been recorded.
|
|
332
|
+
*/
|
|
333
|
+
function getProfilingData(fiberId: number): ProfilingSnapshot | null;
|
|
334
|
+
|
|
335
|
+
interface ProfilingSnapshot {
|
|
336
|
+
renderCount: number;
|
|
337
|
+
renderDuration: number; // sum of selfBaseDuration across all recorded commits (ms)
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Clear all profiling data and reset the installed state.
|
|
342
|
+
* Call this before starting a new capture session.
|
|
343
|
+
*/
|
|
344
|
+
function resetProfilingData(): void;
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
**How it works:**
|
|
348
|
+
|
|
349
|
+
On each React commit, the profiler walks `root.current.alternate` (the work-in-progress fiber subtree). For any fiber with `actualDuration >= 0` (meaning it was actually rendered in this commit — bailed-out fibers have `actualDuration === -1`), it accumulates `selfBaseDuration` (render time for this fiber only, excluding children) into a `Map<fiberId, ProfilingRecord>`.
|
|
350
|
+
|
|
351
|
+
```ts
|
|
352
|
+
// After 3 re-renders of the same component:
|
|
353
|
+
getProfilingData(componentId);
|
|
354
|
+
// → { renderCount: 3, renderDuration: 6.5 } (sum of selfBaseDuration across 3 commits)
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
---
|
|
358
|
+
|
|
359
|
+
### Console Interceptor
|
|
360
|
+
|
|
361
|
+
```ts
|
|
362
|
+
import {
|
|
363
|
+
installConsoleInterceptor,
|
|
364
|
+
uninstallConsoleInterceptor,
|
|
365
|
+
getConsoleEntries,
|
|
366
|
+
clearConsoleEntries,
|
|
367
|
+
} from "@agent-scope/runtime";
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
Monkey-patches all `console.*` methods to record `ConsoleEntry` objects, including React component attribution.
|
|
371
|
+
|
|
372
|
+
```ts
|
|
373
|
+
/** Patch console.log/warn/error/info/debug/group/groupCollapsed/table/trace. Idempotent. */
|
|
374
|
+
function installConsoleInterceptor(): void;
|
|
375
|
+
|
|
376
|
+
/** Restore all original console methods. Idempotent. */
|
|
377
|
+
function uninstallConsoleInterceptor(): void;
|
|
378
|
+
|
|
379
|
+
/** Returns a shallow copy of all captured entries since the last clear. */
|
|
380
|
+
function getConsoleEntries(): ConsoleEntry[];
|
|
381
|
+
|
|
382
|
+
/** Empty the capture buffer. */
|
|
383
|
+
function clearConsoleEntries(): void;
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
**Component attribution** — reads `window.__REACT_DEVTOOLS_GLOBAL_HOOK__._currentFiber` to identify the currently-rendering component. `componentName` is `null` for calls made outside a render (effects, event handlers, async callbacks).
|
|
387
|
+
|
|
388
|
+
The interceptor **never suppresses output** — every wrapped method calls the original implementation first.
|
|
389
|
+
|
|
390
|
+
**Example entries** (from `src/__tests__/console-interceptor.test.ts`):
|
|
391
|
+
|
|
392
|
+
```ts
|
|
393
|
+
// console.log("test message") inside MyComponent's render:
|
|
394
|
+
{
|
|
395
|
+
level: "log",
|
|
396
|
+
args: [{ type: "string", value: "test message", preview: '"test message"' }],
|
|
397
|
+
timestamp: 1700000000000,
|
|
398
|
+
componentName: "MyComponent",
|
|
399
|
+
preview: "test message",
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// console.warn("outside render") outside a component:
|
|
403
|
+
{
|
|
404
|
+
level: "warn",
|
|
405
|
+
args: [{ type: "string", value: "outside render", preview: '"outside render"' }],
|
|
406
|
+
timestamp: 1700000000001,
|
|
407
|
+
componentName: null,
|
|
408
|
+
preview: "outside render",
|
|
409
|
+
}
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
---
|
|
413
|
+
|
|
414
|
+
### Error Detector
|
|
415
|
+
|
|
416
|
+
```ts
|
|
417
|
+
import { detectErrors } from "@agent-scope/runtime";
|
|
418
|
+
|
|
419
|
+
function detectErrors(rootFiber: Fiber | null): CapturedError[];
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
Walks the fiber tree to find React error boundaries (`getDerivedStateFromError` or `componentDidCatch`) that are currently holding a caught error in their `memoizedState`.
|
|
423
|
+
|
|
424
|
+
State shape detection (boundaries vary in how they store caught errors):
|
|
425
|
+
1. `state instanceof Error` — direct Error instance
|
|
426
|
+
2. `state.hasError === true && state.error instanceof Error` — two-field pattern
|
|
427
|
+
3. `state.error instanceof Error` — one-field pattern
|
|
428
|
+
4. Scans all state keys for any value that `instanceof Error`
|
|
429
|
+
|
|
430
|
+
```ts
|
|
431
|
+
// Example output for a caught TypeError:
|
|
432
|
+
{
|
|
433
|
+
message: "type error",
|
|
434
|
+
name: "TypeError",
|
|
435
|
+
stack: "TypeError: type error\n at ...",
|
|
436
|
+
source: null, // stack parsing left to consumers
|
|
437
|
+
componentName: "ThrowingChild", // attributed to boundary's first child
|
|
438
|
+
timestamp: 1700000000000,
|
|
439
|
+
capturedBy: "boundary",
|
|
440
|
+
}
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
---
|
|
444
|
+
|
|
445
|
+
### Suspense Detector
|
|
446
|
+
|
|
447
|
+
```ts
|
|
448
|
+
import { detectSuspenseBoundaries } from "@agent-scope/runtime";
|
|
449
|
+
|
|
450
|
+
function detectSuspenseBoundaries(rootFiber: Fiber | null): SuspenseBoundaryInfo[];
|
|
451
|
+
```
|
|
452
|
+
|
|
453
|
+
Finds all `<Suspense>` boundaries in the fiber tree and classifies their current status:
|
|
454
|
+
|
|
455
|
+
| `memoizedState` | `isSuspended` | Meaning |
|
|
456
|
+
|---|---|---|
|
|
457
|
+
| `null` | `false` | Resolved — primary content visible |
|
|
458
|
+
| `{ dehydrated: ... }` | `true` | Pending — SSR dehydrated boundary |
|
|
459
|
+
| `{ then: function }` | `true` | Pending — thrown promise still in-flight |
|
|
460
|
+
| any other non-null | `true` | Fallback shown |
|
|
461
|
+
|
|
462
|
+
```ts
|
|
463
|
+
// Example output:
|
|
464
|
+
{
|
|
465
|
+
id: 99, // fiber._debugID or fiber.index
|
|
466
|
+
componentName: "DataPage", // nearest named ancestor
|
|
467
|
+
isSuspended: true,
|
|
468
|
+
suspendedDuration: null, // timing unavailable at capture time
|
|
469
|
+
fallbackName: "LoadingSpinner", // second child of Suspense fiber
|
|
470
|
+
source: { fileName: "App.tsx", lineNumber: 42, columnNumber: 6 },
|
|
471
|
+
}
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
---
|
|
475
|
+
|
|
476
|
+
### Context Extractor
|
|
477
|
+
|
|
478
|
+
```ts
|
|
479
|
+
import { extractContextConsumptions, extractContextProviders } from "@agent-scope/runtime";
|
|
480
|
+
```
|
|
481
|
+
|
|
482
|
+
```ts
|
|
483
|
+
/** Extract all contexts consumed by a fiber (via useContext or contextType). */
|
|
484
|
+
function extractContextConsumptions(fiber: Fiber): ContextConsumption[];
|
|
485
|
+
|
|
486
|
+
/** Extract the provided value from a ContextProvider fiber (tag 10). */
|
|
487
|
+
function extractContextProviders(fiber: Fiber): Array<{ contextName: string | null; value: SerializedValue }>;
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
Reads `fiber.dependencies.firstContext` (React 17+) or `fiber.contextDependencies.first` (React 16) to walk the context dependency linked list. For each dependency, resolves the context name from `context.displayName` (or constructor name heuristic), and serializes `context._currentValue`.
|
|
491
|
+
|
|
492
|
+
**Example** (from `src/__tests__/context-extractor.test.ts`):
|
|
493
|
+
|
|
494
|
+
```ts
|
|
495
|
+
// Component consuming ThemeContext with value "dark":
|
|
496
|
+
[{
|
|
497
|
+
contextName: "ThemeContext",
|
|
498
|
+
value: { type: "string", value: "dark", preview: '"dark"' },
|
|
499
|
+
didTriggerRender: false,
|
|
500
|
+
}]
|
|
501
|
+
|
|
502
|
+
// Component consuming multiple contexts:
|
|
503
|
+
[
|
|
504
|
+
{ contextName: "ThemeContext", value: { type: "string", value: "dark" }, didTriggerRender: false },
|
|
505
|
+
{ contextName: "LocaleContext", value: { type: "string", value: "en" }, didTriggerRender: false },
|
|
506
|
+
{ contextName: "AuthContext", value: { type: "object", preview: "..." }, didTriggerRender: false },
|
|
507
|
+
]
|
|
508
|
+
```
|
|
509
|
+
|
|
510
|
+
---
|
|
511
|
+
|
|
512
|
+
### `ScopeRuntime` Class
|
|
513
|
+
|
|
514
|
+
A lightweight buffer manager for PageReport objects, useful when you capture continuously.
|
|
515
|
+
|
|
516
|
+
```ts
|
|
517
|
+
import { ScopeRuntime, createRuntime } from "@agent-scope/runtime";
|
|
518
|
+
|
|
519
|
+
interface RuntimeOptions {
|
|
520
|
+
/** Called whenever a new PageReport is captured. */
|
|
521
|
+
onCapture?: (report: PageReport) => void;
|
|
522
|
+
/** Maximum number of reports to buffer. Default: 100. */
|
|
523
|
+
bufferSize?: number;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
class ScopeRuntime {
|
|
527
|
+
constructor(options?: RuntimeOptions);
|
|
528
|
+
|
|
529
|
+
/** Add a report to the buffer (evicts oldest if full). */
|
|
530
|
+
record(report: PageReport): void;
|
|
531
|
+
|
|
532
|
+
/** Return all buffered reports and clear the buffer. */
|
|
533
|
+
flush(): PageReport[];
|
|
534
|
+
|
|
535
|
+
/** Read-only snapshot of the current buffer. */
|
|
536
|
+
getBuffer(): readonly PageReport[];
|
|
537
|
+
|
|
538
|
+
/** Find a ComponentNode by fiber ID within a tree. */
|
|
539
|
+
static findNode(root: ComponentNode, id: number): ComponentNode | null;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function createRuntime(options?: RuntimeOptions): ScopeRuntime;
|
|
543
|
+
```
|
|
544
|
+
|
|
545
|
+
---
|
|
546
|
+
|
|
547
|
+
## Internal Architecture
|
|
548
|
+
|
|
549
|
+
```
|
|
550
|
+
src/
|
|
551
|
+
├── index.ts ← barrel export
|
|
552
|
+
├── capture.ts ← capture() — orchestrates all subsystems
|
|
553
|
+
├── devtools-hook.ts ← installHook(), waitForReact(), ScopeDevToolsHook
|
|
554
|
+
├── fiber-walker.ts ← walkFiber(), walkFiberRoot(), lightweight variants
|
|
555
|
+
│ extractName(), classifyType()
|
|
556
|
+
├── hooks-extractor.ts ← extractHooks() — hook type detection via duck-typing
|
|
557
|
+
├── profiler.ts ← installProfiler(), getProfilingData(), resetProfilingData()
|
|
558
|
+
├── context-extractor.ts ← extractContextConsumptions(), extractContextProviders()
|
|
559
|
+
├── console-interceptor.ts ← installConsoleInterceptor(), getConsoleEntries()
|
|
560
|
+
├── error-detector.ts ← detectErrors()
|
|
561
|
+
└── suspense-detector.ts ← detectSuspenseBoundaries()
|
|
562
|
+
```
|
|
563
|
+
|
|
564
|
+
**Capture orchestration** (`capture.ts`):
|
|
565
|
+
|
|
566
|
+
```
|
|
567
|
+
capture()
|
|
568
|
+
│
|
|
569
|
+
├─ installHook() ← ensure hook is at window.__REACT_DEVTOOLS_GLOBAL_HOOK__
|
|
570
|
+
├─ installProfiler(hook) ← start accumulating render timing
|
|
571
|
+
├─ installConsoleInterceptor() + clearConsoleEntries()
|
|
572
|
+
├─ waitForReact() ← async: wait for renderer.inject()
|
|
573
|
+
│
|
|
574
|
+
├─ [renderer registered]
|
|
575
|
+
│ │
|
|
576
|
+
│ ├─ fiberRoot = renderer.fiberRoots.first
|
|
577
|
+
│ ├─ detectErrors(rootChild)
|
|
578
|
+
│ ├─ detectSuspenseBoundaries(rootChild)
|
|
579
|
+
│ ├─ getConsoleEntries()
|
|
580
|
+
│ │
|
|
581
|
+
│ └─ lightweight ?
|
|
582
|
+
│ ├─ yes → walkFiberRootLightweight(fiberRoot)
|
|
583
|
+
│ └─ no → walkFiberRoot(fiberRoot)
|
|
584
|
+
│
|
|
585
|
+
└─ uninstallConsoleInterceptor() ← always runs (finally block)
|
|
586
|
+
```
|
|
587
|
+
|
|
588
|
+
---
|
|
589
|
+
|
|
590
|
+
## Used by
|
|
591
|
+
|
|
592
|
+
| Package | How |
|
|
593
|
+
|---|---|
|
|
594
|
+
| `@agent-scope/cli` | Calls `capture()` to produce PageReport JSON for the report command |
|
|
595
|
+
| `@agent-scope/manifest` | Builds component manifests from `CaptureResult.tree` |
|
|
596
|
+
| Test fixture apps | Import `capture()` in integration tests to assert on live React trees |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agent-scope/runtime",
|
|
3
|
-
"version": "1.17.
|
|
3
|
+
"version": "1.17.3",
|
|
4
4
|
"description": "Browser instrumentation for Scope — captures React render events",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -20,7 +20,8 @@
|
|
|
20
20
|
"module": "./dist/index.js",
|
|
21
21
|
"types": "./dist/index.d.ts",
|
|
22
22
|
"files": [
|
|
23
|
-
"dist"
|
|
23
|
+
"dist",
|
|
24
|
+
"README.md"
|
|
24
25
|
],
|
|
25
26
|
"scripts": {
|
|
26
27
|
"build": "tsup",
|
|
@@ -29,7 +30,7 @@
|
|
|
29
30
|
"clean": "rm -rf dist"
|
|
30
31
|
},
|
|
31
32
|
"dependencies": {
|
|
32
|
-
"@agent-scope/core": "1.17.
|
|
33
|
+
"@agent-scope/core": "1.17.3"
|
|
33
34
|
},
|
|
34
35
|
"devDependencies": {
|
|
35
36
|
"tsup": "*",
|