diff --git a/.changeset/social-candies-count.md b/.changeset/social-candies-count.md new file mode 100644 index 000000000..3acc41a82 --- /dev/null +++ b/.changeset/social-candies-count.md @@ -0,0 +1,6 @@ +--- +'@tanstack/react-form': patch +'@tanstack/form-core': patch +--- + +Fixed issues with React Compiler diff --git a/packages/form-core/src/FormApi.ts b/packages/form-core/src/FormApi.ts index b4887b97d..e7e0dd08b 100644 --- a/packages/form-core/src/FormApi.ts +++ b/packages/form-core/src/FormApi.ts @@ -979,7 +979,7 @@ export class FormApi< /** * @private */ - private _formId: string + _formId: string /** * @private */ diff --git a/packages/react-form/src/useField.tsx b/packages/react-form/src/useField.tsx index ced50e2a7..1a76801d9 100644 --- a/packages/react-form/src/useField.tsx +++ b/packages/react-form/src/useField.tsx @@ -1,10 +1,12 @@ 'use client' -import { useMemo, useRef } from 'react' +import { useMemo, useRef, useState } from 'react' import { useStore } from '@tanstack/react-store' import { FieldApi, functionalUpdate } from '@tanstack/form-core' import { useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect' import type { + AnyFieldApi, + AnyFieldMeta, DeepKeys, DeepValue, FieldAsyncValidateOrFn, @@ -195,17 +197,103 @@ export function useField< ) { // Keep a snapshot of options so that React Compiler doesn't // wrongly optimize fieldApi. - const optsRef = useRef(opts) - optsRef.current = opts + const [prevOptions, setPrevOptions] = useState(() => ({ + form: opts.form, + name: opts.name, + })) - const fieldApi = useMemo(() => { - const api = new FieldApi({ - ...optsRef.current, - form: opts.form, - name: opts.name, + const [fieldApi, setFieldApi] = useState(() => { + return new FieldApi({ + ...opts, }) + }) + + // We only want to + // update on name changes since those are at risk of becoming stale. The field + // state must be up to date for the internal JSX render. + // The other options can freely be in `fieldApi.update` + if (prevOptions.form !== opts.form || prevOptions.name !== opts.name) { + setFieldApi( + new FieldApi({ + ...opts, + }), + ) + setPrevOptions({ form: opts.form, name: opts.name }) + } + + const reactiveStateValue = useStore(fieldApi.store, (state) => state.value) + const reactiveMetaIsTouched = useStore( + fieldApi.store, + (state) => state.meta.isTouched, + ) + const reactiveMetaIsBlurred = useStore( + fieldApi.store, + (state) => state.meta.isBlurred, + ) + const reactiveMetaIsDirty = useStore( + fieldApi.store, + (state) => state.meta.isDirty, + ) + const reactiveMetaErrorMap = useStore( + fieldApi.store, + (state) => state.meta.errorMap, + ) + const reactiveMetaErrorSourceMap = useStore( + fieldApi.store, + (state) => state.meta.errorSourceMap, + ) + const reactiveMetaIsValidating = useStore( + fieldApi.store, + (state) => state.meta.isValidating, + ) + + // This makes me sad, but if I understand correctly, this is what we have to do for reactivity to work properly with React compiler. + const extendedFieldApi = useMemo(() => { + const reactiveFieldApi = { + ...fieldApi, + get state() { + return { + value: reactiveStateValue, + get meta() { + return { + ...fieldApi.state.meta, + isTouched: reactiveMetaIsTouched, + isBlurred: reactiveMetaIsBlurred, + isDirty: reactiveMetaIsDirty, + errorMap: reactiveMetaErrorMap, + errorSourceMap: reactiveMetaErrorSourceMap, + isValidating: reactiveMetaIsValidating, + } satisfies AnyFieldMeta + }, + } satisfies AnyFieldApi['state'] + }, + } - const extendedApi: typeof api & + const extendedApi: FieldApi< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync, + TFormOnServer, + TPatentSubmitMeta + > & ReactFieldApi< TParentData, TFormOnMount, @@ -219,16 +307,21 @@ export function useField< TFormOnDynamicAsync, TFormOnServer, TPatentSubmitMeta - > = api as never + > = reactiveFieldApi as never extendedApi.Field = Field as never return extendedApi - // We only want to - // update on name changes since those are at risk of becoming stale. The field - // state must be up to date for the internal JSX render. - // The other options can freely be in `fieldApi.update` - }, [opts.form, opts.name]) + }, [ + fieldApi, + reactiveStateValue, + reactiveMetaIsTouched, + reactiveMetaIsBlurred, + reactiveMetaIsDirty, + reactiveMetaErrorMap, + reactiveMetaErrorSourceMap, + reactiveMetaIsValidating, + ]) useIsomorphicLayoutEffect(fieldApi.mount, [fieldApi]) @@ -252,7 +345,7 @@ export function useField< : undefined, ) - return fieldApi + return extendedFieldApi } /** @@ -655,13 +748,7 @@ export const Field = (< const jsxToDisplay = useMemo( () => functionalUpdate(children, fieldApi as any), - /** - * The reason this exists is to fix an issue with the React Compiler. - * Namely, functionalUpdate is memoized where it checks for `fieldApi`, which is a static type. - * This means that when `state.value` changes, it does not trigger a re-render. The useMemo explicitly fixes this problem - */ - // eslint-disable-next-line react-hooks/exhaustive-deps - [children, fieldApi, fieldApi.state.value, fieldApi.state.meta], + [children, fieldApi], ) return (<>{jsxToDisplay}) as never }) satisfies FunctionComponent< diff --git a/packages/react-form/src/useForm.tsx b/packages/react-form/src/useForm.tsx index 3246b41a4..59b2c5402 100644 --- a/packages/react-form/src/useForm.tsx +++ b/packages/react-form/src/useForm.tsx @@ -2,7 +2,7 @@ import { FormApi, functionalUpdate, uuid } from '@tanstack/form-core' import { useStore } from '@tanstack/react-store' -import { useRef, useState } from 'react' +import { useMemo, useState } from 'react' import { Field } from './useField' import { useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect' import type { @@ -13,12 +13,7 @@ import type { FormState, FormValidateOrFn, } from '@tanstack/form-core' -import type { - FunctionComponent, - PropsWithChildren, - ReactElement, - ReactNode, -} from 'react' +import type { FunctionComponent, PropsWithChildren, ReactNode } from 'react' import type { FieldComponent } from './useField' import type { NoInfer } from '@tanstack/react-store' @@ -189,14 +184,11 @@ export function useForm< TSubmitMeta >, ) { - const formId = useRef(opts?.formId as never) - - if (!formId.current) { - formId.current = uuid() - } + const fallbackFormId = useState(() => uuid())[0] + const [prevFormId, setPrevFormId] = useState(opts?.formId as never) - const [formApi] = useState(() => { - const api = new FormApi< + const [formApi, setFormApi] = useState(() => { + return new FormApi< TFormData, TOnMount, TOnChange, @@ -209,8 +201,16 @@ export function useForm< TOnDynamicAsync, TOnServer, TSubmitMeta - >({ ...opts, formId: formId.current }) + >({ ...opts, formId: opts?.formId ?? fallbackFormId }) + }) + if (prevFormId !== opts?.formId) { + const formId = opts?.formId ?? fallbackFormId + setFormApi(new FormApi({ ...opts, formId })) + setPrevFormId(formId) + } + + const extendedFormApi = useMemo(() => { const extendedApi: ReactFormExtendedApi< TFormData, TOnMount, @@ -224,16 +224,25 @@ export function useForm< TOnDynamicAsync, TOnServer, TSubmitMeta - > = api as never + > = { + ...formApi, + // We must add all `get`ters from `core`'s `FormApi` here, as otherwise the spread operator won't catch those + get formId(): string { + return formApi._formId + }, + get state() { + return formApi.store.state + }, + } as never extendedApi.Field = function APIField(props) { - return + return } extendedApi.Subscribe = function Subscribe(props: any) { return ( @@ -241,7 +250,7 @@ export function useForm< } return extendedApi - }) + }, [formApi]) useIsomorphicLayoutEffect(formApi.mount, []) @@ -253,5 +262,5 @@ export function useForm< formApi.update(opts) }) - return formApi + return extendedFormApi } diff --git a/packages/react-form/tests/useForm.test.tsx b/packages/react-form/tests/useForm.test.tsx index 4f4aa707a..dbb7ff845 100644 --- a/packages/react-form/tests/useForm.test.tsx +++ b/packages/react-form/tests/useForm.test.tsx @@ -909,8 +909,9 @@ describe('useForm', () => { <> {(arrayField) => - arrayField.state.value.map((row, i) => ( - + arrayField.state.value.map((_, i) => ( + // eslint-disable-next-line @eslint-react/no-array-index-key + {(field) => { expect(field.name).toBe(`foo[${i}].name`) expect(field.state.value).not.toBeUndefined()