Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/keyed-array-field-names.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions packages/vue-form/src/VueForm/Components.lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
2 changes: 2 additions & 0 deletions packages/vue-form/src/VueForm/fieldView.lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type { ShallowRef } from 'vue'
*/
export function createFieldView(
fieldApi: ShallowRef<AnyInternalFieldApi>,
name: Readonly<ShallowRef<string>>,
selection: ShallowRef<unknown>,
meta: ShallowRef<AnyFieldMeta>,
): AnyFieldApi {
Expand All @@ -17,6 +18,7 @@ export function createFieldView(
return fieldApi.value.form
},
get name() {
void name.value
return fieldApi.value.name
},
get atom() {
Expand Down
18 changes: 14 additions & 4 deletions packages/vue-form/src/VueForm/useField.lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,17 @@ export interface InternalFieldProps {
[key: string]: unknown
}

export function useField(
options: () => InternalFieldProps,
): ShallowRef<AnyInternalFieldApi> {
export interface FieldBinding {
fieldApi: ShallowRef<AnyInternalFieldApi>
/**
* 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<ShallowRef<string>>
}

export function useField(options: () => InternalFieldProps): FieldBinding {
const initialOptions = options()
const resetVersion = useSelector(initialOptions.form._atoms.resetVersion)

Expand All @@ -30,11 +38,13 @@ export function useField(
}

const fieldApi = shallowRef(createField())
const name = shallowRef<string>(fieldApi.value.name)

watch(
[() => options().form, () => options().name, resetVersion],
() => {
fieldApi.value = createField()
name.value = fieldApi.value.name
},
{ flush: 'sync' },
)
Expand All @@ -60,5 +70,5 @@ export function useField(
})
onUnmounted(() => unregister?.())

return fieldApi
return { fieldApi, name }
}
70 changes: 70 additions & 0 deletions packages/vue-form/tests/array-field-names.spec.ts
Original file line number Diff line number Diff line change
@@ -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)
},
)
},
)
48 changes: 48 additions & 0 deletions packages/vue-form/tests/fixtures/ArrayFieldNames.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<script setup lang="ts">
import { createFormHook, getFormHookHelpers } from '../../src'
import ArrayItemControl from './ArrayItemControl.vue'

defineProps<{ mode: 'slot' | 'injected' }>()

const { fieldComponent } = getFormHookHelpers()
const { useAppForm } = createFormHook({
fieldComponents: {
ItemControl: fieldComponent.strict(ArrayItemControl, 'field'),
},
formComponents: {},
})
const form = useAppForm({
defaultValues: { items: [] as Array<{ id: string; title: string }> },
})
// Push the items instead of using defaultValues: with no default at any index,
// moving a field leaves its value and meta unchanged, so only `name` changes.
form.pushFieldValue('items', { id: 'a', title: 'A' })
form.pushFieldValue('items', { id: 'b', title: 'B' })
form.pushFieldValue('items', { id: 'c', title: 'C' })
</script>

<template>
<form.ArrayField name="items" v-slot="{ field: array }">
<div v-for="(item, index) in array.value" :key="item.id">
<form.Field :name="`items[${index}].title`" v-slot="{ field }">
<ArrayItemControl
v-if="mode === 'slot'"
:field="field"
:item-id="item.id"
/>
<field.ItemControl v-else :item-id="item.id" />
</form.Field>
</div>
<button @click="array.removeValue(0)">Remove first</button>
<button @click="array.swapValues(0, 1)">Swap first two</button>
<button @click="array.moveValue(0, 2)">Move first to last</button>
<button @click="array.insertValue(0, { id: 'z', title: 'Z' })">
Insert first
</button>
</form.ArrayField>
<form.Subscribe :selector="(state) => state.values.items" v-slot="items">
<output data-testid="titles">{{
items.map((item) => `${item.id}:${item.title}`).join(',')
}}</output>
</form.Subscribe>
</template>
15 changes: 15 additions & 0 deletions packages/vue-form/tests/fixtures/ArrayItemControl.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { FieldWithValue } from '../../src'

defineProps<{ field: FieldWithValue<string>; itemId: string }>()
</script>

<template>
<input
:aria-label="itemId"
:name="field.name"
:value="field.value"
@input="field.handleChange(($event.target as HTMLInputElement).value)"
/>
<output :data-testid="`name-${itemId}`">{{ field.name }}</output>
</template>