From 883899746aa765638b1978929696cd37a2751aed Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Sat, 24 Feb 2024 23:31:36 -0800 Subject: [PATCH 01/11] fix: react strict mode should no longer crash Fixes #571 --- packages/form-core/src/FieldApi.ts | 26 +++++-------- packages/form-core/src/FormApi.ts | 39 +++++++++---------- packages/form-core/src/tests/FieldApi.spec.ts | 6 +-- 3 files changed, 30 insertions(+), 41 deletions(-) diff --git a/packages/form-core/src/FieldApi.ts b/packages/form-core/src/FieldApi.ts index 4b92d62a7..fea61084f 100644 --- a/packages/form-core/src/FieldApi.ts +++ b/packages/form-core/src/FieldApi.ts @@ -237,8 +237,6 @@ export type FieldMeta = { isValidating: boolean } -let uid = 0 - export type FieldState = { value: TData meta: FieldMeta @@ -259,7 +257,6 @@ export class FieldApi< | undefined = undefined, TData extends DeepValue = DeepValue, > { - uid: number form: FieldApiOptions< TParentData, TName, @@ -289,13 +286,6 @@ export class FieldApi< >, ) { this.form = opts.form as never - this.uid = uid++ - // Support field prefixing from FieldScope - // let fieldPrefix = '' - // if (this.form.fieldName) { - // fieldPrefix = `${this.form.fieldName}.` - // } - this.name = opts.name as never if (opts.defaultValue !== undefined) { @@ -360,9 +350,15 @@ export class FieldApi< return (props.validate as FieldValidateFn)(props.value) as never } + _unmount: (() => void) | null = null + mount = () => { const info = this.getInfo() - info.instances[this.uid] = this as never + // Clear out the old instance + if (info.instance?._unmount) { + info.instance._unmount() + } + info.instance = this as never const unsubscribe = this.form.store.subscribe(() => { this.store.batch(() => { const nextValue = this.getValue() @@ -399,18 +395,14 @@ export class FieldApi< } } - return () => { + this._unmount = () => { const preserveValue = this.options.preserveValue unsubscribe() if (!preserveValue) { - delete info.instances[this.uid] this.form.deleteField(this.name) } - - if (!Object.keys(info.instances).length && !preserveValue) { - delete this.form.fieldInfo[this.name] - } } + return this._unmount } update = ( diff --git a/packages/form-core/src/FormApi.ts b/packages/form-core/src/FormApi.ts index ae5cfab4b..c3b367a5f 100644 --- a/packages/form-core/src/FormApi.ts +++ b/packages/form-core/src/FormApi.ts @@ -104,15 +104,12 @@ export type FieldInfo< TFormData, TFormValidator extends Validator | undefined = undefined, > = { - instances: Record< - string, - FieldApi< - TFormData, - any, - Validator | undefined, - TFormValidator - > - > + instance: FieldApi< + TFormData, + any, + Validator | undefined, + TFormValidator + > | null validationMetaMap: Record } @@ -340,17 +337,16 @@ export class FormApi< void ( Object.values(this.fieldInfo) as FieldInfo[] ).forEach((field) => { - Object.values(field.instances).forEach((instance) => { - // Validate the field - fieldValidationPromises.push( - Promise.resolve().then(() => instance.validate(cause)), - ) - // If any fields are not touched - if (!instance.state.meta.isTouched) { - // Mark them as touched - instance.setMeta((prev) => ({ ...prev, isTouched: true })) - } - }) + if (!field.instance) return + // Validate the field + fieldValidationPromises.push( + Promise.resolve().then(() => field.instance!.validate(cause)), + ) + // If any fields are not touched + if (!field.instance.state.meta.isTouched) { + // Mark them as touched + field.instance.setMeta((prev) => ({ ...prev, isTouched: true })) + } }) }) @@ -587,7 +583,7 @@ export class FormApi< ): FieldInfo => { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition return (this.fieldInfo[field] ||= { - instances: {}, + instance: null, validationMetaMap: { onChange: undefined, onBlur: undefined, @@ -645,6 +641,7 @@ export class FormApi< return newState }) + delete this.fieldInfo[field] } pushFieldValue = >( diff --git a/packages/form-core/src/tests/FieldApi.spec.ts b/packages/form-core/src/tests/FieldApi.spec.ts index 2e1106de8..cf0b54e12 100644 --- a/packages/form-core/src/tests/FieldApi.spec.ts +++ b/packages/form-core/src/tests/FieldApi.spec.ts @@ -602,7 +602,7 @@ describe('field api', () => { const unmount = field.mount() unmount() - expect(form.getFieldInfo(field.name).instances[field.uid]).toBeDefined() + expect(form.getFieldInfo(field.name).instance).toBeDefined() expect(form.getFieldInfo(field.name)).toBeDefined() }) @@ -624,8 +624,8 @@ describe('field api', () => { unmount() const info = form.getFieldInfo(field.name) subscription() - expect(info.instances[field.uid]).toBeUndefined() - expect(Object.keys(info.instances).length).toBe(0) + expect(info.instance).toBeNull() + expect(Object.keys(info.instance ?? {}).length).toBe(0) // Check that form store has been updated expect(callback).toHaveBeenCalledOnce() From f63faa5b80f8bcd25031f48492b477ff348fabab Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Sat, 24 Feb 2024 23:48:55 -0800 Subject: [PATCH 02/11] docs: remove old apis from docs --- docs/reference/fieldApi.md | 4 ---- docs/reference/formApi.md | 43 ++++++++++++++++++-------------------- 2 files changed, 20 insertions(+), 27 deletions(-) diff --git a/docs/reference/fieldApi.md b/docs/reference/fieldApi.md index 50623ce06..e601b9343 100644 --- a/docs/reference/fieldApi.md +++ b/docs/reference/fieldApi.md @@ -164,10 +164,6 @@ A class representing the API for managing a form field. #### Properties -- ```tsx - uid: number - ``` - - A unique identifier for the field instance. - ```tsx form: FormApi ``` diff --git a/docs/reference/formApi.md b/docs/reference/formApi.md index 3b71c867c..e79ed610a 100644 --- a/docs/reference/formApi.md +++ b/docs/reference/formApi.md @@ -29,7 +29,7 @@ An object representing the options for a form. defaultValues?: TData ``` - Set initial values for your form. - + - ```tsx defaultState?: Partial> ``` @@ -61,7 +61,7 @@ An object representing the options for a form. onMount?: (values: TData, formApi: FormApi) => ValidationError ``` - Optional function that fires as soon as the component mounts. - + - ```tsx onChange?: (values: TData, formApi: FormApi) => ValidationError ``` @@ -102,17 +102,17 @@ A class representing the Form API. It handles the logic and interactions with th options: FormOptions = {} ``` - The options for the form. - + - ```tsx store: Store> ``` - A [TanStack Store instance](https://tanstack.com/store/latest/docs/reference/Store) that keeps track of the form's state. - + - ```tsx state: FormState ``` - The current state of the form. - + - ```tsx fieldInfo: Record, FieldInfo> = {} as any @@ -197,62 +197,62 @@ An object representing the current state of the form. errorMap: ValidationErrorMap ``` - The error map for the form itself. - + - ```tsx isFormValidating: boolean ``` - A boolean indicating if the form is currently validating. - + - ```tsx isFormValid: boolean ``` - A boolean indicating if the form is valid. - + - ```tsx fieldMeta: Record, FieldMeta> ``` - A record of field metadata for each field in the form. - + - ```tsx isFieldsValidating: boolean ``` - A boolean indicating if any of the form fields are currently validating. - + - ```tsx isFieldsValid: boolean ``` - A boolean indicating if all the form fields are valid. - + - ```tsx isSubmitting: boolean ``` - A boolean indicating if the form is currently submitting. - + - ```tsx isTouched: boolean ``` - A boolean indicating if any of the form fields have been touched. - + - ```tsx isSubmitted: boolean ``` - A boolean indicating if the form has been submitted. - + - ```tsx isValidating: boolean ``` - A boolean indicating if the form or any of its fields are currently validating. - + - ```tsx isValid: boolean ``` - A boolean indicating if the form and all its fields are valid. - + - ```tsx canSubmit: boolean ``` - A boolean indicating if the form can be submitted based on its current state. - + - ```tsx submissionAttempts: number ``` @@ -268,17 +268,14 @@ An object representing the current state of the form. An object representing the field information for a specific field within the form. - ```tsx - instances: Record< - string, - FieldApi< + instance: FieldApi< TFormData, any, Validator | undefined, TFormValidator - > - > + > | null ``` - - A record of field instances with unique identifiers as keys. + - An instance of the `FieldAPI`. - ```tsx validationMetaMap: Record From a398a7b60d2fd9e5938b85ac06947cf65d705ace Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Sun, 25 Feb 2024 10:59:12 -0800 Subject: [PATCH 03/11] chore: return previous instance instead of generating a new one in React adapter --- packages/react-form/src/useField.tsx | 18 ++++++++++++++++++ packages/react-form/src/useForm.tsx | 1 - 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/react-form/src/useField.tsx b/packages/react-form/src/useField.tsx index 299d6c06b..7180858ae 100644 --- a/packages/react-form/src/useField.tsx +++ b/packages/react-form/src/useField.tsx @@ -76,6 +76,24 @@ export function useField< .filter((d) => d !== undefined) .join('.') + const previousInstance = formApi.getFieldInfo(name).instance as never as + | FieldApi< + TParentData, + TName, + TFieldValidator, + TFormValidator, + DeepValue + > + | undefined + + if (previousInstance) { + /* + * executes functional useStates twice, so we need to return the previous instance + * @see https://react.dev/reference/react/useState#my-initializer-or-updater-function-runs-twice + */ + return previousInstance + } + const api = new FieldApi({ ...opts, form: formApi as never, diff --git a/packages/react-form/src/useForm.tsx b/packages/react-form/src/useForm.tsx index e69d72db2..505d110bd 100644 --- a/packages/react-form/src/useForm.tsx +++ b/packages/react-form/src/useForm.tsx @@ -35,7 +35,6 @@ export function useForm< opts?: FormOptions, ): FormApi { const [formApi] = useState(() => { - // @ts-ignore const api = new FormApi(opts) api.Provider = function Provider(props) { From d6a04a0c88ad40ff97256947807345788d0d22f1 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Sun, 25 Feb 2024 12:12:25 -0800 Subject: [PATCH 04/11] chore: fix issue with `mount` running twice --- packages/form-core/src/FieldApi.ts | 5 +++-- packages/react-form/src/useField.tsx | 33 ++++++++++++++-------------- packages/react-form/src/useForm.tsx | 3 ++- 3 files changed, 21 insertions(+), 20 deletions(-) diff --git a/packages/form-core/src/FieldApi.ts b/packages/form-core/src/FieldApi.ts index fea61084f..ed6641ca0 100644 --- a/packages/form-core/src/FieldApi.ts +++ b/packages/form-core/src/FieldApi.ts @@ -354,9 +354,9 @@ export class FieldApi< mount = () => { const info = this.getInfo() - // Clear out the old instance + // Eagerly opt out of mount if we are already mounted if (info.instance?._unmount) { - info.instance._unmount() + return info.instance._unmount } info.instance = this as never const unsubscribe = this.form.store.subscribe(() => { @@ -401,6 +401,7 @@ export class FieldApi< if (!preserveValue) { this.form.deleteField(this.name) } + this._unmount = null } return this._unmount } diff --git a/packages/react-form/src/useField.tsx b/packages/react-form/src/useField.tsx index 7180858ae..775689d34 100644 --- a/packages/react-form/src/useField.tsx +++ b/packages/react-form/src/useField.tsx @@ -67,6 +67,8 @@ export function useField< // Get the form API either manually or from context const { formApi, parentFieldName } = useFormContext() + const unmountFn = useRef<(() => void) | null>(null) + const [fieldApi] = useState(() => { const name = ( typeof opts.index === 'number' @@ -86,11 +88,13 @@ export function useField< > | undefined + /* + * executes functional useStates twice, so we need to return the previous instance + * @see https://react.dev/reference/react/useState#my-initializer-or-updater-function-runs-twice + */ if (previousInstance) { - /* - * executes functional useStates twice, so we need to return the previous instance - * @see https://react.dev/reference/react/useState#my-initializer-or-updater-function-runs-twice - */ + // `mount` will return the previous unmount function if the instance is already mounted + unmountFn.current = previousInstance.mount() return previousInstance } @@ -103,9 +107,17 @@ export function useField< api.Field = Field as never + unmountFn.current = api.mount() + return api }) + useIsomorphicEffectOnce(() => { + return () => { + unmountFn.current?.() + } + }) + /** * fieldApi.update should not have any side effects. Think of it like a `useRef` * that we need to keep updated every render with the most up-to-date information. @@ -122,19 +134,6 @@ export function useField< } : undefined, ) - const unmountFn = useRef<(() => void) | null>(null) - - useIsomorphicEffectOnce(() => { - return () => { - unmountFn.current?.() - } - }) - - // We have to mount it right as soon as it renders, otherwise we get: - // https://github.com/TanStack/form/issues/523 - if (!unmountFn.current) { - unmountFn.current = fieldApi.mount() - } return fieldApi as never } diff --git a/packages/react-form/src/useForm.tsx b/packages/react-form/src/useForm.tsx index 505d110bd..1e60f5396 100644 --- a/packages/react-form/src/useForm.tsx +++ b/packages/react-form/src/useForm.tsx @@ -11,6 +11,7 @@ import { useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect' import type { NoInfer } from '@tanstack/react-store' import type { FormOptions, FormState, Validator } from '@tanstack/form-core' import type { FieldComponent, UseField } from './useField' +import { useIsomorphicEffectOnce } from './useIsomorphicEffectOnce' declare module '@tanstack/form-core' { // eslint-disable-next-line no-shadow @@ -38,7 +39,7 @@ export function useForm< const api = new FormApi(opts) api.Provider = function Provider(props) { - useIsomorphicLayoutEffect(api.mount, []) + useIsomorphicEffectOnce(api.mount) return ( ) From b1aecd3dbc29f6b2fd599dc58b947f82fe48c1fe Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Sun, 25 Feb 2024 12:19:35 -0800 Subject: [PATCH 05/11] test: add test for this edgecase --- .../react-form/src/tests/useField.test.tsx | 115 +++++++++++++++++- 1 file changed, 113 insertions(+), 2 deletions(-) diff --git a/packages/react-form/src/tests/useField.test.tsx b/packages/react-form/src/tests/useField.test.tsx index a8e4e9f69..114e6be74 100644 --- a/packages/react-form/src/tests/useField.test.tsx +++ b/packages/react-form/src/tests/useField.test.tsx @@ -3,9 +3,9 @@ import * as React from 'react' import { render, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import '@testing-library/jest-dom' -import { createFormFactory } from '../index' +import { createFormFactory, useForm } from '../index' import { sleep } from './utils' -import type { FormApi } from '../index' +import type { FieldApi, FormApi } from '../index' const user = userEvent.setup() @@ -462,4 +462,115 @@ describe('useField', () => { const info = form!.fieldInfo expect(Object.keys(info)).toHaveLength(0) }) + + it('should handle strict mode properly with conditional fields', async () => { + function FieldInfo({ field }: { field: FieldApi }) { + return ( + <> + {field.state.meta.touchedErrors ? ( + {field.state.meta.touchedErrors} + ) : null} + {field.state.meta.isValidating ? 'Validating...' : null} + + ) + } + + function Comp() { + const [showField, setShowField] = React.useState(true) + + const form = useForm({ + defaultValues: { + firstName: '', + lastName: '', + }, + // eslint-disable-next-line @typescript-eslint/no-empty-function + onSubmit: async () => {}, + }) + + return ( +
+ +
{ + e.preventDefault() + e.stopPropagation() + void form.handleSubmit() + }} + > +
+ {/* A type-safe field component*/} + {showField ? ( + + !value ? 'A first name is required' : undefined, + }} + children={(field) => { + // Avoid hasty abstractions. Render props are great! + return ( + <> + + field.handleChange(e.target.value)} + /> + + + ) + }} + /> + ) : null} +
+
+ ( + <> + + field.handleChange(e.target.value)} + /> + + + )} + /> +
+ [state.canSubmit, state.isSubmitting]} + children={([canSubmit, isSubmitting]) => ( + + )} + /> + + +
+
+ ) + } + + const { getByText, findByText, queryByText } = render( + + + , + ) + + await user.click(getByText('Submit')) + expect(await findByText('A first name is required')).toBeInTheDocument() + await user.click(getByText('Hide field')) + await user.click(getByText('Submit')) + expect(queryByText('A first name is required')).not.toBeInTheDocument() + }) }) From d8407585594dc45033d624cc25503c184be0c9d5 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Sun, 25 Feb 2024 23:18:19 -0800 Subject: [PATCH 06/11] chore: migrate implementation of useIsomorphicEffectOnce --- packages/react-form/src/useIsomorphicEffectOnce.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/react-form/src/useIsomorphicEffectOnce.ts b/packages/react-form/src/useIsomorphicEffectOnce.ts index ee0fc972a..31cd664a2 100644 --- a/packages/react-form/src/useIsomorphicEffectOnce.ts +++ b/packages/react-form/src/useIsomorphicEffectOnce.ts @@ -1,4 +1,4 @@ -import { useRef, useState } from 'rehackt' +import { useReducer, useRef } from 'rehackt' import { useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect' import type { EffectCallback } from 'rehackt' @@ -9,7 +9,7 @@ export const useIsomorphicEffectOnce = (effect: EffectCallback) => { const destroyFunc = useRef void)>() const effectCalled = useRef(false) const renderAfterCalled = useRef(false) - const [val, setVal] = useState(0) + const [_, rerender] = useReducer(() => ({}), {}) if (effectCalled.current) { renderAfterCalled.current = true @@ -23,7 +23,7 @@ export const useIsomorphicEffectOnce = (effect: EffectCallback) => { } // this forces one render after the effect is run - setVal((v) => v + 1) + rerender() return () => { // if the comp didn't render since the useEffect was called, From a0b5ea7e58a5dea3863bc5d149d3a752e9213f79 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Sun, 25 Feb 2024 23:33:22 -0800 Subject: [PATCH 07/11] chore: add name property to useForm --- .../src/app/client-component.tsx | 1 + examples/react/simple/src/index.tsx | 1 + .../react/ui-libraries/src/MainComponent.tsx | 1 + examples/react/valibot/src/index.tsx | 1 + examples/react/yup/src/index.tsx | 1 + examples/react/zod/src/index.tsx | 1 + packages/react-form/src/createFormFactory.ts | 4 +-- .../src/tests/createFormFactory.test.tsx | 4 ++- .../react-form/src/tests/useField.test-d.tsx | 2 ++ .../react-form/src/tests/useField.test.tsx | 19 +++++----- .../react-form/src/tests/useForm.test-d.tsx | 2 ++ .../react-form/src/tests/useForm.test.tsx | 12 ++++++- packages/react-form/src/useForm.tsx | 16 +++++++-- packages/react-form/src/useStateOnce.ts | 36 +++++++++++++++++++ 14 files changed, 86 insertions(+), 15 deletions(-) create mode 100644 packages/react-form/src/useStateOnce.ts diff --git a/examples/react/next-server-actions/src/app/client-component.tsx b/examples/react/next-server-actions/src/app/client-component.tsx index 6628926eb..b8206f559 100644 --- a/examples/react/next-server-actions/src/app/client-component.tsx +++ b/examples/react/next-server-actions/src/app/client-component.tsx @@ -11,6 +11,7 @@ export const ClientComp = () => { const [state, action] = useFormState(someAction, formFactory.initialFormState) const form = formFactory.useForm({ + name: 'client-form', transform: useTransform( (baseForm: FormApi) => mergeForm(baseForm, state), [state], diff --git a/examples/react/simple/src/index.tsx b/examples/react/simple/src/index.tsx index b70d52c16..e081865db 100644 --- a/examples/react/simple/src/index.tsx +++ b/examples/react/simple/src/index.tsx @@ -16,6 +16,7 @@ function FieldInfo({ field }: { field: FieldApi }) { export default function App() { const form = useForm({ + name: "example-form", defaultValues: { firstName: '', lastName: '', diff --git a/examples/react/ui-libraries/src/MainComponent.tsx b/examples/react/ui-libraries/src/MainComponent.tsx index 4b58630ee..7d4c9e832 100644 --- a/examples/react/ui-libraries/src/MainComponent.tsx +++ b/examples/react/ui-libraries/src/MainComponent.tsx @@ -8,6 +8,7 @@ import { Checkbox as MuiCheckbox } from '@mui/material' export default function MainComponent() { const { Provider, Field, Subscribe, handleSubmit, state, useStore } = useForm( { + name: 'example-form', defaultValues: { firstName: '', lastName: '', diff --git a/examples/react/valibot/src/index.tsx b/examples/react/valibot/src/index.tsx index 6e5af1fad..cb6a58c19 100644 --- a/examples/react/valibot/src/index.tsx +++ b/examples/react/valibot/src/index.tsx @@ -18,6 +18,7 @@ function FieldInfo({ field }: { field: FieldApi }) { export default function App() { const form = useForm({ + name: 'example-form', defaultValues: { firstName: '', lastName: '', diff --git a/examples/react/yup/src/index.tsx b/examples/react/yup/src/index.tsx index 0a3ed3a51..bba04feef 100644 --- a/examples/react/yup/src/index.tsx +++ b/examples/react/yup/src/index.tsx @@ -18,6 +18,7 @@ function FieldInfo({ field }: { field: FieldApi }) { export default function App() { const form = useForm({ + name: 'example-form', defaultValues: { firstName: '', lastName: '', diff --git a/examples/react/zod/src/index.tsx b/examples/react/zod/src/index.tsx index e6c030808..ffc0146d2 100644 --- a/examples/react/zod/src/index.tsx +++ b/examples/react/zod/src/index.tsx @@ -18,6 +18,7 @@ function FieldInfo({ field }: { field: FieldApi }) { export default function App() { const form = useForm({ + name: 'example-form', defaultValues: { firstName: '', lastName: '', diff --git a/packages/react-form/src/createFormFactory.ts b/packages/react-form/src/createFormFactory.ts index a415c4b0e..de3d1996f 100644 --- a/packages/react-form/src/createFormFactory.ts +++ b/packages/react-form/src/createFormFactory.ts @@ -1,5 +1,5 @@ import { Field, useField } from './useField' -import { useForm } from './useForm' +import { type UseFormProps, useForm } from './useForm' import { getValidateFormData } from './validateFormData' import type { ValidateFormData } from './validateFormData' import type { FieldComponent, UseField } from './useField' @@ -10,7 +10,7 @@ export type FormFactory< TFormValidator extends Validator | undefined = undefined, > = { useForm: ( - opts?: FormOptions, + opts: FormOptions & UseFormProps, ) => FormApi useField: UseField Field: FieldComponent diff --git a/packages/react-form/src/tests/createFormFactory.test.tsx b/packages/react-form/src/tests/createFormFactory.test.tsx index 22f080540..5330c893a 100644 --- a/packages/react-form/src/tests/createFormFactory.test.tsx +++ b/packages/react-form/src/tests/createFormFactory.test.tsx @@ -19,7 +19,9 @@ describe('createFormFactory', () => { }) function Comp() { - const form = formFactory.useForm({}) + const form = formFactory.useForm({ + name: 'example-form', + }) return ( diff --git a/packages/react-form/src/tests/useField.test-d.tsx b/packages/react-form/src/tests/useField.test-d.tsx index c6a45629e..87e3307e0 100644 --- a/packages/react-form/src/tests/useField.test-d.tsx +++ b/packages/react-form/src/tests/useField.test-d.tsx @@ -5,6 +5,7 @@ import { useForm } from '../useForm' it('should type state.value properly', () => { function Comp() { const form = useForm({ + name: 'example-form', defaultValues: { firstName: 'test', age: 84, @@ -33,6 +34,7 @@ it('should type state.value properly', () => { it('should type onChange properly', () => { function Comp() { const form = useForm({ + name: 'example-form', defaultValues: { firstName: 'test', age: 84, diff --git a/packages/react-form/src/tests/useField.test.tsx b/packages/react-form/src/tests/useField.test.tsx index 114e6be74..4e975177f 100644 --- a/packages/react-form/src/tests/useField.test.tsx +++ b/packages/react-form/src/tests/useField.test.tsx @@ -20,6 +20,7 @@ describe('useField', () => { function Comp() { const form = formFactory.useForm({ + name: 'example-form', defaultValues: { firstName: 'FirstName', lastName: 'LastName', @@ -60,6 +61,7 @@ describe('useField', () => { function Comp() { const form = formFactory.useForm({ + name: 'example-form', defaultValues: { firstName: 'FirstName', lastName: 'LastName', @@ -101,7 +103,7 @@ describe('useField', () => { const formFactory = createFormFactory() function Comp() { - const form = formFactory.useForm() + const form = formFactory.useForm({ name: 'example-form' }) return ( @@ -143,7 +145,7 @@ describe('useField', () => { const formFactory = createFormFactory() function Comp() { - const form = formFactory.useForm() + const form = formFactory.useForm({ name: 'example-form' }) return ( @@ -188,7 +190,7 @@ describe('useField', () => { const formFactory = createFormFactory() function Comp() { - const form = formFactory.useForm() + const form = formFactory.useForm({ name: 'example-form' }) return ( @@ -239,7 +241,7 @@ describe('useField', () => { const formFactory = createFormFactory() function Comp() { - const form = formFactory.useForm() + const form = formFactory.useForm({ name: 'example-form' }) return ( @@ -288,7 +290,7 @@ describe('useField', () => { const formFactory = createFormFactory() function Comp() { - const form = formFactory.useForm() + const form = formFactory.useForm({ name: 'example-form' }) return ( @@ -346,7 +348,7 @@ describe('useField', () => { const formFactory = createFormFactory() function Comp() { - const form = formFactory.useForm() + const form = formFactory.useForm({ name: 'example-form' }) return ( @@ -395,7 +397,7 @@ describe('useField', () => { const formFactory = createFormFactory() let form: FormApi | null = null function Comp() { - form = formFactory.useForm() + form = formFactory.useForm({ name: 'example-form' }) return ( { const formFactory = createFormFactory() let form: FormApi | null = null function Comp() { - form = formFactory.useForm() + form = formFactory.useForm({ name: 'example-form' }) return ( { const [showField, setShowField] = React.useState(true) const form = useForm({ + name: 'example-form', defaultValues: { firstName: '', lastName: '', diff --git a/packages/react-form/src/tests/useForm.test-d.tsx b/packages/react-form/src/tests/useForm.test-d.tsx index 93bfd9e85..921bae06d 100644 --- a/packages/react-form/src/tests/useForm.test-d.tsx +++ b/packages/react-form/src/tests/useForm.test-d.tsx @@ -5,6 +5,7 @@ import { useForm } from '../useForm' it('should type onSubmit properly', () => { function Comp() { const form = useForm({ + name: 'example-form', defaultValues: { firstName: 'test', age: 84, @@ -20,6 +21,7 @@ it('should type onSubmit properly', () => { it('should type a validator properly', () => { function Comp() { const form = useForm({ + name: "example-form", defaultValues: { firstName: 'test', age: 84, diff --git a/packages/react-form/src/tests/useForm.test.tsx b/packages/react-form/src/tests/useForm.test.tsx index 34d1d4f23..ceba40feb 100644 --- a/packages/react-form/src/tests/useForm.test.tsx +++ b/packages/react-form/src/tests/useForm.test.tsx @@ -18,7 +18,7 @@ describe('useForm', () => { const formFactory = createFormFactory() function Comp() { - const form = formFactory.useForm() + const form = formFactory.useForm({ name: 'example-form' }) return ( @@ -57,6 +57,7 @@ describe('useForm', () => { function Comp() { const form = formFactory.useForm({ + name: 'example-form', defaultValues: { firstName: 'FirstName', lastName: 'LastName', @@ -87,6 +88,7 @@ describe('useForm', () => { } | null>(null) const form = useForm({ + name: 'example-form', defaultValues: { firstName: 'FirstName', }, @@ -132,6 +134,7 @@ describe('useForm', () => { const [mountForm, setMountForm] = React.useState(false) const form = useForm({ + name: 'example-form', defaultValues: { firstName: 'FirstName', }, @@ -172,6 +175,7 @@ describe('useForm', () => { function Comp() { const form = formFactory.useForm({ + name: 'example-form', validators: { onChange() { return error @@ -217,6 +221,7 @@ describe('useForm', () => { function Comp() { const form = formFactory.useForm({ + name: 'example-form', validators: { onChange: ({ value }) => value.firstName === 'other' ? error : undefined, @@ -262,6 +267,7 @@ describe('useForm', () => { function Comp() { const form = formFactory.useForm({ + name: 'example-form', validators: { onChange: ({ value }) => value.firstName === 'other' ? error : undefined, @@ -303,6 +309,7 @@ describe('useForm', () => { function Comp() { const form = useForm({ + name: 'example-form', defaultValues: { firstName: '', }, @@ -362,6 +369,7 @@ describe('useForm', () => { function Comp() { const form = formFactory.useForm({ + name: 'example-form', validators: { onChangeAsync: async () => { await sleep(10) @@ -412,6 +420,7 @@ describe('useForm', () => { function Comp() { const form = formFactory.useForm({ + name: 'example-form', validators: { onChangeAsync: async () => { await sleep(10) @@ -472,6 +481,7 @@ describe('useForm', () => { function Comp() { const form = formFactory.useForm({ + name: 'example-form', validators: { onChangeAsyncDebounceMs: 100, onChangeAsync: async () => { diff --git a/packages/react-form/src/useForm.tsx b/packages/react-form/src/useForm.tsx index 1e60f5396..820ecf9a8 100644 --- a/packages/react-form/src/useForm.tsx +++ b/packages/react-form/src/useForm.tsx @@ -8,10 +8,11 @@ import React, { import { Field, useField } from './useField' import { formContext } from './formContext' import { useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect' +import { useIsomorphicEffectOnce } from './useIsomorphicEffectOnce' +import { useStateOnce } from './useStateOnce' import type { NoInfer } from '@tanstack/react-store' import type { FormOptions, FormState, Validator } from '@tanstack/form-core' import type { FieldComponent, UseField } from './useField' -import { useIsomorphicEffectOnce } from './useIsomorphicEffectOnce' declare module '@tanstack/form-core' { // eslint-disable-next-line no-shadow @@ -29,13 +30,22 @@ declare module '@tanstack/form-core' { } } +export interface UseFormProps { + name: string +} + export function useForm< TFormData, TFormValidator extends Validator | undefined = undefined, >( - opts?: FormOptions, + opts: FormOptions & UseFormProps, ): FormApi { - const [formApi] = useState(() => { + if (!opts.name) { + console.warn( + 'useForm requires a `name` option, otherwise it will unintentionally merge state with other forms', + ) + } + const [formApi] = useStateOnce(opts.name, () => { const api = new FormApi(opts) api.Provider = function Provider(props) { diff --git a/packages/react-form/src/useStateOnce.ts b/packages/react-form/src/useStateOnce.ts new file mode 100644 index 000000000..4b23bb3d9 --- /dev/null +++ b/packages/react-form/src/useStateOnce.ts @@ -0,0 +1,36 @@ +import { useState } from 'rehackt' +import { useIsomorphicEffectOnce } from './useIsomorphicEffectOnce' + +const ComponentMap = new Map() + +function isInitialStateAFn(fn: any): fn is () => S { + return typeof fn === 'function' +} + +/** + * `id` here must be unique for each component and manually passed to the hook, rather than using `useId`. + * This is because useId does not guarantee uniqueness across different renders in StrictMode. + */ +export const useStateOnce = (id: string, initialState: S | (() => S)) => { + const arr = useState(() => { + if (ComponentMap.has(id)) { + return ComponentMap.get(id) as S + } + let val!: S + if (isInitialStateAFn(initialState)) { + val = initialState() + } else { + val = initialState + } + ComponentMap.set(id, val) + return val + }) + + useIsomorphicEffectOnce(() => { + return () => { + ComponentMap.delete(id) + } + }) + + return arr +} From 443c4b953b89f8e54f663ad9a0e1955be804f25c Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Sun, 25 Feb 2024 23:33:27 -0800 Subject: [PATCH 08/11] Revert "chore: add name property to useForm" This reverts commit a0b5ea7e58a5dea3863bc5d149d3a752e9213f79. --- .../src/app/client-component.tsx | 1 - examples/react/simple/src/index.tsx | 1 - .../react/ui-libraries/src/MainComponent.tsx | 1 - examples/react/valibot/src/index.tsx | 1 - examples/react/yup/src/index.tsx | 1 - examples/react/zod/src/index.tsx | 1 - packages/react-form/src/createFormFactory.ts | 4 +-- .../src/tests/createFormFactory.test.tsx | 4 +-- .../react-form/src/tests/useField.test-d.tsx | 2 -- .../react-form/src/tests/useField.test.tsx | 19 +++++----- .../react-form/src/tests/useForm.test-d.tsx | 2 -- .../react-form/src/tests/useForm.test.tsx | 12 +------ packages/react-form/src/useForm.tsx | 16 ++------- packages/react-form/src/useStateOnce.ts | 36 ------------------- 14 files changed, 15 insertions(+), 86 deletions(-) delete mode 100644 packages/react-form/src/useStateOnce.ts diff --git a/examples/react/next-server-actions/src/app/client-component.tsx b/examples/react/next-server-actions/src/app/client-component.tsx index b8206f559..6628926eb 100644 --- a/examples/react/next-server-actions/src/app/client-component.tsx +++ b/examples/react/next-server-actions/src/app/client-component.tsx @@ -11,7 +11,6 @@ export const ClientComp = () => { const [state, action] = useFormState(someAction, formFactory.initialFormState) const form = formFactory.useForm({ - name: 'client-form', transform: useTransform( (baseForm: FormApi) => mergeForm(baseForm, state), [state], diff --git a/examples/react/simple/src/index.tsx b/examples/react/simple/src/index.tsx index e081865db..b70d52c16 100644 --- a/examples/react/simple/src/index.tsx +++ b/examples/react/simple/src/index.tsx @@ -16,7 +16,6 @@ function FieldInfo({ field }: { field: FieldApi }) { export default function App() { const form = useForm({ - name: "example-form", defaultValues: { firstName: '', lastName: '', diff --git a/examples/react/ui-libraries/src/MainComponent.tsx b/examples/react/ui-libraries/src/MainComponent.tsx index 7d4c9e832..4b58630ee 100644 --- a/examples/react/ui-libraries/src/MainComponent.tsx +++ b/examples/react/ui-libraries/src/MainComponent.tsx @@ -8,7 +8,6 @@ import { Checkbox as MuiCheckbox } from '@mui/material' export default function MainComponent() { const { Provider, Field, Subscribe, handleSubmit, state, useStore } = useForm( { - name: 'example-form', defaultValues: { firstName: '', lastName: '', diff --git a/examples/react/valibot/src/index.tsx b/examples/react/valibot/src/index.tsx index cb6a58c19..6e5af1fad 100644 --- a/examples/react/valibot/src/index.tsx +++ b/examples/react/valibot/src/index.tsx @@ -18,7 +18,6 @@ function FieldInfo({ field }: { field: FieldApi }) { export default function App() { const form = useForm({ - name: 'example-form', defaultValues: { firstName: '', lastName: '', diff --git a/examples/react/yup/src/index.tsx b/examples/react/yup/src/index.tsx index bba04feef..0a3ed3a51 100644 --- a/examples/react/yup/src/index.tsx +++ b/examples/react/yup/src/index.tsx @@ -18,7 +18,6 @@ function FieldInfo({ field }: { field: FieldApi }) { export default function App() { const form = useForm({ - name: 'example-form', defaultValues: { firstName: '', lastName: '', diff --git a/examples/react/zod/src/index.tsx b/examples/react/zod/src/index.tsx index ffc0146d2..e6c030808 100644 --- a/examples/react/zod/src/index.tsx +++ b/examples/react/zod/src/index.tsx @@ -18,7 +18,6 @@ function FieldInfo({ field }: { field: FieldApi }) { export default function App() { const form = useForm({ - name: 'example-form', defaultValues: { firstName: '', lastName: '', diff --git a/packages/react-form/src/createFormFactory.ts b/packages/react-form/src/createFormFactory.ts index de3d1996f..a415c4b0e 100644 --- a/packages/react-form/src/createFormFactory.ts +++ b/packages/react-form/src/createFormFactory.ts @@ -1,5 +1,5 @@ import { Field, useField } from './useField' -import { type UseFormProps, useForm } from './useForm' +import { useForm } from './useForm' import { getValidateFormData } from './validateFormData' import type { ValidateFormData } from './validateFormData' import type { FieldComponent, UseField } from './useField' @@ -10,7 +10,7 @@ export type FormFactory< TFormValidator extends Validator | undefined = undefined, > = { useForm: ( - opts: FormOptions & UseFormProps, + opts?: FormOptions, ) => FormApi useField: UseField Field: FieldComponent diff --git a/packages/react-form/src/tests/createFormFactory.test.tsx b/packages/react-form/src/tests/createFormFactory.test.tsx index 5330c893a..22f080540 100644 --- a/packages/react-form/src/tests/createFormFactory.test.tsx +++ b/packages/react-form/src/tests/createFormFactory.test.tsx @@ -19,9 +19,7 @@ describe('createFormFactory', () => { }) function Comp() { - const form = formFactory.useForm({ - name: 'example-form', - }) + const form = formFactory.useForm({}) return ( diff --git a/packages/react-form/src/tests/useField.test-d.tsx b/packages/react-form/src/tests/useField.test-d.tsx index 87e3307e0..c6a45629e 100644 --- a/packages/react-form/src/tests/useField.test-d.tsx +++ b/packages/react-form/src/tests/useField.test-d.tsx @@ -5,7 +5,6 @@ import { useForm } from '../useForm' it('should type state.value properly', () => { function Comp() { const form = useForm({ - name: 'example-form', defaultValues: { firstName: 'test', age: 84, @@ -34,7 +33,6 @@ it('should type state.value properly', () => { it('should type onChange properly', () => { function Comp() { const form = useForm({ - name: 'example-form', defaultValues: { firstName: 'test', age: 84, diff --git a/packages/react-form/src/tests/useField.test.tsx b/packages/react-form/src/tests/useField.test.tsx index 4e975177f..114e6be74 100644 --- a/packages/react-form/src/tests/useField.test.tsx +++ b/packages/react-form/src/tests/useField.test.tsx @@ -20,7 +20,6 @@ describe('useField', () => { function Comp() { const form = formFactory.useForm({ - name: 'example-form', defaultValues: { firstName: 'FirstName', lastName: 'LastName', @@ -61,7 +60,6 @@ describe('useField', () => { function Comp() { const form = formFactory.useForm({ - name: 'example-form', defaultValues: { firstName: 'FirstName', lastName: 'LastName', @@ -103,7 +101,7 @@ describe('useField', () => { const formFactory = createFormFactory() function Comp() { - const form = formFactory.useForm({ name: 'example-form' }) + const form = formFactory.useForm() return ( @@ -145,7 +143,7 @@ describe('useField', () => { const formFactory = createFormFactory() function Comp() { - const form = formFactory.useForm({ name: 'example-form' }) + const form = formFactory.useForm() return ( @@ -190,7 +188,7 @@ describe('useField', () => { const formFactory = createFormFactory() function Comp() { - const form = formFactory.useForm({ name: 'example-form' }) + const form = formFactory.useForm() return ( @@ -241,7 +239,7 @@ describe('useField', () => { const formFactory = createFormFactory() function Comp() { - const form = formFactory.useForm({ name: 'example-form' }) + const form = formFactory.useForm() return ( @@ -290,7 +288,7 @@ describe('useField', () => { const formFactory = createFormFactory() function Comp() { - const form = formFactory.useForm({ name: 'example-form' }) + const form = formFactory.useForm() return ( @@ -348,7 +346,7 @@ describe('useField', () => { const formFactory = createFormFactory() function Comp() { - const form = formFactory.useForm({ name: 'example-form' }) + const form = formFactory.useForm() return ( @@ -397,7 +395,7 @@ describe('useField', () => { const formFactory = createFormFactory() let form: FormApi | null = null function Comp() { - form = formFactory.useForm({ name: 'example-form' }) + form = formFactory.useForm() return ( { const formFactory = createFormFactory() let form: FormApi | null = null function Comp() { - form = formFactory.useForm({ name: 'example-form' }) + form = formFactory.useForm() return ( { const [showField, setShowField] = React.useState(true) const form = useForm({ - name: 'example-form', defaultValues: { firstName: '', lastName: '', diff --git a/packages/react-form/src/tests/useForm.test-d.tsx b/packages/react-form/src/tests/useForm.test-d.tsx index 921bae06d..93bfd9e85 100644 --- a/packages/react-form/src/tests/useForm.test-d.tsx +++ b/packages/react-form/src/tests/useForm.test-d.tsx @@ -5,7 +5,6 @@ import { useForm } from '../useForm' it('should type onSubmit properly', () => { function Comp() { const form = useForm({ - name: 'example-form', defaultValues: { firstName: 'test', age: 84, @@ -21,7 +20,6 @@ it('should type onSubmit properly', () => { it('should type a validator properly', () => { function Comp() { const form = useForm({ - name: "example-form", defaultValues: { firstName: 'test', age: 84, diff --git a/packages/react-form/src/tests/useForm.test.tsx b/packages/react-form/src/tests/useForm.test.tsx index ceba40feb..34d1d4f23 100644 --- a/packages/react-form/src/tests/useForm.test.tsx +++ b/packages/react-form/src/tests/useForm.test.tsx @@ -18,7 +18,7 @@ describe('useForm', () => { const formFactory = createFormFactory() function Comp() { - const form = formFactory.useForm({ name: 'example-form' }) + const form = formFactory.useForm() return ( @@ -57,7 +57,6 @@ describe('useForm', () => { function Comp() { const form = formFactory.useForm({ - name: 'example-form', defaultValues: { firstName: 'FirstName', lastName: 'LastName', @@ -88,7 +87,6 @@ describe('useForm', () => { } | null>(null) const form = useForm({ - name: 'example-form', defaultValues: { firstName: 'FirstName', }, @@ -134,7 +132,6 @@ describe('useForm', () => { const [mountForm, setMountForm] = React.useState(false) const form = useForm({ - name: 'example-form', defaultValues: { firstName: 'FirstName', }, @@ -175,7 +172,6 @@ describe('useForm', () => { function Comp() { const form = formFactory.useForm({ - name: 'example-form', validators: { onChange() { return error @@ -221,7 +217,6 @@ describe('useForm', () => { function Comp() { const form = formFactory.useForm({ - name: 'example-form', validators: { onChange: ({ value }) => value.firstName === 'other' ? error : undefined, @@ -267,7 +262,6 @@ describe('useForm', () => { function Comp() { const form = formFactory.useForm({ - name: 'example-form', validators: { onChange: ({ value }) => value.firstName === 'other' ? error : undefined, @@ -309,7 +303,6 @@ describe('useForm', () => { function Comp() { const form = useForm({ - name: 'example-form', defaultValues: { firstName: '', }, @@ -369,7 +362,6 @@ describe('useForm', () => { function Comp() { const form = formFactory.useForm({ - name: 'example-form', validators: { onChangeAsync: async () => { await sleep(10) @@ -420,7 +412,6 @@ describe('useForm', () => { function Comp() { const form = formFactory.useForm({ - name: 'example-form', validators: { onChangeAsync: async () => { await sleep(10) @@ -481,7 +472,6 @@ describe('useForm', () => { function Comp() { const form = formFactory.useForm({ - name: 'example-form', validators: { onChangeAsyncDebounceMs: 100, onChangeAsync: async () => { diff --git a/packages/react-form/src/useForm.tsx b/packages/react-form/src/useForm.tsx index 820ecf9a8..1e60f5396 100644 --- a/packages/react-form/src/useForm.tsx +++ b/packages/react-form/src/useForm.tsx @@ -8,11 +8,10 @@ import React, { import { Field, useField } from './useField' import { formContext } from './formContext' import { useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect' -import { useIsomorphicEffectOnce } from './useIsomorphicEffectOnce' -import { useStateOnce } from './useStateOnce' import type { NoInfer } from '@tanstack/react-store' import type { FormOptions, FormState, Validator } from '@tanstack/form-core' import type { FieldComponent, UseField } from './useField' +import { useIsomorphicEffectOnce } from './useIsomorphicEffectOnce' declare module '@tanstack/form-core' { // eslint-disable-next-line no-shadow @@ -30,22 +29,13 @@ declare module '@tanstack/form-core' { } } -export interface UseFormProps { - name: string -} - export function useForm< TFormData, TFormValidator extends Validator | undefined = undefined, >( - opts: FormOptions & UseFormProps, + opts?: FormOptions, ): FormApi { - if (!opts.name) { - console.warn( - 'useForm requires a `name` option, otherwise it will unintentionally merge state with other forms', - ) - } - const [formApi] = useStateOnce(opts.name, () => { + const [formApi] = useState(() => { const api = new FormApi(opts) api.Provider = function Provider(props) { diff --git a/packages/react-form/src/useStateOnce.ts b/packages/react-form/src/useStateOnce.ts deleted file mode 100644 index 4b23bb3d9..000000000 --- a/packages/react-form/src/useStateOnce.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { useState } from 'rehackt' -import { useIsomorphicEffectOnce } from './useIsomorphicEffectOnce' - -const ComponentMap = new Map() - -function isInitialStateAFn(fn: any): fn is () => S { - return typeof fn === 'function' -} - -/** - * `id` here must be unique for each component and manually passed to the hook, rather than using `useId`. - * This is because useId does not guarantee uniqueness across different renders in StrictMode. - */ -export const useStateOnce = (id: string, initialState: S | (() => S)) => { - const arr = useState(() => { - if (ComponentMap.has(id)) { - return ComponentMap.get(id) as S - } - let val!: S - if (isInitialStateAFn(initialState)) { - val = initialState() - } else { - val = initialState - } - ComponentMap.set(id, val) - return val - }) - - useIsomorphicEffectOnce(() => { - return () => { - ComponentMap.delete(id) - } - }) - - return arr -} From 5b9bd0ef259f9ac7523d73c6cb6091630d2d1ad7 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Mon, 26 Feb 2024 00:00:43 -0800 Subject: [PATCH 09/11] chore: refactor internals to be more React-y --- packages/form-core/src/FieldApi.ts | 10 +--- .../react-form/src/tests/useField.test.tsx | 56 ++++++++++++++++--- .../react-form/src/tests/useForm.test.tsx | 24 ++++++++ packages/react-form/src/useField.tsx | 33 +---------- packages/react-form/src/useForm.tsx | 3 +- .../react-form/src/useIsomorphicEffectOnce.ts | 39 ------------- 6 files changed, 76 insertions(+), 89 deletions(-) delete mode 100644 packages/react-form/src/useIsomorphicEffectOnce.ts diff --git a/packages/form-core/src/FieldApi.ts b/packages/form-core/src/FieldApi.ts index ed6641ca0..e977e9319 100644 --- a/packages/form-core/src/FieldApi.ts +++ b/packages/form-core/src/FieldApi.ts @@ -350,14 +350,8 @@ export class FieldApi< return (props.validate as FieldValidateFn)(props.value) as never } - _unmount: (() => void) | null = null - mount = () => { const info = this.getInfo() - // Eagerly opt out of mount if we are already mounted - if (info.instance?._unmount) { - return info.instance._unmount - } info.instance = this as never const unsubscribe = this.form.store.subscribe(() => { this.store.batch(() => { @@ -395,15 +389,13 @@ export class FieldApi< } } - this._unmount = () => { + return () => { const preserveValue = this.options.preserveValue unsubscribe() if (!preserveValue) { this.form.deleteField(this.name) } - this._unmount = null } - return this._unmount } update = ( diff --git a/packages/react-form/src/tests/useField.test.tsx b/packages/react-form/src/tests/useField.test.tsx index 114e6be74..24b805c03 100644 --- a/packages/react-form/src/tests/useField.test.tsx +++ b/packages/react-form/src/tests/useField.test.tsx @@ -101,7 +101,12 @@ describe('useField', () => { const formFactory = createFormFactory() function Comp() { - const form = formFactory.useForm() + const form = formFactory.useForm({ + defaultValues: { + firstName: '', + lastName: '', + }, + }) return ( @@ -143,7 +148,12 @@ describe('useField', () => { const formFactory = createFormFactory() function Comp() { - const form = formFactory.useForm() + const form = formFactory.useForm({ + defaultValues: { + firstName: '', + lastName: '', + }, + }) return ( @@ -188,7 +198,12 @@ describe('useField', () => { const formFactory = createFormFactory() function Comp() { - const form = formFactory.useForm() + const form = formFactory.useForm({ + defaultValues: { + firstName: '', + lastName: '', + }, + }) return ( @@ -239,7 +254,12 @@ describe('useField', () => { const formFactory = createFormFactory() function Comp() { - const form = formFactory.useForm() + const form = formFactory.useForm({ + defaultValues: { + firstName: '', + lastName: '', + }, + }) return ( @@ -288,7 +308,12 @@ describe('useField', () => { const formFactory = createFormFactory() function Comp() { - const form = formFactory.useForm() + const form = formFactory.useForm({ + defaultValues: { + firstName: '', + lastName: '', + }, + }) return ( @@ -346,7 +371,12 @@ describe('useField', () => { const formFactory = createFormFactory() function Comp() { - const form = formFactory.useForm() + const form = formFactory.useForm({ + defaultValues: { + firstName: '', + lastName: '', + }, + }) return ( @@ -395,7 +425,12 @@ describe('useField', () => { const formFactory = createFormFactory() let form: FormApi | null = null function Comp() { - form = formFactory.useForm() + form = formFactory.useForm({ + defaultValues: { + firstName: '', + lastName: '', + }, + }) return ( { const formFactory = createFormFactory() let form: FormApi | null = null function Comp() { - form = formFactory.useForm() + form = formFactory.useForm({ + defaultValues: { + firstName: '', + lastName: '', + }, + }) return ( { function Comp() { const form = formFactory.useForm({ + defaultValues: { + firstName: '', + lastName: '', + }, validators: { onChange() { return error @@ -217,6 +221,10 @@ describe('useForm', () => { function Comp() { const form = formFactory.useForm({ + defaultValues: { + firstName: '', + lastName: '', + }, validators: { onChange: ({ value }) => value.firstName === 'other' ? error : undefined, @@ -262,6 +270,10 @@ describe('useForm', () => { function Comp() { const form = formFactory.useForm({ + defaultValues: { + firstName: '', + lastName: '', + }, validators: { onChange: ({ value }) => value.firstName === 'other' ? error : undefined, @@ -362,6 +374,10 @@ describe('useForm', () => { function Comp() { const form = formFactory.useForm({ + defaultValues: { + firstName: '', + lastName: '', + }, validators: { onChangeAsync: async () => { await sleep(10) @@ -412,6 +428,10 @@ describe('useForm', () => { function Comp() { const form = formFactory.useForm({ + defaultValues: { + firstName: '', + lastName: '', + }, validators: { onChangeAsync: async () => { await sleep(10) @@ -472,6 +492,10 @@ describe('useForm', () => { function Comp() { const form = formFactory.useForm({ + defaultValues: { + firstName: '', + lastName: '', + }, validators: { onChangeAsyncDebounceMs: 100, onChangeAsync: async () => { diff --git a/packages/react-form/src/useField.tsx b/packages/react-form/src/useField.tsx index 775689d34..afebf4a0d 100644 --- a/packages/react-form/src/useField.tsx +++ b/packages/react-form/src/useField.tsx @@ -1,9 +1,8 @@ -import React, { useRef, useState } from 'rehackt' +import React, { useState } from 'rehackt' import { useStore } from '@tanstack/react-store' import { FieldApi, functionalUpdate } from '@tanstack/form-core' import { formContext, useFormContext } from './formContext' import { useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect' -import { useIsomorphicEffectOnce } from './useIsomorphicEffectOnce' import type { UseFieldOptions } from './types' import type { DeepKeys, @@ -67,8 +66,6 @@ export function useField< // Get the form API either manually or from context const { formApi, parentFieldName } = useFormContext() - const unmountFn = useRef<(() => void) | null>(null) - const [fieldApi] = useState(() => { const name = ( typeof opts.index === 'number' @@ -78,26 +75,6 @@ export function useField< .filter((d) => d !== undefined) .join('.') - const previousInstance = formApi.getFieldInfo(name).instance as never as - | FieldApi< - TParentData, - TName, - TFieldValidator, - TFormValidator, - DeepValue - > - | undefined - - /* - * executes functional useStates twice, so we need to return the previous instance - * @see https://react.dev/reference/react/useState#my-initializer-or-updater-function-runs-twice - */ - if (previousInstance) { - // `mount` will return the previous unmount function if the instance is already mounted - unmountFn.current = previousInstance.mount() - return previousInstance - } - const api = new FieldApi({ ...opts, form: formApi as never, @@ -107,16 +84,10 @@ export function useField< api.Field = Field as never - unmountFn.current = api.mount() - return api }) - useIsomorphicEffectOnce(() => { - return () => { - unmountFn.current?.() - } - }) + useIsomorphicLayoutEffect(fieldApi.mount, [fieldApi]) /** * fieldApi.update should not have any side effects. Think of it like a `useRef` diff --git a/packages/react-form/src/useForm.tsx b/packages/react-form/src/useForm.tsx index 1e60f5396..505d110bd 100644 --- a/packages/react-form/src/useForm.tsx +++ b/packages/react-form/src/useForm.tsx @@ -11,7 +11,6 @@ import { useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect' import type { NoInfer } from '@tanstack/react-store' import type { FormOptions, FormState, Validator } from '@tanstack/form-core' import type { FieldComponent, UseField } from './useField' -import { useIsomorphicEffectOnce } from './useIsomorphicEffectOnce' declare module '@tanstack/form-core' { // eslint-disable-next-line no-shadow @@ -39,7 +38,7 @@ export function useForm< const api = new FormApi(opts) api.Provider = function Provider(props) { - useIsomorphicEffectOnce(api.mount) + useIsomorphicLayoutEffect(api.mount, []) return ( ) diff --git a/packages/react-form/src/useIsomorphicEffectOnce.ts b/packages/react-form/src/useIsomorphicEffectOnce.ts deleted file mode 100644 index 31cd664a2..000000000 --- a/packages/react-form/src/useIsomorphicEffectOnce.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { useReducer, useRef } from 'rehackt' -import { useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect' -import type { EffectCallback } from 'rehackt' - -/** - * This hook handles StrictMode and prod mode - */ -export const useIsomorphicEffectOnce = (effect: EffectCallback) => { - const destroyFunc = useRef void)>() - const effectCalled = useRef(false) - const renderAfterCalled = useRef(false) - const [_, rerender] = useReducer(() => ({}), {}) - - if (effectCalled.current) { - renderAfterCalled.current = true - } - - useIsomorphicLayoutEffect(() => { - // only execute the effect first time around - if (!effectCalled.current) { - destroyFunc.current = effect() - effectCalled.current = true - } - - // this forces one render after the effect is run - rerender() - - return () => { - // if the comp didn't render since the useEffect was called, - // we know it's the dummy React cycle - if (!renderAfterCalled.current) { - return - } - if (destroyFunc.current) { - destroyFunc.current() - } - } - }, []) -} From ab591c93afe7e536755e4be1abe1e8a1bd938f02 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Mon, 26 Feb 2024 00:00:57 -0800 Subject: [PATCH 10/11] docs: add a minor docs mention in our debugging guide --- docs/config.json | 5 ++++- docs/framework/react/guides/debugging.md | 17 +++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) create mode 100644 docs/framework/react/guides/debugging.md diff --git a/docs/config.json b/docs/config.json index ea14d4fd6..eaf6aec8b 100644 --- a/docs/config.json +++ b/docs/config.json @@ -85,7 +85,10 @@ { "label": "SSR/Next.js", "to": "framework/react/guides/ssr" - + }, + { + "label": "Debugging", + "to": "framework/react/guides/debugging" } ] }, diff --git a/docs/framework/react/guides/debugging.md b/docs/framework/react/guides/debugging.md new file mode 100644 index 000000000..305b2f35e --- /dev/null +++ b/docs/framework/react/guides/debugging.md @@ -0,0 +1,17 @@ +--- +id: debugging +title: Debugging React Usage +--- + +Here's a list of common errors you might see in the console and how to fix them. + +# Changing an uncontrolled input to be controlled + +If you see this error in the console: + +``` +Warning: A component is changing an uncontrolled input to be controlled. This is likely caused by the value changing from undefined to a defined value, which should not happen. Decide between using a controlled or uncontrolled input element for the lifetime of the component. More info: https://reactjs.org/link/controlled-components +``` + +It's likely you forgot the `defaultValues` in your `useForm` Hook or `form.Field` component usage. This is occurring +because the input is being rendered before the form value is initialized and is therefore changing from `undefined` to `""` when a text input is made. From 2c94f77455c7259e10d5954be26b156663523cf2 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Mon, 4 Mar 2024 15:07:21 -0800 Subject: [PATCH 11/11] chore: apply suggestion from fulopkovacs --- packages/form-core/src/FormApi.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/form-core/src/FormApi.ts b/packages/form-core/src/FormApi.ts index c3b367a5f..abc3f8bce 100644 --- a/packages/form-core/src/FormApi.ts +++ b/packages/form-core/src/FormApi.ts @@ -338,9 +338,10 @@ export class FormApi< Object.values(this.fieldInfo) as FieldInfo[] ).forEach((field) => { if (!field.instance) return + const fieldInstance = field.instance // Validate the field fieldValidationPromises.push( - Promise.resolve().then(() => field.instance!.validate(cause)), + Promise.resolve().then(() => fieldInstance.validate(cause)), ) // If any fields are not touched if (!field.instance.state.meta.isTouched) {