Skip to content

feat: focus visually first invalid field across composed forms#1682

Open
serikjensen wants to merge 2 commits intomainfrom
feat/cross-form-dom-order-focus
Open

feat: focus visually first invalid field across composed forms#1682
serikjensen wants to merge 2 commits intomainfrom
feat/cross-form-dom-order-focus

Conversation

@serikjensen
Copy link
Copy Markdown
Member

Summary

When composeSubmitHandler validates multiple forms and any are invalid, focus now lands on the field that is visually first in the DOM rather than the first form's first error in array order. This fixes the cross-form gap that surfaces when partners interleave fields from multiple form hooks on the same page.

How it works

A per-form FieldElementRegistry is populated by useField on mount/unmount (via a ref callback added to its existing useForkRef) and read by composeSubmitHandler to sort invalid fields by getBoundingClientRect() and focus the winner directly.

Direct element.focus() bypasses RHF's setFocus, which doesn't reliably resolve refs through react-aria-components (_fields[name]._f.ref ends up as a synthetic placeholder rather than the DOM node).

Two registry-publish paths

The registry is published via context through two complementary paths covering both field-connection modes documented in docs/hooks/hooks.md:

  1. Provider-basedSDKFormProvider wraps with FieldElementRegistryProvider.
  2. Prop-based — each HookField self-wraps via withFieldElementRegistry using the registry forwarded on the formHookResult prop, so the focus behavior works even when SDKFormProvider is absent.

Errors captured from onInvalid

Reads errors from each form's handleSubmit(onValid, onInvalid) callback rather than formState.errors — the latter is a proxy that returns {} when accessed outside a component subscription.

Hooks aligned

All eight SDK form hooks now construct hookFormInternals via the new useHookFormInternals(formMethods) helper, which attaches the registry instance:

Wrapper hooks (useCurrentHomeAddressForm, useCurrentWorkAddressForm) need no changes — they spread the underlying hook's hookFormInternals.

Compatibility with raw UseFormReturn slots

The ComposeSubmitInput union (raw UseFormReturn<T> support added in a recent main commit) is preserved. Raw forms have no registry, so they don't participate in DOM-order sorting and gracefully fall back to RHF's setFocus for their own first error.

Test plan

  • New integration test composeSubmitHandler.focus.test.tsx covers both paths:
    • Two SDK hooks composed under SDKFormProvider with interleaved fields → focus lands on the visually first invalid field.
    • Same scenario using formHookResult prop (no SDKFormProvider) → still focuses the visually first invalid field.
  • New unit tests in composeSubmitHandler.test.ts cover DOM-order sorting, ties broken by left, fallback when no form publishes a registry, fallback when registry has no entries for the error fields.
  • All 2,466 existing tests pass.
  • TypeScript: clean.
  • Lint: clean (no new warnings).

Made with Cursor

When composeSubmitHandler validates multiple forms and any are invalid, focus
now lands on the field that's visually first in the DOM rather than the first
form's first error in array order. This fixes the cross-form gap that surfaced
when partners interleave fields from multiple form hooks on the same page.

Implementation introduces a per-form FieldElementRegistry populated by useField
on mount/unmount, read by composeSubmitHandler via _fieldElementRegistry to
sort invalid fields by getBoundingClientRect() and focus the winner directly
(bypassing RHF setFocus, which doesn't reliably resolve refs through
react-aria-components).

The registry is published via context through two complementary paths covering
both field-connection modes documented in docs/hooks/hooks.md:
- SDKFormProvider wraps with FieldElementRegistryProvider for the
  provider-based mode.
- HookField components self-wrap via withFieldElementRegistry for the
  formHookResult-prop mode, where SDKFormProvider is absent.

Each SDK form hook now constructs hookFormInternals via the new
useHookFormInternals helper, which attaches the registry instance.

Co-authored-by: Cursor <cursoragent@cursor.com>
@gusto-fresh-eyes
Copy link
Copy Markdown

gusto-fresh-eyes Bot commented May 5, 2026

Fresh Eyes Review

Found 3 issues in this PR.

Download findings.json — drag the file into Claude or use /add to propose fixes


Generated by Fresh Eyes Reviewer | Share feedback in #ai-code-reviews

Co-authored-by: Cursor <cursoragent@cursor.com>
import { useController, useFormContext } from 'react-hook-form'
import React, { useMemo, type Ref } from 'react'
import React, { useCallback, useMemo, type Ref } from 'react'
import { useFieldElementRegistryContext } from '@/partner-hook-utils/form/fieldElementRegistry'
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 Major | module-boundaries

Reverse dependency: Common/Fields/hooks/useField.ts imports from @/partner-hook-utils/form/fieldElementRegistry. The established pattern is one-directional: partner-hook-utils depends on Common, never the reverse. This creates a bidirectional coupling between the two modules (partner-hook-utils/form/fields/*HookFieldCommon (via TextInputField etc.) → partner-hook-utils). The fieldElementRegistry context/hook could live in Common/Fields/hooks/ or a shared lower-level location instead, since it's consumed at the useField level and has no dependencies on partner-hook-utils internals.

Comment on lines +68 to +73
const fieldElementRegistry = useFieldElementRegistryContext()
const registryRef = useCallback(
(element: HTMLElement | null) => {
if (!fieldElementRegistry) return
fieldElementRegistry.register(name, element)
},
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Minor | test-coverage

The new registryRef callback in useField is not tested in useField.test.tsx. While it's exercised indirectly via the composeSubmitHandler.focus.test.tsx integration test, useField's own test file has no coverage for the registry registration/unregistration lifecycle.

Comment on lines +19 to +21
elements.set(name, element)
} else {
elements.delete(name)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Minor | test-coverage

The cleanup/unmount path (register called with null to delete an entry) has no direct test. The integration tests verify registration works but don't explicitly exercise field unregistration when a component unmounts.

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