diff --git a/.changeset/silent-tools-cry.md b/.changeset/silent-tools-cry.md new file mode 100644 index 000000000..5fba16a46 --- /dev/null +++ b/.changeset/silent-tools-cry.md @@ -0,0 +1,5 @@ +--- +'@tanstack/vue-form': patch +--- + +Keep Vue field values and validation state reactive in child components, and keep slot and injected field handlers connected after resets and field name changes. diff --git a/packages/preact-form/tsconfig.json b/packages/preact-form/tsconfig.json index ee96cd609..31131ce94 100644 --- a/packages/preact-form/tsconfig.json +++ b/packages/preact-form/tsconfig.json @@ -4,7 +4,7 @@ "jsx": "react-jsx", "jsxImportSource": "preact", "moduleResolution": "Bundler", - "types": ["vitest/browser"] + "types": ["vitest/browser", "node"] }, "include": ["src", "tests", "vitest.config.ts"], "exclude": ["eslint.config.js"] diff --git a/packages/vue-form/src/AppForm/contexts.lib.ts b/packages/vue-form/src/AppForm/contexts.lib.ts index 2e223cc73..472c0941a 100644 --- a/packages/vue-form/src/AppForm/contexts.lib.ts +++ b/packages/vue-form/src/AppForm/contexts.lib.ts @@ -1,6 +1,6 @@ import { inject } from 'vue' import type { InjectionKey } from 'vue' -import type { AnyInternalFieldApi } from '@tanstack/form-core/internals' +import type { AnyFieldApi } from '@tanstack/form-core' import type { InternalVueFormApi } from '../VueForm/VueFormApi.lib' export const FormContext = Symbol( @@ -8,9 +8,9 @@ export const FormContext = Symbol( ) as InjectionKey export const FieldContext = Symbol( 'TanStackForm.FieldContext', -) as InjectionKey +) as InjectionKey -export function useFieldContext(): AnyInternalFieldApi { +export function useFieldContext(): AnyFieldApi { const field = inject(FieldContext) if (field === undefined) { throw new Error( diff --git a/packages/vue-form/src/VueForm/Components.lib.ts b/packages/vue-form/src/VueForm/Components.lib.ts index 3ab0aeec3..23636f4ea 100644 --- a/packages/vue-form/src/VueForm/Components.lib.ts +++ b/packages/vue-form/src/VueForm/Components.lib.ts @@ -13,11 +13,12 @@ import { createArrayFieldSubscription, createValueFieldSubscription, } from './fieldSubscriptions.lib' +import { createFieldView } from './fieldView.lib' import { useField } from './useField.lib' +import type { AnyFieldApi } from '@tanstack/form-core' import type { Component, InjectionKey, Slots } from 'vue' import type { AnyFieldApiOptions, - AnyInternalFieldApi, AnyInternalFormApi, InternalFormGroupApi as InternalFormGroupApiType, } from '@tanstack/form-core/internals' @@ -26,7 +27,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,26 +51,25 @@ function createFieldComponent( form: AnyInternalFormApi, fieldComponents: Record | null, array: boolean, - fieldContext?: InjectionKey, + fieldContext?: InjectionKey, ) { return defineComponent( (_props, context) => { const options = () => ({ ...context.attrs, form }) as never - const fieldApi = useField(options, fieldComponents) - const selection = array + const fieldApi = useField(options) + const { selection, meta } = array ? createArrayFieldSubscription(fieldApi) : createValueFieldSubscription(fieldApi) + const field = createFieldView(fieldApi, selection, meta) + if (fieldComponents !== null) Object.assign(field, fieldComponents) 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) + provide(fieldContext, field) } return () => { void selection.value - return context.slots.default?.({ field: fieldApi.value }) + return context.slots.default?.({ field }) } }, { diff --git a/packages/vue-form/src/VueForm/fieldSubscriptions.lib.ts b/packages/vue-form/src/VueForm/fieldSubscriptions.lib.ts index 7ee300b6a..4f8632ebd 100644 --- a/packages/vue-form/src/VueForm/fieldSubscriptions.lib.ts +++ b/packages/vue-form/src/VueForm/fieldSubscriptions.lib.ts @@ -11,12 +11,17 @@ function createFieldSelection( selector: (field: AnyInternalFieldApi) => TSelected, ) { const selected = shallowRef(selector(fieldApi.value)) as ShallowRef + // Metadata can change without an array's structure changing. Keep it + // reactive for metadata consumers without invalidating value-only slots. + const meta = shallowRef(fieldApi.value.meta) watch( fieldApi, (field, _previous, onCleanup) => { + meta.value = field.meta selected.value = selector(field) const subscription = field.atom.subscribe(() => { + meta.value = field.meta const next = selector(field) if (!shallow(toRaw(selected.value), next)) selected.value = next }) @@ -25,7 +30,7 @@ function createFieldSelection( { immediate: true, flush: 'sync' }, ) - return selected + return { selection: selected, meta } } export function createValueFieldSubscription( diff --git a/packages/vue-form/src/VueForm/fieldView.lib.ts b/packages/vue-form/src/VueForm/fieldView.lib.ts new file mode 100644 index 000000000..fe7ea102d --- /dev/null +++ b/packages/vue-form/src/VueForm/fieldView.lib.ts @@ -0,0 +1,50 @@ +import type { AnyFieldApi, AnyFieldMeta } from '@tanstack/form-core' +import type { AnyInternalFieldApi } from '@tanstack/form-core/internals' +import type { ShallowRef } from 'vue' + +/** + * The public field shared by slots and injected components. State getters + * track the existing subscription in the component that reads them, while + * handlers follow the current core field after a reset or name change. + */ +export function createFieldView( + fieldApi: ShallowRef, + selection: ShallowRef, + meta: ShallowRef, +): AnyFieldApi { + return { + get form() { + return fieldApi.value.form + }, + get name() { + return fieldApi.value.name + }, + get atom() { + return fieldApi.value.atom + }, + get value() { + void selection.value + return fieldApi.value.value + }, + get meta() { + return meta.value + }, + get errors() { + return meta.value.errors + }, + handleChange: (value, options) => + fieldApi.value.handleChange(value, options), + handleBlur: () => fieldApi.value.handleBlur(), + reset: () => fieldApi.value.reset(), + swapValues: (indexA, indexB) => fieldApi.value.swapValues(indexA, indexB), + moveValue: (fromIndex, toIndex, options) => + fieldApi.value.moveValue(fromIndex, toIndex, options), + pushValue: (value, options) => fieldApi.value.pushValue(value, options), + insertValue: (index, value, options) => + fieldApi.value.insertValue(index, value, options), + clearValues: (options) => fieldApi.value.clearValues(options), + removeValue: (index, options) => fieldApi.value.removeValue(index, options), + filterValues: (predicate, options) => + fieldApi.value.filterValues(predicate, options), + } +} diff --git a/packages/vue-form/src/VueForm/useField.lib.ts b/packages/vue-form/src/VueForm/useField.lib.ts index 20bc7f788..418c74b8b 100644 --- a/packages/vue-form/src/VueForm/useField.lib.ts +++ b/packages/vue-form/src/VueForm/useField.lib.ts @@ -1,6 +1,6 @@ import { useSelector } from '@tanstack/vue-store' import { onMounted, onUnmounted, shallowRef, watch, watchEffect } from 'vue' -import type { Component, ShallowRef } from 'vue' +import type { ShallowRef } from 'vue' import type { AnyInternalFieldApi, AnyInternalFormApi, @@ -14,22 +14,19 @@ export interface InternalFieldProps { export function useField( options: () => InternalFieldProps, - fieldComponents: Record | null, ): ShallowRef { const initialOptions = options() const resetVersion = useSelector(initialOptions.form._atoms.resetVersion) const createField = () => { const current = options() - const field = current.form._getOrCreateFieldApi( + return current.form._getOrCreateFieldApi( { ...current, name: current.name, } as never, 'field', ) - if (fieldComponents !== null) Object.assign(field, fieldComponents) - return field } const fieldApi = shallowRef(createField()) diff --git a/packages/vue-form/tests/array-field-reactivity.spec.ts b/packages/vue-form/tests/array-field-reactivity.spec.ts new file mode 100644 index 000000000..4b085aa79 --- /dev/null +++ b/packages/vue-form/tests/array-field-reactivity.spec.ts @@ -0,0 +1,26 @@ +import { render } from 'vitest-browser-vue' +import { expect, it } from 'vitest' +import ArrayFieldReactivity from './fixtures/ArrayFieldReactivity.vue' + +it('updates ArrayField validation in a compiled child without another structural change', async () => { + let resolveValidation!: (error: string) => void + const validation = new Promise((resolve) => { + resolveValidation = resolve + }) + const view = await render(ArrayFieldReactivity, { + props: { validate: () => validation }, + }) + + await view.getByRole('button', { name: 'Add item' }).click() + await expect.element(view.getByTestId('length')).toHaveTextContent('2') + await expect.element(view.getByTestId('validating')).toHaveTextContent('true') + + resolveValidation('Array error') + await expect + .element(view.getByTestId('validating')) + .toHaveTextContent('false') + await expect + .element(view.getByTestId('errors')) + .toHaveTextContent('Array error') + await expect.element(view.getByTestId('length')).toHaveTextContent('2') +}) diff --git a/packages/vue-form/tests/field-reactivity.spec.ts b/packages/vue-form/tests/field-reactivity.spec.ts new file mode 100644 index 000000000..1038d2e87 --- /dev/null +++ b/packages/vue-form/tests/field-reactivity.spec.ts @@ -0,0 +1,108 @@ +import { render } from 'vitest-browser-vue' +import { describe, expect, it, vi } from 'vitest' +import FieldReactivity from './fixtures/FieldReactivity.vue' + +describe.each(['slot', 'injected'] as const)( + 'compiled Vue field consumers (%s)', + (mode) => { + it('updates value, synchronous errors, and pending asynchronous validation without remounting', async () => { + let resolveValidation!: (error: string | null) => void + const validation = new Promise((resolve) => { + resolveValidation = resolve + }) + const onFieldMount = vi.fn() + const view = await render(FieldReactivity, { + props: { + mode, + onFieldMount, + validate: (value) => { + if (value === 'bad') return 'Synchronous error' + if (value === 'pending') return validation + return null + }, + }, + }) + const input = view.getByRole('textbox', { name: 'Value' }) + + await input.fill('bad') + await expect.element(view.getByTestId('value')).toHaveTextContent('bad') + await expect + .element(view.getByTestId('meta-errors')) + .toHaveTextContent('Synchronous error') + await expect + .element(view.getByTestId('errors')) + .toHaveTextContent('Synchronous error') + await expect + .element(view.getByTestId('touched')) + .toHaveTextContent('true') + + await input.fill('valid') + await expect.element(view.getByTestId('errors')).toBeEmptyDOMElement() + await expect + .element(view.getByTestId('meta-errors')) + .toBeEmptyDOMElement() + + await input.fill('pending') + await expect + .element(view.getByTestId('validating')) + .toHaveTextContent('true') + resolveValidation('Asynchronous error') + await expect + .element(view.getByTestId('validating')) + .toHaveTextContent('false') + await expect + .element(view.getByTestId('meta-errors')) + .toHaveTextContent('Asynchronous error') + await expect + .element(view.getByTestId('errors')) + .toHaveTextContent('Asynchronous error') + + await input.fill('valid again') + await expect.element(view.getByTestId('errors')).toBeEmptyDOMElement() + await expect + .element(view.getByTestId('meta-errors')) + .toBeEmptyDOMElement() + expect(onFieldMount).toHaveBeenCalledOnce() + }) + + it('reads and writes the current field after reset and a name change without remounting', async () => { + const onFieldMount = vi.fn() + const view = await render(FieldReactivity, { + props: { mode, onFieldMount, validate: () => null }, + }) + const input = view.getByRole('textbox', { name: 'Value' }) + + await input.fill('Changed') + await expect + .element(view.getByTestId('first-value')) + .toHaveTextContent('Changed') + await view.getByRole('button', { name: 'Reset' }).click() + await expect.element(input).toHaveValue('First') + await expect + .element(view.getByTestId('touched')) + .toHaveTextContent('false') + await input.fill('After reset') + await expect + .element(view.getByTestId('first-value')) + .toHaveTextContent('After reset') + await expect + .element(view.getByTestId('value')) + .toHaveTextContent('After reset') + + await view.getByRole('button', { name: 'Switch field' }).click() + await expect.element(view.getByTestId('name')).toHaveTextContent('second') + await expect.element(input).toHaveValue('Second') + await input.fill('Updated second') + await expect + .element(view.getByTestId('value')) + .toHaveTextContent('Updated second') + await expect + .element(view.getByTestId('second-value')) + .toHaveTextContent('Updated second') + await expect + .element(view.getByTestId('first-value')) + .toHaveTextContent('After reset') + expect(onFieldMount).toHaveBeenCalledOnce() + }) + }, +) diff --git a/packages/vue-form/tests/fixtures/ArrayFieldControl.vue b/packages/vue-form/tests/fixtures/ArrayFieldControl.vue new file mode 100644 index 000000000..1a9577fbd --- /dev/null +++ b/packages/vue-form/tests/fixtures/ArrayFieldControl.vue @@ -0,0 +1,14 @@ + + + diff --git a/packages/vue-form/tests/fixtures/ArrayFieldReactivity.vue b/packages/vue-form/tests/fixtures/ArrayFieldReactivity.vue new file mode 100644 index 000000000..a95d5029d --- /dev/null +++ b/packages/vue-form/tests/fixtures/ArrayFieldReactivity.vue @@ -0,0 +1,17 @@ + + + diff --git a/packages/vue-form/tests/fixtures/FieldControl.vue b/packages/vue-form/tests/fixtures/FieldControl.vue new file mode 100644 index 000000000..bf674d592 --- /dev/null +++ b/packages/vue-form/tests/fixtures/FieldControl.vue @@ -0,0 +1,32 @@ + + + diff --git a/packages/vue-form/tests/fixtures/FieldReactivity.vue b/packages/vue-form/tests/fixtures/FieldReactivity.vue new file mode 100644 index 000000000..aaea97fb9 --- /dev/null +++ b/packages/vue-form/tests/fixtures/FieldReactivity.vue @@ -0,0 +1,46 @@ + + + diff --git a/packages/vue-form/vitest.config.ts b/packages/vue-form/vitest.config.ts index 9bccaba0b..cd8409ad2 100644 --- a/packages/vue-form/vitest.config.ts +++ b/packages/vue-form/vitest.config.ts @@ -18,7 +18,7 @@ export default defineConfig({ instances: [{ browser: 'chromium', headless: true }], }, coverage: { enabled: true, provider: 'istanbul', include: ['src/**/*'] }, - typecheck: { enabled: true }, + typecheck: { enabled: true, checker: 'vue-tsc' }, }, oxc: { jsx: {