diff --git a/.changeset/quiet-fields-follow.md b/.changeset/quiet-fields-follow.md new file mode 100644 index 000000000..090ebc39f --- /dev/null +++ b/.changeset/quiet-fields-follow.md @@ -0,0 +1,5 @@ +--- +'@tanstack/vue-form': patch +--- + +Fix: Field components registered through `createFormHook` now rerender when their field state changes and stay connected to the current field API after `form.reset()` diff --git a/packages/vue-form/src/AppForm/contexts.lib.ts b/packages/vue-form/src/AppForm/contexts.lib.ts index 2e223cc73..a534aa8e3 100644 --- a/packages/vue-form/src/AppForm/contexts.lib.ts +++ b/packages/vue-form/src/AppForm/contexts.lib.ts @@ -1,16 +1,16 @@ import { inject } from 'vue' -import type { InjectionKey } from 'vue' +import type { ComputedRef, InjectionKey } from 'vue' import type { AnyInternalFieldApi } from '@tanstack/form-core/internals' import type { InternalVueFormApi } from '../VueForm/VueFormApi.lib' export const FormContext = Symbol( 'TanStackForm.FormContext', ) as InjectionKey -export const FieldContext = Symbol( - 'TanStackForm.FieldContext', -) as InjectionKey +export const FieldContext = Symbol('TanStackForm.FieldContext') as InjectionKey< + ComputedRef +> -export function useFieldContext(): AnyInternalFieldApi { +export function useFieldContext(): ComputedRef { const field = inject(FieldContext) if (field === undefined) { throw new Error( diff --git a/packages/vue-form/src/AppForm/fieldComponentHelpers.lib.ts b/packages/vue-form/src/AppForm/fieldComponentHelpers.lib.ts index 1f721791b..226c31ae8 100644 --- a/packages/vue-form/src/AppForm/fieldComponentHelpers.lib.ts +++ b/packages/vue-form/src/AppForm/fieldComponentHelpers.lib.ts @@ -10,7 +10,11 @@ export function wrapField( (_props, context) => { const field = useFieldContext() return () => - h(component, { ...context.attrs, [fieldPropKey]: field }, context.slots) + h( + component, + { ...context.attrs, [fieldPropKey]: field.value }, + context.slots, + ) }, { name: 'TanStackForm.FieldComponent', inheritAttrs: false }, ) diff --git a/packages/vue-form/src/VueForm/Components.lib.ts b/packages/vue-form/src/VueForm/Components.lib.ts index 3ab0aeec3..252f12512 100644 --- a/packages/vue-form/src/VueForm/Components.lib.ts +++ b/packages/vue-form/src/VueForm/Components.lib.ts @@ -1,6 +1,7 @@ import { shallow, useSelector } from '@tanstack/vue-store' import { InternalFormGroupApi } from '@tanstack/form-core/internals' import { + computed, defineComponent, h, onMounted, @@ -12,9 +13,10 @@ import { Subscribe } from '../Subscribe.public' import { createArrayFieldSubscription, createValueFieldSubscription, + trackFieldApi, } from './fieldSubscriptions.lib' import { useField } from './useField.lib' -import type { Component, InjectionKey, Slots } from 'vue' +import type { Component, ComputedRef, InjectionKey, Slots } from 'vue' import type { AnyFieldApiOptions, AnyInternalFieldApi, @@ -26,7 +28,7 @@ import type { InternalVueFormApi } from './VueFormApi.lib' export function attachVueFormComponents( form: AnyInternalFormApi, fieldComponents: Record | null, - fieldContext?: InjectionKey, + fieldContext?: InjectionKey>, ): InternalVueFormApi { const resultForm = form as InternalVueFormApi resultForm.Field = createFieldComponent( @@ -50,7 +52,7 @@ function createFieldComponent( form: AnyInternalFormApi, fieldComponents: Record | null, array: boolean, - fieldContext?: InjectionKey, + fieldContext?: InjectionKey>, ) { return defineComponent( (_props, context) => { @@ -61,10 +63,16 @@ function createFieldComponent( : createValueFieldSubscription(fieldApi) if (fieldContext) { - // Field APIs are stable for a mounted name. Supplying the current API - // mirrors Vue v1 composition components while the parent subscription - // handles state-driven renders. - provide(fieldContext, fieldApi.value) + // Injected field components are separate component instances, so the + // parent subscription rerendering this slot does not update them when + // the field API object is unchanged. The field API can also be + // replaced for a mounted name (for example after `form.reset()`). + // Provide a computed, tracked view so injected components follow both + // the current API and its subscribed state. + provide( + fieldContext, + computed(() => trackFieldApi(fieldApi.value, selection)), + ) } return () => { diff --git a/packages/vue-form/src/VueForm/fieldSubscriptions.lib.ts b/packages/vue-form/src/VueForm/fieldSubscriptions.lib.ts index 7ee300b6a..c59b7db93 100644 --- a/packages/vue-form/src/VueForm/fieldSubscriptions.lib.ts +++ b/packages/vue-form/src/VueForm/fieldSubscriptions.lib.ts @@ -37,6 +37,37 @@ export function createValueFieldSubscription( })) } +/** + * Wraps a field API so reads made during a component render are tracked by + * Vue. Injected field components are separate component instances, so they + * do not rerender when only the parent `Field` subscription changes. + * + * Methods from the prototype chain are bound to the underlying field API so + * they never run with the proxy as `this`. Own properties, including the + * attached field components, are returned unchanged. + */ +export function trackFieldApi( + field: TField, + selection: ShallowRef, +): TField { + const boundMethods = new Map() + + return new Proxy(field, { + get(target, key) { + void selection.value + const value = Reflect.get(target, key, target) + if (typeof value !== 'function' || Object.hasOwn(target, key)) { + return value + } + if (!boundMethods.has(key)) boundMethods.set(key, value.bind(target)) + return boundMethods.get(key) + }, + set(target, key, value) { + return Reflect.set(target, key, value, target) + }, + }) +} + export function createArrayFieldSubscription( fieldApi: ShallowRef, ) { diff --git a/packages/vue-form/tests/adapter.spec.tsx b/packages/vue-form/tests/adapter.spec.tsx index 920c8a2a8..b22a2c4ab 100644 --- a/packages/vue-form/tests/adapter.spec.tsx +++ b/packages/vue-form/tests/adapter.spec.tsx @@ -361,6 +361,120 @@ describe('Vue adapter parity', () => { expect(view.getByTestId('app-field')).toHaveTextContent('Name:name:Tony') }) + const createComposedTextFieldHook = () => { + const TextField = defineComponent<{ + field: FieldWithValue + label: string + }>( + (props) => () => ( + + ), + { props: ['field', 'label'] }, + ) + + const { fieldComponent } = getFormHookHelpers() + return createFormHook({ + fieldComponents: { + AppTextField: fieldComponent.strict(TextField, 'field'), + }, + formComponents: {}, + }) + } + + it('rerenders composed field components when field state changes', async () => { + const { useAppForm } = createComposedTextFieldHook() + + const Component = defineComponent(() => { + const form = useAppForm({ + defaultValues: { name: '' }, + validators: [ + { + triggers: ['change'], + run: ({ value, createErrorMap }) => { + const errors = createErrorMap() + if (value.name.length < 2) errors.fields.name = 'Too short' + return errors + }, + }, + ], + }) + return () => ( + + {({ field }: { field: AnyFieldApi & { AppTextField: any } }) => ( + + )} + + ) + }) + + const view = render(Component) + + await fireEvent.update(view.getByLabelText('Name'), 'T') + await waitFor(() => { + expect(view.getByTestId('composed-value')).toHaveTextContent('T') + expect(view.getByTestId('composed-errors')).toHaveTextContent('Too short') + expect(view.getByLabelText('Name')).toHaveAttribute( + 'aria-invalid', + 'true', + ) + }) + + await fireEvent.update(view.getByLabelText('Name'), 'Tony') + await waitFor(() => { + expect(view.getByTestId('composed-value')).toHaveTextContent('Tony') + expect(view.getByTestId('composed-errors')).toBeEmptyDOMElement() + expect(view.getByLabelText('Name')).toHaveAttribute( + 'aria-invalid', + 'false', + ) + }) + }) + + it('keeps composed field components connected after form reset', async () => { + const { useAppForm } = createComposedTextFieldHook() + let formApi: any + + const Component = defineComponent(() => { + const form = useAppForm({ defaultValues: { name: '' } }) + formApi = form + return () => ( + + {({ field }: { field: AnyFieldApi & { AppTextField: any } }) => ( + + )} + + ) + }) + + const view = render(Component) + + await fireEvent.update(view.getByLabelText('Name'), 'before') + expect(formApi.state.values.name).toBe('before') + + formApi.reset() + await nextTick() + + await fireEvent.update(view.getByLabelText('Name'), 'after') + expect(formApi.state.values.name).toBe('after') + await waitFor(() => + expect(view.getByTestId('composed-value')).toHaveTextContent('after'), + ) + }) + it('binds reusable field groups to concrete form paths', async () => { const profileFieldGroup = defineFieldGroup(({ strict }) => ({ name: strict(),