Troubleshooting
First move, always: open the inspector. It shows every field’s value, its valid, touched, dirty
and pending flags, and each error with its origin — [validation], [async], [cross-field] or
[server].
It is part of the engine, so it works with any adapter:
import { mountMdyDevtools } from "@modyra/core/devtools";
const unmount = mountMdyDevtools(form, document.querySelector("#inspector"));In Angular, add mdyDevtools to the <mdy-form> and press Ctrl+Shift+D for the same panel as
an overlay. See the devtools guide.
Why is canSubmit() false?
Section titled “Why is canSubmit() false?”canSubmit = !submitting && valid && !pending (in the default
"valid-only" mode). In the devtools check, in order:
- valid: false — filter “only invalid”: some field has errors, or a
cross-field validator failed. A cross-field error goes where its
pathsays, so look there first:crossField(["a", "b"], …)attributes the message toaandb, anderrorsFor("")is empty. Only a validator that names no path —crossField([], …), or one returningpath: null— shows onerrorsFor(""). - pending: true — an async validator is still in its debounce+run
window;
canSubmitwaits for it by design. - submitting: true — the previous submit’s promise never resolved. Check your action for a hanging request.
- Mode is
"manual"—canSubmitis always false there; drive submission yourself.
Why is a field still pending?
Section titled “Why is a field still pending?”pending covers the whole debounce window plus the validator run. Three
things keep it from settling:
- The validator’s promise never resolves. Pass
timeoutMstoserverValidator()/upsertAsyncValidators()so the run aborts and settles with akind: "async-timeout"error instead of hanging forever. - The value keeps changing — every change restarts the debounce.
- The run was already in flight when the form was paused.
deactivate()does not take the answer of a run that had already started, sopendingnever reaches a terminal state andactivate()does not clear it either. The escape is a new write: that starts a new run, and the form frees itself when that one settles. A run paused during its debounce window is not affected — it runs afteractivate()and closes normally.
Why was a server error cleared?
Section titled “Why was a server error cleared?”Server errors are snapshotted against the submitted value and shown only
while the field still holds that value — editing the field clears them
(that is the contract). They also clear on reset() and are replaced
wholesale by the next submit. An error whose path matches no registered
field is not lost: it surfaces on errorsFor("").
Why was my draft not restored?
Section titled “Why was my draft not restored?”In order of likelihood:
- The draft was cleared by a successful submit (by design).
ttlMsexpired orversionchanged — both discard the stored draft.- The field is listed in
exclude— excluded fields are never restored. - The form was pristine when it last closed — a pristine form writes no draft.
- Storage unavailable (private mode, blocked cookies, SSR) — the default storage silently degrades to a no-op.
Why is a control not registered, or its state empty?
Section titled “Why is a control not registered, or its state empty?”- A typo in the field name. Anything that addresses a field by string — a template attribute, a
contract document — creates a new field for an unrecognised name rather than failing. The typed
handles (
form.f.email) make the same mistake a compile error. - Two controls share one name. Both bind to the same field, and dev mode warns about it. Rename one.
- The control is outside the form. It found no registry to claim a field from; dev mode logs it.
Why did my value change after setValue()?
Section titled “Why did my value change after setValue()?”setValue has replace semantics: a field the passed object does not name
goes back to its initial — the same place reset() returns it to, which is
a state the form could have started in. It is not set to null, so a field that
declares initial: "pro" reads "pro" afterwards, not empty. Use
patch()/patchValue() to change a subset.
A whole value that names none of the form’s fields is refused rather than obeyed: one transposed key used to empty the form silently.
Why does getChanges() report an unchanged object field?
Section titled “Why does getChanges() report an unchanged object field?”Leaves compare with Object.is (reference equality for objects/arrays).
A re-created array/object counts as changed even if deep-equal. See the
mental model.