The form engine implements useFormEngine, the client half of
server/client mirroring, and the reactivity
placement rules of protocol v1 §5/§7. Everything lives in
packages/ui: the JsonLogic evaluator in src/json_logic.ts, the engine
internals in src/form/, the hook in src/hooks/use_form_engine.ts.
What the engine is for
A form descriptor arrives as a nested layout tree with a flat set of state
keys, per-node reactive blocks, and capability URLs. useFormEngine turns
that into live state plus one FieldController per field, so a field component
never parses a rule, never builds a URL, and never touches Inertia.
export function PostForm({ descriptor, state }: ResourceFormPageProps) {
const engine = useFormEngine(descriptor, state, { action: '/admin/posts' })
// `schema` is absent in index mode (protocol v1 §3), hence the guard.
return descriptor.schema === undefined ? null : (
<SchemaRenderer
node={descriptor.schema}
controllers={engine.controllers}
visible={engine.visible}
required={engine.required}
fieldOptions={engine.fieldOptions}
/>
)
}Returned: state, controllers, errors, dirty, visible, disabled,
required, derived, fieldOptions, submit(), reset().
Nothing here is authoritative
Every rule the engine evaluates is mirrored server-side in the compiled validator and the fill set (§11.2). A tampered client can only make its own UI wrong. The engine exists for latency — no round-trip per keystroke — not for enforcement.
That constraint has a concrete consequence: the client must reach the same
verdicts as the server, or the user watches values change after saving. Both
sides therefore implement the same closed operator set, and
packages/ui/tests/reactivity_fixtures.test.ts runs the entire fixture corpus
(docs/reactivity/fixtures/, 23 files, 40 cases × both modes) through the
engine and asserts the engine’s verdicts against the same expectations the
server suite asserts.
The evaluator
Closed operator set (the reactivity contract): var === !== > >= < <= in ! and or if. Strict
equality only, structural for arrays and objects; relational operators return
false when either operand is null; JS truthiness, so [] and {} are
truthy. var reads dotted paths (items.0.price) and ./-prefixed paths
resolve against a repeater row scope.
src/json_logic.ts is a verbatim port of
packages/core/src/validation/json_logic.ts, deliberately duplicated rather
than shared: @adonia/ui ships to the browser and must not depend on the
server runtime. tests/json_logic.test.ts imports both evaluators and runs
a rule battery through them, asserting identical values and identical
warnings — drift fails CI on the spot.
State hygiene
buildFormState projects the incoming state onto exactly the descriptor’s node
keys:
- a key with no node is dropped and can therefore never be submitted (§19 mass-assignment guard);
- a declared key the server omitted falls back to
props.default, thennull— the same “defaults merged under the submitted state” rule the server evaluates with (R1), so both sides see one identical evaluation state.
A field hidden by visibleWhen keeps its value in state (draft safety, §5:
hiding is not discarding) but is not submitted. The server excludes it from
the validator and the fill set anyway (R3), so omitting it keeps the two sides
describing the same record. This is a different mechanism from canSee, which
removes the node server-side before it ever reaches the wire.
Derivations
reactive.sets entries run in document order. source defaults to the
declaring node’s own key; if guards the entry; slugify/uppercase/
lowercase/copy are built in and fn:<name> resolves from
registry.transform(name, fn). An unregistered transform is skipped with a
warning — fail closed, never eval.
One pass, no fixpoint: every source reads the pre-derivation state, so
a → b → c does not cascade within a single edit. That matches the server
exactly (R6).
Two moments apply derivations:
- live, debounced by the edited node’s
reactive.live.debounceMs, and scoped to that node’s own entries — re-deriving every target on every keystroke would overwrite whatever field the user is currently typing into; - at submit, a full pass over every node, because the server recomputes them at save time regardless. Sending the derived value is what makes the optimistic UI and the persisted row agree.
engine.derived exposes the full-pass map for the current state.
Dependent options
reactive.refetch.url is a reference — the literal 'urls.options' — never a
URL. The engine resolves it against the same node’s urls map (§4), appends
only the declared withState keys as query parameters, and stores the response
under engine.fieldOptions[key]. SchemaRenderer overlays that onto
props.options, so a field component consumes late-arriving options exactly as
it consumes server-rendered ones and needs no engine awareness.
A node declaring refetch without the referenced capability is a compile error
server-side and a dev warning here. Responses may be a bare array or an
{ options } / { data } envelope; anything else leaves the previous options
standing rather than blanking a working select.
That overlay is also how a component tells “waiting” from “empty”: until the
response lands the node has NO options prop, and once it lands it has one —
empty or not. The built-in select reads exactly that to choose between a
loading state and an empty list (see
field components), which is why neither the engine
nor the protocol needs a loading flag.
reactive.refetchSchema: true is the closure-visibility fallback for rules the
wire grammar cannot express: after the field settles, the engine issues
router.reload({ only: ['descriptor'] }). Local edits survive the swap — the
engine re-projects state onto the new node set instead of rebuilding it.
Errors, dirtiness, submit
Validation failures arrive as the Inertia errors page prop, either as a named
inputErrorsBag or flattened; extractInputErrors unwraps both and the engine
maps them onto controller.error by state key.
dirty compares live state against the last server-supplied baseline by deep
value equality, so re-typing the same value is not a change. While dirty (and
not mid-submit) the engine confirms Inertia visits through a before listener
and installs a beforeunload handler; a submit navigates on purpose and is
never confirmed away. Pass { guard: false } to opt out.
The Inertia half is confirmed by Adonia’s own alert dialog: PanelShell mounts
DirtyGuardDialog, which registers itself through registerDirtyGuardPrompt. A
before listener has to answer synchronously, so the visit is cancelled, the
dialog awaited, and — if the user discards — the same visit replayed with the
guard suppressed for that one visit. A form rendered outside a shell has no
dialog mounted and falls back to window.confirm. The beforeunload half
cannot be customized: closing a tab or reloading always shows the browser’s own
prompt with the browser’s own copy.
Reactive verdicts reach components as props
FieldController is ABI-frozen at
{ value, setValue, error, disabled, readonly, urls, refetchOptions }
(§13.2), so reactive requiredness and refetched options are overlaid onto
props.required and props.options by SchemaRenderer. A component that
already reads those props becomes reactive with no changes.