Implements the "Above the Prompt" proposal for the form-graph generation lane (flag `formGraphGenerator`, mod-only). The header's five controls become two: a workflow chip and a checkpoint row. - WorkflowPicker: every workflow listed once with its input type as an attribute and a filter across the top, replacing the four-segment picker and the getWorkflowModes strip that set the same key. - CheckpointRow: the checkpoint as one row (name, version, ecosystem) instead of a 52px card. Version stays a field beneath it. - EcosystemRail: the ecosystem control moves out of the header and into the checkpoint picker, with a consequence footer that counts the resources a switch would actually drop, before the commit. - ResourceSelectProvider gains opt-in `role`, `onSelectMultiple`, `limit`, `rail` and `footer`. Consumers that pass none behave as before; only the form-graph lane opts in. - Resource picking gains batching: the card's primary click still adds one and closes, a separate control stages into a tray. The picker's chrome changes for every consumer, not only the roled ones: wider modal, three-band layout, compact catalog tabs, a grid that fills its pane, and an always-reserved scrollbar gutter so a result set at the overflow threshold stops re-flowing. Shared with the data-graph lane rather than forked: `useBaseModelPickerState` extracted from BaseModelInput so the rail renders the same ecosystem list, and workflow-visibility.ts so both pickers resolve gates and feature flags through one implementation. Also includes the ModelVersionUpsertForm submit extraction and the form-graph reconcile changes authored alongside this work. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
8.6 KiB
react-hook-form → form-graph: where switching pays, and what a full migration retires
Status: discussion (2026-09-08). Question being answered: would our react-hook-form usage
be simplified by moving forms onto form-graph — and does anything short of a full
migration actually retire the Input* boilerplate layer?
Current state, measured
54 form components run on the house wrapper (~/libs/form: useForm + Form +
withController + ~35 stamped Input* exports). Only 2 files in the app own a form on
raw react-hook-form; RHF is not spread across the app, it is spread across
src/libs/form. The form-graph lane (generation) is fully disjoint — no file imports
from both.
By character:
| Bucket | Count | Examples |
|---|---|---|
| Static CRUD — schema + submit, zero watch/setValue | ~24 | the createReportForm family (9 forms from one factory), account modals, schedule modals |
| Mildly dynamic — 1–2 watches gating a section, reset-on-data | ~24 | ArticleUpsertForm, CollectionEditModal, UserProfileEditModal |
| Value-dependent shape — the field set/validation/defaults change with the form's own values | 6 | see below |
The complexity is depth, not sprawl: app-wide there are 74 watch/useWatch
occurrences, 98 setValue, and 22 setError outside the lib — and the six
value-dependent forms account for most of them.
The six, worst first (signals = watch + setValue + useEffect + setError + reset):
src/components/Resource/Forms/ModelVersionUpsertForm.tsx— 2,055 lines, 46 signals.usageControl/availabilitycascade nulls five monetization fields; two competing effects reconcilelicensingSourceVersionId; four separate comments document which hidden values must survive unmount and which must be hand-cleared.src/components/Challenge/ChallengeUpsertForm.tsx— 1,365 lines, 26 signals.prizeModeswitches the prize fields between three shapes (a discriminated union); 11 hand-rolledsetErrorcalls exist because the houseuseFormcasts the schema toZodObjectto read.shape, which forbids.refine()(comment at line 85). ItsCategoryWeightschild is the app's only field array.src/components/Resource/Forms/ModelUpsertForm.tsx— 907 lines, 23 signals. Thensfw/poi/sfwOnly/minorfour-way interlock is spread across fivesetValuesites; a self-watching subscription buildslockedPropertiesfrom touched fields.src/components/Bounty/BountyUpsertForm.tsx— 906 lines, 16 signals.typeswitches the wholedetailssub-object shape — anduseFormStoragepersists that branching shape to localStorage, so a draft saved on one branch restores against another.src/components/CosmeticShop/CosmeticShopItemUpsertForm.tsx— 696 lines; cosmetic type rewrites the item's field set.src/components/Generation/PromptEnhance/EnhanceTab.tsx— 520 lines; RHF used as a state bag (watch + setValue on unregistered fields). Weakest case — zustand would also fit.
The loudest single signal: the house useForm defaults shouldUnregister: true
(with a literal // TODO - do we need this?), and 30 call sites individually override
it back to false — several with comments explaining which hidden values must
survive. The whole codebase votes, one file at a time, for "a field leaving the DOM
keeps its value." That is form-graph's core model (intent persists; visible state
derives), which RHF makes a global boolean plus hand-managed exceptions.
The Input* layer: what it actually is, and what each option retires
The layer is two things fused:
- ~20 presentational wrappers (
TextInputWrapper,NumberInputWrapper,SelectWrapper,NumberSlider, upload components, …) — Mantine adaptation. These are controlled components withvalue/onChange/errorprops and know nothing about RHF. They survive any migration, full or partial. No form library removes the need to adapt Mantine. - The binding:
withController(Component)stamped 35 times, plus per-component mapper hacks. This is where the debt lives:- the ref-forwarding check compares against
Symbol('react.forward_ref')— a fresh symbol, so it is always false and refs are never forwarded (latent bug); - a
resetCountcounter threaded to every input as aresetprop, because RHF's reset doesn't notify non-field UI; - the
InputNumbermapper readsform.getValues()to defeatuseController's defaultValue resurrection; any-typed props,@ts-ignore, error-shape special-casing.
- the ref-forwarding check compares against
Under form-graph the binding becomes either direct Controller/createTypedController
render props (what the generation form does) or a ~30-line typed withField(Component)
factory giving the same <InputText name=… /> call-site ergonomics. So a full
migration does not reduce 35 stamped exports to zero — it reduces them to one small,
properly typed factory with no mapper hacks, no reset prop, and no resurrection
workarounds.
The boilerplate that genuinely disappears is different, and bigger:
- Call-site config duplication. Today
min/max/step/options live in the JSX at every call site AND (sometimes disagreeing) in the zod schema. In form-graph they live once, in the field def, and reach the component throughmeta— the same def the server validates with. The JSX shrinks to name + label + presentation. - The
useFormhook itself — thelooseObjectcast (which is what forbids.refine()and forces Challenge's 11 hand-rolledsetErrors), theshouldUnregisterbattle,resetCount. Form.tsxsubmit plumbing — replaced bystore.validate()+focusFirstError.useFormStorage— replaced bypersistedStorage, which is per-branch safe (scoped intent) where the localStorage envelope is not.- The per-form effect soup — the watch/setValue cascades in the six forms above
become resolver branches,
.effectrules,correctpolicies, and computeds. ThelockedPropertiesself-watch becomesdirtyFields(). - Dead code found along the way:
src/libs/form/components/FieldArray.tsxis unexported with zero users.
Recommendation
Partial first, full as the eventual end-state — but let the partial pay for itself before committing to the tail.
- Now, regardless of any migration (small, benefits all 54 forms):
- fix the always-false ref-forward check in
withController; - flip the house
shouldUnregisterdefault tofalse(30/54 already override it; audit the remainder against the documented resurrection trap first).
- fix the always-false ref-forward check in
- Port the value-dependent six, one at a time, after the training-form rewrite
(the agreed next form-graph consumer). Oracle-first, same as the generation port:
freeze the current submit payload as golden fixtures, differential-test until
byte-equal. Suggested first: ChallengeUpsertForm — the clearest categorical win
(a shape union RHF cannot express, plus the app's only field array →
list()) at two-thirds the size of ModelVersionUpsertForm. - Then decide on the tail. Once the six are ported,
src/libs/formserves only static/mild forms. The remaining ~48 migrate mechanically (a typedwithFieldfactory keeps call sites nearly identical), which is when the RHF dependency and thewithControllerlayer can actually be deleted — the payoff the partial migration alone never delivers. Whether that mechanical pass is worth its regression surface is best judged after the six, when the per-form cost is known instead of estimated. ThecreateReportFormfactory (9 forms from one schema+render spec) would port as one unit, not nine.
What stays no matter what: the presentational wrappers, the upload components, rich text — and submission orchestration (mutations, notifications), which is app code under either library.
Costs and risks, honestly
- Each of the six ports is real work — they are the app's hairiest forms, which is both why they pay and why they bite. The generation port's method (fixtures + differential oracle) is what made that tractable; budget for it per form.
- form-graph is pre-1.0 and we own it. The generation form in production plus its 12k-case differential suite is the stability gate; breaking changes go through us.
- Two forms in the six carry localStorage drafts (
useFormStorage); the port must migrate or deliberately drop existing drafts (the generation port'smigrate-v1-storage.tsis the precedent for migrating). - A half-migrated app has two form idioms for however long the tail survives. The mitigation is that the lanes are already cleanly disjoint today, and the boundary (value-dependent vs static) is legible enough to state in a review comment.