Skip to content

Commit 541abc8

Browse files
crutchcornharry-whorlowautofix-ci[bot]coderabbitai[bot]
authored
[v2] Vue reactivity now works (#2401)
* chore(vitest): migrate to vitest browser mode * fix: import vite types * chore: update some deprecated angular stuff * chore: define process.env in vite conf * chore: migrate to vitest-browser-vue * chore: migrate to vitest-browser-react - react * chore: migrate to vitest-browser-react - next * chore: migrate to vitest-browser-react - start * chore: migrate to vitest-browser-svelte * chore: migrate to vitest-browser-angular * chore: migrate to vitest-browser-solid * chore: migrate to vitest browser for lit * chore: that thing called knip * fix: knip was a little too eager * ci: apply automated fixes and generate docs * chore: replace renderHooks APIs * chore: fix React tests * chore: migrate Preact to Vitest library * chore: fix Svelte and Vue tests * chore: remove optimizeDeps for testing * chore: remove more optimizedeps * chore: fix CI * fix: Vue reactivity in simple example now works * Update packages/preact-form/tsconfig.json Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --------- Co-authored-by: Harry Whorlow <whorlowharry@gmail.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
1 parent dc92e8a commit 541abc8

14 files changed

Lines changed: 321 additions & 21 deletions

‎.changeset/silent-tools-cry.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@tanstack/vue-form': patch
3+
---
4+
5+
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.

‎packages/preact-form/tsconfig.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"jsx": "react-jsx",
55
"jsxImportSource": "preact",
66
"moduleResolution": "Bundler",
7-
"types": ["vitest/browser"]
7+
"types": ["vitest/browser", "node"]
88
},
99
"include": ["src", "tests", "vitest.config.ts"],
1010
"exclude": ["eslint.config.js"]

‎packages/vue-form/src/AppForm/contexts.lib.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
11
import { inject } from 'vue'
22
import type { InjectionKey } from 'vue'
3-
import type { AnyInternalFieldApi } from '@tanstack/form-core/internals'
3+
import type { AnyFieldApi } from '@tanstack/form-core'
44
import type { InternalVueFormApi } from '../VueForm/VueFormApi.lib'
55

66
export const FormContext = Symbol(
77
'TanStackForm.FormContext',
88
) as InjectionKey<InternalVueFormApi>
99
export const FieldContext = Symbol(
1010
'TanStackForm.FieldContext',
11-
) as InjectionKey<AnyInternalFieldApi>
11+
) as InjectionKey<AnyFieldApi>
1212

13-
export function useFieldContext(): AnyInternalFieldApi {
13+
export function useFieldContext(): AnyFieldApi {
1414
const field = inject(FieldContext)
1515
if (field === undefined) {
1616
throw new Error(

‎packages/vue-form/src/VueForm/Components.lib.ts‎

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,12 @@ import {
1313
createArrayFieldSubscription,
1414
createValueFieldSubscription,
1515
} from './fieldSubscriptions.lib'
16+
import { createFieldView } from './fieldView.lib'
1617
import { useField } from './useField.lib'
18+
import type { AnyFieldApi } from '@tanstack/form-core'
1719
import type { Component, InjectionKey, Slots } from 'vue'
1820
import type {
1921
AnyFieldApiOptions,
20-
AnyInternalFieldApi,
2122
AnyInternalFormApi,
2223
InternalFormGroupApi as InternalFormGroupApiType,
2324
} from '@tanstack/form-core/internals'
@@ -26,7 +27,7 @@ import type { InternalVueFormApi } from './VueFormApi.lib'
2627
export function attachVueFormComponents(
2728
form: AnyInternalFormApi,
2829
fieldComponents: Record<string, Component> | null,
29-
fieldContext?: InjectionKey<AnyInternalFieldApi>,
30+
fieldContext?: InjectionKey<AnyFieldApi>,
3031
): InternalVueFormApi {
3132
const resultForm = form as InternalVueFormApi
3233
resultForm.Field = createFieldComponent(
@@ -50,26 +51,25 @@ function createFieldComponent(
5051
form: AnyInternalFormApi,
5152
fieldComponents: Record<string, Component> | null,
5253
array: boolean,
53-
fieldContext?: InjectionKey<AnyInternalFieldApi>,
54+
fieldContext?: InjectionKey<AnyFieldApi>,
5455
) {
5556
return defineComponent(
5657
(_props, context) => {
5758
const options = () => ({ ...context.attrs, form }) as never
58-
const fieldApi = useField(options, fieldComponents)
59-
const selection = array
59+
const fieldApi = useField(options)
60+
const { selection, meta } = array
6061
? createArrayFieldSubscription(fieldApi)
6162
: createValueFieldSubscription(fieldApi)
63+
const field = createFieldView(fieldApi, selection, meta)
64+
if (fieldComponents !== null) Object.assign(field, fieldComponents)
6265

6366
if (fieldContext) {
64-
// Field APIs are stable for a mounted name. Supplying the current API
65-
// mirrors Vue v1 composition components while the parent subscription
66-
// handles state-driven renders.
67-
provide(fieldContext, fieldApi.value)
67+
provide(fieldContext, field)
6868
}
6969

7070
return () => {
7171
void selection.value
72-
return context.slots.default?.({ field: fieldApi.value })
72+
return context.slots.default?.({ field })
7373
}
7474
},
7575
{

‎packages/vue-form/src/VueForm/fieldSubscriptions.lib.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,17 @@ function createFieldSelection<TSelected>(
1111
selector: (field: AnyInternalFieldApi) => TSelected,
1212
) {
1313
const selected = shallowRef(selector(fieldApi.value)) as ShallowRef<TSelected>
14+
// Metadata can change without an array's structure changing. Keep it
15+
// reactive for metadata consumers without invalidating value-only slots.
16+
const meta = shallowRef(fieldApi.value.meta)
1417

1518
watch(
1619
fieldApi,
1720
(field, _previous, onCleanup) => {
21+
meta.value = field.meta
1822
selected.value = selector(field)
1923
const subscription = field.atom.subscribe(() => {
24+
meta.value = field.meta
2025
const next = selector(field)
2126
if (!shallow(toRaw(selected.value), next)) selected.value = next
2227
})
@@ -25,7 +30,7 @@ function createFieldSelection<TSelected>(
2530
{ immediate: true, flush: 'sync' },
2631
)
2732

28-
return selected
33+
return { selection: selected, meta }
2934
}
3035

3136
export function createValueFieldSubscription(
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import type { AnyFieldApi, AnyFieldMeta } from '@tanstack/form-core'
2+
import type { AnyInternalFieldApi } from '@tanstack/form-core/internals'
3+
import type { ShallowRef } from 'vue'
4+
5+
/**
6+
* The public field shared by slots and injected components. State getters
7+
* track the existing subscription in the component that reads them, while
8+
* handlers follow the current core field after a reset or name change.
9+
*/
10+
export function createFieldView(
11+
fieldApi: ShallowRef<AnyInternalFieldApi>,
12+
selection: ShallowRef<unknown>,
13+
meta: ShallowRef<AnyFieldMeta>,
14+
): AnyFieldApi {
15+
return {
16+
get form() {
17+
return fieldApi.value.form
18+
},
19+
get name() {
20+
return fieldApi.value.name
21+
},
22+
get atom() {
23+
return fieldApi.value.atom
24+
},
25+
get value() {
26+
void selection.value
27+
return fieldApi.value.value
28+
},
29+
get meta() {
30+
return meta.value
31+
},
32+
get errors() {
33+
return meta.value.errors
34+
},
35+
handleChange: (value, options) =>
36+
fieldApi.value.handleChange(value, options),
37+
handleBlur: () => fieldApi.value.handleBlur(),
38+
reset: () => fieldApi.value.reset(),
39+
swapValues: (indexA, indexB) => fieldApi.value.swapValues(indexA, indexB),
40+
moveValue: (fromIndex, toIndex, options) =>
41+
fieldApi.value.moveValue(fromIndex, toIndex, options),
42+
pushValue: (value, options) => fieldApi.value.pushValue(value, options),
43+
insertValue: (index, value, options) =>
44+
fieldApi.value.insertValue(index, value, options),
45+
clearValues: (options) => fieldApi.value.clearValues(options),
46+
removeValue: (index, options) => fieldApi.value.removeValue(index, options),
47+
filterValues: (predicate, options) =>
48+
fieldApi.value.filterValues(predicate, options),
49+
}
50+
}

‎packages/vue-form/src/VueForm/useField.lib.ts‎

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { useSelector } from '@tanstack/vue-store'
22
import { onMounted, onUnmounted, shallowRef, watch, watchEffect } from 'vue'
3-
import type { Component, ShallowRef } from 'vue'
3+
import type { ShallowRef } from 'vue'
44
import type {
55
AnyInternalFieldApi,
66
AnyInternalFormApi,
@@ -14,22 +14,19 @@ export interface InternalFieldProps {
1414

1515
export function useField(
1616
options: () => InternalFieldProps,
17-
fieldComponents: Record<string, Component> | null,
1817
): ShallowRef<AnyInternalFieldApi> {
1918
const initialOptions = options()
2019
const resetVersion = useSelector(initialOptions.form._atoms.resetVersion)
2120

2221
const createField = () => {
2322
const current = options()
24-
const field = current.form._getOrCreateFieldApi(
23+
return current.form._getOrCreateFieldApi(
2524
{
2625
...current,
2726
name: current.name,
2827
} as never,
2928
'field',
3029
)
31-
if (fieldComponents !== null) Object.assign(field, fieldComponents)
32-
return field
3330
}
3431

3532
const fieldApi = shallowRef(createField())
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { render } from 'vitest-browser-vue'
2+
import { expect, it } from 'vitest'
3+
import ArrayFieldReactivity from './fixtures/ArrayFieldReactivity.vue'
4+
5+
it('updates ArrayField validation in a compiled child without another structural change', async () => {
6+
let resolveValidation!: (error: string) => void
7+
const validation = new Promise<string>((resolve) => {
8+
resolveValidation = resolve
9+
})
10+
const view = await render(ArrayFieldReactivity, {
11+
props: { validate: () => validation },
12+
})
13+
14+
await view.getByRole('button', { name: 'Add item' }).click()
15+
await expect.element(view.getByTestId('length')).toHaveTextContent('2')
16+
await expect.element(view.getByTestId('validating')).toHaveTextContent('true')
17+
18+
resolveValidation('Array error')
19+
await expect
20+
.element(view.getByTestId('validating'))
21+
.toHaveTextContent('false')
22+
await expect
23+
.element(view.getByTestId('errors'))
24+
.toHaveTextContent('Array error')
25+
await expect.element(view.getByTestId('length')).toHaveTextContent('2')
26+
})
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { render } from 'vitest-browser-vue'
2+
import { describe, expect, it, vi } from 'vitest'
3+
import FieldReactivity from './fixtures/FieldReactivity.vue'
4+
5+
describe.each(['slot', 'injected'] as const)(
6+
'compiled Vue field consumers (%s)',
7+
(mode) => {
8+
it('updates value, synchronous errors, and pending asynchronous validation without remounting', async () => {
9+
let resolveValidation!: (error: string | null) => void
10+
const validation = new Promise<string | null>((resolve) => {
11+
resolveValidation = resolve
12+
})
13+
const onFieldMount = vi.fn()
14+
const view = await render(FieldReactivity, {
15+
props: {
16+
mode,
17+
onFieldMount,
18+
validate: (value) => {
19+
if (value === 'bad') return 'Synchronous error'
20+
if (value === 'pending') return validation
21+
return null
22+
},
23+
},
24+
})
25+
const input = view.getByRole('textbox', { name: 'Value' })
26+
27+
await input.fill('bad')
28+
await expect.element(view.getByTestId('value')).toHaveTextContent('bad')
29+
await expect
30+
.element(view.getByTestId('meta-errors'))
31+
.toHaveTextContent('Synchronous error')
32+
await expect
33+
.element(view.getByTestId('errors'))
34+
.toHaveTextContent('Synchronous error')
35+
await expect
36+
.element(view.getByTestId('touched'))
37+
.toHaveTextContent('true')
38+
39+
await input.fill('valid')
40+
await expect.element(view.getByTestId('errors')).toBeEmptyDOMElement()
41+
await expect
42+
.element(view.getByTestId('meta-errors'))
43+
.toBeEmptyDOMElement()
44+
45+
await input.fill('pending')
46+
await expect
47+
.element(view.getByTestId('validating'))
48+
.toHaveTextContent('true')
49+
resolveValidation('Asynchronous error')
50+
await expect
51+
.element(view.getByTestId('validating'))
52+
.toHaveTextContent('false')
53+
await expect
54+
.element(view.getByTestId('meta-errors'))
55+
.toHaveTextContent('Asynchronous error')
56+
await expect
57+
.element(view.getByTestId('errors'))
58+
.toHaveTextContent('Asynchronous error')
59+
60+
await input.fill('valid again')
61+
await expect.element(view.getByTestId('errors')).toBeEmptyDOMElement()
62+
await expect
63+
.element(view.getByTestId('meta-errors'))
64+
.toBeEmptyDOMElement()
65+
expect(onFieldMount).toHaveBeenCalledOnce()
66+
})
67+
68+
it('reads and writes the current field after reset and a name change without remounting', async () => {
69+
const onFieldMount = vi.fn()
70+
const view = await render(FieldReactivity, {
71+
props: { mode, onFieldMount, validate: () => null },
72+
})
73+
const input = view.getByRole('textbox', { name: 'Value' })
74+
75+
await input.fill('Changed')
76+
await expect
77+
.element(view.getByTestId('first-value'))
78+
.toHaveTextContent('Changed')
79+
await view.getByRole('button', { name: 'Reset' }).click()
80+
await expect.element(input).toHaveValue('First')
81+
await expect
82+
.element(view.getByTestId('touched'))
83+
.toHaveTextContent('false')
84+
await input.fill('After reset')
85+
await expect
86+
.element(view.getByTestId('first-value'))
87+
.toHaveTextContent('After reset')
88+
await expect
89+
.element(view.getByTestId('value'))
90+
.toHaveTextContent('After reset')
91+
92+
await view.getByRole('button', { name: 'Switch field' }).click()
93+
await expect.element(view.getByTestId('name')).toHaveTextContent('second')
94+
await expect.element(input).toHaveValue('Second')
95+
await input.fill('Updated second')
96+
await expect
97+
.element(view.getByTestId('value'))
98+
.toHaveTextContent('Updated second')
99+
await expect
100+
.element(view.getByTestId('second-value'))
101+
.toHaveTextContent('Updated second')
102+
await expect
103+
.element(view.getByTestId('first-value'))
104+
.toHaveTextContent('After reset')
105+
expect(onFieldMount).toHaveBeenCalledOnce()
106+
})
107+
},
108+
)
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<script setup lang="ts">
2+
import type { FieldWithValue } from '../../src'
3+
4+
defineProps<{ field: FieldWithValue<Array<string>> }>()
5+
</script>
6+
7+
<template>
8+
<button @click="field.pushValue('Next')">Add item</button>
9+
<output data-testid="length">{{ field.value.length }}</output>
10+
<output data-testid="validating">{{ field.meta.isValidating }}</output>
11+
<output data-testid="errors">{{
12+
field.errors.map((error) => error.message).join(',')
13+
}}</output>
14+
</template>

0 commit comments

Comments
 (0)