From 348258927d3384363a41a91254e7afb5969b6bc6 Mon Sep 17 00:00:00 2001 From: Roya Date: Tue, 15 Sep 2026 02:18:53 +0800 Subject: [PATCH] [v2] Fix(vue): Rerender createFormHook field components and keep them connected after reset Field components registered through `createFormHook` are rendered by a wrapper that injected the field API once and passed the same object as a prop. The parent `Field` subscription rerendered its slot, but Vue skipped updating the injected component because its props were unchanged, so value, meta and error changes never reached it. The injected API was also captured at setup, so after `form.reset()` replaced the field API the component kept writing to the stale instance. `Field` now provides a computed, tracked view of the current field API. Reads made while rendering are tracked by Vue, and the computed follows API replacement. --- .changeset/quiet-fields-follow.md | 5 + packages/vue-form/src/AppForm/contexts.lib.ts | 10 +- .../src/AppForm/fieldComponentHelpers.lib.ts | 6 +- .../vue-form/src/VueForm/Components.lib.ts | 22 ++-- .../src/VueForm/fieldSubscriptions.lib.ts | 31 +++++ packages/vue-form/tests/adapter.spec.tsx | 114 ++++++++++++++++++ 6 files changed, 175 insertions(+), 13 deletions(-) create mode 100644 .changeset/quiet-fields-follow.md diff --git a/.changeset/quiet-fields-follow.md b/.changeset/quiet-fields-follow.md new file mode 100644 index 0000000000..090ebc39fe --- /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 2e223cc73e..a534aa8e3b 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 1f721791bb..226c31ae8d 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 3ab0aeec39..252f125123 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 7ee300b6aa..c59b7db937 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 920c8a2a80..b22a2c4ab0 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(),