npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@agent-scope/runtime

v1.20.0

Published

Browser instrumentation for Scope — captures React render events

Readme

@agent-scope/runtime

Browser-side React instrumentation for the Scope platform.

@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.


Installation

npm install @agent-scope/runtime

Requires @agent-scope/core as a peer dependency (automatically installed as a direct dependency).


What it does / when to use it

Use @agent-scope/runtime whenever you need a programmatic snapshot of a React application at runtime. Typical use-cases:

  • AI agents / LLM tools — capture the current UI state so a language model can reason about it.
  • Automated tests — assert on the rendered component tree without touching the DOM.
  • Performance monitoring — surface re-render counts and render durations per component.
  • DevTools / debugging dashboards — build custom UI inspectors that augment React DevTools.

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.


Quick Start

import { capture } from "@agent-scope/runtime";

// capture() must be called from the browser, after React has rendered
const result = await capture();

console.log(result.tree.name);           // "App"
console.log(result.tree.children[0]?.name); // "Router"
console.log(result.capturedIn);          // e.g. 12 (ms)

For large trees (2 500+ components), use the lightweight mode to reduce payload size by ~99%:

const result = await capture({ lightweight: true });
// result.tree is a LightweightComponentNode — no props/state/context/source

API Reference

capture(options?)

// Full capture
function capture(options?: CaptureOptions): Promise<CaptureResult>;

// Lightweight overload (explicit discriminant)
function capture(options: CaptureOptions & { lightweight: true }): Promise<LightweightCaptureResult>;

CaptureOptions

interface CaptureOptions {
  /**
   * URL to record in the result. Defaults to window.location.href.
   */
  url?: string;

  /**
   * Milliseconds to wait for React to register a renderer before timing out.
   * Default: 10_000.
   */
  reactTimeout?: number;

  /**
   * When true, skip serialising props/state/context/source/renderCount/renderDuration.
   * Returns a LightweightCaptureResult with a LightweightComponentNode tree.
   * Expected size reduction: ~99%.
   * Default: false.
   */
  lightweight?: boolean;

  /**
   * When true, include host (DOM) elements (div, span, etc.) in the tree.
   * Default: false — host elements are skipped and their children are
   * promoted up to the nearest React component.
   */
  includeHostElements?: boolean;
}

CaptureResult

type CaptureResult = {
  url: string;
  timestamp: number;          // Unix ms when capture started
  capturedIn: number;         // wall-clock ms for the full capture
  tree: ComponentNode;        // full component tree (from @agent-scope/core)
  consoleEntries: ConsoleEntry[];
  errors: CapturedError[];
  suspenseBoundaries: SuspenseBoundaryInfo[];
};

LightweightCaptureResult

interface LightweightCaptureResult {
  url: string;
  timestamp: number;
  capturedIn: number;
  tree: LightweightComponentNode; // no props/state/context/source/renderCount/renderDuration
  consoleEntries: ConsoleEntry[];
  errors: CapturedError[];
  suspenseBoundaries: SuspenseBoundaryInfo[];
}

Throws when:

  • No React renderer registers within reactTimeout ms.
  • React has rendered no components (empty fiber tree).

DevTools Hook

import { installHook, getHook, getRenderers, waitForReact } from "@agent-scope/runtime";

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.

/**
 * Install the Scope DevTools hook at window.__REACT_DEVTOOLS_GLOBAL_HOOK__.
 *
 * - If no hook exists: installs a fresh Scope hook.
 * - If a Scope hook is already there: returns the existing instance (idempotent).
 * - If real React DevTools are already installed: patches inject() to forward
 *   renderer registrations to Scope as well, without displacing the existing hook.
 */
function installHook(): ScopeDevToolsHook;

/** Returns the installed hook, or null if installHook() has not been called. */
function getHook(): ScopeDevToolsHook | null;

/** Returns all React renderers registered since the hook was installed. */
function getRenderers(): ReactRenderer[];

/**
 * Wait until at least one React renderer registers.
 * @param timeout Maximum wait in ms (default: 10_000).
 * @throws If timeout elapses with no renderer.
 */
function waitForReact(timeout?: number): Promise<ReactRenderer>;

ScopeDevToolsHook shape (installed at window.__REACT_DEVTOOLS_GLOBAL_HOOK__):

interface ScopeDevToolsHook {
  isDisabled: false;              // tells React that DevTools are present
  supportsFiber: true;            // signals React fiber-mode support
  inject(renderer: unknown): number;
  onCommitFiberRoot(rendererID: number, root: unknown, priorityLevel: unknown): void;
  onCommitFiberUnmount(rendererID: number, fiber: unknown): void;
  onPostCommitFiberRoot(rendererID: number, root: unknown): void;
  _renderers: Map<number, ReactRenderer>;
  _isScopeHook: true;             // marker so we can detect our own hook
}

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.


Fiber Walker

import { walkFiber, walkFiberRoot, walkFiberLightweight, walkFiberRootLightweight } from "@agent-scope/runtime";

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.

/**
 * Walk a fiber and its descendants, returning a ComponentNode tree.
 * Returns null if the root fiber should be skipped (HostRoot, Fragment, etc.).
 */
function walkFiber(fiber: Fiber | null, options?: WalkOptions): ComponentNode | null;

/**
 * Walk from a fiber root object (e.g. from ReactRenderer.fiberRoots).
 * Skips the HostRoot wrapper; returns the first meaningful ComponentNode.
 */
function walkFiberRoot(fiberRoot: any, options?: WalkOptions): ComponentNode | null;

/** Same as walkFiber but emits LightweightComponentNode (no props/state/etc.). */
function walkFiberLightweight(fiber: Fiber | null, options?: WalkOptions): LightweightComponentNode | null;

/** Same as walkFiberRoot but lightweight. */
function walkFiberRootLightweight(fiberRoot: any, options?: WalkOptions): LightweightComponentNode | null;

Walking algorithm:

  1. Start at the given fiber.
  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.
  3. Otherwise: extract name, type, source, props, hooks, context, and profiling data, then recurse into fiber.child and fiber.sibling.
  4. A WeakSet cycle guard prevents infinite loops in corrupt fiber trees.

Name resolution order (mirrors extractName()):

  1. type.displayName
  2. type.name (function/class name; inferred property names like "render" are rejected)
  3. Inner render.displayName / render.name for forwardRef wrappers
  4. Inner type.displayName / type.name for memo wrappers
  5. String tag for host elements ("div", "span", …)
  6. "Anonymous" as last resort

Type classification (the ComponentType field):

| React fiber tag | type result | |---|---| | FunctionComponent, IndeterminateComponent | "function" | | ClassComponent | "class" | | ForwardRef | "forward_ref" | | MemoComponent, SimpleMemoComponent | "memo" | | HostComponent, HostText | "host" |

Example (from src/__tests__/fiber-walker.test.ts):

// Tree: App → [Header, Footer]
// fiber structure:
const footer = makeFiber({ tag: FunctionComponent, type: Footer, index: 1 });
const header = makeFiber({ tag: FunctionComponent, type: Header, index: 0, sibling: footer });
const app    = makeFiber({ tag: FunctionComponent, type: App, child: header });

const node = walkFiber(app);
// node.name        → "App"
// node.children[0].name → "Header"
// node.children[1].name → "Footer"

// Host element transparent skip:
// App → div → Button  (div is skipped, Button is promoted to App's child)
const button = makeFiber({ tag: FunctionComponent, type: Button });
const div    = makeFiber({ tag: HostComponent, type: "div", child: button });
const app2   = makeFiber({ tag: FunctionComponent, type: App, child: div });

walkFiber(app2).children[0].name // → "Button"  (div is invisible)

Hooks Extractor

import { extractHooks } from "@agent-scope/runtime";

Extracts HookState[] from a fiber's memoizedState linked list by duck-typing each node's shape (React does not tag hooks internally).

function extractHooks(fiber: any): HookState[];

Detection heuristics per hook type:

| Hook | Detection pattern | |---|---| | useEffect | memoizedState.create is a function AND "deps" key exists AND tag & 0b01000 (Passive flag) | | useLayoutEffect | Same as useEffect but tag & 0b00100 (Layout flag) | | useRef | queue === null, memoizedState is { current: value } (exactly one key named "current") | | useMemo | queue === null, memoizedState is [value, deps] tuple (array of length 2, deps is array or null), value is NOT a function | | useCallback | Same tuple as useMemo, but value IS a function | | useState | queue.dispatch is a function AND queue.lastRenderedReducer.name === "basicStateReducer" | | useReducer | queue.dispatch is a function AND reducer name is NOT "basicStateReducer" | | custom | None of the above |

Example outputs (from src/__tests__/hooks-extractor.test.ts):

// useState(42)
{ type: "useState", name: null, value: { type: "number", value: 42 }, deps: null, hasCleanup: null }

// useEffect(() => cleanup, [dep])
{ type: "useEffect", name: null, value: { type: "undefined" }, deps: [{ type: "string", value: "abc" }], hasCleanup: true }

// useLayoutEffect(() => {}, ["dep"])
{ type: "useLayoutEffect", name: null, value: { type: "undefined" }, deps: [{ type: "string", value: "dep" }], hasCleanup: true }

// useMemo(() => 99, [1, 2])
{ type: "useMemo", name: null, value: { type: "number", value: 99 }, deps: [{ type: "number", value: 1 }, { type: "number", value: 2 }], hasCleanup: null }

// useCallback(fn, ["dep"])
{ type: "useCallback", name: null, value: { type: "function", preview: "..." }, deps: [{ type: "string", value: "dep" }], hasCleanup: null }

// useRef(domEl)
{ type: "useRef", name: null, value: { type: "object", ... }, deps: null, hasCleanup: null }

// useReducer (user-supplied reducer)
{ type: "useReducer", name: null, value: { type: "object", ... }, deps: null, hasCleanup: null }

Profiler

import { installProfiler, getProfilingData, resetProfilingData } from "@agent-scope/runtime";

Accumulates per-component render counts and self-durations across React commits.

/**
 * Wrap hook.onCommitFiberRoot to intercept every React commit.
 * Safe to call multiple times (idempotent).
 */
function installProfiler(hook: ScopeDevToolsHook): void;

/**
 * Get accumulated profiling data for a fiber by its numeric ID.
 * Returns null when no data has been recorded.
 */
function getProfilingData(fiberId: number): ProfilingSnapshot | null;

interface ProfilingSnapshot {
  renderCount: number;
  renderDuration: number; // sum of selfBaseDuration across all recorded commits (ms)
}

/**
 * Clear all profiling data and reset the installed state.
 * Call this before starting a new capture session.
 */
function resetProfilingData(): void;

How it works:

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>.

// After 3 re-renders of the same component:
getProfilingData(componentId);
// → { renderCount: 3, renderDuration: 6.5 }  (sum of selfBaseDuration across 3 commits)

Console Interceptor

import {
  installConsoleInterceptor,
  uninstallConsoleInterceptor,
  getConsoleEntries,
  clearConsoleEntries,
} from "@agent-scope/runtime";

Monkey-patches all console.* methods to record ConsoleEntry objects, including React component attribution.

/** Patch console.log/warn/error/info/debug/group/groupCollapsed/table/trace. Idempotent. */
function installConsoleInterceptor(): void;

/** Restore all original console methods. Idempotent. */
function uninstallConsoleInterceptor(): void;

/** Returns a shallow copy of all captured entries since the last clear. */
function getConsoleEntries(): ConsoleEntry[];

/** Empty the capture buffer. */
function clearConsoleEntries(): void;

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).

The interceptor never suppresses output — every wrapped method calls the original implementation first.

Example entries (from src/__tests__/console-interceptor.test.ts):

// console.log("test message") inside MyComponent's render:
{
  level: "log",
  args: [{ type: "string", value: "test message", preview: '"test message"' }],
  timestamp: 1700000000000,
  componentName: "MyComponent",
  preview: "test message",
}

// console.warn("outside render") outside a component:
{
  level: "warn",
  args: [{ type: "string", value: "outside render", preview: '"outside render"' }],
  timestamp: 1700000000001,
  componentName: null,
  preview: "outside render",
}

Error Detector

import { detectErrors } from "@agent-scope/runtime";

function detectErrors(rootFiber: Fiber | null): CapturedError[];

Walks the fiber tree to find React error boundaries (getDerivedStateFromError or componentDidCatch) that are currently holding a caught error in their memoizedState.

State shape detection (boundaries vary in how they store caught errors):

  1. state instanceof Error — direct Error instance
  2. state.hasError === true && state.error instanceof Error — two-field pattern
  3. state.error instanceof Error — one-field pattern
  4. Scans all state keys for any value that instanceof Error
// Example output for a caught TypeError:
{
  message: "type error",
  name: "TypeError",
  stack: "TypeError: type error\n  at ...",
  source: null,         // stack parsing left to consumers
  componentName: "ThrowingChild",  // attributed to boundary's first child
  timestamp: 1700000000000,
  capturedBy: "boundary",
}

Suspense Detector

import { detectSuspenseBoundaries } from "@agent-scope/runtime";

function detectSuspenseBoundaries(rootFiber: Fiber | null): SuspenseBoundaryInfo[];

Finds all <Suspense> boundaries in the fiber tree and classifies their current status:

| memoizedState | isSuspended | Meaning | |---|---|---| | null | false | Resolved — primary content visible | | { dehydrated: ... } | true | Pending — SSR dehydrated boundary | | { then: function } | true | Pending — thrown promise still in-flight | | any other non-null | true | Fallback shown |

// Example output:
{
  id: 99,               // fiber._debugID or fiber.index
  componentName: "DataPage",  // nearest named ancestor
  isSuspended: true,
  suspendedDuration: null,    // timing unavailable at capture time
  fallbackName: "LoadingSpinner",  // second child of Suspense fiber
  source: { fileName: "App.tsx", lineNumber: 42, columnNumber: 6 },
}

Context Extractor

import { extractContextConsumptions, extractContextProviders } from "@agent-scope/runtime";
/** Extract all contexts consumed by a fiber (via useContext or contextType). */
function extractContextConsumptions(fiber: Fiber): ContextConsumption[];

/** Extract the provided value from a ContextProvider fiber (tag 10). */
function extractContextProviders(fiber: Fiber): Array<{ contextName: string | null; value: SerializedValue }>;

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.

Example (from src/__tests__/context-extractor.test.ts):

// Component consuming ThemeContext with value "dark":
[{
  contextName: "ThemeContext",
  value: { type: "string", value: "dark", preview: '"dark"' },
  didTriggerRender: false,
}]

// Component consuming multiple contexts:
[
  { contextName: "ThemeContext",  value: { type: "string", value: "dark" },  didTriggerRender: false },
  { contextName: "LocaleContext", value: { type: "string", value: "en" },    didTriggerRender: false },
  { contextName: "AuthContext",   value: { type: "object", preview: "..." }, didTriggerRender: false },
]

ScopeRuntime Class

A lightweight buffer manager for PageReport objects, useful when you capture continuously.

import { ScopeRuntime, createRuntime } from "@agent-scope/runtime";

interface RuntimeOptions {
  /** Called whenever a new PageReport is captured. */
  onCapture?: (report: PageReport) => void;
  /** Maximum number of reports to buffer. Default: 100. */
  bufferSize?: number;
}

class ScopeRuntime {
  constructor(options?: RuntimeOptions);

  /** Add a report to the buffer (evicts oldest if full). */
  record(report: PageReport): void;

  /** Return all buffered reports and clear the buffer. */
  flush(): PageReport[];

  /** Read-only snapshot of the current buffer. */
  getBuffer(): readonly PageReport[];

  /** Find a ComponentNode by fiber ID within a tree. */
  static findNode(root: ComponentNode, id: number): ComponentNode | null;
}

function createRuntime(options?: RuntimeOptions): ScopeRuntime;

Internal Architecture

src/
├── index.ts              ← barrel export
├── capture.ts            ← capture() — orchestrates all subsystems
├── devtools-hook.ts      ← installHook(), waitForReact(), ScopeDevToolsHook
├── fiber-walker.ts       ← walkFiber(), walkFiberRoot(), lightweight variants
│                            extractName(), classifyType()
├── hooks-extractor.ts    ← extractHooks() — hook type detection via duck-typing
├── profiler.ts           ← installProfiler(), getProfilingData(), resetProfilingData()
├── context-extractor.ts  ← extractContextConsumptions(), extractContextProviders()
├── console-interceptor.ts ← installConsoleInterceptor(), getConsoleEntries()
├── error-detector.ts     ← detectErrors()
└── suspense-detector.ts  ← detectSuspenseBoundaries()

Capture orchestration (capture.ts):

capture()
  │
  ├─ installHook()           ← ensure hook is at window.__REACT_DEVTOOLS_GLOBAL_HOOK__
  ├─ installProfiler(hook)   ← start accumulating render timing
  ├─ installConsoleInterceptor() + clearConsoleEntries()
  ├─ waitForReact()          ← async: wait for renderer.inject()
  │
  ├─ [renderer registered]
  │    │
  │    ├─ fiberRoot = renderer.fiberRoots.first
  │    ├─ detectErrors(rootChild)
  │    ├─ detectSuspenseBoundaries(rootChild)
  │    ├─ getConsoleEntries()
  │    │
  │    └─ lightweight ?
  │         ├─ yes → walkFiberRootLightweight(fiberRoot)
  │         └─ no  → walkFiberRoot(fiberRoot)
  │
  └─ uninstallConsoleInterceptor()  ← always runs (finally block)

Used by

| Package | How | |---|---| | @agent-scope/cli | Calls capture() to produce PageReport JSON for the report command | | @agent-scope/manifest | Builds component manifests from CaptureResult.tree | | Test fixture apps | Import capture() in integration tests to assert on live React trees |