Skip to content

Chrome DevTools object inspector (TypeScript / DevTools front end)

The only subject inspecting a live, foreign, cyclic value graph it does not own — across a process boundary — where every expansion is a protocol round trip and no walk is ever automatic.

Language / toolkitTypeScript / DevTools front end (TreeOutline)
LicenseBSD-3-Clause
RepositoryChromeDevTools/devtools-frontend
Revision read788f6469
CategoryRemote handles; lazy per-expansion fetch
Metadata sourcethe CDP Runtime.getProperties reply — descriptors, not types
Undonone (edits are Runtime.callFunctionOn evaluations)

Overview

What it solves

Showing a JavaScript value the inspector cannot hold: it lives in another process, it changes under you, it is routinely cyclic (window.window), its "fields" may be getters with side effects, and an array may have a million elements. Every hard case this survey treats as an edge case is the normal case here.

Design philosophy

The front end never has the object — only an id for it. RemoteObject.objectId (RemoteObject.ts:179) is the handle; properties arrive as RemoteObjectProperty descriptors (:661) carrying enumerable, writable, isOwn, wasThrown, symbol, synthetic, getter, setter. Expansion is getOwnProperties / getAllProperties (:195), i.e. a round trip; collapse can release the handle (releaseObject, :568).

Model & addressing

Handle plus path. The tree element is ObjectPropertyTreeElement (ObjectPropertiesSection.ts:1969) over a RemoteObjectProperty, and each property exposes path() (:2306) — a dotted route from the root used for persistence-free identity (which properties were expanded, which were edited).

Two consequences no in-process subject has:

  • Identity is owned by the other side. A objectId is valid until released or until the execution context is destroyed; the inspector is holding a lease, not a pointer.
  • Nothing is ever fully known. The tree cannot be flattened, counted or searched exhaustively without asking the page — so every "how many rows?" question the type-driven subjects answer statically is unanswerable here in principle.

Recursion

Descent is per expansion, and expansion is I/O. onpopulate clears and refetches children (:2187); onexpand/oncollapse record the state on the node (:2206).

Three bounds keep a hostile graph presentable:

BoundValueEffect
InitialVisibleChildrenLimit200 (:1670)beyond it, a "show all" button rather than more rows (:2173)
ArrayGroupingTreeElement.bucketThreshold100 (:2528)an array longer than this is grouped into [from … to] buckets (:2450)
sparseIterationThreshold250000 (:2529)above it, ranges are computed without iterating every index

Bucketing is itself recursive — a bucket of 100 000 elements contains buckets — so an array of any size presents as a fixed handful of rows. That is a different answer from Godot's pagination (a window with page controls): a hierarchy of ranges, navigable by expansion rather than by paging.

Cycles

There is no cycle guard, and none is needed: no walk is automatic. A cyclic graph is expanded one level per click, each level a fresh fetch, so window.window.window… is finite work per interaction and terminates when the reader stops — the position Godot and WinForms take by accident, here as an explicit consequence of the protocol. Compare Unity's visited set and rjsf's cycle tag: both are needed precisely because those subjects have a walk (expand-all; schema resolution) that runs without a human. DevTools has neither.

Getters are not invoked. A property backed by a getter renders as an invoke affordance, and the call happens only when clicked (invokeGetter, :859, wired to a click at :1742). Reading a value can be a side effect, so the inspector refuses to read it on the reader's behalf — the strongest statement in the corpus that displaying and evaluating are different acts. Nothing in Tier 1 distinguishes them: every other subject calls the getter to paint the row.

Editing & mutation

  • Dispatch — by the descriptor and the value's remote type/subtype, not by a type registry: primitives get an editable text form, objects get expanders, functions get links to source, DOM nodes get element links.
  • Mutation — an edit is compiled into an assignment evaluated in the page (callFunctionOn), so the write is a remote side effect. There is no transaction and no undo; the console log is the audit trail.
  • Commit — on Enter/blur of the inline editor; a failed evaluation surfaces as a thrown value (wasThrown on the descriptor).
  • Change notification — none, deliberately. The displayed value is a snapshot from the last fetch; the corpus's other subjects re-read every frame or subscribe. Here re-reading is a network cost, so the tree goes stale by design and refreshes on re-expansion.

Type coverage

  • Collections — the bucket hierarchy above, plus Map/Set entries as synthetic [[Entries]] groups.
  • Polymorphic values — read-only inspection, so no picker; the runtime type is shown as a prefix and the internal [[Prototype]] is a child row.
  • Optional / unsetundefined vs absent vs null are all distinct and shown distinctly; non-enumerable and own-vs-inherited are rendered differently (dimmed / grouped).
  • Opaque values — nothing is opaque, but everything may be unavailable: a released handle, a destroyed context, a getter that throws (wasThrown), or an object too large to preview. The renderer has a state for each.

Presentation & control

  • Grouping — own vs inherited, enumerable vs not, internal slots ([[Prototype]], [[Entries]]), and index buckets.
  • Conditional visibility — descriptor-driven (enumerable, isOwn, private, symbol).
  • Search / filter — a filter box over the currently materialised rows only; the graph cannot be searched exhaustively because it is not held.
  • Escape hatches — custom formatters (CustomPreviewComponent), the page's own devtoolsFormatters hook, and the console REPL as the ultimate one.
  • Virtualization — the TreeOutline renders materialised rows; the real bound is the fetch limits above, which are cheaper than virtualization because unfetched rows do not exist.

Strengths

  • Handles a graph that is foreign, live, cyclic and unbounded, without owning any of it.
  • Bounds by fetch policy (200 / 100 / 250 000) rather than by rendering policy.
  • Distinguishes displaying from evaluating: getters stay uninvoked until asked.
  • Rich descriptor vocabulary (own, enumerable, private, symbol, synthetic, thrown) surfaced as presentation.
  • Recursive bucketing makes any array size a constant number of rows.

Weaknesses

  • Values go stale; there is no live binding.
  • Editing is a remote evaluation with no undo and no validation.
  • Identity is a lease that can be revoked (context destroyed) — every operation must handle the object having vanished.
  • Search is limited to what has already been fetched.

Key design decisions and trade-offs

DecisionRationaleTrade-off
Handles, never valuesThe graph lives in another process and may be huge or cyclicEverything is async; the row set is never fully known
Expansion = fetchCost is proportional to what the reader opensStale data between expansions; no live updates
Fetch-time limits (200 / bucket 100)A hostile object cannot flood the UIThe reader sees a truncated truth and must ask for more
Recursive index bucketsAny array length becomes a few rowsAn element's row is several clicks deep
Getters never auto-invokedReading must not cause side effectsThe reader sees "(...)" rather than a value until they ask
No cycle guardNo automatic walk exists to guardAny future "expand all" feature would need one — as Unity's does

Sources

All line numbers are at 788f6469.