Skip to content

The form engine

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, then null — 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.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close