Skip to content

refactor: replace react-hook-form with TanStack Form - #756

Open
azizbecha wants to merge 2 commits into
BearStudio:mainfrom
azizbecha:feat/migrate-to-tanstack-form
Open

refactor: replace react-hook-form with TanStack Form#756
azizbecha wants to merge 2 commits into
BearStudio:mainfrom
azizbecha:feat/migrate-to-tanstack-form

Conversation

@azizbecha

@azizbecha azizbecha commented Jul 2, 2026

Copy link
Copy Markdown

Summary

Replaces react-hook-form (+ @hookform/resolvers) with @tanstack/react-form@1.33.0 while keeping the existing component surface (Form, FormField*, FormFieldController type="..."). Zod 4 is validated natively through Standard Schema — no adapter package.

Core changes (src/components/form/)

  • New useForm wrapper: useForm({ schema, mode?, defaultValues, onSubmit }). Built on TanStack's revalidateLogic for react-hook-form validation-timing parity (validate on submit/blur, revalidate on change after first submission). onSubmit receives schema.parse(value), so zod transforms (trim, '' → null) still apply exactly like zodResolver did.
  • FormFieldController: control={form.control}form={form}. Submit handlers move into useForm({ onSubmit }); <Form form={form}> replaces <Form {...form} onSubmit>.
  • Controller context now exposes { field, fieldState, isInvalid }, where fieldState is a useStore-subscribed snapshot — keeps field components and custom renders reactive under React Compiler memoization.
  • useFormContext provided by the library (replaces the react-hook-form one).
  • Form re-implements focus-first-invalid-field on failed submit.
  • FormFieldError standalone usage: control+nameform+name.

Consumer mappings

react-hook-form now
form.setError(name, { message }) form.setErrorMap({ onDynamic: { fields: { [name]: message } } }) (clears on revalidation)
form.formState.isDirty !useStore(form.store, (s) => s.isDefaultValue) (value-based parity)
useWatch / form.formState.X useStore(form.store, selector)
reactive values: option computed defaultValues (updates pristine fields on re-render)
form.setValue(n, v, { shouldValidate }) form.setFieldValue(n, v)

Behavior change (deliberate)

Disabled fields now submit their current value — react-hook-form submitted undefined. Browser specs updated accordingly.

Also: Input gained a typed name prop (react-hook-form previously smuggled it through an untyped spread).

Verification

  • tsc --noEmit clean
  • oxlint clean (0 warnings)
  • vitest run: 16/16 files, 150/150 tests pass — including all 10 real-browser field-component specs

Generated with Claude

Summary by CodeRabbit

  • New Features

    • Form experiences across the app now use a more consistent validation and submission flow.
    • Several form controls gained improved keyboard, error, and accessibility behavior.
  • Bug Fixes

    • Disabled form fields now keep and submit their existing values instead of appearing empty.
    • Error messages and invalid states are now shown more reliably in forms.
    • Login, onboarding, and admin forms now handle submit and reset behavior more consistently.

Swap the form engine while keeping the component surface (Form,
FormField*, FormFieldController). Zod 4 validates natively via
Standard Schema, so @hookform/resolvers is dropped too.

- new useForm wrapper ({ schema, mode, defaultValues, onSubmit })
  built on revalidateLogic for react-hook-form validation-timing
  parity; onSubmit receives schema.parse(value) so zod transforms
  still apply
- controller context exposes { field, fieldState, isInvalid } with
  a useStore-subscribed snapshot to stay reactive under React
  Compiler memoization
- setError -> setErrorMap onDynamic fields; isDirty -> !isDefaultValue;
  watch/formState -> useStore selectors; reactive values: -> computed
  defaultValues
- Form keeps focus-first-invalid-field behavior on failed submit
- disabled fields now submit their value (react-hook-form submitted
  undefined); browser specs updated accordingly

Co-authored-by: Claude <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 2, 2026

Copy link
Copy Markdown

@azizbecha is attempting to deploy a commit to the Team Bearstudio Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai

coderabbitai Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

This PR migrates the entire form system from react-hook-form to @tanstack/react-form. Core infrastructure (Form, FormFieldController, FormFieldError, useForm, types) is rewritten around TanStack's Field/store model with an isInvalid flag, and every field component, story, test, and consuming feature page is rewired accordingly.

Changes

TanStack React Form Migration

Layer / File(s) Summary
Dependency swap
package.json
Removes react-hook-form and @hookform/resolvers, adds @tanstack/react-form.
Core form infrastructure
src/components/form/form.tsx, form-field-controller/*, form-field-error.tsx, types.ts, use-form.ts, index.ts, form-test-utils.tsx, src/components/ui/input.tsx
Introduces FormInstance, useForm, setFormFieldError, rewrites Form/useFormContext, migrates FormFieldController/context to TanStack Field/useStore with isInvalid, reworks FormFieldError error extraction, updates FormMocked, and widens Input's allowed props.
Checkbox, checkbox-group, radio-group fields
field-checkbox*/..., field-radio-group/...
Migrates implementations, stories, and specs to field.handleChange/handleBlur and isInvalid-driven ARIA wiring.
Combobox, combobox-multiple, select fields
field-combobox*/..., field-select/...
Migrates implementations, stories, and specs to the new controller API and fieldState.value-driven selection state.
Text, textarea, number, date, OTP, custom, upload-input fields
field-text/..., field-textarea/..., field-number/..., field-date/..., field-otp/..., field-custom/..., field-upload-input/...
Migrates implementations, stories, and specs to explicit field bindings, field.handleChange/handleBlur, and store-derived submission state.
Top-level Form/FormFieldError/Popover docs
form/docs.stories.tsx, form-field-error.stories.tsx, src/components/ui/popover.stories.tsx
Updates shared documentation stories to the new useForm/Form/FormFieldController wiring.
Auth and account feature integration
change-name-drawer.tsx, page-login*.tsx, page-onboarding.tsx, login-hint.tsx
Rewires feature pages to useForm, setFormFieldError, and useStore-derived error/submitting state.
Book and user manager feature integration
form-book*.tsx, page-book-*.tsx, form-user.tsx, page-user-*.tsx
Migrates field watching to useStore, form wiring to form={form}, and error handling to setFormFieldError/isDefaultValue.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

  • BearStudio/start-ui-web#656: Introduced the same field-container/controller architecture and rewired multiple Field* components that this PR further migrates to TanStack.
  • BearStudio/start-ui-web#679: Overlaps with this PR's FieldSelect/FieldCombobox rewiring of controller state and isInvalid/ARIA handling.
  • BearStudio/start-ui-web#635: Directly overlaps changes to form-field-error.tsx's error extraction logic.

Suggested labels: components, v3

Suggested reviewers: ntatoud, ivan-dalmet, yoannfleurydev

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the PR’s main change: migrating the form system from react-hook-form to TanStack Form.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Dedupe the setErrorMap incantation repeated across five pages;
SonarCloud flagged the copies on the quality gate.

Co-authored-by: Claude <noreply@anthropic.com>
@sonarqubecloud

sonarqubecloud Bot commented Jul 2, 2026

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (4)
src/features/user/manager/page-user-update.tsx (1)

62-93: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Initialize the form from resolved user data src/features/user/manager/page-user-update.tsx:81-93
defaultValues are mount-time only here, so the form can stay on the empty fallback values and submit blanks if the query resolves later. Gate the form on userQuery.status === 'success' or call form.reset(userQuery.data) when the data arrives.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/features/user/manager/page-user-update.tsx` around lines 62 - 93, The
user update form is using mount-time default values from userQuery.data in
useForm, so it can stay on empty fallback values when the query resolves later.
Update the form initialization in page-user-update.tsx by either
rendering/gating the form until userQuery.status is success, or by resetting the
existing form with the resolved userQuery.data once it arrives. Use the useForm
setup, userQuery, and the form.reset path to ensure the fields are populated
from the fetched user before submit.
src/features/book/manager/page-book-update.tsx (1)

27-33: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Gate the update form until the book query resolves
useForm is seeded from bookQuery.data, but the form mounts immediately and never rehydrates when that query finishes. On a direct load, the edit screen can stay on the empty fallback values and submit blanks for an existing book.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/features/book/manager/page-book-update.tsx` around lines 27 - 33, The
update screen in PageBookUpdate mounts the form before bookQuery finishes, so
useForm is initialized with empty fallback values and never picks up the loaded
book. Gate rendering of the form until bookQuery resolves successfully, and
initialize the form only from the resolved book data in PageBookUpdate/useForm
so direct loads cannot submit blank fields.
src/components/form/field-radio-group/index.tsx (1)

45-59: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Inconsistent aria-invalid handling in renderOption path.

Line 53 passes raw isInvalid to the custom render path, while line 64 (default Radio path) uses isInvalid ? true : undefined. This means custom-rendered radios always get an explicit aria-invalid attribute (even "false"), diverging from the pattern established elsewhere in this same file.

🐛 Proposed fix
                 {renderOption({
                   label,
-                  'aria-invalid': isInvalid,
+                  'aria-invalid': isInvalid ? true : undefined,
                   size: ctx.size,
                   ...option,
                 })}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/form/field-radio-group/index.tsx` around lines 45 - 59, The
`renderOption` branch in `FieldRadioGroup` is passing `isInvalid` directly as
`aria-invalid`, which can emit an explicit false value and differs from the
default `Radio` path. Update the custom render payload in
`field-radio-group/index.tsx` so `aria-invalid` follows the same pattern as the
non-custom branch, using the `isInvalid ? true : undefined` behavior when
calling `renderOption` from `FieldRadioGroup`.
src/components/form/field-otp/index.tsx (1)

24-58: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

onComplete wrapper can be overridden by consumer-supplied onComplete, silently disabling auto-submit.

onComplete (Line 36) is defined before {...rest} (Line 48), while onChange/onBlur/name/value are correctly placed after {...rest} to enforce the controlled-field bindings. Since onComplete isn't destructured out of rest, any consumer passing their own onComplete prop to FieldOtp will have it clobber this wrapper via the spread, silently skipping the auto-submit logic (Lines 39-46) even when autoSubmit is set.

🐛 Proposed fix: destructure `onComplete` and place it after the spread
-  const { containerProps, autoSubmit, ...rest } = props;
+  const { containerProps, autoSubmit, onComplete, ...rest } = props;
   ...
       <InputOTP
         id={ctx.id}
         aria-invalid={isInvalid ? true : undefined}
         aria-describedby={ctx.describedBy(isInvalid)}
-        onComplete={(v) => {
-          rest.onComplete?.(v);
-          // Only auto submit on first try
-          if (!isSubmitted && autoSubmit) {
-            const button = document.createElement('button');
-            button.type = 'submit';
-            button.style.display = 'none';
-            containerRef.current?.append(button);
-            button.click();
-            button.remove();
-          }
-        }}
         {...rest}
         name={field.name}
         value={fieldState.value ?? ''}
+        onComplete={(v) => {
+          onComplete?.(v);
+          // Only auto submit on first try
+          if (!isSubmitted && autoSubmit) {
+            const button = document.createElement('button');
+            button.type = 'submit';
+            button.style.display = 'none';
+            containerRef.current?.append(button);
+            button.click();
+            button.remove();
+          }
+        }}
         onChange={(value) => {
           field.handleChange(value);
           rest.onChange?.(value);
         }}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/form/field-otp/index.tsx` around lines 24 - 58, The `FieldOtp`
`onComplete` handler is being overridden by the consumer-provided prop from
`rest`, which disables the auto-submit wrapper. In
`src/components/form/field-otp/index.tsx`, destructure `onComplete` from `props`
alongside the other props, then pass the wrapper handler after spreading `rest`
in the `InputOTP` props so it cannot be clobbered. Keep the existing auto-submit
logic inside the `onComplete` wrapper and still call the consumer callback from
that wrapper.
🧹 Nitpick comments (3)
src/components/form/use-form.ts (2)

19-19: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Redundant union type flagged by SonarCloud.

unknown | Promise<unknown> collapses to unknown since Promise<unknown> is already assignable to unknown. No runtime impact, but consider void | Promise<void> for clearer intent.

♻️ Suggested fix
-  onSubmit?: (values: z.output<TSchema>) => unknown | Promise<unknown>;
+  onSubmit?: (values: z.output<TSchema>) => void | Promise<void>;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/form/use-form.ts` at line 19, The onSubmit type in use-form is
using a redundant union because Promise<unknown> already fits within unknown, so
simplify the signature to make the intent clearer. Update the onSubmit property
in the use-form type definition to use a cleaner return type such as void |
Promise<void>, and keep the change localized to the onSubmit declaration so the
rest of the form API remains unchanged.

Source: Linters/SAST tools


48-59: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Blanket as ExplicitAny cast hides type errors on setErrorMap payload.

The whole options object is cast away rather than just the ambiguous parts, so any future mismatch between this shape and FormApi's expected errorMap type (e.g. a library upgrade changing the onDynamic payload shape) would silently typecheck. Confirmed via TanStack Form docs that setErrorMap({ cause: { fields, form } }) is the correct shape, so a narrower assertion (or improving FormInstance's generics to accept this) would be safer.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/form/use-form.ts` around lines 48 - 59, The blanket as
ExplicitAny cast in setFormFieldError is hiding the real type of the setErrorMap
payload. Update setFormFieldError to use the FormApi/FormInstance error-map
shape directly, or narrow the assertion to only the ambiguous nested part
instead of casting the whole object. Check the setErrorMap call in use-form.ts
and align the payload with the expected errorMap structure from TanStack Form so
future shape changes still typecheck.
src/components/form/form-field-controller/index.tsx (1)

57-69: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Mark the internal component props as read-only.

This resolves the current SonarCloud warning without changing behavior.

Proposed fix
 }: {
+}: Readonly<{
   field: AnyFieldApi;
   type: FieldType | 'custom';
   displayError: boolean;
   fieldProps: Record<string, unknown>;
   customRender?: FormFieldControllerCustomRender;
-}) {
+}>) {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/form/form-field-controller/index.tsx` around lines 57 - 69,
Mark the props for FormFieldControllerRender as read-only by updating its
parameter type so the field, type, displayError, fieldProps, and customRender
values cannot be reassigned inside the component. Keep the behavior unchanged
and apply the readonly typing directly on the FormFieldControllerRender props
shape to satisfy the SonarCloud warning.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/components/form/field-upload-input/index.tsx`:
- Around line 30-40: The field upload input rebuilds UploadInput.defaultValue on
every render, which breaks the cleared-state reference check in UploadInput and
can make a cleared file reappear. In the field-upload-input component, memoize
the object passed as defaultValue so the same reference is reused when the
underlying URL value from getFieldUrl(fieldState.value) has not changed; keep
the logic localized around the UploadInput props and the value-to-defaultValue
mapping.

In `@src/components/form/form-field-controller/index.tsx`:
- Around line 13-16: The custom render API in FormFieldController is dropping
the disabled prop, so type="custom" callers never receive it in render. Update
FormFieldControllerCustomRender and the FormFieldController custom branch to
pass disabled through alongside field and fieldState, using the existing
FormFieldController / render / fieldProps flow so custom renderers can consume
the common prop directly.

In `@src/components/form/form-field-error.tsx`:
- Around line 26-34: The standalone props union in FormFieldErrorProps currently
allows partial combinations like form without name, which can slip into the
controller path and fail at runtime. Tighten the union by replacing the empty
object branch with an explicit “neither” shape using never fields so
FormFieldErrorDisplayProps is accepted only when both form and name are provided
together or both are omitted. Update the type alias in form-field-error.tsx
accordingly, keeping the FormFieldErrorProps and DeepKeys/FormInstance
references aligned.

In `@src/features/auth/page-login-verify.tsx`:
- Around line 62-64: The submit flow in the login verification handler is
resolving before the session refresh finishes, which can let users resubmit too
early. Update the `onSubmit` logic in `page-login-verify.tsx` so
`session.refetch()` is awaited before the handler completes, ensuring
`isSubmitting` stays true until the refresh and redirect guard work is done.

In `@src/features/auth/page-onboarding.tsx`:
- Around line 43-46: The onboarding form seeds `defaultValues` from
`session.data?.user.name`, but that only runs once, so late-arriving session
data can leave the name field empty. Update the `page-onboarding.tsx` form setup
to react to session changes by resetting/re-seeding the form when
`session.data?.user.name` updates, or defer rendering until the session is
ready; use the `zFormFieldsOnboarding` form initialization and the onboarding
form component as the places to adjust.

---

Outside diff comments:
In `@src/components/form/field-otp/index.tsx`:
- Around line 24-58: The `FieldOtp` `onComplete` handler is being overridden by
the consumer-provided prop from `rest`, which disables the auto-submit wrapper.
In `src/components/form/field-otp/index.tsx`, destructure `onComplete` from
`props` alongside the other props, then pass the wrapper handler after spreading
`rest` in the `InputOTP` props so it cannot be clobbered. Keep the existing
auto-submit logic inside the `onComplete` wrapper and still call the consumer
callback from that wrapper.

In `@src/components/form/field-radio-group/index.tsx`:
- Around line 45-59: The `renderOption` branch in `FieldRadioGroup` is passing
`isInvalid` directly as `aria-invalid`, which can emit an explicit false value
and differs from the default `Radio` path. Update the custom render payload in
`field-radio-group/index.tsx` so `aria-invalid` follows the same pattern as the
non-custom branch, using the `isInvalid ? true : undefined` behavior when
calling `renderOption` from `FieldRadioGroup`.

In `@src/features/book/manager/page-book-update.tsx`:
- Around line 27-33: The update screen in PageBookUpdate mounts the form before
bookQuery finishes, so useForm is initialized with empty fallback values and
never picks up the loaded book. Gate rendering of the form until bookQuery
resolves successfully, and initialize the form only from the resolved book data
in PageBookUpdate/useForm so direct loads cannot submit blank fields.

In `@src/features/user/manager/page-user-update.tsx`:
- Around line 62-93: The user update form is using mount-time default values
from userQuery.data in useForm, so it can stay on empty fallback values when the
query resolves later. Update the form initialization in page-user-update.tsx by
either rendering/gating the form until userQuery.status is success, or by
resetting the existing form with the resolved userQuery.data once it arrives.
Use the useForm setup, userQuery, and the form.reset path to ensure the fields
are populated from the fetched user before submit.

---

Nitpick comments:
In `@src/components/form/form-field-controller/index.tsx`:
- Around line 57-69: Mark the props for FormFieldControllerRender as read-only
by updating its parameter type so the field, type, displayError, fieldProps, and
customRender values cannot be reassigned inside the component. Keep the behavior
unchanged and apply the readonly typing directly on the
FormFieldControllerRender props shape to satisfy the SonarCloud warning.

In `@src/components/form/use-form.ts`:
- Line 19: The onSubmit type in use-form is using a redundant union because
Promise<unknown> already fits within unknown, so simplify the signature to make
the intent clearer. Update the onSubmit property in the use-form type definition
to use a cleaner return type such as void | Promise<void>, and keep the change
localized to the onSubmit declaration so the rest of the form API remains
unchanged.
- Around line 48-59: The blanket as ExplicitAny cast in setFormFieldError is
hiding the real type of the setErrorMap payload. Update setFormFieldError to use
the FormApi/FormInstance error-map shape directly, or narrow the assertion to
only the ambiguous nested part instead of casting the whole object. Check the
setErrorMap call in use-form.ts and align the payload with the expected errorMap
structure from TanStack Form so future shape changes still typecheck.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 05408bb6-dc35-4612-a65f-92920b25c7ba

📥 Commits

Reviewing files that changed from the base of the PR and between f74b508 and 9eed63e.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (59)
  • package.json
  • src/components/form/docs.stories.tsx
  • src/components/form/field-checkbox-group/docs.stories.tsx
  • src/components/form/field-checkbox-group/field-checkbox-group.browser.spec.tsx
  • src/components/form/field-checkbox-group/index.tsx
  • src/components/form/field-checkbox/docs.stories.tsx
  • src/components/form/field-checkbox/field-checkbox.browser.spec.tsx
  • src/components/form/field-checkbox/index.tsx
  • src/components/form/field-combobox-multiple/docs.stories.tsx
  • src/components/form/field-combobox-multiple/field-combobox-multiple.browser.spec.tsx
  • src/components/form/field-combobox-multiple/index.tsx
  • src/components/form/field-combobox/docs.stories.tsx
  • src/components/form/field-combobox/field-combobox.browser.spec.tsx
  • src/components/form/field-combobox/index.tsx
  • src/components/form/field-custom/docs.stories.tsx
  • src/components/form/field-date/docs.stories.tsx
  • src/components/form/field-date/index.tsx
  • src/components/form/field-number/docs.stories.tsx
  • src/components/form/field-number/index.tsx
  • src/components/form/field-otp/docs.stories.tsx
  • src/components/form/field-otp/field-otp.browser.spec.tsx
  • src/components/form/field-otp/index.tsx
  • src/components/form/field-radio-group/docs.stories.tsx
  • src/components/form/field-radio-group/field-radio-group.browser.spec.tsx
  • src/components/form/field-radio-group/index.tsx
  • src/components/form/field-select/docs.stories.tsx
  • src/components/form/field-select/field-select.browser.spec.tsx
  • src/components/form/field-select/index.tsx
  • src/components/form/field-text/docs.stories.tsx
  • src/components/form/field-text/index.browser.spec.tsx
  • src/components/form/field-text/index.tsx
  • src/components/form/field-textarea/docs.stories.tsx
  • src/components/form/field-textarea/field-textarea.browser.spec.tsx
  • src/components/form/field-textarea/index.tsx
  • src/components/form/field-upload-input/docs.stories.tsx
  • src/components/form/field-upload-input/index.tsx
  • src/components/form/form-field-controller/context.tsx
  • src/components/form/form-field-controller/index.tsx
  • src/components/form/form-field-error.stories.tsx
  • src/components/form/form-field-error.tsx
  • src/components/form/form-test-utils.tsx
  • src/components/form/form.tsx
  • src/components/form/index.ts
  • src/components/form/types.ts
  • src/components/form/use-form.ts
  • src/components/ui/input.tsx
  • src/components/ui/popover.stories.tsx
  • src/features/account/change-name-drawer.tsx
  • src/features/auth/page-login-verify.tsx
  • src/features/auth/page-login.tsx
  • src/features/auth/page-onboarding.tsx
  • src/features/book/manager/form-book-cover.tsx
  • src/features/book/manager/form-book.tsx
  • src/features/book/manager/page-book-new.tsx
  • src/features/book/manager/page-book-update.tsx
  • src/features/devtools/login-hint.tsx
  • src/features/user/manager/form-user.tsx
  • src/features/user/manager/page-user-new.tsx
  • src/features/user/manager/page-user-update.tsx

Comment on lines +30 to 40
const value = getFieldUrl(fieldState.value);

return (
<FormFieldContainer {...containerProps}>
<UploadInput
{...rest}
disabled={field.disabled ?? rest.disabled}
defaultValue={
value
? { name: value.split('/').at(-1) ?? value, url: value }
: undefined
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Memoize defaultValue to preserve clear behavior.

defaultValue is rebuilt as a new object literal on every render. UploadInput detects a "cleared" state via defaultValue !== clearedDefaultValue (strict reference equality), so a fresh object each render will make the cleared file/URL reappear on the next unrelated re-render.

🐛 Proposed fix: stabilize the object reference
+  const value = getFieldUrl(fieldState.value);
+  const defaultValue = useMemo(
+    () =>
+      value ? { name: value.split('/').at(-1) ?? value, url: value } : undefined,
+    [value]
+  );
-
-  const value = getFieldUrl(fieldState.value);

   return (
     <FormFieldContainer {...containerProps}>
       <UploadInput
         {...rest}
-        defaultValue={
-          value
-            ? { name: value.split('/').at(-1) ?? value, url: value }
-            : undefined
-        }
+        defaultValue={defaultValue}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const value = getFieldUrl(fieldState.value);
return (
<FormFieldContainer {...containerProps}>
<UploadInput
{...rest}
disabled={field.disabled ?? rest.disabled}
defaultValue={
value
? { name: value.split('/').at(-1) ?? value, url: value }
: undefined
}
const value = getFieldUrl(fieldState.value);
const defaultValue = useMemo(
() =>
value ? { name: value.split('/').at(-1) ?? value, url: value } : undefined,
[value]
);
return (
<FormFieldContainer {...containerProps}>
<UploadInput
{...rest}
defaultValue={defaultValue}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/form/field-upload-input/index.tsx` around lines 30 - 40, The
field upload input rebuilds UploadInput.defaultValue on every render, which
breaks the cleared-state reference check in UploadInput and can make a cleared
file reappear. In the field-upload-input component, memoize the object passed as
defaultValue so the same reference is reused when the underlying URL value from
getFieldUrl(fieldState.value) has not changed; keep the logic localized around
the UploadInput props and the value-to-defaultValue mapping.

Comment on lines +13 to +16
type FormFieldControllerCustomRender = (props: {
field: AnyFieldApi;
fieldState: AnyFieldApi['state'];
}) => ReactNode;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Pass disabled through the custom render API.

disabled is accepted by FormFieldController, but for type="custom" it stays in fieldProps and is never exposed to render, making the common prop a no-op unless callers duplicate it manually.

Proposed fix
 type FormFieldControllerCustomRender = (props: {
   field: AnyFieldApi;
   fieldState: AnyFieldApi['state'];
+  disabled?: boolean;
 }) => ReactNode;
...
-    if (type === 'custom') return customRender?.({ field, fieldState });
+    if (type === 'custom') {
+      return customRender?.({
+        field,
+        fieldState,
+        disabled:
+          typeof fieldProps.disabled === 'boolean'
+            ? fieldProps.disabled
+            : undefined,
+      });
+    }

Also applies to: 84-85

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/form/form-field-controller/index.tsx` around lines 13 - 16,
The custom render API in FormFieldController is dropping the disabled prop, so
type="custom" callers never receive it in render. Update
FormFieldControllerCustomRender and the FormFieldController custom branch to
pass disabled through alongside field and fieldState, using the existing
FormFieldController / render / fieldProps flow so custom renderers can consume
the common prop directly.

Comment on lines +26 to 34
type FormFieldErrorProps<TFormData = ExplicitAny> = FormFieldErrorDisplayProps &
(
| {
form: FormInstance<TFormData>;
name: DeepKeys<TFormData>;
}
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
| {}
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Tighten the standalone props union.

The {} branch allows partial standalone usage such as form without name, which then falls through to the controller path and throws at runtime. Use never fields to enforce “both or neither”.

Proposed fix
 type FormFieldErrorProps<TFormData = ExplicitAny> = FormFieldErrorDisplayProps &
   (
     | {
         form: FormInstance<TFormData>;
         name: DeepKeys<TFormData>;
       }
-    // eslint-disable-next-line `@typescript-eslint/no-empty-object-type`
-    | {}
+    | {
+        form?: never;
+        name?: never;
+      }
   );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
type FormFieldErrorProps<TFormData = ExplicitAny> = FormFieldErrorDisplayProps &
(
| {
form: FormInstance<TFormData>;
name: DeepKeys<TFormData>;
}
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
| {}
);
type FormFieldErrorProps<TFormData = ExplicitAny> = FormFieldErrorDisplayProps &
(
| {
form: FormInstance<TFormData>;
name: DeepKeys<TFormData>;
}
| {
form?: never;
name?: never;
}
);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/form/form-field-error.tsx` around lines 26 - 34, The
standalone props union in FormFieldErrorProps currently allows partial
combinations like form without name, which can slip into the controller path and
fail at runtime. Tighten the union by replacing the empty object branch with an
explicit “neither” shape using never fields so FormFieldErrorDisplayProps is
accepted only when both form and name are provided together or both are omitted.
Update the type alias in form-field-error.tsx accordingly, keeping the
FormFieldErrorProps and DeepKeys/FormInstance references aligned.

Comment on lines +62 to +64
// Refetch session to update guards and redirect
session.refetch();
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Await the session refresh before completing submit.

isSubmitting clears as soon as onSubmit resolves; without await, users can resubmit while the session refresh/redirect guard is still pending.

Proposed fix
       // Refetch session to update guards and redirect
-      session.refetch();
+      await session.refetch();
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Refetch session to update guards and redirect
session.refetch();
},
// Refetch session to update guards and redirect
await session.refetch();
},
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/features/auth/page-login-verify.tsx` around lines 62 - 64, The submit
flow in the login verification handler is resolving before the session refresh
finishes, which can let users resubmit too early. Update the `onSubmit` logic in
`page-login-verify.tsx` so `session.refetch()` is awaited before the handler
completes, ensuring `isSubmitting` stays true until the refresh and redirect
guard work is done.

Comment on lines +43 to 46
schema: zFormFieldsOnboarding(),
defaultValues: {
name: session.data?.user.name ?? '',
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Preserve reactive hydration of the session name. defaultValues only applies on first render, so if session.data?.user.name arrives later the onboarding field can remain empty. Reset or re-seed the form when the session name changes, or delay rendering until the session is ready.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/features/auth/page-onboarding.tsx` around lines 43 - 46, The onboarding
form seeds `defaultValues` from `session.data?.user.name`, but that only runs
once, so late-arriving session data can leave the name field empty. Update the
`page-onboarding.tsx` form setup to react to session changes by
resetting/re-seeding the form when `session.data?.user.name` updates, or defer
rendering until the session is ready; use the `zFormFieldsOnboarding` form
initialization and the onboarding form component as the places to adjust.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant