77 waitFor ,
88} from "@testing-library/react" ;
99import React from "react" ;
10+ import type { Control , UseFormReturn } from "react-hook-form" ;
1011import { type Navigation , useFetcher } from "react-router" ;
1112import { RemixFormProvider , useRemixForm , useRemixFormContext } from "./index" ;
1213
@@ -23,7 +24,9 @@ const useNavigationMock = vi.hoisted(() =>
2324 } ) ) ,
2425) ;
2526
26- const useHrefMock = vi . hoisted ( ( ) => vi . fn ( ) ) ;
27+ const useHrefMock = vi . hoisted ( ( ) =>
28+ vi . fn < ( to : string ) => string | undefined > ( ( ) => "/" ) ,
29+ ) ;
2730
2831vi . mock ( "react-router" , ( ) => ( {
2932 useSubmit : ( ) => submitMock ,
@@ -451,6 +454,243 @@ describe("useRemixForm", () => {
451454 expect ( booleanFieldProps . defaultChecked ) . toBe ( false ) ;
452455 expect ( booleanFieldProps . defaultValue ) . toBe ( undefined ) ;
453456 } ) ;
457+
458+ it ( "types the return value with every react-hook-form member (#185, #116)" , ( ) => {
459+ type Values = { name : string } ;
460+ const { result } = renderHook ( ( ) => useRemixForm < Values > ( { } ) ) ;
461+ // Fails to compile when the return type misses a member of UseFormReturn
462+ const members : Omit <
463+ UseFormReturn < Values > ,
464+ "handleSubmit" | "reset" | "register"
465+ > = result . current ;
466+ const control : Control < Values > = result . current . control ;
467+ expect ( members . getValues ) . toBeInstanceOf ( Function ) ;
468+ expect ( control ) . toBe ( result . current . control ) ;
469+ } ) ;
470+
471+ it ( "keeps the leading slash of the action when the basename ends with a slash (#175)" , async ( ) => {
472+ submitMock . mockReset ( ) ;
473+ useActionDataMock . mockReturnValue ( undefined ) ;
474+ useHrefMock . mockImplementation ( ( ) => "/my-basename/" ) ;
475+ vi . spyOn ( window , "location" , "get" ) . mockReturnValueOnce ( {
476+ origin : "http://example.com" ,
477+ // biome-ignore lint/suspicious/noExplicitAny: partial location mock
478+ } as any ) ;
479+
480+ const { result } = renderHook ( ( ) =>
481+ useRemixForm ( {
482+ resolver : ( ) => ( { values : { } , errors : { } } ) ,
483+ } ) ,
484+ ) ;
485+
486+ act ( ( ) => {
487+ result . current . handleSubmit ( {
488+ currentTarget : {
489+ action : "http://example.com/my-basename/basename-test-submit" ,
490+ } ,
491+ // biome-ignore lint/suspicious/noExplicitAny: partial event mock
492+ } as any ) ;
493+ } ) ;
494+ await waitFor ( ( ) => {
495+ expect ( submitMock ) . toHaveBeenCalledWith ( expect . any ( FormData ) , {
496+ method : "post" ,
497+ action : "/basename-test-submit" ,
498+ } ) ;
499+ } ) ;
500+ useHrefMock . mockImplementation ( ( ) => "/" ) ;
501+ } ) ;
502+
503+ it ( "submits when the form has inputs named action, method or enctype (#139)" , async ( ) => {
504+ submitMock . mockReset ( ) ;
505+ useActionDataMock . mockReturnValue ( undefined ) ;
506+ const form = document . createElement ( "form" ) ;
507+ // In a browser, an input replaces the form property with the same name
508+ for ( const name of [ "action" , "method" , "enctype" ] ) {
509+ const input = document . createElement ( "input" ) ;
510+ input . name = name ;
511+ form . appendChild ( input ) ;
512+ Object . defineProperty ( form , name , { value : input } ) ;
513+ }
514+ // The HTMLFormElement getters still return the real values
515+ const getters = [
516+ vi
517+ . spyOn ( HTMLFormElement . prototype , "action" , "get" )
518+ . mockReturnValue ( `${ window . location . origin } /submit` ) ,
519+ vi
520+ . spyOn ( HTMLFormElement . prototype , "method" , "get" )
521+ . mockReturnValue ( "post" ) ,
522+ vi
523+ . spyOn ( HTMLFormElement . prototype , "enctype" , "get" )
524+ . mockReturnValue ( "multipart/form-data" ) ,
525+ ] ;
526+
527+ const { result } = renderHook ( ( ) =>
528+ useRemixForm ( {
529+ resolver : ( ) => ( { values : { } , errors : { } } ) ,
530+ } ) ,
531+ ) ;
532+
533+ act ( ( ) => {
534+ // biome-ignore lint/suspicious/noExplicitAny: partial event mock
535+ result . current . handleSubmit ( { currentTarget : form } as any ) ;
536+ } ) ;
537+ await waitFor ( ( ) => {
538+ expect ( submitMock ) . toHaveBeenCalledWith ( expect . any ( FormData ) , {
539+ method : "post" ,
540+ action : "/submit" ,
541+ encType : "multipart/form-data" ,
542+ } ) ;
543+ } ) ;
544+ for ( const getter of getters ) getter . mockRestore ( ) ;
545+ } ) ;
546+
547+ it ( "lets clearErrors remove errors returned by the server (#12)" , async ( ) => {
548+ const serverData = {
549+ errors : {
550+ root : { message : "Server error" } ,
551+ name : { message : "Name is taken" } ,
552+ } ,
553+ } ;
554+ useActionDataMock . mockReturnValue ( serverData ) ;
555+
556+ const { result, rerender } = renderHook ( ( ) =>
557+ useRemixForm < { name : string } > ( { defaultValues : { name : "" } } ) ,
558+ ) ;
559+ await waitFor ( ( ) => {
560+ expect ( result . current . formState . errors . root ?. message ) . toBe (
561+ "Server error" ,
562+ ) ;
563+ } ) ;
564+
565+ act ( ( ) => {
566+ result . current . clearErrors ( "root" ) ;
567+ result . current . clearErrors ( "name" ) ;
568+ } ) ;
569+ rerender ( ) ;
570+
571+ expect ( result . current . formState . errors . root ) . toBeUndefined ( ) ;
572+ expect ( result . current . formState . errors . name ) . toBeUndefined ( ) ;
573+ useActionDataMock . mockReturnValue ( undefined ) ;
574+ } ) ;
575+
576+ it ( "exposes the default submit handler so onValid can wrap it (#148)" , async ( ) => {
577+ submitMock . mockReset ( ) ;
578+ useActionDataMock . mockReturnValue ( undefined ) ;
579+
580+ const { result } = renderHook ( ( ) => {
581+ const form = useRemixForm < { name : string ; extra ?: string } > ( {
582+ resolver : ( ) => ( { values : { name : "John" } , errors : { } } ) ,
583+ submitHandlers : {
584+ onValid : ( data ) => {
585+ form . defaultSubmitHandler ( { ...data , extra : "from onValid" } ) ;
586+ } ,
587+ } ,
588+ } ) ;
589+ return form ;
590+ } ) ;
591+
592+ act ( ( ) => {
593+ result . current . handleSubmit ( {
594+ currentTarget : {
595+ action : `${ window . location . origin } /wrapped` ,
596+ method : "post" ,
597+ } ,
598+ // biome-ignore lint/suspicious/noExplicitAny: partial event mock
599+ } as any ) ;
600+ } ) ;
601+ await waitFor ( ( ) => expect ( submitMock ) . toHaveBeenCalledTimes ( 1 ) ) ;
602+
603+ const [ formData , options ] = submitMock . mock . calls [ 0 ] ;
604+ expect ( formData . get ( "name" ) ) . toBe ( JSON . stringify ( "John" ) ) ;
605+ expect ( formData . get ( "extra" ) ) . toBe ( JSON . stringify ( "from onValid" ) ) ;
606+ // The form action and method still apply without passing them through
607+ expect ( options ) . toEqual ( { method : "post" , action : "/wrapped" } ) ;
608+ } ) ;
609+
610+ it ( "submits the raw field values when submitRawValues is true (#172)" , async ( ) => {
611+ submitMock . mockReset ( ) ;
612+ useActionDataMock . mockReturnValue ( undefined ) ;
613+
614+ const { result } = renderHook ( ( ) =>
615+ // biome-ignore lint/suspicious/noExplicitAny: default context type
616+ useRemixForm < { date : string } , any , { date : Date } > ( {
617+ defaultValues : { date : "2025-01-01" } ,
618+ resolver : ( values ) => ( {
619+ values : { date : new Date ( values . date ) } ,
620+ errors : { } ,
621+ } ) ,
622+ submitRawValues : true ,
623+ } ) ,
624+ ) ;
625+
626+ act ( ( ) => {
627+ // biome-ignore lint/suspicious/noExplicitAny: partial event mock
628+ result . current . handleSubmit ( { } as any ) ;
629+ } ) ;
630+ await waitFor ( ( ) => expect ( submitMock ) . toHaveBeenCalledTimes ( 1 ) ) ;
631+
632+ expect ( submitMock . mock . calls [ 0 ] [ 0 ] . get ( "date" ) ) . toBe (
633+ JSON . stringify ( "2025-01-01" ) ,
634+ ) ;
635+ } ) ;
636+
637+ describe ( "resetOnSuccess (#157)" , ( ) => {
638+ const submitAndFinish = async ( actionData : unknown ) => {
639+ submitMock . mockReset ( ) ;
640+ useActionDataMock . mockReturnValue ( undefined ) ;
641+ useNavigationMock . mockReturnValue ( {
642+ state : "idle" ,
643+ formData : undefined ,
644+ json : undefined ,
645+ } ) ;
646+
647+ const hook = renderHook ( ( ) =>
648+ useRemixForm < { name : string } > ( {
649+ defaultValues : { name : "default" } ,
650+ resolver : ( values ) => ( { values, errors : { } } ) ,
651+ resetOnSuccess : true ,
652+ } ) ,
653+ ) ;
654+ act ( ( ) => hook . result . current . setValue ( "name" , "changed" ) ) ;
655+ act ( ( ) => {
656+ // biome-ignore lint/suspicious/noExplicitAny: partial event mock
657+ hook . result . current . handleSubmit ( { } as any ) ;
658+ } ) ;
659+ await waitFor ( ( ) => expect ( submitMock ) . toHaveBeenCalledTimes ( 1 ) ) ;
660+
661+ useNavigationMock . mockReturnValue ( {
662+ state : "submitting" ,
663+ formData : new FormData ( ) ,
664+ json : undefined ,
665+ } ) ;
666+ hook . rerender ( ) ;
667+ expect ( hook . result . current . getValues ( "name" ) ) . toBe ( "changed" ) ;
668+
669+ useActionDataMock . mockReturnValue ( actionData ) ;
670+ useNavigationMock . mockReturnValue ( {
671+ state : "idle" ,
672+ formData : undefined ,
673+ json : undefined ,
674+ } ) ;
675+ hook . rerender ( ) ;
676+ await act ( async ( ) => { } ) ;
677+ return hook ;
678+ } ;
679+
680+ it ( "resets the form when the action returns no errors" , async ( ) => {
681+ const { result } = await submitAndFinish ( { ok : true } ) ;
682+ expect ( result . current . getValues ( "name" ) ) . toBe ( "default" ) ;
683+ useActionDataMock . mockReturnValue ( undefined ) ;
684+ } ) ;
685+
686+ it ( "keeps the values when the action returns errors" , async ( ) => {
687+ const { result } = await submitAndFinish ( {
688+ errors : { name : { message : "Name is taken" } } ,
689+ } ) ;
690+ expect ( result . current . getValues ( "name" ) ) . toBe ( "changed" ) ;
691+ useActionDataMock . mockReturnValue ( undefined ) ;
692+ } ) ;
693+ } ) ;
454694} ) ;
455695
456696afterEach ( cleanup ) ;
0 commit comments