Working notes for coding agents making changes in this repository.
The README documents how to use the library. This file is about how to change it: the commands that have to pass, where a given kind of change belongs, and the handful of rules that are not visible from the call site and are therefore easy to get wrong.
npm install
npm run dev # playground at dev/
npm test # vitest, single run
npm run test:watch
npm run lint # eslint src
npm run typecheck # tsc --noEmit
npm run build # library build
npm run format # prettier over srcA change is not done until npm run lint, npm run typecheck and npm test all pass. CI
runs exactly those three, in that order, on Node 20 (.github/workflows/ci.yml), so a green
local run and a green CI run mean the same thing. Lint is first there, so a formatting slip
fails the job before any test has run. npm run build is not in CI; run it anyway
when you touch anything under src/, because the library build resolves the @/ alias
differently from tsc and is the only step that would catch a bad import path.
src/
form/ BasicForm, Form, the loading overlay, submit-button HOCs
field/ FormField, Field, the field HOC chain, ConditionalField, FormFieldArray
controls/ Control (the type router) + every concrete input
validation/ validators, the message map, form-level rules, required detection
config/ config types, buildSchema, useFormFromConfig, ConfigFields, the renderer seam
adapters/ renderer registries for component libraries, one entry point each (mantine/)
styles/ theme.scss (CSS custom properties)
index.ts the public API barrel
tests/ mirrors src/ by area
Imports inside src/ use the @/ alias for src/ (tsconfig.json paths, mirrored in
vite.config.ts). Relative imports are fine within a directory.
Every field goes down one path, and new work should join it rather than route around it:
Form / BasicForm FormProvider + zodResolver + ValidationSchemaContext
FormField useController bridge + useIsFieldRequired
Field HOC chain: withRequired, withFieldMessage, withLabel, withControlProps
Control type router
TextInput | SelectInput | SwitchInput | ...
Form is BasicForm plus an automatic loading-aware submit button. Use BasicForm when the
consumer supplies their own submit control.
A config joins that path one step higher. ConfigFields resolves each field to a renderer and
renders it; the native renderer is what re-enters the chain at FormField:
ConfigFields conditional wrapper + resolveFieldRenderer(registry, field.type)
NativeFieldRenderer FormField with the field's config props
Field ... the chain above
The registry comes from RendererContext, defaulting to nativeRenderers, and the renderers
prop on ConfigFields overrides it for one list.
An adapter renderer joins at the same step as the native one but goes around FormField: it calls
useController and useIsFieldRequired itself and hands the value, the error message and the
required flag to the library's own input. adapters/mantine/MantineTextRenderer.tsx is the model.
A new control. Add the component under the right controls/ subdirectory (inputs/,
toggles/, selects/, datetime/), export it from that subdirectory's index.ts, add the
type to ControlType, and register it in the Control router. A control that is not in the
router is unreachable from FormField and from a config.
A new validator. Add it to the matching file under validation/validators/ and export it
from validation/index.ts. Its default message goes in defaultMessages in
validation/messages.ts and the validator reads it from getMessages(). Never inline an
English string in a validator - that is the one thing the message map exists to prevent, and
an inlined string is unreachable by every override mechanism the library offers.
A new config rule. Extend ValidationDescriptor in config/config.types.ts and handle it
in applyRule in config/buildSchema.ts. Give it a message?: string, and fall back to the
message map when the descriptor does not carry one.
A renderer, or an adapter's worth of them. A renderer is a ComponentType<FieldRenderProps>;
group them into a FieldRendererRegistry and hand it to RendererProvider or to the renderers
prop. Do not add entries to nativeRenderers.byType - see the seam rule below.
A renderer for a component library. It goes under adapters/<library>/ and is exported from
that directory's index.ts, which is its own build entry (vite.config.ts build.lib.entry)
and its own subpath in package.json exports. The library itself is an optional peer
dependency plus a dev dependency, so the rollup externals pick it up from the manifest. Never
re-export an adapter from src/index.ts: the root would then import the library for every
consumer, which is the react-select problem over again.
Anything exported. Re-export it from the area barrel; src/index.ts re-exports the five
area barrels and nothing else.
These are the ones to read before touching validation or config.
Messages resolve when the schema is built, not when the module loads. Validator factories
are ordinary functions called at schema-build time, outside React rendering. That is why the
active map is a module-level binding in validation/messages.ts and not context: by the time
React renders, the strings are already baked into the Zod schema, so a provider can never reach
them. Anything that needs to affect a message has to be active while the schema is built.
withMessages is a synchronous scoped swap, and it must stay synchronous. It layers
overrides over the current map, runs build(), and restores the previous map in a finally so
a throw cannot leak into the next form. Making it async would let two forms interleave and take
each other's messages.
That is why validationSchema accepts ZodSchema | (() => ZodSchema). Only a builder can
run inside the swap. A ready-made schema already carries its strings, so per-form messages
cannot affect it - a real limit, stated in the prop comment rather than papered over.
A parameterised message is a function, not a string. A translation needs to put the number
somewhere else in the sentence. Overrides may also be templates with named placeholders
('Must be at least {min} characters') so a JSON bundle, which cannot hold functions, can
supply every entry; placeholders in messages.ts maps each parameterised key to its argument
names in order. An unknown placeholder is left as written rather than blanked, so a wrong key is
visible instead of silently vanishing. Adding a parameterised message means adding its entry
there too.
zodResolver already parses asynchronously, so an async rule needs nothing from the resolver
setup. useFormConfig builds it with no mode option, and that default path calls
schema.parseAsync. The config engine is the exception: buildSchema validates each showWhen
field with a synchronous safeParse inside a superRefine, and zod throws when an async
refinement is reached during a synchronous parse. That is why asyncCheck is for hand-written
schemas only; supporting it in a config means making that refinement async for every config form,
and finding a way for a data-only ValidationDescriptor to name a function.
A debounced validator holds its state in the closure it was built with. Same reason as the
message map: the factory runs at schema-build time, outside React, so there is no ref or state to
put it in. One asyncCheck call is one debounce channel PER FIELD PATH, which makes a stable
schema a correctness requirement rather than an optimisation - a schema rebuilt each render
restarts the window each render and it never elapses.
Only a change to the value re-arms that window. The refinement does not see keystrokes, it sees
parses, and under a resolver the parse is the whole schema - so editing any other field runs it
again with a value nobody touched. Treating that as a keystroke starves the check twice over: the
window is pushed out for as long as the neighbouring field is being typed into, and a request
already in flight for the same value is stranded behind the timer !== undefined guard in finish,
so the answer is discarded and re-requested. ask compares against latest and joins the existing
wait when the value is unchanged. Anything added there has to keep that comparison ahead of the
re-arm.
The state of an async check is keyed off the schema instance AND the field path, and it needs
both. The instance alone is not an address: every doc here tells consumers to hoist the schema,
so one validator routinely sits on two fields or on every row of an array, and one set of
latest/waiting/timer behind two fields lets one field's verdict answer for the other - the
rejected value submits. The path alone is not an address either, since two forms may both have a
username. So the WeakMap useIsAsyncValidating subscribes through is keyed by the schema the
factory returned and hands back a channel per path, while the refinement reads its own path off
ctx.path; the hook finds the node with fieldAt over ValidationSchemaContext, the same context
useIsFieldRequired reads. The consequence to keep in mind: zod methods that clone rather than wrap
(.describe() builds new This({ ...this._def })) return an instance the channel is not attached
to. Wrapping is safe - unwrapField in schema.utils.ts peels ZodEffects, ZodOptional,
ZodNullable and ZodDefault - so a new wrapper type needs a case there or the indicator silently
goes dead.
React Hook Form's per-field isValidating cannot stand in for it. Measured against RHF 7.71
under zodResolver: editing one field marks only that field validating even while a sibling's
remote check is in flight on the same parse, and handleSubmit marks every mounted field, remote
check or not. Both follow from the resolver parsing the whole schema at once. RHF also only
maintains the map at all once something subscribes to formState.isValidating or
formState.validatingFields, which is why the test that pins this reads formState.validatingFields
rather than getFieldState.
The loading overlay is its own component because the focus trap needs a lifecycle.
BasicForm renders FormLoadingOverlay only while the form is submitting, so the trap is set
up on mount and torn down on unmount rather than tracked against a boolean. Its cleanup detaches
the focusin and keydown guards BEFORE restoring focus to the control that had it: the guard
cannot tell a restore from an escape, so with the listeners still attached it pulls focus back
onto an overlay that is being removed and focus ends up on the body. Anything added to that
cleanup goes above the restore.
Tab and Shift+Tab are refused and focus is returned to the overlay container rather than cycled through focusable elements inside it, because the overlay's content is a fixed label with nothing focusable in it. Interactive content in the overlay - a cancel button, say - means writing the cycle and its test in the same change.
Blocking the pointer is pointer-events: none on the <form> and z-index: 10 on the
overlay, and both halves are needed: the z-index alone still lets a click land on the form, and
the pointer-events alone leaves the overlay painted under anything the form renders with a
z-index of its own (react-select gives its menu z-index: 1). inert on the form would be the
platform answer and is deliberately not used - React commits deletions before other mutations,
so the overlay's cleanup runs while the attribute is still on the form and the focus restore
would be refused.
The success side is derived state, not a second copy of it. successContent renders on
formState.isSubmitSuccessful, the same object the overlay reads isSubmitting from, which is
why neither a schema error nor a handler that throws can show it: react-hook-form catches the
error out of the submit handler, records isSubmitSuccessful: false, and re-throws. The flag
stays true from one submit until the next one settles, so the render condition excludes
isSubmitting as well - without that, a resubmit displays the previous outcome while the new
one is still running. resetOnSubmit resets inside the submit handler and reset() clears the
flag, which RHF's own post-submit update then sets back; the two props do work together, and
that is pinned by a test rather than left resting on the ordering staying put.
Required detection is inferred from the schema, not declared. useIsFieldRequired reads the
resolved schema, so it has to unwrap wrappers (ZodEffects among them) to find the field. A
change that wraps the schema and forgets the unwrap silently drops the required marker from
every field on the form rather than failing loudly.
A field name is a PATH, and the lookup walks it. fieldAt in schema.utils.ts resolves
address.street and contacts.0.email as well as email, unwrapping each container on the way
down and handing back the field it lands on still wrapped - whether that field is optional is
usually the question being asked, so unwrapping it would answer a different one. An array index
is matched as digits and discarded, because every element of an array shares one schema; that is
also why an index past the end, which happens for one render after a row is removed, still
resolves. The cost of the walk is that a literal field name containing a dot is no longer
findable, which react-hook-form does not allow anyway.
useIsAsyncValidating walks the path, and the path is also what addresses the wait. Two rows
of one element schema are one identical schema node - verified - so the node alone cannot tell
them apart and asking it for a channel would light every row's indicator at once. The channel is
looked up by schema node and field path, which is the same pair asyncCheck keys its state on.
Config conditionals clear; hand-written ones do not, by default. A showWhen field in a
config is validated only while visible and its value is dropped when it hides. The standalone
ConditionalField takes clear as an opt-in, because a branch that is only collapsed for space
should keep what was typed into it. Do not quietly align the two: they are different defaults on
purpose.
The config memo is keyed on content, and each signature covers exactly what its value reads.
useFormFromConfig serialises name, type, validation and showWhen per field for the
schema, and name, type and defaultValue for the defaults. An inline array literal costs one
serialisation per render and no rebuild. A RegExp is written as its source and flags, a function
as an identity id, because JSON.stringify would otherwise drop both and the memo would go stale
again. Anything buildSchema starts reading has to join the schema signature in the same change;
do not add fields neither value reads (options, label), and do not narrow it back to the array
reference either.
fields is returned as the caller's own array rather than a memoised copy, which is what keeps
label, options, disabled and showWhen live. ConfigFields keys its children on
field.name, so the changing array identity does not remount anything. A change that starts
memoising fields breaks the live half and buys nothing.
A duplicate field name is a throw, not a warning. Names are checked before either memo, so it
throws on the first render: useFormFromConfig: duplicate field name(s): <names>. The reason it
cannot be a last-wins is that the defaults and the schema would disagree about which field won.
Anything that starts accepting duplicates needs an answer for both.
nativeRenderers has one entry, and filling in its byType undoes the reason it exists.
Control's switch is the single place a control type is mapped to a component. A per-type map in
config/ was tried once here and reverted for exactly that reason - it was a second copy of the
router's table, free to drift from it. byType is for a registry that overrides some types and
leaves the rest to fallback; the native registry overrides nothing, so it names nothing.
showWhen is applied by ConfigFields, not by the renderer. The conditional wrapper and the
field.name key stay outside the resolved component, so every renderer gets conditional
visibility and clear-on-hide without implementing the predicate. That is also why
fieldRenderProps computes required from the config for a conditional field - the renderer is
told the answer rather than asked to work it out. Moving either into a renderer means a second
place they can be got wrong.
A nested RendererProvider replaces the registry above it rather than merging. Merging reads
friendlier and is the wrong default: an inner registry chosen to replace an outer one would keep
inheriting the outer byType for any type the inner one covers through its fallback. Consumers
who want a merge can spread the two themselves, where the precedence is visible.
Vitest. The default environment is node, not jsdom (vitest.config.ts), because most of
the suite does not need a DOM and node starts faster. A file that renders opts in with a docblock
on its first line, above the imports:
// @vitest-environment jsdom
import { render, screen } from '@testing-library/react'Without it the first render fails with document is not defined, which reads like a broken
setup and is not one. tests/setup.ts pulls in @testing-library/jest-dom for every file either
way. A Mantine test also needs a MantineProvider and a window.matchMedia stub, which jsdom
lacks and the provider calls on mount, plus a ResizeObserver stub for any Select, whose
dropdown constructs one; tests/adapters/mantine.test.tsx sets all three up in the file rather
than in the shared setup, with env="test" on the provider so the dropdown renders in place
instead of through a portal. Files run sequentially (fileParallelism: false) to avoid jsdom worker-timeout flakiness.
tests/ mirrors src/ by area, and a new test belongs in the existing file for its area when
one exists.
Write the failing test first and check that it fails for the reason you think. A test
written after the change passes for reasons nobody has verified. When a change could plausibly
break an existing behaviour, pin that behaviour with its own test in the same commit - for
instance the message work pins that an explicit message on a descriptor still wins over the
map, since that is exactly what the fallback could have broken.
Prefer behaviour through the public surface over reaching into internals; most of the suite renders a form and asserts on what a user would see.
Prettier owns formatting: no semicolons, single quotes, 4-space indent, trailing commas,
100-column width. Run npm run format rather than hand-aligning.
Comments explain why, and the existing ones are the model: they carry the constraint that forced the shape, not a restatement of the code. A comment that would go stale the moment someone edits the line below it is not worth adding.
The README's "Known limitations" list is the current one; read it before proposing a fix for
something already known and deliberately open. Type declarations are not generated yet, so the
published package ships JavaScript and CSS without .d.ts files.