diff --git a/services/platform/app/components/ui/editor/use-form-editor.test.tsx b/services/platform/app/components/ui/editor/use-form-editor.test.tsx index 81540bb53..14d0926ce 100644 --- a/services/platform/app/components/ui/editor/use-form-editor.test.tsx +++ b/services/platform/app/components/ui/editor/use-form-editor.test.tsx @@ -119,6 +119,31 @@ describe('useFormEditor', () => { expect(result.current.reset).toBe(reset0); }); + it('seeds defined defaultValues while data is still loading (controlled from first render)', () => { + const { result, rerender } = renderHook( + ({ data }: { data: Form | undefined }) => + useFormEditor
({ + data, + defaultValues: { name: '', color: '' }, + schema, + save: vi.fn().mockResolvedValue(undefined), + }), + { initialProps: { data: undefined as Form | undefined } }, + ); + + // Loading, but the controlled fields already read defined values rather + // than undefined — no uncontrolled→controlled churn when data arrives. + expect(result.current.isLoading).toBe(true); + expect(result.current.form.getValues('name')).toBe(''); + expect(result.current.form.getValues('color')).toBe(''); + expect(result.current.isDirty).toBe(false); + + rerender({ data: { name: 'A', color: '#FF0000' } }); + expect(result.current.isLoading).toBe(false); + expect(result.current.form.getValues('name')).toBe('A'); + expect(result.current.isDirty).toBe(false); + }); + it('reports isValid:false for schema-invalid input', async () => { const { result } = mount({ name: 'A', color: '#FF0000' }); act(() => result.current.form.setValue('name', '', { shouldDirty: true })); diff --git a/services/platform/app/components/ui/editor/use-form-editor.ts b/services/platform/app/components/ui/editor/use-form-editor.ts index 12fbc413a..5d67585bc 100644 --- a/services/platform/app/components/ui/editor/use-form-editor.ts +++ b/services/platform/app/components/ui/editor/use-form-editor.ts @@ -31,6 +31,14 @@ interface UseFormEditorArgs { * want to clobber — the Convex baseline-drift fix). */ data: T | undefined; + /** + * Stable, fully-defined initial values for the very first render, before the + * async `data` resolves. RHF reads `defaultValues` once at mount, so seeding + * it here keeps controlled inputs (Select/Switch) controlled from the first + * render instead of mounting with `undefined` and tripping React's + * uncontrolled→controlled warning when `data` arrives. + */ + defaultValues?: DefaultValues; /** Optional Zod schema; when present, drives validation + `isValid`. */ schema?: AnyZodSchema; /** Persists the form values. Throw to keep `isDirty` true. */ @@ -60,13 +68,14 @@ interface FormEditor extends EditorController { */ export function useFormEditor({ data, + defaultValues, schema, save, mapServerError, }: UseFormEditorArgs): FormEditor { const form = useForm({ // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- T extends FieldValues - defaultValues: data as DefaultValues | undefined, + defaultValues: (data ?? defaultValues) as DefaultValues | undefined, resolver: schema ? // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- zodResolver returns Resolver; widen (zodResolver(schema) as unknown as Resolver) diff --git a/services/platform/app/features/settings/enterprise-sso/components/enterprise-sso-form.tsx b/services/platform/app/features/settings/enterprise-sso/components/enterprise-sso-form.tsx index 67155cbc0..e8e8294fa 100644 --- a/services/platform/app/features/settings/enterprise-sso/components/enterprise-sso-form.tsx +++ b/services/platform/app/features/settings/enterprise-sso/components/enterprise-sso-form.tsx @@ -114,6 +114,36 @@ interface SsoFormData { excludeGroups: string; } +/** + * Fully-defined empty form state used as RHF's initial `defaultValues` while + * the connection config is still loading. Every field is defined (empty + * strings for text/Selects, booleans for Switches) so the controlled Select + * and Switch inputs are controlled from the first render — without this they + * would mount with `undefined` and log React's uncontrolled→controlled warning + * once the seeded `data` resolves. + */ +const EMPTY_FORM_DATA: SsoFormData = { + protocol: 'entra-id', + displayName: '', + domain: '', + issuer: '', + clientId: '', + clientSecret: '', + scopes: '', + pkce: true, + authzEndpoint: '', + tokenEndpoint: '', + userinfoEndpoint: '', + domainHint: '', + idpEntityId: '', + idpSsoUrl: '', + idpCertificate: '', + defaultRole: 'member', + autoRole: false, + autoTeam: false, + excludeGroups: '', +}; + const isOidcProtocol = (p: UiProtocol): p is Exclude => p !== 'saml'; @@ -404,7 +434,12 @@ export function EnterpriseSsoForm({ organizationId, config }: Props) { [config, organizationId, t, toast, upsertOidc, upsertSaml], ); - const editor = useFormEditor({ data, schema, save }); + const editor = useFormEditor({ + data, + defaultValues: EMPTY_FORM_DATA, + schema, + save, + }); useRegisterActiveEditor(editor);