Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Form State DevTools

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.

Why this exists

Form bugs are almost never "the value is wrong". They are:

  • a field that says aria-invalid="true" while checkValidity() returns true, so screen reader users are told about an error that no longer exists;
  • an aria-describedby pointing 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.

Install (load unpacked)

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 it

Then, in Chrome or Edge:

  1. Open chrome://extensions (or edge://extensions).
  2. Turn on Developer mode (top right).
  3. Click Load unpacked.
  4. Select the repository root — the folder containing manifest.json.
  5. The extension appears as Form State DevTools. No toolbar icon is needed; it lives in DevTools.
  6. 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:5175

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

The panel

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 a form:orphans node 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 and kind: / 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.

Toolbar

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.

State model

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

How it works

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 document for input, change, focus, blur, invalid, submit, reset. Capture phase specifically, so a page calling stopPropagation() in a bubble handler cannot blind the panel.
  • A MutationObserver over the whole document, filtered to the attributes that matter (aria-invalid, aria-describedby, aria-busy, disabled, required, …).
  • All work is coalesced into one requestAnimationFrame callback. Typing quickly into a 200-field form produces one scan per frame, not one per keystroke.
  • Baselines live in a Map keyed by formId::fieldKey, not a WeakMap keyed 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.

Message protocol

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.

Adapter detection

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.

Limitations

Read these before trusting a reading.

  • validating is a heuristic. The platform has no "async validation in flight" signal, so the probe looks for aria-busy="true", data-validating="true" or data-state="validating". If your app does not set one of those, the badge will never light up. If your app sets aria-busy for 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 register and formState will be reported as react-hook-form. A heavily minified or production-mode build with an unusual fiber shape may report react. 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 validity property.
  • 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/blur are 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 no name, no id and no aria-label, and the DOM order changes, their baselines will be mismatched.

Development

npm install
npm test             # node:test, DOM-backed by linkedom
npm run check        # manifest sanity check
npm start            # demo server on :5175

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

Further reading

The concepts this tool visualises, explained properly:

License

MIT — see LICENSE.

About

Chrome DevTools panel that inspects live form state — dirty/pristine/touched flags, validation lifecycle timeline, and per-field error and ARIA wiring.

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages