Repository: react-hook-form/resolvers
Pull request #620 (2023-08-18 13:39:59)

feat(valibot): add more tests, support of `criteriaMode` and reduce size

github-actions[bot]: **Size Change:** +176 B (+1%) 

**Total Size:** 25.1 kB

| Filename | Size | Change |  |
| :--- | :---: | :---: | :---: |
| `valibot/dist/valibot.js` | 577 B | +56 B (+11%) | ⚠️ |
| `valibot/dist/valibot.module.js` | 594 B | +57 B (+11%) | ⚠️ |
| `valibot/dist/valibot.umd.js` | 684 B | +63 B (+10%) | ⚠️ |

<details><summary>ℹ️ <strong>View Unchanged</strong></summary>

| Filename | Size |
| :--- | :---: |
| `ajv/dist/ajv.js` | 638 B |
| `ajv/dist/ajv.module.js` | 597 B |
| `ajv/dist/ajv.umd.js` | 751 B |
| `arktype/dist/arktype.js` | 327 B |
| `arktype/dist/arktype.module.js` | 343 B |
| `arktype/dist/arktype.umd.js` | 422 B |
| `class-validator/dist/class-validator.js` | 537 B |
| `class-validator/dist/class-validator.module.js` | 561 B |
| `class-validator/dist/class-validator.umd.js` | 640 B |
| `computed-types/dist/computed-types.js` | 386 B |
| `computed-types/dist/computed-types.module.js` | 401 B |
| `computed-types/dist/computed-types.umd.js` | 477 B |
| `dist/resolvers.js` | 359 B |
| `dist/resolvers.module.js` | 373 B |
| `dist/resolvers.umd.js` | 454 B |
| `io-ts/dist/io-ts.js` | 1.28 kB |
| `io-ts/dist/io-ts.module.js` | 1.18 kB |
| `io-ts/dist/io-ts.umd.js` | 1.41 kB |
| `joi/dist/joi.js` | 592 B |
| `joi/dist/joi.module.js` | 610 B |
| `joi/dist/joi.umd.js` | 698 B |
| `nope/dist/nope.js` | 345 B |
| `nope/dist/nope.module.js` | 365 B |
| `nope/dist/nope.umd.js` | 434 B |
| `superstruct/dist/superstruct.js` | 324 B |
| `superstruct/dist/superstruct.module.js` | 342 B |
| `superstruct/dist/superstruct.umd.js` | 424 B |
| `typanion/dist/typanion.js` | 323 B |
| `typanion/dist/typanion.module.js` | 336 B |
| `typanion/dist/typanion.umd.js` | 418 B |
| `typebox/dist/typebox.js` | 464 B |
| `typebox/dist/typebox.module.js` | 482 B |
| `typebox/dist/typebox.umd.js` | 579 B |
| `vest/dist/vest.js` | 480 B |
| `vest/dist/vest.module.js` | 443 B |
| `vest/dist/vest.umd.js` | 569 B |
| `yup/dist/yup.js` | 626 B |
| `yup/dist/yup.module.js` | 640 B |
| `yup/dist/yup.umd.js` | 729 B |
| `zod/dist/zod.js` | 581 B |
| `zod/dist/zod.module.js` | 602 B |
| `zod/dist/zod.umd.js` | 688 B |

</details>



<a href="https://github.com/preactjs/compressed-size-action"><sub>compressed-size-action</sub></a>

Patch:
diff --git a/valibot/src/valibot.ts b/valibot/src/valibot.ts
index 9569bd4..ec269b1 100644
--- a/valibot/src/valibot.ts
+++ b/valibot/src/valibot.ts
@@ -7,70 +7,85 @@ import {
   parse,
   parseAsync,
 } from 'valibot';
-import { FieldErrors, FieldError } from 'react-hook-form';
-
-type FlatErrors = Record<string, [FieldError, ...FieldError[]]>;
+import { FieldErrors, FieldError, appendErrors } from 'react-hook-form';
+const parseErrors = (
+  valiErrors: ValiError,
+  validateAllFieldCriteria: boolean,
+): FieldErrors => {
+  const errors: Record<string, FieldError> = {};
+  for (; valiErrors.issues.length;) {
+    const error = valiErrors.issues[0];
+    if (!error.path) {
+      continue;
+    }
+    const _path = error.path.map(({ key }) => key).join('.');
 
-const parseErrors = (error: ValiError): FieldErrors => {
-  const errors = error.issues.reduce<FlatErrors>((flatErrors, issue) => {
-    if (issue.path) {
-      const path = issue.path.map(({ key }) => key).join('.');
-      flatErrors[path] = [
-        ...(flatErrors[path] || []),
-        {
-          message: issue.message,
-          type: issue.validation,
-        },
-      ];
+    if (!errors[_path]) {
+      errors[_path] = { message: error.message, type: error.validation };
     }
 
-    return flatErrors;
-  }, {});
+    if (validateAllFieldCriteria) {
+      const types = errors[_path].types;
+      const messages = types && types[error.validation];
 
-  return Object.entries(errors).reduce<FieldErrors>((acc, [path, errors]) => {
-    const [firstError] = errors;
-    acc[path] = {
-      message: firstError.message,
-      type: firstError.type,
-    };
+      errors[_path] = appendErrors(
+        _path,
+        validateAllFieldCriteria,
+        errors,
+        error.validation,
+        messages
+          ? ([] as string[]).concat(messages as string[], error.message)
+          : error.message,
+      ) as FieldError;
+    }
+
+    valiErrors.issues.shift();
+  }
 
-    return acc;
-  }, {});
+  return errors;
 };
 
 export const valibotResolver: Resolver =
-  (
-    schema,
-    schemaOptions = {
-      abortEarly: false,
-      abortPipeEarly: false,
-    },
-    resolverOptions = {
-      mode: 'async',
-      raw: false,
-    },
-  ) =>
-  async (values, _, options) => {
-    try {
-      const { mode, raw } = resolverOptions;
-      const parsed =
-        mode === 'sync'
-          ? parse(schema as BaseSchema, values, schemaOptions)
-          : await parseAsync(
+  (schema, schemaOptions, resolverOptions = {}) =>
+    async (values, _, options) => {
+      try {
+        const schemaOpts = Object.assign(
+          {},
+          {
+            abortEarly: false,
+            abortPipeEarly: false,
+          },
+          schemaOptions,
+        );
+
+        const parsed =
+          resolverOptions.mode === 'sync'
+            ? parse(schema as BaseSchema, values, schemaOpts)
+            : await parseAsync(
               schema as BaseSchema | BaseSchemaAsync,
               values,
-              schemaOptions,
+              schemaOpts,
             );
 
-      return { values: raw ? values : parsed, errors: {} as FieldErrors };
-    } catch (error) {
-      if (error instanceof ValiError) {
         return {
-          values: {},
-          errors: toNestError(parseErrors(error), options),
+          values: resolverOptions.raw ? values : parsed,
+          errors: {} as FieldErrors,
         };
-      }
+      } catch (error) {
+        if (error instanceof ValiError) {
+          return {
+            values: {},
+            errors: toNestError(
+              parseErrors(
+                error,
+                !options.shouldUseNativeValidation &&
+                options.criteriaMode === 'all',
+              ),
+              options,
+            ),
+          };
+        }
 
-      throw error;
-    }
-  };
+        throw error;
+      }
+    };


Test patch:
diff --git a/valibot/src/__tests__/Form-native-validation.tsx b/valibot/src/__tests__/Form-native-validation.tsx
new file mode 100644
index 0000000..4439cbb
--- /dev/null
+++ b/valibot/src/__tests__/Form-native-validation.tsx
@@ -0,0 +1,81 @@
+import React from 'react';
+import { useForm } from 'react-hook-form';
+import { render, screen } from '@testing-library/react';
+import user from '@testing-library/user-event';
+import { string, required, object, minLength } from 'valibot';
+import { valibotResolver } from '..';
+
+const USERNAME_REQUIRED_MESSAGE = 'username field is required';
+const PASSWORD_REQUIRED_MESSAGE = 'password field is required';
+
+const schema = required(
+  object({
+    username: string(USERNAME_REQUIRED_MESSAGE, [
+      minLength(2, USERNAME_REQUIRED_MESSAGE),
+    ]),
+    password: string(PASSWORD_REQUIRED_MESSAGE, [
+      minLength(2, PASSWORD_REQUIRED_MESSAGE),
+    ]),
+  }),
+);
+
+type FormData = { username: string; password: string };
+
+interface Props {
+  onSubmit: (data: FormData) => void;
+}
+
+function TestComponent({ onSubmit }: Props) {
+  const { register, handleSubmit } = useForm<FormData>({
+    resolver: valibotResolver(schema),
+    shouldUseNativeValidation: true,
+  });
+
+  return (
+    <form onSubmit={handleSubmit(onSubmit)}>
+      <input {...register('username')} placeholder="username" />
+
+      <input {...register('password')} placeholder="password" />
+
+      <button type="submit">submit</button>
+    </form>
+  );
+}
+
+test("form's native validation with Zod", async () => {
+  const handleSubmit = vi.fn();
+  render(<TestComponent onSubmit={handleSubmit} />);
+
+  // username
+  let usernameField = screen.getByPlaceholderText(
+    /username/i,
+  ) as HTMLInputElement;
+  expect(usernameField.validity.valid).toBe(true);
+  expect(usernameField.validationMessage).toBe('');
+
+  // password
+  let passwordField = screen.getByPlaceholderText(
+    /password/i,
+  ) as HTMLInputElement;
+  expect(passwordField.validity.valid).toBe(true);
+  expect(passwordField.validationMessage).toBe('');
+
+  await user.click(screen.getByText(/submit/i));
+
+  // username
+  usernameField = screen.getByPlaceholderText(/username/i) as HTMLInputElement;
+  expect(usernameField.validity.valid).toBe(false);
+  expect(usernameField.validationMessage).toBe(USERNAME_REQUIRED_MESSAGE);
+
+  // password
+  passwordField = screen.getByPlaceholderText(/password/i) as HTMLInputElement;
+  expect(passwordField.validity.valid).toBe(false);
+  expect(passwordField.validationMessage).toBe(PASSWORD_REQUIRED_MESSAGE);
+
+  await user.type(screen.getByPlaceholderText(/password/i), 'password');
+
+  // password
+  passwordField = screen.getByPlaceholderText(/password/i) as HTMLInputElement;
+  expect(passwordField.validity.valid).toBe(true);
+  expect(passwordField.validationMessage).toBe('');
+});
diff --git a/valibot/src/__tests__/Form.tsx b/valibot/src/__tests__/Form.tsx
new file mode 100644
index 0000000..058a9c8
--- /dev/null
+++ b/valibot/src/__tests__/Form.tsx
@@ -0,0 +1,61 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import user from '@testing-library/user-event';
+import { useForm } from 'react-hook-form';
+import { string, required, object, minLength } from 'valibot';
+import { valibotResolver } from '..';
+
+const USERNAME_REQUIRED_MESSAGE = 'username field is required';
+const PASSWORD_REQUIRED_MESSAGE = 'password field is required';
+
+const schema = required(
+  object({
+    username: string(USERNAME_REQUIRED_MESSAGE, [
+      minLength(2, USERNAME_REQUIRED_MESSAGE),
+    ]),
+    password: string(PASSWORD_REQUIRED_MESSAGE, [
+      minLength(2, PASSWORD_REQUIRED_MESSAGE),
+    ]),
+  }),
+);
+
+type FormData = { username: string; password: string };
+
+interface Props {
+  onSubmit: (data: FormData) => void;
+}
+
+function TestComponent({ onSubmit }: Props) {
+  const {
+    register,
+    handleSubmit,
+    formState: { errors },
+  } = useForm<FormData>({
+    resolver: valibotResolver(schema), // Useful to check TypeScript regressions
+  });
+
+  return (
+    <form onSubmit={handleSubmit(onSubmit)}>
+      <input {...register('username')} />
+      {errors.username && <span role="alert">{errors.username.message}</span>}
+
+      <input {...register('password')} />
+      {errors.password && <span role="alert">{errors.password.message}</span>}
+
+      <button type="submit">submit</button>
+    </form>
+  );
+}
+
+test("form's validation with Valibot and TypeScript's integration", async () => {
+  const handleSubmit = vi.fn();
+  render(<TestComponent onSubmit={handleSubmit} />);
+
+  expect(screen.queryAllByRole('alert')).toHaveLength(0);
+
+  await user.click(screen.getByText(/submit/i));
+
+  expect(screen.getByText(/username field is required/i)).toBeInTheDocument();
+  expect(screen.getByText(/password field is required/i)).toBeInTheDocument();
+  expect(handleSubmit).not.toHaveBeenCalled();
+});
diff --git a/valibot/src/__tests__/__fixtures__/data.ts b/valibot/src/__tests__/__fixtures__/data.ts
index c0c9360..c390572 100644
--- a/valibot/src/__tests__/__fixtures__/data.ts
+++ b/valibot/src/__tests__/__fixtures__/data.ts
@@ -12,6 +12,7 @@ import {
   array,
   boolean,
   required,
+  union
 } from 'valibot';
 
 export const schema = required(
@@ -28,7 +29,7 @@ export const schema = required(
       minLength(8, 'Must be at least 8 characters in length'),
     ]),
     repeatPassword: string('Repeat Password is required'),
-    accessToken: string('Access token is required'),
+    accessToken: union([string('Access token should be a string'), number('Access token  should be a number')], "access token is required"),
     birthYear: number('Please enter your birth year', [
       minValue(1900),
       maxValue(2013),
diff --git a/valibot/src/__tests__/__snapshots__/valibot.ts.snap b/valibot/src/__tests__/__snapshots__/valibot.ts.snap
index 96be6d9..bf5ed90 100644
--- a/valibot/src/__tests__/__snapshots__/valibot.ts.snap
+++ b/valibot/src/__tests__/__snapshots__/valibot.ts.snap
@@ -77,3 +77,342 @@ exports[`valibotResolver > should return a single error from valibotResolver whe
   "values": {},
 }
 `;
+
+exports[`valibotResolver > should return a single error from valibotResolver with \`mode: sync\` when validation fails 1`] = `
+{
+  "errors": {
+    "accessToken": {
+      "message": "Invalid type",
+      "ref": undefined,
+      "type": "non_optional",
+    },
+    "birthYear": {
+      "message": "Please enter your birth year",
+      "ref": undefined,
+      "type": "number",
+    },
+    "email": {
+      "message": "Invalid email address",
+      "ref": {
+        "name": "email",
+      },
+      "type": "email",
+    },
+    "enabled": {
+      "message": "Invalid type",
+      "ref": undefined,
+      "type": "non_optional",
+    },
+    "like": {
+      "id": {
+        "message": "Like id is required",
+        "ref": undefined,
+        "type": "number",
+      },
+      "name": {
+        "message": "Invalid type",
+        "ref": undefined,
+        "type": "non_optional",
+      },
+    },
+    "password": {
+      "message": "One uppercase character",
+      "ref": {
+        "name": "password",
+      },
+      "type": "regex",
+    },
+    "repeatPassword": {
+      "message": "Invalid type",
+      "ref": undefined,
+      "type": "non_optional",
+    },
+    "tags": [
+      {
+        "message": "Tags should be strings",
+        "ref": undefined,
+        "type": "string",
+      },
+      {
+        "message": "Tags should be strings",
+        "ref": undefined,
+        "type": "string",
+      },
+      {
+        "message": "Tags should be strings",
+        "ref": undefined,
+        "type": "string",
+      },
+    ],
+    "username": {
+      "message": "Invalid type",
+      "ref": {
+        "name": "username",
+      },
+      "type": "non_optional",
+    },
+  },
+  "values": {},
+}
+`;
+
+exports[`valibotResolver > should return all the errors from valibotResolver when validation fails with \`validateAllFieldCriteria\` set to true 1`] = `
+{
+  "errors": {
+    "accessToken": {
+      "message": "Invalid type",
+      "ref": undefined,
+      "type": "non_optional",
+      "types": {
+        "non_optional": "Invalid type",
+      },
+    },
+    "birthYear": {
+      "message": "Please enter your birth year",
+      "ref": undefined,
+      "type": "number",
+      "types": {
+        "number": "Please enter your birth year",
+      },
+    },
+    "email": {
+      "message": "Invalid email address",
+      "ref": {
+        "name": "email",
+      },
+      "type": "email",
+      "types": {
+        "email": "Invalid email address",
+      },
+    },
+    "enabled": {
+      "message": "Invalid type",
+      "ref": undefined,
+      "type": "non_optional",
+      "types": {
+        "non_optional": "Invalid type",
+      },
+    },
+    "like": {
+      "id": {
+        "message": "Like id is required",
+        "ref": undefined,
+        "type": "number",
+        "types": {
+          "number": "Like id is required",
+        },
+      },
+      "name": {
+        "message": "Invalid type",
+        "ref": undefined,
+        "type": "non_optional",
+        "types": {
+          "non_optional": "Invalid type",
+        },
+      },
+    },
+    "password": {
+      "message": "One uppercase character",
+      "ref": {
+        "name": "password",
+      },
+      "type": "regex",
+      "types": {
+        "min_length": "Must be at least 8 characters in length",
+        "regex": [
+          "One uppercase character",
+          "One lowercase character",
+          "One number",
+        ],
+      },
+    },
+    "repeatPassword": {
+      "message": "Invalid type",
+      "ref": undefined,
+      "type": "non_optional",
+      "types": {
+        "non_optional": "Invalid type",
+      },
+    },
+    "tags": [
+      {
+        "message": "Tags should be strings",
+        "ref": undefined,
+        "type": "string",
+        "types": {
+          "string": "Tags should be strings",
+        },
+      },
+      {
+        "message": "Tags should be strings",
+        "ref": undefined,
+        "type": "string",
+        "types": {
+          "string": "Tags should be strings",
+        },
+      },
+      {
+        "message": "Tags should be strings",
+        "ref": undefined,
+        "type": "string",
+        "types": {
+          "string": "Tags should be strings",
+        },
+      },
+    ],
+    "username": {
+      "message": "Invalid type",
+      "ref": {
+        "name": "username",
+      },
+      "type": "non_optional",
+      "types": {
+        "non_optional": "Invalid type",
+      },
+    },
+  },
+  "values": {},
+}
+`;
+
+exports[`valibotResolver > should return all the errors from valibotResolver when validation fails with \`validateAllFieldCriteria\` set to true and \`mode: sync\` 1`] = `
+{
+  "errors": {
+    "accessToken": {
+      "message": "Invalid type",
+      "ref": undefined,
+      "type": "non_optional",
+      "types": {
+        "non_optional": "Invalid type",
+      },
+    },
+    "birthYear": {
+      "message": "Please enter your birth year",
+      "ref": undefined,
+      "type": "number",
+      "types": {
+        "number": "Please enter your birth year",
+      },
+    },
+    "email": {
+      "message": "Invalid email address",
+      "ref": {
+        "name": "email",
+      },
+      "type": "email",
+      "types": {
+        "email": "Invalid email address",
+      },
+    },
+    "enabled": {
+      "message": "Invalid type",
+      "ref": undefined,
+      "type": "non_optional",
+      "types": {
+        "non_optional": "Invalid type",
+      },
+    },
+    "like": {
+      "id": {
+        "message": "Like id is required",
+        "ref": undefined,
+        "type": "number",
+        "types": {
+          "number": "Like id is required",
+        },
+      },
+      "name": {
+        "message": "Invalid type",
+        "ref": undefined,
+        "type": "non_optional",
+        "types": {
+          "non_optional": "Invalid type",
+        },
+      },
+    },
+    "password": {
+      "message": "One uppercase character",
+      "ref": {
+        "name": "password",
+      },
+      "type": "regex",
+      "types": {
+        "min_length": "Must be at least 8 characters in length",
+        "regex": [
+          "One uppercase character",
+          "One lowercase character",
+          "One number",
+        ],
+      },
+    },
+    "repeatPassword": {
+      "message": "Invalid type",
+      "ref": undefined,
+      "type": "non_optional",
+      "types": {
+        "non_optional": "Invalid type",
+      },
+    },
+    "tags": [
+      {
+        "message": "Tags should be strings",
+        "ref": undefined,
+        "type": "string",
+        "types": {
+          "string": "Tags should be strings",
+        },
+      },
+      {
+        "message": "Tags should be strings",
+        "ref": undefined,
+        "type": "string",
+        "types": {
+          "string": "Tags should be strings",
+        },
+      },
+      {
+        "message": "Tags should be strings",
+        "ref": undefined,
+        "type": "string",
+        "types": {
+          "string": "Tags should be strings",
+        },
+      },
+    ],
+    "username": {
+      "message": "Invalid type",
+      "ref": {
+        "name": "username",
+      },
+      "type": "non_optional",
+      "types": {
+        "non_optional": "Invalid type",
+      },
+    },
+  },
+  "values": {},
+}
+`;
+
+exports[`valibotResolver > should return parsed values from valibotResolver with \`mode: sync\` when validation pass 1`] = `
+{
+  "errors": {},
+  "values": {
+    "accessToken": "accessToken",
+    "birthYear": 2000,
+    "email": "john@doe.com",
+    "enabled": true,
+    "like": {
+      "id": 1,
+      "name": "name",
+    },
+    "password": "Password123_",
+    "repeatPassword": "Password123_",
+    "tags": [
+      "tag1",
+      "tag2",
+    ],
+    "username": "Doe",
+  },
+}
+`;
diff --git a/valibot/src/__tests__/valibot.ts b/valibot/src/__tests__/valibot.ts
index a7d5144..c91835f 100644
--- a/valibot/src/__tests__/valibot.ts
+++ b/valibot/src/__tests__/valibot.ts
@@ -1,9 +1,51 @@
 /* eslint-disable no-console, @typescript-eslint/ban-ts-comment */
 import { valibotResolver } from '..';
 import { schema, validData, fields, invalidData } from './__fixtures__/data';
+import * as valibot from 'valibot';
 
 const shouldUseNativeValidation = false;
 describe('valibotResolver', () => {
+  it('should return parsed values from valibotResolver with `mode: sync` when validation pass', async () => {
+    vi.mock('valibot', async () => {
+      const a = await vi.importActual<any>('valibot');
+      return {
+        __esModule: true,
+        ...a,
+      };
+    });
+    const parseSpy = vi.spyOn(valibot, 'parse');
+    const parseAsyncSpy = vi.spyOn(valibot, 'parseAsync');
+
+    const result = await valibotResolver(schema, undefined, {
+      mode: 'sync',
+    })(validData, undefined, { fields, shouldUseNativeValidation });
+
+    expect(parseSpy).toHaveBeenCalledTimes(1);
+    expect(parseAsyncSpy).not.toHaveBeenCalled();
+    expect(result.errors).toEqual({});
+    expect(result).toMatchSnapshot();
+  });
+
+  it('should return a single error from valibotResolver with `mode: sync` when validation fails', async () => {
+    vi.mock('valibot', async () => {
+      const a = await vi.importActual<any>('valibot');
+      return {
+        __esModule: true,
+        ...a,
+      };
+    });
+    const parseSpy = vi.spyOn(valibot, 'parse');
+    const parseAsyncSpy = vi.spyOn(valibot, 'parseAsync');
+
+    const result = await valibotResolver(schema, undefined, {
+      mode: 'sync',
+    })(invalidData, undefined, { fields, shouldUseNativeValidation });
+
+    expect(parseSpy).toHaveBeenCalledTimes(1);
+    expect(parseAsyncSpy).not.toHaveBeenCalled();
+    expect(result).toMatchSnapshot();
+  });
+
   it('should return values from valibotResolver when validation pass', async () => {
     const result = await valibotResolver(schema)(validData, undefined, {
       fields,
@@ -21,4 +63,39 @@ describe('valibotResolver', () => {
 
     expect(result).toMatchSnapshot();
   });
+
+  it('should return values from valibotResolver when validation pass & raw=true', async () => {
+    const result = await valibotResolver(schema, undefined, {
+      raw: true,
+    })(validData, undefined, {
+      fields,
+      shouldUseNativeValidation,
+    });
+
+    expect(result).toEqual({ errors: {}, values: validData });
+  });
+
+  it('should return all the errors from valibotResolver when validation fails with `validateAllFieldCriteria` set to true', async () => {
+    const result = await valibotResolver(schema)(invalidData, undefined, {
+      fields,
+      criteriaMode: 'all',
+      shouldUseNativeValidation,
+    });
+
+    expect(result).toMatchSnapshot();
+  });
+
+  it('should return all the errors from valibotResolver when validation fails with `validateAllFieldCriteria` set to true and `mode: sync`', async () => {
+    const result = await valibotResolver(schema, undefined, { mode: 'sync' })(
+      invalidData,
+      undefined,
+      {
+        fields,
+        criteriaMode: 'all',
+        shouldUseNativeValidation,
+      },
+    );
+
+    expect(result).toMatchSnapshot();
+  });
 });