A Chrome/Edge DevTools extension (Manifest V3) that shows you what your form is actually doing:
every field's value, baseline, dirty/touched/visited flags, flattened ValidityState, aria-invalid,
the aria-describedby chain, the error message a user would really perceive — plus a timeline of raw
DOM events and the derived state transitions between them.
No build step, no bundler, no framework. Plain ES modules and about 2,000 lines of JavaScript.
Form bugs are almost never "the value is wrong". They are:
- a field that says
aria-invalid="true"whilecheckValidity()returnstrue, so screen reader users are told about an error that no longer exists; - an
aria-describedbypointing at an id that a conditional render removed, so the error text is visible but never announced; - a "save" button that stays disabled because one hidden field never left its pristine baseline — or stays enabled because a field silently re-baselined on re-mount;
- an async validator whose stale response lands after a newer one and flips the field back.
None of those show up in the Elements panel, and none of them show up in a library's own devtools either, because they live in the gap between your state model and the DOM. This extension watches the DOM and reports the gap. If you want the concepts behind the vocabulary it uses, the dirty and pristine state tracking write-up is the shortest path in.
There is no store listing. Clone and load it yourself:
git clone <this repo>
cd form-state-devtools
npm install # only devDependencies (linkedom, for the tests)
npm run check # optional: verifies the manifest before Chrome sees itThen, in Chrome or Edge:
- Open
chrome://extensions(oredge://extensions). - Turn on Developer mode (top right).
- Click Load unpacked.
- Select the repository root — the folder containing
manifest.json. - The extension appears as Form State DevTools. No toolbar icon is needed; it lives in DevTools.
- Open any page with a form, open DevTools (F12), and select the Form State tab. If DevTools was already open when you loaded the extension, close and reopen it.
To try it immediately against a form built for the purpose:
npm start # serves demo/ at http://localhost:5175The demo has a three-step wizard with a debounced async email check, a cross-field password
confirmation, radio and checkbox groups, a double-submit guard, and one control with a deliberately
dangling aria-describedby so you can see the panel catch it.
DevTools does not let extensions ship screenshots that stay accurate, so here is the layout in text. The panel is three regions plus a toolbar, and it follows your DevTools light/dark theme:
┌──────────────────────────────────────────────────────────────────────────────────────┐
│ [Refresh] [Re-baseline] ☑ Recording (native) snapshot #142 │
├────────────────────────────────────┬─────────────────────────────────────────────────┤
│ Forms [filter fields ] │ Email │ Detail │ Diff since pristine │
│ ├─────────────────────────────────────────────────┤
│ form#signup 3 fields │ STATE │
│ [2 dirty][1 invalid][native] │ (dirty ?) (invalid ?) (touched ?) │
│ ├ Email email a@b.c │ value a@b.c │
│ │ (dirty ?)(invalid ?)(touched)│ initialValue "" │
│ ├ Password password •••••• │ dirty true │
│ │ (dirty ?)(touched ?) │ touched true │
│ └ Plan radio team │ [Reveal in page] │
│ (pristine ?) │ │
│ │ VALIDITYSTATE │
│ form:orphans 1 field │ valid false │
│ └ q text hello │ typeMismatch true │
│ (pristine ?) │ valueMissing false … 8 more flags │
│ │ │
│ │ ACCESSIBILITY WIRING │
│ │ aria-invalid true │
│ │ agrees w/ … true │
│ │ error message Enter a valid email address. │
│ │ message origin aria-describedby │
│ │ #email-error resolved Enter a valid … │
│ │ #missing-id MISSING │
├────────────────────────────────────┴─────────────────────────────────────────────────┤
│ Timeline [kind:dirty ] buffer [500] [Clear] 18/240 │
│ 1284.0ms focus name:email │
│ 1284.1ms ⇒ unvisited->visited name:email ? │
│ 1512.7ms input name:email │
│ 1512.8ms ⇒ pristine->dirty name:email ? │
│ 2106.4ms blur name:email │
│ 2106.5ms ⇒ untouched->touched name:email ? │
│ 2107.9ms invalid name:email validationMessage=Enter a valid email… │
│ 2108.0ms ⇒ valid->invalid name:email ? │
└──────────────────────────────────────────────────────────────────────────────────────┘
- Left — the form/field tree. One node per
<form>, plus aform:orphansnode for controls that belong to no form (single-page apps often have none). Radio and checkbox groups collapse into one field, because that is what they are. Each field carries state badges. - Right — everything known about the selected field, in four sections: State, Element, ValidityState (all ten flags), and Accessibility wiring. The Diff since pristine tab lists only the fields whose value has left its baseline, with both sides.
- Bottom — the timeline.
·rows are raw DOM events,⇒rows are derived transitions. The filter box supports plain substrings andkind:/field:/form:/category:prefixes, ANDed together. - The small
?next to a badge or a timeline row opens the page explaining that particular concept. It is a link, not a banner; ignore it and nothing changes.
| Control | What it does |
|---|---|
| Refresh | Forces a re-scan and a fresh snapshot. Useful after something outside the observer's reach changed. |
| Re-baseline | Adopts the current values as the new pristine baseline for every form. The per-form baseline button does the same for one form. This is what your app should conceptually do after a successful save. |
| Recording | Stops all scanning and event recording. The probe stays installed but goes quiet. |
| buffer | Timeline ring buffer capacity, 25–10,000 entries. Older entries are dropped, and the drop count is reported in the snapshot. |
| Clear | Empties the ring buffer on both sides. |
| Field | Meaning |
|---|---|
value |
Normalised: boolean for a lone checkbox, string[] for a checkbox group or multi-select, the checked member's value (or null) for a radio group, file names for a file input, masked for type="password". |
initialValue |
The value captured the first time the field was seen, or the last time it was re-baselined. |
dirty / pristine |
value differs from / equals initialValue, compared structurally so array values behave. |
visited |
Has received focus at least once. |
touched |
Has been focused and then blurred. A blur with no prior focus does not count. |
validating |
Heuristic — see limitations. |
validity |
All ten ValidityState flags flattened, plus valid and a failing list. {supported: false} where the platform has no constraint validation for that control. |
ariaInvalid |
The raw attribute value, or null when absent. |
ariaInvalidMismatch |
true when aria-invalid="true" disagrees with the platform's own verdict, in either direction. This is the single most useful flag in the tool. |
describedBy |
Every id in aria-describedby, whether it resolves, its text, role and aria-live, plus a broken list of dangling ids. |
errorMessage / errorOrigin |
The message a user would perceive and where it came from: aria-errormessage > aria-describedby (only when aria-invalid is set) > the browser's validationMessage. |
Derived transitions recorded in the timeline: pristine->dirty, dirty->pristine,
untouched->touched, unvisited->visited, valid->invalid, invalid->valid, and
validating->validated (which carries the settled verdict, so one row tells the whole story).
page world isolated world devtools
┌──────────────┐ ┌────────────────┐ ┌─────────────────┐
│ probe.js │ postMsg │ content.js │ Port │ panel.js │
│ MutationObs ├──────────►│ relay + inject├─────────►│ render tree / │
│ capture │◄──────────┤ │◄─────────┤ detail / diff │
│ listeners │ └────────────────┘ ▲ │ / timeline │
└──────────────┘ │ └─────────────────┘
service-worker.js
(pairs ports by tabId)
The probe must run in the page world, not the content script's isolated world, because that is
the only place React fibers are visible. The content script injects it as a module <script>
pointing at a web-accessible extension URL — inline code would be blocked by any page with a strict
CSP, and the probe uses import.
Inside the probe:
- Capture-phase listeners on
documentforinput,change,focus,blur,invalid,submit,reset. Capture phase specifically, so a page callingstopPropagation()in a bubble handler cannot blind the panel. - A
MutationObserverover the whole document, filtered to the attributes that matter (aria-invalid,aria-describedby,aria-busy,disabled,required, …). - All work is coalesced into one
requestAnimationFramecallback. Typing quickly into a 200-field form produces one scan per frame, not one per keystroke. - Baselines live in a
Mapkeyed byformId::fieldKey, not aWeakMapkeyed by the element. Frameworks recreate DOM nodes constantly; an element-keyed baseline would silently reset every time a wizard step re-mounted. There is a test for exactly this.
The probe mutates the page in exactly two ways: a data-fsdt-form-id marker attribute on each
<form> (so ids stay stable across scans), and a temporary outline when you click Reveal in page.
Everything is wrapped in {__ns: "form-state-devtools", source, type, payload}. The __ns and
source fields are checked on receipt, so the page's own postMessage traffic is never mistaken
for ours. Constants live in src/core/protocol.js.
Page → panel (source: "form-state-devtools/page"):
type |
Payload | When |
|---|---|---|
probe/ready |
{probeVersion, href, capacity} |
Probe booted, or answered a panel/hello. |
probe/snapshot |
{seq, t, href, title, capacity, dropped, forms} |
Every coalesced recompute. forms is the full tree. |
probe/events |
{entries, replace?} |
New timeline entries. replace: true means "this is the whole buffer". |
probe/gone |
{} |
pagehide — the document is going away. |
Panel → page (source: "form-state-devtools/content"):
type |
Payload | Effect |
|---|---|---|
panel/hello |
{} |
Re-announce and send a full snapshot plus the whole ring buffer. |
panel/refresh |
{} |
Force one recompute. |
panel/set-capacity |
{capacity} |
Resize the ring buffer, keeping the newest entries. |
panel/clear-timeline |
{} |
Empty the ring buffer. |
panel/reset-baseline |
{formId?} |
Adopt current values as pristine, for one form or all. |
panel/highlight |
{formId, fieldKey} |
Scroll to and briefly outline the element. |
panel/set-enabled |
{enabled} |
Suspend or resume all probing. |
The service worker pairs a panel port to content ports by tab id. MV3 recycles service workers
aggressively, so nothing that matters is kept across a restart: the panel reconnects on port
disconnect, on chrome.devtools.network.onNavigated, and on a heartbeat that notices five seconds
of silence.
The panel reports which form library owns each form, when it can tell.
| Result | How it is reached |
|---|---|
native |
No React fiber on the <form> element. Also the result for controls outside any form. |
react-hook-form |
A value with register + handleSubmit + formState, or a control with _formState/_fields, found on the form fiber or an ancestor. |
formik |
A bag with values + errors + touched and setFieldValue (or handleSubmit + handleChange). |
react |
A fiber is present but no library signature matched. |
The panel shows the evidence in the chip's tooltip — for example "react-hook-form control/formState signature found 2 fibers above the form (<FormProvider>)" — so you can judge the claim rather than trust it.
Why not the official hooks? React Hook Form's devtools global only exists if you installed
@hookform/devtools, and Formik exposes nothing at all. So detection reads the fiber attached to
the DOM node and walks the return chain, checking props, context provider values, the
dependencies.firstContext chain, and the memoizedState hook chain for structural signatures.
It is deliberately conservative — the Formik check requires a method alongside the state trio,
because three objects named values/errors/touched is far too common a shape to claim.
Detection never gates functionality. Every state the panel reports is derived from the DOM. Adapter detection only changes a label.
Read these before trusting a reading.
validatingis a heuristic. The platform has no "async validation in flight" signal, so the probe looks foraria-busy="true",data-validating="true"ordata-state="validating". If your app does not set one of those, the badge will never light up. If your app setsaria-busyfor an unrelated reason, it will light up wrongly.- Detection is a guess, and it can be wrong in both directions. A custom hook that happens to
expose
registerandformStatewill be reported as react-hook-form. A heavily minified or production-mode build with an unusual fiber shape may reportreact. React internals are private and change between versions; this is best-effort by construction. - Only the DOM is observed. If your framework holds a value in state that it has not committed to the DOM, the panel cannot see it. Controlled components in React commit synchronously, so this is rarely visible in practice — but a debounced or deferred write will lag.
- Baselines are captured on first sighting, which may not be your "initial" values. If a form is populated asynchronously after mount, the probe's baseline is whatever was there when it first scanned, which is usually empty. Use Re-baseline once the data has landed.
- Password values are masked (shown as
•characters, length-capped at 32). Dirty tracking still works on the real value; only the display is masked. File inputs report names, never contents. - Shadow DOM is not traversed. Controls inside a closed shadow root are invisible;
controls inside an open shadow root are not currently walked into either. Form-associated custom
elements are seen only if they expose a
validityproperty. - Cross-origin iframes each get their own probe, and the panel currently renders their forms merged into one list, tagged only by frame id in the transport. Same-origin frames are fine.
focus/blurare recorded from capture-phase listeners on the document. Programmatic focus changes appear identically to user ones — the panel cannot tell them apart, and neither can the platform.- Anonymous fields fall back to a positional key (
index:3). If your form has controls with noname, noidand noaria-label, and the DOM order changes, their baselines will be mismatched.
npm install
npm test # node:test, DOM-backed by linkedom
npm run check # manifest sanity check
npm start # demo server on :5175The pure state logic lives in src/core/ and is where the tests point. derive.js, scan.js,
timeline.js, adapters.js and badges.js never touch a global — documents and DOM nodes are
always passed in — which is what lets the suite run under linkedom instead of a headless browser.
adapters.js is tested against plain object fixtures shaped like React fibers, so no React is
needed to test React detection.
manifest.json
assets/icon-128.png
src/
core/ derive.js scan.js timeline.js adapters.js badges.js docs.js protocol.js
page/ probe.js (page world, injected by URL)
content/ content.js (isolated world, relay + injector)
background/ service-worker.js (port switchboard)
devtools/ devtools.html devtools.js
panel/ panel.html panel.css panel.js render.js
demo/ index.html demo.js server.js
scripts/ check-manifest.js
test/ derive scan timeline adapters badges integrity + helpers/dom.js
After editing extension source, click the reload arrow on the card in chrome://extensions, then
close and reopen DevTools. The panel document is only created once per DevTools session.
The concepts this tool visualises, explained properly:
- Dirty and pristine state tracking — what a baseline is, and when to move it.
- The form validation lifecycle — the states a field moves through, which is what the timeline's derived rows are drawn from.
- aria-invalid timing and announcements — why
ariaInvalidMismatchis the flag worth watching. - Wiring aria-describedby for multiple errors — the dangling-id failure the panel flags.
- Asynchronous validation strategies — background for the
validatingheuristic and the stale-response guard in the demo. - Moving focus to the first invalid field — what the demo does after a failed step.
MIT — see LICENSE.