Files
suaveplan/openspec/changes/enterprise-react-forms-v2/specs/react-forms/spec.md
2026-05-27 23:29:53 +01:00

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-forms runs
  • THEN no module under primitives/ imports from core/, factory/, recipes/, or validation/
  • AND no module under core/ imports from factory/, recipes/, or validation/
  • AND no module under factory/ imports from recipes/
  • 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 RegisterRequestSchema from @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 select field config without an options property
  • 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 form is the same UseFormReturn reference that the underlying useDomainForm() returns
  • AND the static accessor <RegisterForm.Field name="email" /> renders the email field with its factory chrome
  • AND calling useDomainForm(config) standalone produces the same form reference 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-forms runs
  • THEN the compiler reports a type error on the literal "Email" because it is not a registered TranslationKey.

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.ts registers auth.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 email to be a valid email
  • AND a cross-field rule requiring passwordConfirm to match password
  • 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 asyncEmailUnique validator with debounceMs: 300
  • AND an injectable clock from @suaveplan/testing allowing time advance
  • WHEN the user types a then ab then abc within 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.onSubmit throws ReactFormsError(REACT_FORMS.SERVER_VALIDATION, { field: "email", code: "auth.email.taken" })
  • WHEN the engine processes the rejection
  • THEN form.formState.errors.email.message equals t("auth.email.taken")
  • AND the form.error_total counter increments with kind: "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 with createDomainForm(...)
  • WHEN the form submits successfully
  • THEN submit.onSubmit({ values, ctx }) is called with ctx.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 ReactFormsError with code === "REACT_FORMS.CONTEXT_NOT_PROVIDED" and context.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 Next without entering an email
  • THEN the wizard remains on step 1
  • AND an error message is rendered on the email field
  • 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 Previous then Next
  • 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: 3 already containing three rows
  • WHEN the consumer calls add()
  • THEN the call throws ReactFormsError with code === "REACT_FORMS.REPEATING_ROW_LIMIT" and context.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 bankAccount shown only when paymentMethod === "transfer"
  • AND the user has selected paymentMethod: "card"
  • WHEN the form submits
  • THEN the values object passed to submit.onSubmit does NOT contain a bankAccount key
  • AND any prior validation error on bankAccount is 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 load that rejects with a thrown Error
  • 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 load that awaits a signal.aborted flag
  • WHEN the consumer unmounts the form before load resolves
  • THEN the signal.aborted flag becomes true
  • 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 email validator called with the value "not-an-email"
  • WHEN the validator runs
  • THEN the result equals { valid: false, messageKey: "react-forms.validation.email" }
  • AND messageKey typechecks as TranslationKey (registered in the package's locales/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 urlValidator was 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 no render property
  • WHEN createDomainForm(...) is called
  • THEN the call throws ReactFormsError with code === "REACT_FORMS.CUSTOM_FIELD_NO_RENDER" and context.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-forms runs
  • 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_ms observation with form_id matching the configured id.

Verified by: core/use-domain-form/telemetry.test.tsx.