Skip to content

DevTools

@umpire/devtools is the in-app inspection surface for Umpire. It mounts a Shadow DOM panel, subscribes to explicit registrations, and lets you inspect the same scorecard() and challenge() data you would otherwise have to print or assert by hand.

The visual language is intentionally tool-like: tabbed panes, tables, logs, and inspectors. It should feel distinct from your product UI, not like another styled feature surface.

Setup has two independent parts:

mount() — call once to create the floating panel. It injects a Shadow DOM host, renders the Preact UI, and subscribes to the registry. Call it at your app entry point or in a top-level component effect. It returns an unmount cleanup function.

register() — call on every render, alongside ump.check(). It updates the registry with the latest snapshot. The panel reacts automatically. You can register before or after mounting — order doesn’t matter.

Step 1 — mount the panel (once, at your app root):

// Vanilla / framework-agnostic entry point
if (import.meta.env.DEV) {
const { mount } = await import('@umpire/devtools')
mount()
}
// React — dedicated component, mount in useEffect
import { useEffect } from 'react'
import { mount } from '@umpire/devtools'
function DevtoolsPanel() {
useEffect(() => mount(), [])
return null
}
// Render once at your app root:
// <DevtoolsPanel />

Step 2 — register your ump instances (on every render, per ump):

import { register } from '@umpire/devtools'
// Called in render, same place as ump.check()
register('checkout', ump, values, conditions)
const availability = ump.check(values, conditions)

Or use a React hook that handles registration automatically — see React Helpers below.

The panel shows:

  • A field matrix built from scorecard()
  • A dedicated conditions tab for current and previous snapshot conditions
  • Per-field challenge traces from challenge()
  • A rolling foul log for disabled-with-value transitions
  • A graph view of the structural dependency DAG
  • Optional extension tabs, including reads when you register a reads table

Umpire is inherently multi-instance — you might have a checkout ump, a shipping ump, and a promo ump all active on the same page. The devtools panel handles this explicitly: each registered ump is tracked independently, and you switch between them in the instance picker.

Two hooks are available depending on whether you want named or anonymous instances.

Identical call signature to useUmpire from @umpire/react. The devtools assign a stable numeric id (ump-0, ump-1 …) per component instance automatically. Swapping in and out is a single import-line change.

// With devtools
import { useUmpire } from '@umpire/devtools/react'
// Without devtools — identical callsite, just change the import
// import { useUmpire } from '@umpire/react'
const { check, fouls } = useUmpire(ump, values, conditions)

Use this when there is only one ump active at a time, or when the auto-assigned label is sufficient for your debugging needs.

Takes an explicit string id as the first argument. Useful when multiple ump instances are active simultaneously and you need to distinguish them in the panel by name.

import { useUmpireWithDevtools } from '@umpire/devtools/react'
// Panel shows these as "checkout" and "shipping" in the instance picker
const { check: checkoutCheck } = useUmpireWithDevtools('checkout', checkoutUmp, values)
const { check: shippingCheck } = useUmpireWithDevtools('shipping', shippingUmp, values)

The return shape of both hooks is identical to useUmpire from @umpire/react: { check, fouls }.

If you are calling ump.check() directly (no hook), call register() in the same place — during render, before or after ump.check():

import { register } from '@umpire/devtools'
register('checkout', ump, values, conditions)
const availability = ump.check(values, conditions)

If you already use @umpire/reads, pass the table alongside the current input:

register('checkout', ump, values, conditions, {
reads: readTable,
readInput: values,
})

When the reads tab is available, the panel shows dependency edges, bridge wiring, and current computed values.

Extensions let other packages contribute their own devtools tabs without teaching @umpire/devtools about those packages directly.

register('checkout', ump, values, conditions, {
extensions: [{
id: 'validation',
label: 'validation',
inspect({ conditions, scorecard }) {
return {
sections: [{
kind: 'rows',
title: 'Summary',
rows: [
{ label: 'plan', value: conditions?.plan },
{ label: 'enabledFields', value: scorecard.graph.nodes.length },
],
}],
}
},
}],
})

Each extension returns structured view data, not framework UI. That keeps the devtools in control of rendering and lets packages like @umpire/zod add a validation tab without becoming a hard dependency of the panel.

mount() and register() are both no-ops in production (NODE_ENV === 'production') unless process.env.UMPIRE_INTERNAL === 'true' is set — the deliberate escape hatch used by this docs site.

This means leaving useUmpire or useUmpireWithDevtools in production code is harmless: registration is skipped, no scorecard computation runs, and nothing is added to the registry. The bundle overhead is the only remaining concern.

mount({
position: 'bottom-right',
offset: { x: 16, y: 16 },
foulLogDepth: 50,
defaultTab: 'matrix',
})
OptionDefaultDescription
position'bottom-right'One of top-left, top-right, bottom-left, bottom-right
offset{ x: 16, y: 16 }Distance from the corner in CSS pixels
foulLogDepth50Max foul events retained in the rolling log
defaultTab'matrix'Starting tab: matrix, conditions, fouls, graph, reads, or a custom extension id

If preact is already in your bundle, use the /slim entrypoint to avoid bundling a second copy:

import { mount, register } from '@umpire/devtools/slim'

The slim build marks Preact as external and saves ~4 KB. The default @umpire/devtools bundle includes Preact for users who don’t have it.