From cfb8e705ce7f9f3a58049e3a76b1d75422061ea7 Mon Sep 17 00:00:00 2001 From: Roya Date: Sat, 31 Oct 2026 17:29:47 +0800 Subject: [PATCH] fix(vue-form): keep field.name reactive after keyed array mutations Array mutations move existing field APIs to new names. With keyed items, the moved item's Field resolves the same field API under its new name, so the field API ref doesn't change and neither do value or meta. Child and injected consumers that read field.name kept rendering the old name. Track the resolved name in useField alongside the field API and read it from the field view's name getter. This avoids triggering the field API ref, which would unregister and re-register the field and fire mount and unmount listeners on every move. --- .changeset/keyed-array-field-names.md | 5 ++ .../vue-form/src/VueForm/Components.lib.ts | 4 +- .../vue-form/src/VueForm/fieldView.lib.ts | 2 + packages/vue-form/src/VueForm/useField.lib.ts | 18 +++-- .../vue-form/tests/array-field-names.spec.ts | 70 +++++++++++++++++++ .../tests/fixtures/ArrayFieldNames.vue | 48 +++++++++++++ .../tests/fixtures/ArrayItemControl.vue | 15 ++++ 7 files changed, 156 insertions(+), 6 deletions(-) create mode 100644 .changeset/keyed-array-field-names.md create mode 100644 packages/vue-form/tests/array-field-names.spec.ts create mode 100644 packages/vue-form/tests/fixtures/ArrayFieldNames.vue create mode 100644 packages/vue-form/tests/fixtures/ArrayItemControl.vue 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 @@ + + +