Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
250d55d
chore(vitest): migrate to vitest browser mode
harry-whorlow Sep 17, 2026
7a9f67d
fix: import vite types
harry-whorlow Sep 17, 2026
063d323
chore: update some deprecated angular stuff
harry-whorlow Sep 17, 2026
5578536
chore: define process.env in vite conf
harry-whorlow Sep 18, 2026
5bff2ad
chore: migrate to vitest-browser-vue
harry-whorlow Sep 18, 2026
ab28a6d
chore: migrate to vitest-browser-react - react
harry-whorlow Sep 18, 2026
c9db721
chore: migrate to vitest-browser-react - next
harry-whorlow Sep 18, 2026
d27ca23
chore: migrate to vitest-browser-react - start
harry-whorlow Sep 18, 2026
52e5050
chore: migrate to vitest-browser-svelte
harry-whorlow Sep 18, 2026
ba95257
chore: migrate to vitest-browser-angular
harry-whorlow Sep 18, 2026
18305fd
chore: migrate to vitest-browser-solid
harry-whorlow Sep 18, 2026
5a6cb37
chore: migrate to vitest browser for lit
harry-whorlow Sep 18, 2026
765a27c
chore: that thing called knip
harry-whorlow Sep 21, 2026
406effd
fix: knip was a little too eager
harry-whorlow Sep 21, 2026
8eefe2c
ci: apply automated fixes and generate docs
autofix-ci[bot] Sep 21, 2026
b504724
Merge branch 'alpha' into vite-browser-mode
crutchcorn Sep 22, 2026
7f28e6e
chore: replace renderHooks APIs
crutchcorn Sep 22, 2026
0407a89
chore: fix React tests
crutchcorn Sep 22, 2026
a5cd6e8
chore: migrate Preact to Vitest library
crutchcorn Sep 22, 2026
d997654
chore: fix Svelte and Vue tests
crutchcorn Sep 22, 2026
9cdc112
chore: remove optimizeDeps for testing
crutchcorn Sep 22, 2026
9f886d2
chore: remove more optimizedeps
crutchcorn Sep 22, 2026
37e2119
chore: fix CI
crutchcorn Sep 22, 2026
f5f142b
fix: Vue reactivity in simple example now works
crutchcorn Sep 22, 2026
084d970
Update packages/preact-form/tsconfig.json
crutchcorn Sep 22, 2026
e187170
Merge branch 'alpha' into alpha-fix-vue
crutchcorn Sep 22, 2026
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/silent-tools-cry.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion packages/preact-form/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
6 changes: 3 additions & 3 deletions packages/vue-form/src/AppForm/contexts.lib.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
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(
'TanStackForm.FormContext',
) as InjectionKey<InternalVueFormApi>
export const FieldContext = Symbol(
'TanStackForm.FieldContext',
) as InjectionKey<AnyInternalFieldApi>
) as InjectionKey<AnyFieldApi>

export function useFieldContext(): AnyInternalFieldApi {
export function useFieldContext(): AnyFieldApi {
const field = inject(FieldContext)
if (field === undefined) {
throw new Error(
Expand Down
20 changes: 10 additions & 10 deletions packages/vue-form/src/VueForm/Components.lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -26,7 +27,7 @@ import type { InternalVueFormApi } from './VueFormApi.lib'
export function attachVueFormComponents(
form: AnyInternalFormApi,
fieldComponents: Record<string, Component> | null,
fieldContext?: InjectionKey<AnyInternalFieldApi>,
fieldContext?: InjectionKey<AnyFieldApi>,
): InternalVueFormApi {
const resultForm = form as InternalVueFormApi
resultForm.Field = createFieldComponent(
Expand All @@ -50,26 +51,25 @@ function createFieldComponent(
form: AnyInternalFormApi,
fieldComponents: Record<string, Component> | null,
array: boolean,
fieldContext?: InjectionKey<AnyInternalFieldApi>,
fieldContext?: InjectionKey<AnyFieldApi>,
) {
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 })
}
},
{
Expand Down
7 changes: 6 additions & 1 deletion packages/vue-form/src/VueForm/fieldSubscriptions.lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,17 @@ function createFieldSelection<TSelected>(
selector: (field: AnyInternalFieldApi) => TSelected,
) {
const selected = shallowRef(selector(fieldApi.value)) as ShallowRef<TSelected>
// 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
})
Expand All @@ -25,7 +30,7 @@ function createFieldSelection<TSelected>(
{ immediate: true, flush: 'sync' },
)

return selected
return { selection: selected, meta }
}

export function createValueFieldSubscription(
Expand Down
50 changes: 50 additions & 0 deletions packages/vue-form/src/VueForm/fieldView.lib.ts
Original file line number Diff line number Diff line change
@@ -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<AnyInternalFieldApi>,
selection: ShallowRef<unknown>,
meta: ShallowRef<AnyFieldMeta>,
): 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),
}
}
7 changes: 2 additions & 5 deletions packages/vue-form/src/VueForm/useField.lib.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -14,22 +14,19 @@ export interface InternalFieldProps {

export function useField(
options: () => InternalFieldProps,
fieldComponents: Record<string, Component> | null,
): ShallowRef<AnyInternalFieldApi> {
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())
Expand Down
26 changes: 26 additions & 0 deletions packages/vue-form/tests/array-field-reactivity.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string>((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')
})
108 changes: 108 additions & 0 deletions packages/vue-form/tests/field-reactivity.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string | null>((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()
})
},
)
14 changes: 14 additions & 0 deletions packages/vue-form/tests/fixtures/ArrayFieldControl.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { FieldWithValue } from '../../src'

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

<template>
<button @click="field.pushValue('Next')">Add item</button>
<output data-testid="length">{{ field.value.length }}</output>
<output data-testid="validating">{{ field.meta.isValidating }}</output>
<output data-testid="errors">{{
field.errors.map((error) => error.message).join(',')
}}</output>
</template>
17 changes: 17 additions & 0 deletions packages/vue-form/tests/fixtures/ArrayFieldReactivity.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<script setup lang="ts">
import { useForm } from '../../src'
import ArrayFieldControl from './ArrayFieldControl.vue'

const props = defineProps<{ validate: () => Promise<string> }>()
const form = useForm({ defaultValues: { items: ['First'] } })
</script>

<template>
<form.ArrayField
name="items"
:validators="[{ triggers: ['change'], run: () => props.validate() }]"
v-slot="{ field }"
>
<ArrayFieldControl :field="field" />
</form.ArrayField>
</template>
Loading
Loading