diff --git a/docs/config.json b/docs/config.json index 57e9a2baf..7fd0732a8 100644 --- a/docs/config.json +++ b/docs/config.json @@ -497,7 +497,7 @@ }, { "label": "FieldGroupHelper", - "to": "framework/react/reference/interfaces/FieldGroupHelper" + "to": "reference/interfaces/FieldGroupHelper" } ] } diff --git a/packages/form-core/src/FieldApi/FieldApi.lib.ts b/packages/form-core/src/FieldApi/FieldApi.lib.ts index 36e276e59..127e52ff0 100644 --- a/packages/form-core/src/FieldApi/FieldApi.lib.ts +++ b/packages/form-core/src/FieldApi/FieldApi.lib.ts @@ -232,7 +232,7 @@ export function getOrCreateFieldApi( return getOrCreateFieldApi(childNode, segments, form, options, scope) } - childNode = new InternalFieldApi( + childNode = new form._FieldApi( { segment, parent: node, diff --git a/packages/form-core/src/FormApi/FormApi.lib.ts b/packages/form-core/src/FormApi/FormApi.lib.ts index 8a8575788..020e75e54 100644 --- a/packages/form-core/src/FormApi/FormApi.lib.ts +++ b/packages/form-core/src/FormApi/FormApi.lib.ts @@ -1,5 +1,6 @@ import { batch, createAtom } from '@tanstack/store' import { + InternalFieldApi, getDefaultValueCacheResult, getOrCreateFieldApi, shouldCacheDefaultValue, @@ -212,6 +213,10 @@ export class InternalFormApi< */ static majorVersion = 2 + // Allows adapters to control which InternalFieldApi subclass creates fields. + get _FieldApi(): typeof InternalFieldApi { + return InternalFieldApi + } atom: ReadonlyAtom< FormState> > diff --git a/packages/react-form/src/AppForm/Components.lib.tsx b/packages/react-form/src/AppForm/Components.lib.tsx index ac18641cd..2e7f2addd 100644 --- a/packages/react-form/src/AppForm/Components.lib.tsx +++ b/packages/react-form/src/AppForm/Components.lib.tsx @@ -1,64 +1,20 @@ import React from 'react' import { InternalFormGroupApi } from '@tanstack/form-core/internals' -import { - attachReactFormComponents, - createArrayFieldComponent, -} from '../ReactForm/Components.lib' import { useField } from '../ReactForm/useField.lib' import { useValueFieldSubscription } from '../ReactForm/fieldSubscriptions.lib' import { Subscribe } from '../Subscribe.public' import { FieldContext, FormContext } from './contexts.lib' -import type { - AnyInternalFormApi, - FieldOptionsScope, -} from '@tanstack/form-core/internals' +import type { FieldOptionsScope } from '@tanstack/form-core/internals' import type { FunctionComponent, ReactNode } from 'react' -import type { - AppFormComponent, - ReactAppFormApi, -} from './ReactAppFormApi.public' -import type { AnyReactFormComponentMap } from './componentMap.public' +import type { AppFormComponent } from './ReactAppFormApi.public' import type { ReactFormFieldProps, ReactFormGroupProps, } from '../ReactForm/Components.public' import type { InternalReactFormApi } from '../ReactForm/ReactFormApi.lib' -type AnyReactAppFormApi = ReactAppFormApi - -export function attachReactAppFormComponents( - form: AnyInternalFormApi, - formComponents: Record>, - fieldComponents: Record>, -): AnyReactAppFormApi { - const resultForm = attachReactFormComponents( - form, - fieldComponents, - ) as never as AnyReactAppFormApi - const field = createFieldWithContext(form, fieldComponents, 'field') - const arrayField = resultForm.ArrayField as FunctionComponent - const groupField = createFieldWithContext(form, fieldComponents, 'field') - const groupArrayField = createArrayFieldComponent( - form, - fieldComponents, - 'field', - ) as FunctionComponent - const formGroup = createFormGroupWithContext( - resultForm as any, - groupField, - groupArrayField, - ) - - resultForm.AppForm = createAppForm(form) - resultForm.Field = field as AnyReactAppFormApi['Field'] - resultForm.ArrayField = arrayField as AnyReactAppFormApi['ArrayField'] - resultForm.FormGroup = formGroup as AnyReactAppFormApi['FormGroup'] - - return Object.assign(resultForm, formComponents) -} - -function createAppForm(form: AnyInternalFormApi): AppFormComponent { +export function createAppForm(form: InternalReactFormApi): AppFormComponent { const AppForm: FunctionComponent<{ children: Exclude> }> = function AppFormComponent(props) { @@ -75,13 +31,12 @@ type AnyFieldComponent = FunctionComponent< ReactFormFieldProps > -function createFieldWithContext( - form: AnyInternalFormApi, - fieldComponents: Record>, +export function createFieldWithContext( + form: InternalReactFormApi, scope: FieldOptionsScope, ) { const TanStackFormField: AnyFieldComponent = (props) => { - const fieldApi = useField({ ...props, form }, fieldComponents, scope) + const fieldApi = useField({ ...props, form }, scope) const field = useValueFieldSubscription(fieldApi) return ( @@ -101,7 +56,7 @@ type AnyFormGroupComponent = FunctionComponent< ReactFormGroupProps > -function createFormGroupWithContext( +export function createFormGroupWithContext( form: InternalReactFormApi, groupField: FunctionComponent, groupArrayField: FunctionComponent, diff --git a/packages/react-form/src/AppForm/ReactAppFieldApi.lib.ts b/packages/react-form/src/AppForm/ReactAppFieldApi.lib.ts new file mode 100644 index 000000000..e95d4c045 --- /dev/null +++ b/packages/react-form/src/AppForm/ReactAppFieldApi.lib.ts @@ -0,0 +1,14 @@ +import { InternalFieldApi } from '@tanstack/form-core/internals' +import type { FunctionComponent } from 'react' + +type FieldComponents = Record> + +export function createInternalReactAppFieldApiClass( + fieldComponents: FieldComponents, +): typeof InternalFieldApi { + class InternalReactAppFieldApi extends InternalFieldApi {} + + Object.assign(InternalReactAppFieldApi.prototype, fieldComponents) + + return InternalReactAppFieldApi as typeof InternalFieldApi +} diff --git a/packages/react-form/src/AppForm/ReactAppFormApi.lib.ts b/packages/react-form/src/AppForm/ReactAppFormApi.lib.ts new file mode 100644 index 000000000..577020f5c --- /dev/null +++ b/packages/react-form/src/AppForm/ReactAppFormApi.lib.ts @@ -0,0 +1,71 @@ +import { createArrayFieldComponent } from '../ReactForm/Components.lib' +import { InternalReactFormApi } from '../ReactForm/ReactFormApi.lib' +import { createInternalReactAppFieldApiClass } from './ReactAppFieldApi.lib' +import { + createAppForm, + createFieldWithContext, + createFormGroupWithContext, +} from './Components.lib' +import type { DefaultOptions, FormOptions } from '@tanstack/form-core' +import type { FunctionComponent } from 'react' +import type { AppFormComponent } from './ReactAppFormApi.public' + +type FormComponents = Record> + +interface InternalReactAppFormApiInstance extends InternalReactFormApi { + AppForm: AppFormComponent +} + +type InternalReactAppFormApiConstructor< + TFormComponents extends FormComponents, +> = new ( + options: FormOptions, + defaultOptions?: DefaultOptions, +) => InternalReactAppFormApiInstance & TFormComponents + +export function createInternalReactAppFormApiClass< + const TFormComponents extends FormComponents, +>( + formComponents: TFormComponents, + fieldComponents: FormComponents, +): InternalReactAppFormApiConstructor { + const InternalReactAppFieldApi = + createInternalReactAppFieldApiClass(fieldComponents) + + class InternalReactAppFormApi + extends InternalReactFormApi + implements InternalReactAppFormApiInstance + { + AppForm: AppFormComponent + + override get _FieldApi(): typeof InternalReactAppFieldApi { + return InternalReactAppFieldApi + } + + constructor( + options: FormOptions, + defaultOptions?: DefaultOptions, + ) { + super(options, defaultOptions) + + const field = createFieldWithContext(this, 'field') + const groupField = createFieldWithContext(this, 'field') + const groupArrayField = createArrayFieldComponent( + this, + 'field', + ) as FunctionComponent + + this.AppForm = createAppForm(this) + this.Field = field as InternalReactAppFormApi['Field'] + this.FormGroup = createFormGroupWithContext( + this, + groupField, + groupArrayField, + ) as InternalReactAppFormApi['FormGroup'] + } + } + + Object.assign(InternalReactAppFormApi.prototype, formComponents) + + return InternalReactAppFormApi as unknown as InternalReactAppFormApiConstructor +} diff --git a/packages/react-form/src/AppForm/initializeAppForm.lib.ts b/packages/react-form/src/AppForm/initializeAppForm.lib.ts index b8bbdca8a..b379feb5e 100644 --- a/packages/react-form/src/AppForm/initializeAppForm.lib.ts +++ b/packages/react-form/src/AppForm/initializeAppForm.lib.ts @@ -1,5 +1,5 @@ -import { InternalFormApi } from '@tanstack/form-core/internals' -import { attachReactAppFormComponents } from './Components.lib' +import { createInternalReactAppFormApiClass } from './ReactAppFormApi.lib' +import type { InternalReactFormApi } from '../ReactForm/ReactFormApi.lib' import type { DefaultFieldOptions, DefaultFormGroupOptions, @@ -7,7 +7,6 @@ import type { DefaultOptions, FormOptions, } from '@tanstack/form-core' -import type { InternalReactFormApi } from '../ReactForm/ReactFormApi.lib' import type { FunctionComponent } from 'react' interface AnyCreateFormHookOptions { @@ -21,6 +20,10 @@ interface AnyCreateFormHookOptions { export function createAppFormInitializer( createOptions: AnyCreateFormHookOptions, ): (options: FormOptions) => InternalReactFormApi { + const InternalReactAppFormApi = createInternalReactAppFormApiClass( + createOptions.formComponents, + createOptions.fieldComponents, + ) const hasDefaultOptions = createOptions.defaultFormOptions || createOptions.defaultFieldOptions || @@ -35,13 +38,6 @@ export function createAppFormInitializer( : undefined return (options) => { - const form = new InternalFormApi(options, defaultOptions) - const extendedForm = attachReactAppFormComponents( - form, - createOptions.formComponents, - createOptions.fieldComponents, - ) - - return extendedForm as never + return new InternalReactAppFormApi(options, defaultOptions) } } diff --git a/packages/react-form/src/ReactForm/Components.lib.tsx b/packages/react-form/src/ReactForm/Components.lib.tsx index 48e70df5c..cfc599974 100644 --- a/packages/react-form/src/ReactForm/Components.lib.tsx +++ b/packages/react-form/src/ReactForm/Components.lib.tsx @@ -6,10 +6,7 @@ import { useValueFieldSubscription, } from './fieldSubscriptions.lib' import { useField } from './useField.lib' -import type { - AnyInternalFormApi, - FieldOptionsScope, -} from '@tanstack/form-core/internals' +import type { FieldOptionsScope } from '@tanstack/form-core/internals' import type { InternalReactFormApi } from './ReactFormApi.lib' import type { FunctionComponent, ReactNode } from 'react' import type { @@ -18,41 +15,16 @@ import type { ReactFormSubscribeProps, } from './Components.public' -export function attachReactFormComponents( - form: AnyInternalFormApi, - fieldComponents: Record> | null, -): InternalReactFormApi { - const resultForm = form as InternalReactFormApi - resultForm.Field = createFieldComponent( - form, - fieldComponents, - 'field', - ) as InternalReactFormApi['Field'] - resultForm.ArrayField = createArrayFieldComponent( - form, - fieldComponents, - 'field', - ) - resultForm.Subscribe = createSubscribeComponent(form) - resultForm.FormGroup = createFormGroupComponent( - resultForm, - fieldComponents, - ) as InternalReactFormApi['FormGroup'] - - return resultForm -} - type AnyFieldComponent = FunctionComponent< ReactFormFieldProps > -function createFieldComponent( - form: AnyInternalFormApi, - fieldComponents: Record> | null, +export function createFieldComponent( + form: InternalReactFormApi, scope: FieldOptionsScope, ): AnyFieldComponent { const TanStackFormField: AnyFieldComponent = (props) => { - const fieldApi = useField({ ...props, form }, fieldComponents, scope) + const fieldApi = useField({ ...props, form }, scope) const field = useValueFieldSubscription(fieldApi) return props.children(field) @@ -69,12 +41,11 @@ type AnyArrayFieldComponent = { } export function createArrayFieldComponent( - form: AnyInternalFormApi, - fieldComponents: Record> | null, + form: InternalReactFormApi, scope: FieldOptionsScope, ): AnyArrayFieldComponent { const TanStackFormArrayField: AnyArrayFieldComponent = (props) => { - const fieldApi = useField({ ...props, form }, fieldComponents, scope) + const fieldApi = useField({ ...props, form }, scope) const field = useArrayFieldSubscription(fieldApi) return props.children(field) @@ -90,8 +61,8 @@ type AnySubscribeComponent = { displayName?: string } -function createSubscribeComponent( - form: AnyInternalFormApi, +export function createSubscribeComponent( + form: InternalReactFormApi, ): AnySubscribeComponent { const TanStackFormSubscribe: AnySubscribeComponent = (props) => { return @@ -106,9 +77,8 @@ type AnyFormGroupComponent = FunctionComponent< ReactFormGroupProps > -function createFormGroupComponent( +export function createFormGroupComponent( form: InternalReactFormApi, - fieldComponents: Record> | null, ): AnyFormGroupComponent { const TanStackFormGroup: AnyFormGroupComponent = (props) => { const groupRef = @@ -118,7 +88,6 @@ function createFormGroupComponent( groupRef.current = attachReactFormGroupComponents( new InternalFormGroupApi({ ...props, form } as never), form, - fieldComponents, ) } @@ -141,7 +110,6 @@ function createFormGroupComponent( function attachReactFormGroupComponents( group: InternalFormGroupApi, form: InternalReactFormApi, - fieldComponents: Record> | null, ) { type FormGroupComponents = InternalFormGroupApi & { Field: FunctionComponent @@ -150,12 +118,8 @@ function attachReactFormGroupComponents( } const resultGroup: FormGroupComponents = group as never - const GroupField = createFieldComponent(form, fieldComponents, 'field') - const GroupArrayField = createArrayFieldComponent( - form, - fieldComponents, - 'field', - ) + const GroupField = createFieldComponent(form, 'field') + const GroupArrayField = createArrayFieldComponent(form, 'field') resultGroup.Field = function Field(props) { return ( diff --git a/packages/react-form/src/ReactForm/ReactFormApi.lib.tsx b/packages/react-form/src/ReactForm/ReactFormApi.lib.tsx index 1236ed81f..c3e6375c4 100644 --- a/packages/react-form/src/ReactForm/ReactFormApi.lib.tsx +++ b/packages/react-form/src/ReactForm/ReactFormApi.lib.tsx @@ -1,25 +1,48 @@ import { InternalFormApi } from '@tanstack/form-core/internals' import * as React from 'react' import { useEffect, useRef } from 'react' -import { attachReactFormComponents } from './Components.lib' -import type { AnyInternalFormApi } from '@tanstack/form-core/internals' -import type { FormOptions } from '@tanstack/form-core' +import { + createArrayFieldComponent, + createFieldComponent, + createFormGroupComponent, + createSubscribeComponent, +} from './Components.lib' +import type { DefaultOptions, FormOptions } from '@tanstack/form-core' import type { ReactTanStackFormComponents } from './Components.public' const useReactId = (React as typeof React & { useId?: () => string }).useId ?? (() => undefined) -export interface InternalReactFormApi - extends AnyInternalFormApi, ReactTanStackFormComponents {} +export class InternalReactFormApi + extends InternalFormApi + implements ReactTanStackFormComponents +{ + Field: ReactTanStackFormComponents['Field'] + ArrayField: ReactTanStackFormComponents['ArrayField'] + Subscribe: ReactTanStackFormComponents['Subscribe'] + FormGroup: ReactTanStackFormComponents['FormGroup'] + + constructor( + options: FormOptions, + defaultOptions?: DefaultOptions, + ) { + super(options, defaultOptions) + this.Field = createFieldComponent( + this, + 'field', + ) as InternalReactFormApi['Field'] + this.ArrayField = createArrayFieldComponent(this, 'field') + this.Subscribe = createSubscribeComponent(this) + this.FormGroup = createFormGroupComponent( + this, + ) as InternalReactFormApi['FormGroup'] + } +} export function initializeForm( options: FormOptions, ): InternalReactFormApi { - const form = new InternalFormApi(options) - - const reactFormApi = attachReactFormComponents(form, null) - - return reactFormApi + return new InternalReactFormApi(options) } export function useInternalForm( diff --git a/packages/react-form/src/ReactForm/useField.lib.ts b/packages/react-form/src/ReactForm/useField.lib.ts index 8f66c6873..ddb5dbb0a 100644 --- a/packages/react-form/src/ReactForm/useField.lib.ts +++ b/packages/react-form/src/ReactForm/useField.lib.ts @@ -1,6 +1,5 @@ import { useEffect, useMemo, useRef } from 'react' import { useSelector } from '@tanstack/react-store' -import type { FunctionComponent } from 'react' import type { AnyInternalFieldApi, FieldOptionsScope, @@ -23,7 +22,6 @@ interface InternalFieldProps extends ReactFormFieldProps< export function useField( options: InternalFieldProps, - fieldComponents: Record> | null, scope: FieldOptionsScope, ): AnyInternalFieldApi { const optionsRef = useRef(options) @@ -40,11 +38,8 @@ export function useField( }, scope, ) - if (fieldComponents === null) return field - Object.assign(field, fieldComponents) - return field - }, [options.name, options.form, resetVersion, fieldComponents, scope]) + }, [options.name, options.form, resetVersion, scope]) useEffect(() => fieldApi._update(options, scope)) diff --git a/packages/react-form/tests/createFormHook.spec.tsx b/packages/react-form/tests/createFormHook.spec.tsx index 2e6481291..c1b7a0720 100644 --- a/packages/react-form/tests/createFormHook.spec.tsx +++ b/packages/react-form/tests/createFormHook.spec.tsx @@ -7,7 +7,88 @@ import type { AnyInternalFormApi, } from '@tanstack/form-core/internals' -describe('createFormHook defaults', () => { +describe('createFormHook', () => { + it('provides registered components to fields created during mount validation', () => { + const SharedComponent = () => Shared field component + const { useAppForm } = createFormHook({ + fieldComponents: { SharedComponent }, + formComponents: {}, + }) + + function Component() { + const form = useAppForm({ + defaultValues: { name: '' }, + validators: [ + { + runOnMount: true, + triggers: [], + run: () => ({ fields: { name: 'Name is required' } }), + }, + ], + }) + + return ( + + {(field) => { + const { SharedComponent: DestructuredComponent } = field + return + }} + + ) + } + + const { getByText } = render() + + expect(getByText('Shared field component')).toBeInTheDocument() + }) + + it('supports destructuring registered form components', () => { + const SharedComponent = () => Shared component + const { useAppForm } = createFormHook({ + fieldComponents: {}, + formComponents: { SharedComponent }, + }) + + /* eslint-disable @eslint-react/static-components -- False positive, component's created once */ + function Component() { + const form = useAppForm({ defaultValues: { name: '' } }) + const { SharedComponent: DestructuredComponent } = form + + return + } + /* eslint-enable @eslint-react/static-components */ + + const { getByText } = render() + + expect(getByText('Shared component')).toBeInTheDocument() + }) + + it('renders inherited form components with per-instance AppForm context', () => { + function CurrentName() { + const form = useFormContext() + return {form.state.values.name} + } + + const { useAppForm, useFormContext } = createFormHook({ + fieldComponents: {}, + formComponents: { CurrentName }, + }) + + function Component() { + const form = useAppForm({ defaultValues: { name: 'Tony' } }) + + return ( + + + + ) + } + + const { getByTestId } = render() + + expect(getByTestId('current-name')).toHaveTextContent('Tony') + }) + it('uses default form options and lets usage options override them', () => { const defaultErrorVisibility = () => true const overriddenErrorVisibility = () => false