@@ -804,3 +804,206 @@ describe("Zod to JSON Schema roundtrip", () => {
804804 } ) ;
805805 } ) ;
806806} ) ;
807+
808+ /**
809+ * These tests confirm and protect against a bug where `useFieldArray` in
810+ * AutoFormArray corrupts primitive string values in arrays like
811+ * `structuredContraindications: z.array(z.string()).default([])`.
812+ *
813+ * When react-hook-form's `useFieldArray` processes a primitive array
814+ * (e.g. `["pregnancy", "active malignancy"]`), it wraps each element as a
815+ * tracking object `{ id: "rhf_generated_id" }`, discarding the original
816+ * string value. When `form.watch()` fires, it returns these objects. The
817+ * `handleValuesChange` callback then calls `setFormData(values)` which stores
818+ * the corrupted objects. On form submit, `zodResolver` validates the corrupted
819+ * values against `z.array(z.string())` and FAILS — causing the Save button
820+ * to do nothing (no error shown, no API request made).
821+ *
822+ * The fix: AutoFormArray must NOT use `useFieldArray` for primitive (non-object)
823+ * arrays. Instead it uses `form.watch` + `form.setValue` directly so primitive
824+ * values are always preserved in the form state.
825+ */
826+ describe ( "Primitive string array — useFieldArray corruption bug" , ( ) => {
827+ /**
828+ * Simulates what zodToFormSchema + formSchemaToZod does:
829+ * Zod schema → JSON Schema (stored in DB) → reconstructed Zod schema.
830+ */
831+ function roundtripSchema ( schema : z . ZodType ) : z . ZodType {
832+ const jsonSchema = z . toJSONSchema ( schema , { unrepresentable : "any" } ) ;
833+ return z . fromJSONSchema ( jsonSchema as z . core . JSONSchema . JSONSchema ) ;
834+ }
835+
836+ it ( "z.array(z.string()) survives JSON Schema roundtrip and accepts string values" , ( ) => {
837+ const schema = z . object ( {
838+ structuredContraindications : z . array ( z . string ( ) ) . default ( [ ] ) ,
839+ } ) ;
840+
841+ const reconstructed = roundtripSchema ( schema ) ;
842+
843+ // Actual compound data — should PASS
844+ expect (
845+ reconstructed . safeParse ( {
846+ structuredContraindications : [
847+ "pregnancy" ,
848+ "active malignancy" ,
849+ "active cancer" ,
850+ "trying to conceive" ,
851+ ] ,
852+ } ) . success ,
853+ ) . toBe ( true ) ;
854+
855+ // Empty array — should PASS (default)
856+ expect (
857+ reconstructed . safeParse ( { structuredContraindications : [ ] } ) . success ,
858+ ) . toBe ( true ) ;
859+ } ) ;
860+
861+ it ( "useFieldArray-corrupted values (objects) fail z.array(z.string()) validation — this is the root cause of the silent save failure" , ( ) => {
862+ const schema = z . object ( {
863+ structuredContraindications : z . array ( z . string ( ) ) . default ( [ ] ) ,
864+ } ) ;
865+
866+ const reconstructed = roundtripSchema ( schema ) ;
867+
868+ // Simulates what react-hook-form's useFieldArray returns when used with
869+ // primitive string arrays: each string is replaced by a tracking object
870+ // { id: "rhf_generated_id" } and the original value is lost.
871+ const corruptedByUseFieldArray = {
872+ structuredContraindications : [
873+ { id : "rhf_internal_id_1" } ,
874+ { id : "rhf_internal_id_2" } ,
875+ { id : "rhf_internal_id_3" } ,
876+ { id : "rhf_internal_id_4" } ,
877+ ] ,
878+ } ;
879+
880+ // This is the actual validation error that occurs when Save is clicked:
881+ // zodResolver validates the corrupted objects against z.array(z.string())
882+ // and FAILS, so onSubmit is never called → no API request → "nothing happens"
883+ const result = reconstructed . safeParse ( corruptedByUseFieldArray ) ;
884+ expect ( result . success ) . toBe ( false ) ;
885+ if ( ! result . success ) {
886+ // Confirm the error is specifically about the string array elements
887+ const paths = result . error . issues . map ( ( i ) => i . path . join ( "." ) ) ;
888+ expect (
889+ paths . some ( ( p ) => p . startsWith ( "structuredContraindications" ) ) ,
890+ ) . toBe ( true ) ;
891+ }
892+ } ) ;
893+
894+ it ( "primitive string array values are preserved correctly (NOT corrupted) when form state is managed without useFieldArray" , ( ) => {
895+ // After the fix, AutoFormArray uses form.watch + form.setValue for primitive
896+ // arrays. The values remain as strings throughout the lifecycle:
897+ // initialData → formData → form state → zodResolver → submit
898+
899+ const schema = z . object ( {
900+ structuredContraindications : z . array ( z . string ( ) ) . default ( [ ] ) ,
901+ } ) ;
902+
903+ const reconstructed = roundtripSchema ( schema ) ;
904+
905+ // The correctly-preserved values (no useFieldArray wrapping)
906+ const preservedValues = {
907+ structuredContraindications : [
908+ "pregnancy" ,
909+ "active malignancy" ,
910+ "active cancer" ,
911+ "trying to conceive" ,
912+ ] ,
913+ } ;
914+
915+ // With the fix applied, these values pass validation → Save works
916+ expect ( reconstructed . safeParse ( preservedValues ) . success ) . toBe ( true ) ;
917+ } ) ;
918+
919+ it ( "compound schema with structuredContraindications passes full validation with string array values" , ( ) => {
920+ // Representative subset of CompoundSchema fields that appear on the
921+ // Epitalon compound page — verifies the full schema round-trip for the
922+ // fields that are relevant to the reported bug.
923+ const compoundSchema = z . object ( {
924+ name : z . string ( ) . min ( 1 ) ,
925+ compoundType : z . enum ( [
926+ "healing-peptide" ,
927+ "gh-axis" ,
928+ "metabolic-peptide" ,
929+ "sarm" ,
930+ "steroid" ,
931+ "nootropic" ,
932+ "supplement" ,
933+ "ancillary-pct" ,
934+ "longevity" ,
935+ "hair" ,
936+ "skin" ,
937+ "sexual" ,
938+ "other" ,
939+ ] ) ,
940+ researchStatus : z . enum ( [
941+ "research-only" ,
942+ "approved" ,
943+ "banned" ,
944+ "grey-market" ,
945+ "supplement" ,
946+ ] ) ,
947+ legalStatus : z . enum ( [
948+ "OTC" ,
949+ "Research" ,
950+ "Grey-Market" ,
951+ "Rx-Only" ,
952+ "Schedule-III" ,
953+ "Banned" ,
954+ ] ) ,
955+ doseUnit : z . enum ( [ "mcg" , "mg" , "IU" , "ml" , "g" ] ) ,
956+ doseFrequency : z . enum ( [
957+ "once-daily" ,
958+ "twice-daily" ,
959+ "three-times-daily" ,
960+ "every-other-day" ,
961+ "weekly" ,
962+ "twice-weekly" ,
963+ "three-times-weekly" ,
964+ "as-needed" ,
965+ "custom" ,
966+ ] ) ,
967+ structuredContraindications : z . array ( z . string ( ) ) . default ( [ ] ) ,
968+ affiliates : z
969+ . array (
970+ z . object ( {
971+ partnerId : z . object ( { id : z . string ( ) } ) . optional ( ) ,
972+ title : z . string ( ) . optional ( ) ,
973+ url : z . string ( ) . min ( 1 ) ,
974+ } ) ,
975+ )
976+ . default ( [ ] ) ,
977+ } ) ;
978+
979+ const reconstructed = roundtripSchema ( compoundSchema ) ;
980+
981+ // Epitalon-like data — all values as they come from parsedData
982+ const epitalon = {
983+ name : "Epitalon" ,
984+ compoundType : "longevity" ,
985+ researchStatus : "research-only" ,
986+ legalStatus : "Research" ,
987+ doseUnit : "mg" ,
988+ doseFrequency : "once-daily" ,
989+ // The 4 string items that were causing the silent save failure
990+ structuredContraindications : [
991+ "pregnancy" ,
992+ "active malignancy" ,
993+ "active cancer" ,
994+ "trying to conceive" ,
995+ ] ,
996+ affiliates : [
997+ {
998+ partnerId : { id : "v6yAqOSO_example_id" } ,
999+ title : "Buy Epitalon 10mg" ,
1000+ url : "https://swisschems.is/product/epitalon-10mg-price-is-per-vial/" ,
1001+ } ,
1002+ ] ,
1003+ } ;
1004+
1005+ const result = reconstructed . safeParse ( epitalon ) ;
1006+ // After the fix, this should PASS (the save button works)
1007+ expect ( result . success ) . toBe ( true ) ;
1008+ } ) ;
1009+ } ) ;
0 commit comments