diff --git a/.changeset/keyed-array-field-names.md b/.changeset/keyed-array-field-names.md new file mode 100644 index 0000000000..9b864e16ea --- /dev/null +++ b/.changeset/keyed-array-field-names.md @@ -0,0 +1,5 @@ +--- +'@tanstack/vue-form': patch +--- + +Keep `field.name` reactive in child and injected field components when an array mutation moves a keyed item's field to a new index. diff --git a/packages/vue-form/src/VueForm/Components.lib.ts b/packages/vue-form/src/VueForm/Components.lib.ts index 23636f4eaf..2073db5303 100644 --- a/packages/vue-form/src/VueForm/Components.lib.ts +++ b/packages/vue-form/src/VueForm/Components.lib.ts @@ -56,11 +56,11 @@ function createFieldComponent( return defineComponent( (_props, context) => { const options = () => ({ ...context.attrs, form }) as never - const fieldApi = useField(options) + const { fieldApi, name } = useField(options) const { selection, meta } = array ? createArrayFieldSubscription(fieldApi) : createValueFieldSubscription(fieldApi) - const field = createFieldView(fieldApi, selection, meta) + const field = createFieldView(fieldApi, name, selection, meta) if (fieldComponents !== null) Object.assign(field, fieldComponents) if (fieldContext) { diff --git a/packages/vue-form/src/VueForm/fieldView.lib.ts b/packages/vue-form/src/VueForm/fieldView.lib.ts index dd8e366f62..37ecfbad88 100644 --- a/packages/vue-form/src/VueForm/fieldView.lib.ts +++ b/packages/vue-form/src/VueForm/fieldView.lib.ts @@ -9,6 +9,7 @@ import type { ShallowRef } from 'vue' */ export function createFieldView( fieldApi: ShallowRef, + name: Readonly>, selection: ShallowRef, meta: ShallowRef, ): AnyFieldApi { @@ -17,6 +18,7 @@ export function createFieldView( return fieldApi.value.form }, get name() { + void name.value return fieldApi.value.name }, get atom() { diff --git a/packages/vue-form/src/VueForm/useField.lib.ts b/packages/vue-form/src/VueForm/useField.lib.ts index 418c74b8be..1ab80e3ae0 100644 --- a/packages/vue-form/src/VueForm/useField.lib.ts +++ b/packages/vue-form/src/VueForm/useField.lib.ts @@ -12,9 +12,17 @@ export interface InternalFieldProps { [key: string]: unknown } -export function useField( - options: () => InternalFieldProps, -): ShallowRef { +export interface FieldBinding { + fieldApi: ShallowRef + /** + * The resolved field's name. Array mutations move existing field APIs to new + * names, so a keyed item can resolve to the same field API under a new name. + * `fieldApi` does not change in that case, so the name is tracked separately. + */ + name: Readonly> +} + +export function useField(options: () => InternalFieldProps): FieldBinding { const initialOptions = options() const resetVersion = useSelector(initialOptions.form._atoms.resetVersion) @@ -30,11 +38,13 @@ export function useField( } const fieldApi = shallowRef(createField()) + const name = shallowRef(fieldApi.value.name) watch( [() => options().form, () => options().name, resetVersion], () => { fieldApi.value = createField() + name.value = fieldApi.value.name }, { flush: 'sync' }, ) @@ -60,5 +70,5 @@ export function useField( }) onUnmounted(() => unregister?.()) - return fieldApi + return { fieldApi, name } } diff --git a/packages/vue-form/tests/array-field-names.spec.ts b/packages/vue-form/tests/array-field-names.spec.ts new file mode 100644 index 0000000000..38c13afd5b --- /dev/null +++ b/packages/vue-form/tests/array-field-names.spec.ts @@ -0,0 +1,70 @@ +import { render } from 'vitest-browser-vue' +import { describe, expect, it } from 'vitest' +import ArrayFieldNames from './fixtures/ArrayFieldNames.vue' + +// Array mutations move existing field APIs to new names. With keyed items the +// same field API is reused under the new name, so nothing else about the field +// changes and consumers must still pick up the new `name`. +const cases = [ + { + action: 'Remove first', + titles: 'b:B,c:C', + names: { b: 'items[0].title', c: 'items[1].title' }, + edit: { id: 'b', value: 'B2', titles: 'b:B2,c:C' }, + }, + { + action: 'Swap first two', + titles: 'b:B,a:A,c:C', + names: { b: 'items[0].title', a: 'items[1].title', c: 'items[2].title' }, + edit: { id: 'a', value: 'A2', titles: 'b:B,a:A2,c:C' }, + }, + { + action: 'Move first to last', + titles: 'b:B,c:C,a:A', + names: { b: 'items[0].title', c: 'items[1].title', a: 'items[2].title' }, + edit: { id: 'a', value: 'A2', titles: 'b:B,c:C,a:A2' }, + }, + { + action: 'Insert first', + titles: 'z:Z,a:A,b:B,c:C', + names: { + z: 'items[0].title', + a: 'items[1].title', + b: 'items[2].title', + c: 'items[3].title', + }, + edit: { id: 'c', value: 'C2', titles: 'z:Z,a:A,b:B,c:C2' }, + }, +] as const + +describe.each(['slot', 'injected'] as const)( + 'Vue field names after array mutations (%s)', + (mode) => { + it.each(cases)( + '$action keeps names current for keyed items', + async ({ action, titles, names, edit }) => { + const view = await render(ArrayFieldNames, { props: { mode } }) + + await view.getByRole('button', { name: action }).click() + await expect + .element(view.getByTestId('titles')) + .toHaveTextContent(titles) + + for (const [id, name] of Object.entries(names)) { + await expect + .element(view.getByTestId(`name-${id}`)) + .toHaveTextContent(name) + await expect + .element(view.getByRole('textbox', { name: id })) + .toHaveAttribute('name', name) + } + + // Handlers still target the moved field. + await view.getByRole('textbox', { name: edit.id }).fill(edit.value) + await expect + .element(view.getByTestId('titles')) + .toHaveTextContent(edit.titles) + }, + ) + }, +) diff --git a/packages/vue-form/tests/fixtures/ArrayFieldNames.vue b/packages/vue-form/tests/fixtures/ArrayFieldNames.vue new file mode 100644 index 0000000000..11e398da3b --- /dev/null +++ b/packages/vue-form/tests/fixtures/ArrayFieldNames.vue @@ -0,0 +1,48 @@ + + + diff --git a/packages/vue-form/tests/fixtures/ArrayItemControl.vue b/packages/vue-form/tests/fixtures/ArrayItemControl.vue new file mode 100644 index 0000000000..6fc2c70b8b --- /dev/null +++ b/packages/vue-form/tests/fixtures/ArrayItemControl.vue @@ -0,0 +1,15 @@ + + +