colinhacks / zod

TypeScript-first schema validation with static type inference
https://zod.dev
MIT License
33.16k stars 1.15k forks source link

ZodObject fails on writing to readonly cache prop. #2214

Closed noire-munich closed 1 year ago

noire-munich commented 1 year ago

I am working on a flow form where each step is an imported module which exports its own schema. I am using react-hook-form for this.

Not all steps of the form have validation. The form works well until I get to my first step with a zod resolver:

export const schema = z
  .object({
    [MONTHLY_WITH_ME]: z.boolean(),
    [MONTHLY_WITH_TEAM]: z.boolean(),
    [CUSTOM_NEEDS]: z.boolean(),
  })
  .partial()
  .refine(
    (options) => Object.values(options).some(Boolean),
    {
      message: 'Please select at least one option to continue',
      path: ['formErrors'],
    }
  )

This schema is tested and the tests work great.

Now I've tried having this flow form and having this isolated step in its own form on the same page and the results are getting weird:

I pass a resolver to the Form's config in each case using zodResolver(currentStep[1].schema):

<Form config={{resolver: zodResolver(currentStep[1].schema)`}} />

The error thrown in the console is not giving much away and is misleading, it comes from a file from another package: https://github.com/react-hook-form/resolvers/blob/master/zod/src/zod.ts#L70.

The actual error being thrown comes from this package:

TypeError: Cannot assign to read only property '_cached' of object '#<ZodObject>'
    at ZodObject._getCached (http://localhost:8910/static/js/src_pages_HomePage_HomePage_tsx.chunk.js:14132:30)
    at ZodObject._parse (http://localhost:8910/static/js/src_pages_HomePage_HomePage_tsx.chunk.js:14146:49)
    at ZodObject._parseAsync (http://localhost:8910/static/js/src_pages_HomePage_HomePage_tsx.chunk.js:12599:29)
    at ZodEffects._parse (http://localhost:8910/static/js/src_pages_HomePage_HomePage_tsx.chunk.js:15417:22)
    at ZodEffects.safeParseAsync (http://localhost:8910/static/js/src_pages_HomePage_HomePage_tsx.chunk.js:12644:39)
    at ZodEffects.parseAsync (http://localhost:8910/static/js/src_pages_HomePage_HomePage_tsx.chunk.js:12626:35)
    at _callee$ (http://localhost:8910/static/js/src_pages_HomePage_HomePage_tsx.chunk.js:462:84)
    at tryCatch (http://localhost:8910/static/js/src_pages_HomePage_HomePage_tsx.chunk.js:386:1357)
    at Generator.<anonymous> (http://localhost:8910/static/js/src_pages_HomePage_HomePage_tsx.chunk.js:386:4174)
    at Generator.next (http://localhost:8910/static/js/src_pages_HomePage_HomePage_tsx.chunk.js:386:2208)

I've tried using async with the resolver but it didn't do much.

Below is the full code:

// FlowForm.tsx
function usePresentationUi({ currentStep }) {
  return { Scene: currentStep?.[1].default }
}

const Container = ({
  currentStep,
  resolver,
  Scene,
  onBack,
  onPass,
  onSubmit,
}) => (
  <Form onSubmit={onSubmit} config={{ resolver }}>
    <h2>
      {'FlowForm'} - {`FormStep - ${currentStep?.[0]}`}
    </h2>
    <Scene onBack={onBack} onPass={onPass} />
    <FormError />
    <FieldError name="formErrors" />
  </Form>
)

export default function FlowForm({ steps, withValidation }) {
  const flowFormProps = useFlowForm({ steps, withValidation })

  const ui = usePresentationUi({ currentStep: flowFormProps.currentStep })

  return ui.Scene ? (
    <Container
      key={`FormStep - ${flowFormProps.currentStep?.[0]}`}
      {...flowFormProps}
      Scene={ui.Scene}
    />
  ) : null
}
// useFlowForm.ts
import { atom, useRecoilState } from 'recoil'
import { v4 as uuidv4 } from 'uuid'

import { UseFlowForm } from './types'
import { zodResolver } from './zodResolver'

const formStore = atom({ key: 'flowForms', default: {} })

function useFlowForm({ steps }) {
  const [storeData, store] = useRecoilState(formStore)

  const form = React.useRef(uuidv4())

  const setStep = (step) => (forms) => ({
    ...forms,
    [form.current]: { ...forms[form.current], step },
  })

  React.useEffect(() => {
    const show = Object.entries(steps)[0]
    store(setStep(show))
    // store((forms) => ({ ...forms, [form.current]: { step: show } }))
  }, [])

  const currentStep = React.useMemo(
    () => storeData[form.current]?.step,
    [storeData]
  )

  const onBack = () => {}

  const onPass = () => {}

  const onSubmit = (data) => {
    console.log('submitting', data)

    const current = storeData[form.current].step[0]

    const next = Object.entries(steps).reduce((Q, [key], N) => {
      if (!Q && key === current) {
        return Object.entries(steps)?.[N + 1]
      }

      return Q
    }, undefined)

    store(setStep(next))
  }

  return {
    currentStep,
    resolver: currentStep?.[1].schema
      ? zodResolver(currentStep[1].schema)
      : undefined,
    onBack,
    onPass,
    onSubmit,
  }
}

export default <UseFlowForm>useFlowForm
// The step component with its own schema.

import { Button, ButtonGroup, Checkbox, Text, VStack } from '@chakra-ui/react'
import * as z from 'zod'

import { Controller, FieldError } from '@redwoodjs/forms'

import { Step } from './types'

const MONTHLY_WITH_ME = 'monthlyWithMe'

const MONTHLY_WITH_TEAM = 'monthlyWithTeam'

const CUSTOM_NEEDS = 'customNeeds'

export const schema = z
  .object({
    [MONTHLY_WITH_ME]: z.boolean(),
    [MONTHLY_WITH_TEAM]: z.boolean(),
    [CUSTOM_NEEDS]: z.boolean(),
  })
  .partial()
  .refine(
    (options) => {
      alert('red')
      console.log(options, Object.values(options).some(Boolean))
      return Object.values(options).some(Boolean)
    },
    {
      message: 'Please select at least one option to continue',
      path: ['formErrors'],
    }
  )

const ActiveContributionProgram: Step = ({ onPass }) => (
  <>
    <Text>Active Contribution Program</Text>
    <Text m={4}>
      {`"The success of each of us is the success of everyone", and the Active
        Contribution Program is exactly about that.

        To support you on your road
        to success, we'd like to assign a Core Team member to you. Regular
        meetings with them and you or your team will provide the extra support
        needed to make sure you don't waste precious time exploring beaten
        paths. We don't want you to repeat solutions, we want to scale them with
        you!`}
    </Text>
    <Text m={4}>I&rsqos;d like to enroll with the following preferences:</Text>
    <VStack m={4}>
      <CheckboxControlled
        name={MONTHLY_WITH_ME}
        testid="input.monthlyWithMe"
        label="Monthly meeting with me only"
      />
      <CheckboxControlled
        name={MONTHLY_WITH_TEAM}
        testid="input.monthlyWithTeam"
        label="Get in touch for custom needs"
      />
      <CheckboxControlled
        name={CUSTOM_NEEDS}
        testid="input.customNeeds"
        label="Get in touch for custom needs"
      />
      <FieldError name="customNeeds" color="red" />
    </VStack>
    <ButtonGroup gap={4} p={4}>
      <Button onClick={onPass} data-testid="button.onPass">
        Hard pass
      </Button>
      <Button type="submit" data-testid="button.onSubmit">
        I&rsquo;m in
      </Button>
    </ButtonGroup>
  </>
)

const CheckboxControlled = ({ label, name, testid }) => (
  <Controller
    name={name}
    render={({ field }) => (
      <Checkbox {...field} data-testid={testid}>
        {label}
      </Checkbox>
    )}
  />
)

export default ActiveContributionProgram
stale[bot] commented 1 year ago

This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.