edmundhung / conform

A type-safe form validation library utilizing web fundamentals to progressively enhance HTML Forms with full support for server frameworks like Remix and Next.js.
https://conform.guide
MIT License
1.8k stars 99 forks source link

Unexpected error hoisting #391

Closed aaronadamsCA closed 1 day ago

aaronadamsCA commented 7 months ago

Describe the bug and the expected behavior

If all of the fields in a fieldset are missing, the error is unexpectedly "hoisted" from each field to the fieldset.

Conform version

1.0.0-rc.0

Steps to Reproduce the Bug or Issue

https://stackblitz.com/edit/remix-run-remix-zhkgb3?file=app%2Froutes%2Fworkaround.2.tsx

```tsx const schema = z.object({ flags: z.object({ one: z.boolean(), two: z.boolean(), }), }); export default function Route(): ReactElement { const [form, fields] = useForm>({ onValidate: ({ formData }) => parseWithZod(formData, { schema }), }); const { allErrors } = form; return (
{JSON.stringify({ allErrors }, null, 2)}
); } interface FlagsFieldsetProps { name: FieldName<{ one: boolean; two: boolean; }>; } function FlagsFieldset({ name }: FlagsFieldsetProps): ReactElement { const [metadata] = useField(name); const fields = metadata.getFieldset(); return (
); } ```
  1. Submit the form

Expected result:

{
  "allErrors": {
    "flags.one": [
      "Required"
    ],
    "flags.two": [
      "Required"
    ]
  }
}

Actual result:

{
  "allErrors": {
    "flags": [
      "Required"
    ]
  }
}

Errors at the fieldset level are unexpected, and most forms won't know how to render these.

What browsers are you seeing the problem on?

No response

Screenshots or Videos

No response

Additional context

No response

edmundhung commented 7 months ago

This is a tricky one 😅 As checkbox does not contribute to the FormData unless it is checked, there is no way Conform can construct the flags object. It is basically sending an empty object to zod and zod stop parsing the details of flags as soon as it finds that flags is not defined.

This would not be an issue if you have a text input inside the flags object, so Conform manages to construct an object even nothing is filled, or if you mark the flag itself as optional (No. This won't work with how zod works).

edmundhung commented 7 months ago

I wonder if this is something safe to do with automatic type coercion

const schema = z.object({
  flags: z.preprocess(value => {
    if (typeof value !== 'undefined') {
      return value;
    }

    // Set it a default object if it's not available
    return {};
  }, z.object({
    one: z.boolean(),
    two: z.boolean(),
  }),
});
aaronadamsCA commented 7 months ago

Yeah, this actually came up by accident—I forgot to append .default(false) to my z.boolean() fields, and the form was silently failing—no validation, no visible errors. I almost wasn't going to report it until I realized it may have other more significant effects.

Your suggested solution looks sound to me, I can't imagine any reason it would be dangerous to default every object in a schema to empty (though I'm hardly an expert in this kind of thing!).

Edit: It turns out this issue can occur whether your boolean fields are optional or required.

edmundhung commented 7 months ago

I think the value should default to an empty object only if the object schema is required. If the object is optional, we should keep it as is.

aaronadamsCA commented 7 months ago

I agree! I do have at least one use case where I might want to explicitly mark an object as .optional(), and it would be nice if that were still an option.

aaronadamsCA commented 7 months ago

I updated the StackBlitz to show two different workarounds for two different scenarios.

aaronadamsCA commented 4 months ago

Note this existing feature request for z.coerce.object(): https://github.com/colinhacks/zod/issues/2786

aaronadamsCA commented 1 month ago

I've come up with my own coerceObject() helper that appears to be working well.

function coerceObject<T extends z.ZodRawShape>(
  shape: T,
  params?: z.RawCreateParams,
) {
  return new z.ZodEffects({
    schema: z.object(shape, params),
    effect: { type: "preprocess", transform: Object },
    typeName: z.ZodFirstPartyTypeKind.ZodEffects,
  });
}

Here it is with an explicit function return type:

```ts function coerceObject( shape: T, params?: z.RawCreateParams, ): z.ZodEffects< z.ZodObject< T, "strip", z.ZodTypeAny, z.objectOutputType, z.objectInputType >, z.objectOutputType, z.objectInputType > { return new z.ZodEffects({ schema: z.object(shape, params), effect: { type: "preprocess", transform: Object }, typeName: z.ZodFirstPartyTypeKind.ZodEffects, }); } ```

Benefits:

Limitations:

@edmundhung, I'm happy to contribute this helper to Conform's Zod package along with some tests and docs, as it solves a large pain point while Zod v3 remains frozen (and v4 in progress). Just let me know if you're interested and I'll try to get to it soon.

edmundhung commented 1 month ago

Thanks for the offer @aaronadamsCA. I think it would be better to stay consistent with the current approach in which we will coerce the object automatically. (I have plan to make the schema integration less coupled so we might want this in the future, let's see.)

For now, #733 should resolve the issue.

aaronadamsCA commented 1 month ago

Agreed, it looks like a good solution. I just hope there are no use cases that depend on an undefined object remaining undefined! But I can't think of any.

(On the topic of "schema integration less coupled", I've often imagined a factory that could return a parseWithZod instance with custom "when you see [this schema], run [this transformer]" schema preprocessing instructions. Even better if I could override built-in coercions, e.g. I'd prefer to convert empty strings to null. I don't know if that's the kind of thing you had in mind, but if so—great!)