* feat(plugins): contribute settings screens and shared controls * fix(plugins): localize settings and verify shared UI consumers * test(composer): unmount draft test roots after each case * test(app): keep launch terminals alive until cleanup The fixed ten-second profile lifetime could expire before a busy CI browser attached or finished revisiting the composer. Keep the real process waiting for input until project teardown terminates it. * test(server): await the initial snapshot before a buffered ref event Watcher setup can read checkout facts before the initial refresh publishes a snapshot. Wait for publication before injecting the event so the test consistently exercises a second refresh.
5.8 KiB
Forms
The paved road for building forms in the app. The schedule form is the golden example; when building or fixing any form, copy its shape, not the shape of whatever screen you happen to be near.
Golden example files:
packages/app/src/schedules/schedule-form-model.ts(+.test.ts) — the modelpackages/app/src/schedules/use-schedule-form-model.ts— model lifetime adapterpackages/app/src/schedules/use-schedule-form-provider-snapshot.ts— async input adapterpackages/app/src/components/schedules/schedule-form-sheet.tsx— render + intent dispatchpackages/app/src/schedules/aggregated-schedules.ts/hooks/use-schedules.ts— load-state gatingpackages/app/e2e/schedules-*.spec.ts— the behavioral contract
The form model
Every non-trivial form gets a plain TypeScript model — zero React imports:
openXxxForm(snapshot)constructs a fresh instance from declared inputs (mode, the record being edited, hosts, defaults). Edit mode seeds every value AND display from the snapshot — never from a previous instance.- Commands mutate (
setHost,setProject(value, display),setModel, …). Derived state (disclosure, canSubmit, displays) is recomputed inside the model on every publish. close()destroys the instance.subscribe/getStatefeed oneuseSyncExternalStorein the component.
The component renders state and dispatches intent. That is all it does.
Lifecycle rules (each one killed a real shipped bug)
- Fresh mount per open. The sheet returns
nullwhen not visible and mounts the open form with akeyderived from mode + record identity. A long-lived component instance shared across create/edit is how edit contaminated create. - Construct the model ONCE per mount —
useState(() => openXxxForm(snapshot)). NEVERuseMemo(() => open(...), [snapshot]): the snapshot's identity depends on live data (projects, hosts, preferences), and any background churn — e.g. a scheduled run creating a workspace — would reconstruct the model and wipe the user's in-progress input. - Late data is an explicit model input, not a reconstruction.
applyProviderSnapshot(serverId, …),applyProjectTargets(…),applyHosts(…). Adapters pipe identity changes into these with mechanical effects. Input plumbing is fine; orchestration effects are not — the sheet itself has zerouseEffect/useRef, and that is the target for every form. - Resolution is explicit model state, per host (
idle | pending | complete), keyed off the opened snapshot's serverId. Waiting for data is a state you can render, not an effect race. - Displays are owned state. The selected option's label is captured at
selection/seed time (
setProject(value, display)), never re-derived from a live options list — list churn must not flicker or blank a selection. - Disclosure is derived in the model from user intent (host → project → model → thinking/mode), so fields cannot pop in from cache timing.
Form kit
- Compose
Field/SelectField/FormTextInput/SegmentedControl/Switchfromcomponents/ui/. Geometry (heights, padding, radii, focus/hover states) is owned bycomponents/ui/control-geometry.ts— controls never declare their own, and screens never nudge global component styles to align a row. - Every text field consumes
EditingTextInput, directly or through a UI wrapper. The editing surface owns in-progress text; React observes committed edits throughonChangeText, and programmatic mutations use the input'sreplaceTextcommand. Its props omitvalueanddefaultValue, web IME candidates stay unpublished until composition commits, and lint rejects raw React NativeTextInputimports outside the primitive. - The form declares one size for all fields:
smon desktop,mdcompact (useIsCompactFormFactor). - Availability hierarchy: a field whose capability doesn't apply is hidden
(isolation on a non-git project — same gating as New Workspace), not rendered
disabled with an explanation. Disabled-with-a-reason
hintis only for transient states the user can resolve. - Copy is opt-in and rare. No hint/subtext unless the maintainer approved the exact string; validation errors are the exception. State a fact (like the timezone) once — never in a preview line AND a helper line.
useUnistylesis banned (see docs/unistyles.md); lint enforces.
Settings rows
Use the named components from @/components/settings for settings cards and controls.
SettingsCard owns row separators; SettingsRow accepts custom content. Heading-only screens
can import SettingsGroup and SettingsSection directly from their modules in
@/components/settings/headings/.
The plugin UI entry exposes the same components; keep them independent of storage and routing.
Data gating
Aggregate hooks return a discriminated load state:
type AggregateLoadState<T> =
| { status: "connecting" } // an answer may still be pending
| { status: "loading" }
| { status: "loaded"; data: T[] };
Empty states are only typeable inside loaded — a fetch that "succeeded"
before hosts connected is connecting, not empty. Query keys carry real fetch
inputs (host set, connection statuses), never synthetic version counters.
Anti-patterns (reject in review on sight)
useEffectchoreography impersonating construct/hydrate/resolve/destroy.- One mounted form instance serving create and edit.
useMemo-keyed model construction on live-data identity.- Selected labels derived from live query lists.
isLoading/isEmptyboolean bags where a load-state union belongs.- Conditional mounting of hint/error rows that shifts layout (subtext renders
only when present, but the pattern for that lives in
Field, not ad hoc).