14 KiB
react-forms — Spec Delta
ADDED Requirements
Requirement: Layered package surface
The @suaveplan/react-forms package SHALL expose its public surface in five layers — primitives/, core/, factory/, recipes/, and validation/ — with each layer building only on the ones above it.
Scenario: Layer dependencies are unidirectional
- GIVEN the package source tree
- WHEN
bunx turbo build --filter=@suaveplan/react-formsruns - THEN no module under
primitives/imports fromcore/,factory/,recipes/, orvalidation/ - AND no module under
core/imports fromfactory/,recipes/, orvalidation/ - AND no module under
factory/imports fromrecipes/ - AND subpath exports allow tree-shaking each layer independently.
Verified by: tests/architecture/layer-deps.test.ts (parses imports and asserts the dependency direction).
Requirement: createDomainForm factory contract
The factory SHALL accept a Zod schema, a typed field-config map, a layout selector, and a submit handler, and SHALL return a ComponentType<FormProps> plus a .Field static accessor and a .useFormContext re-export of react-hook-form's useFormContext.
Scenario: Successful factory call returns a working component
- GIVEN a valid
RegisterRequestSchemafrom@suaveplan/api-schemas - AND a field-config map covering every field in the schema with
TranslationKey-typed labels - WHEN the consumer calls
createDomainForm({ schema, fields, layout, submit }) - THEN the returned component renders without throwing
- AND the rendered DOM contains an accessible
<form>with one labelled control per field - AND submitting with valid values fires
submit.onSubmit({ values, ctx })once.
Verified by: factory/create-domain-form/create-domain-form.test.tsx.
Scenario: Misconfigured field fails at module evaluation
- GIVEN a
selectfield config without anoptionsproperty - WHEN the consumer calls
createDomainForm(...)at module evaluation - THEN the call throws
ReactFormsError(REACT_FORMS.FIELD_CONFIG_INVALID)with the offending field name in the error context.
Verified by: factory/field-config/field-config.test.ts.
Scenario: Three escape hatches preserve direct RHF access
- GIVEN a form built with
createDomainForm(...) - WHEN the consumer renders
<RegisterForm>{({ form, fields, submit }) => <CustomShell {...} />}</RegisterForm> - THEN
formis the sameUseFormReturnreference that the underlyinguseDomainForm()returns - AND the static accessor
<RegisterForm.Field name="email" />renders theemailfield with its factory chrome - AND calling
useDomainForm(config)standalone produces the sameformreference shape.
Verified by: factory/create-domain-form/escape-hatches.test.tsx.
Requirement: FieldConfig strings are TranslationKey-typed
Every user-facing string property in a FieldConfig SHALL be typed as TranslationKey (from @suaveplan/react-i18n), never plain string. Properties covered: label, placeholder, helperText, errorMessage (or its { [code]: TranslationKey } map form), submit.label, submit.cancelLabel, recipe step title, and recipe conditional-field gate labels.
Scenario: Plain-string label is a type error
- GIVEN a TypeScript file containing
createDomainForm({ fields: { email: { kind: "email", label: "Email" } } }) - WHEN
bunx turbo typecheck --filter=@suaveplan/react-formsruns - THEN the compiler reports a type error on the literal
"Email"because it is not a registeredTranslationKey.
Verified by: factory/field-config/field-config.types.test.ts (uses expectTypeOf from @suaveplan/testing/runner).
Scenario: Registered translation key compiles
- GIVEN the consumer's
app.translations.augmentation.d.tsregistersauth.email.label - WHEN the consumer writes
createDomainForm({ fields: { email: { kind: "email", label: "auth.email.label" } } }) - THEN the compile passes
- AND the rendered label resolves via
useTranslation()against the active locale.
Verified by: factory/field-config/field-config.types.test.ts.
Requirement: ValidationEngine four-phase pipeline
The ValidationEngine SHALL run validation in four ordered phases on every form mutation: (1) Zod schema, (2) cross-field rules, (3) per-field async validators, (4) server-error mapping. Phases 1 and 2 are synchronous; phase 3 is debounced and AbortSignal-cancellable; phase 4 is invoked when onSubmit throws a ReactFormsError(REACT_FORMS.SERVER_VALIDATION).
Scenario: Schema runs first and short-circuits cross-field rules
- GIVEN a form with a Zod schema requiring
emailto be a valid email - AND a cross-field rule requiring
passwordConfirmto matchpassword - WHEN the user submits
{ email: "not-an-email", password: "a", passwordConfirm: "b" } - THEN the validation result contains a schema error on
email - AND the cross-field rule does not fire (or its result is discarded)
- AND the rendered error message is
t(messageKey)with the active locale.
Verified by: core/validation-engine/phases.test.ts.
Scenario: Async validator is debounced and cancelled on supersede
- GIVEN an
asyncEmailUniquevalidator withdebounceMs: 300 - AND an injectable
clockfrom@suaveplan/testingallowing time advance - WHEN the user types
athenabthenabcwithin 100 ms each - THEN only the final
check("abc", signal)call resolves - AND the earlier two calls receive an aborted
AbortSignal(raised before the network mock fires).
Verified by: core/validation-engine/async.test.ts.
Scenario: Server error maps to RHF field error
- GIVEN
submit.onSubmitthrowsReactFormsError(REACT_FORMS.SERVER_VALIDATION, { field: "email", code: "auth.email.taken" }) - WHEN the engine processes the rejection
- THEN
form.formState.errors.email.messageequalst("auth.email.taken") - AND the
form.error_totalcounter increments withkind: "server".
Verified by: core/validation-engine/server-error.test.ts.
Requirement: ApiClientContext and AuthContext interfaces
The package SHALL export <ApiClientProvider value={api}> + useApiClient<TApi>() and <AuthProvider value={auth}> + useAuth<TUser>() as generic context wrappers. Both hooks SHALL throw ReactFormsError(REACT_FORMS.CONTEXT_NOT_PROVIDED) when called outside their provider. Neither context bakes in a specific transport or auth implementation.
Scenario: Provider value is consumed by the factory's submit handler
- GIVEN a
<ApiClientProvider value={apiInstance}>wrapping a form built withcreateDomainForm(...) - WHEN the form submits successfully
- THEN
submit.onSubmit({ values, ctx })is called withctx.api === apiInstance.
Verified by: core/api-client-context/api-client-context.test.tsx.
Scenario: Hook outside provider throws CONTEXT_NOT_PROVIDED
- GIVEN a component calling
useApiClient()rendered without an<ApiClientProvider>ancestor - WHEN the component mounts
- THEN rendering throws
ReactFormsErrorwithcode === "REACT_FORMS.CONTEXT_NOT_PROVIDED"andcontext.name === "ApiClient".
Verified by: core/api-client-context/api-client-context.test.tsx.
Requirement: Wizard recipe contract
createWizardForm({ steps, onComplete }) SHALL produce a multi-step form where each step has its own Zod sub-schema, navigating forward validates the current step, navigating backward preserves entered data, and onComplete runs after the final step's validation succeeds.
Scenario: Forward navigation blocks on invalid step
- GIVEN a wizard with two steps; step 1 requires
email - WHEN the user clicks
Nextwithout entering an email - THEN the wizard remains on step 1
- AND an error message is rendered on the
emailfield - AND
useWizardState().currentStep.id === "step-1".
Verified by: recipes/wizard/wizard.test.tsx.
Scenario: Backward navigation preserves entered data
- GIVEN a wizard the user has filled through step 2
- WHEN the user clicks
PreviousthenNext - THEN step 2's previously entered values are still present in the form state.
Verified by: recipes/wizard/wizard.test.tsx.
Requirement: Repeating-fieldset recipe contract
createRepeatingFieldset({ name, minRows, maxRows, fieldset }) SHALL render a dynamic-array field backed by RHF's useFieldArray, and SHALL throw ReactFormsError(REACT_FORMS.REPEATING_ROW_LIMIT) when an add() call is made at maxRows or a remove() call is made at minRows.
Scenario: Add at max rows throws
- GIVEN a fieldset with
maxRows: 3already containing three rows - WHEN the consumer calls
add() - THEN the call throws
ReactFormsErrorwithcode === "REACT_FORMS.REPEATING_ROW_LIMIT"andcontext.action === "add".
Verified by: recipes/repeating-fieldset/repeating-fieldset.test.tsx.
Requirement: Conditional-field recipe contract
createConditionalField({ when, render }) SHALL hide its rendered field when when(values) returns false, AND SHALL exclude the hidden field's value from the submitted payload, AND SHALL clear any validation errors for the hidden field.
Scenario: Hidden field is not in submit payload
- GIVEN a conditional field
bankAccountshown only whenpaymentMethod === "transfer" - AND the user has selected
paymentMethod: "card" - WHEN the form submits
- THEN the values object passed to
submit.onSubmitdoes NOT contain abankAccountkey - AND any prior validation error on
bankAccountis cleared.
Verified by: recipes/conditional/conditional.test.tsx.
Requirement: Async-search recipe contract
createAsyncSearchForm({ load, schema, fields }) SHALL render a <Skeleton> placeholder while load(id, signal) resolves, mount the underlying form with the resolved values as defaultValues on success, and surface a translated error message via useTranslation on failure. The signal SHALL be aborted if the consumer unmounts the form during the load.
Scenario: Load failure renders translated error
- GIVEN a
loadthat rejects with a thrownError - WHEN the form renders
- THEN the user sees a region with the translated content of
t("react-forms.async-search.load-failed") - AND the underlying form does not mount.
Verified by: recipes/async-search/async-search.test.tsx.
Scenario: Unmount during load aborts the signal
- GIVEN a
loadthat awaits asignal.abortedflag - WHEN the consumer unmounts the form before
loadresolves - THEN the
signal.abortedflag becomestrue - AND the load is not retained in memory after unmount.
Verified by: recipes/async-search/async-search.test.tsx.
Requirement: Validator return shape
Every validator (synchronous and asynchronous) SHALL return a discriminated union: { valid: true } | { valid: false; messageKey: TranslationKey; messageArgs?: Record<string, unknown> }. Plain-string returns SHALL be a TypeScript error.
Scenario: Validator failure carries a translatable key
- GIVEN the
emailvalidator called with the value"not-an-email" - WHEN the validator runs
- THEN the result equals
{ valid: false, messageKey: "react-forms.validation.email" } - AND
messageKeytypechecks asTranslationKey(registered in the package'slocales/en-GB.json).
Verified by: validation/validators/email/email.test.ts.
Scenario: Composed or short-circuits on first success
- GIVEN
or(emailValidator, urlValidator) - WHEN the composed validator is called with a valid email
- THEN the result is
{ valid: true } - AND the
urlValidatorwas not called.
Verified by: validation/compose/or/or.test.ts.
Requirement: Field-kind validation at factory-define time
Every field kind in v1 (text, email, password, number, currency, date, dateTime, select, multi-select, combobox, checkbox, radio, toggle, textarea, rich-text, file, tag-input, otp, custom) SHALL have a sibling Zod schema validating its FieldConfig shape. Misconfigured fields SHALL throw ReactFormsError(REACT_FORMS.FIELD_CONFIG_INVALID) at module evaluation time, before the component renders.
Scenario: kind: "custom" requires render
- GIVEN a field config
{ kind: "custom", label: "auth.captcha.label" }with norenderproperty - WHEN
createDomainForm(...)is called - THEN the call throws
ReactFormsErrorwithcode === "REACT_FORMS.CUSTOM_FIELD_NO_RENDER"andcontext.field === "<field-name>".
Verified by: factory/field-config/custom-field.test.ts.
Requirement: No hardcoded user-facing strings invariant
No source file in packages/forms/react-forms/src/ (excluding *.test.ts(x), *.spec.ts, *.stories.tsx) SHALL contain a hardcoded user-facing string. Every user-perceivable string SHALL be a TranslationKey resolved at render time via useTranslation or <Trans>.
Scenario: CI gate flags literal aria-label
- GIVEN a source file containing
<button aria-label="Close"> - WHEN
bun scripts/lint/check-no-hardcoded-strings.ts packages/forms/react-formsruns - THEN the script exits non-zero
- AND stderr contains the file, line, column, and the rule name
no-hardcoded-strings.
Verified by: the CI gate itself; the package's own src/ tree must produce zero output from the gate at archive time.
Requirement: Telemetry contract
The package SHALL emit OTel histograms form.submit.duration_ms, form.validation.duration_ms (with phase attribute in {schema, cross-field, async}), form.async-validator.duration_ms (with validator attribute), the counter form.field.touched_total (with field attribute), and the counter form.error_total (with kind attribute in {schema, cross-field, async, submit, server}). Instrument names are stable contracts; renaming them requires a migration note per AGENTS.md §13a.
Scenario: Submit emits duration histogram
- GIVEN a form built with
createDomainForm(...)and an in-memory OTel meter exporter - WHEN the form submits successfully
- THEN the exporter records exactly one
form.submit.duration_msobservation withform_idmatching the configured id.
Verified by: core/use-domain-form/telemetry.test.tsx.