@teamnovu/kit-vue-forms 0.2.17 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -18,6 +18,13 @@ export interface FieldItem<Item, Path extends string> {
18
18
  export interface FieldArray<Item, Path extends string> {
19
19
  items: ShallowRef<FieldItem<Item, Path>[]>;
20
20
  push: (item: Item) => FieldItem<Item, Path>;
21
+ /**
22
+ * Pushes a new item and anchors its subtree as the baseline for that index
23
+ * (subtree-scoped `setInitialData`). The new item's subfields are clean from
24
+ * the moment they're registered, while the array field itself stays dirty
25
+ * because its baseline still reflects the external `initialData`.
26
+ */
27
+ pushPristine: (item: Item) => FieldItem<Item, Path>;
21
28
  remove: (id: string) => void;
22
29
  insert: (item: Item, index: number) => FieldItem<Item, Path>;
23
30
  field: FormField<Item[], Path>;
@@ -33,8 +40,15 @@ export interface FormField<T, P extends string> {
33
40
  /**
34
41
  * Sets the initial data for the field. If the field is not dirty, it also updates the current data.
35
42
  * @param newData - The new initial data to set.
43
+ * @param options - Optional. Pass `{ replace: true }` to replace the subtree entirely instead of deep-merging.
44
+ * Pass `{ scope: 'subtree' }` to anchor the baseline only for this field and its descendants — ancestors
45
+ * continue to read from the external initialData and stay dirty if the override changed the tree shape.
46
+ * Defaults to `{ scope: 'tree' }`, which makes the override visible to ancestors as well.
36
47
  */
37
- setInitialData: (newData: T) => void;
48
+ setInitialData: (newData: T, options?: {
49
+ replace?: boolean;
50
+ scope?: 'tree' | 'subtree';
51
+ }) => void;
38
52
  onBlur: () => void;
39
53
  onFocus: () => void;
40
54
  reset: () => void;
@@ -10,5 +10,6 @@ export declare function setNestedValue<T, K extends Paths<T>>(obj: MaybeRef<T>,
10
10
  export declare const getLens: <T, K extends Paths<T>>(data: MaybeRef<T>, key: MaybeRef<K | SplitPath<K>>) => WritableComputedRef<PickProps<T, K>, PickProps<T, K>>;
11
11
  type JoinPath<Base extends string, Sub extends string> = `${Base}${Base extends '' ? '' : Sub extends '' ? '' : '.'}${Sub}`;
12
12
  export declare function joinPath<Base extends string, Sub extends string>(basePath: Base, subPath: Sub): JoinPath<Base, Sub>;
13
+ export declare function dropOverridesAtAndBelow(overrides: Map<string, unknown>, path: string): void;
13
14
  export declare function filterErrorsForPath(errors: ErrorBag, path: string): ErrorBag;
14
15
  export {};
@@ -1,4 +1,4 @@
1
1
  import { Awaitable } from '@vueuse/core';
2
2
  import { ValidationState } from '../composables/useValidation';
3
3
  import { Form, FormDataDefault } from '../types/form';
4
- export declare function makeSubmitHandler<T extends FormDataDefault, TOut = T>(form: Form<T, TOut>, validationState: ValidationState<T, TOut>): (onSubmit: (data: TOut) => Awaitable<void>) => (event?: SubmitEvent) => Promise<void>;
4
+ export declare function makeSubmitHandler<T extends FormDataDefault, TOut = T>(form: Form<T, TOut>, validationState: Pick<ValidationState<T, TOut>, 'canValidate'>): (onSubmit: (data: TOut) => Awaitable<void>) => (event?: SubmitEvent) => Promise<void>;
package/docs/reference.md CHANGED
@@ -116,6 +116,103 @@ interface Form<T extends object, TOut = T> {
116
116
  }
117
117
  ```
118
118
 
119
+ ## Method `setInitialData`
120
+ The `setInitialData` method is available on `FormField` objects (returned by `getField` or `defineField`). It updates what is considered the "initial" value of a field. If the field is not dirty, it also updates the current data.
121
+
122
+ This is useful when loading data asynchronously after the form is initialized:
123
+
124
+ ```typescript
125
+ const { setInitialData } = form.getField('person.firstName')
126
+
127
+ // After fetching data from an API
128
+ const fetchedData = await fetchUserData()
129
+ setInitialData(fetchedData.firstName)
130
+ // If the field wasn't dirty, the current data is also updated
131
+ ```
132
+
133
+ ### Calling `setInitialData` on a parent path
134
+ Overrides set on a parent path propagate down to its subfields. The default behavior is a **deep merge** with the underlying `initialData`, so subfields that are not mentioned in the override fall through to the external value:
135
+
136
+ ```typescript
137
+ const form = useForm({
138
+ initialData: { user: { name: 'A', email: 'x@x' } },
139
+ })
140
+
141
+ form.getField('user').setInitialData({ name: 'B' })
142
+
143
+ form.getField('user.name').initialValue.value // 'B' (from override)
144
+ form.getField('user.email').initialValue.value // 'x@x' (from external)
145
+ form.getField('user.name').dirty.value // false
146
+ ```
147
+
148
+ Pass `{ replace: true }` as the second argument to replace the subtree wholesale instead of merging — keys missing from the override resolve to `undefined`:
149
+
150
+ ```typescript
151
+ form.getField('user').setInitialData({ name: 'B' }, { replace: true })
152
+
153
+ form.getField('user.email').initialValue.value // undefined
154
+ ```
155
+
156
+ ### Scoping the override: `tree` (default) vs `subtree`
157
+ `setInitialData` accepts a `scope` option that controls who sees the new baseline:
158
+
159
+ - **`scope: 'tree'` (default)** — the override becomes part of the global baseline. Both the targeted path *and its ancestors* read it, so the form goes back to non-dirty if `data` matches. Use this when an async load gives you a value that should be treated as if it had been the original initial all along.
160
+ - **`scope: 'subtree'`** — the override anchors the baseline only for the targeted path and its descendants. Strict ancestors keep reading the external `initialData`, so they stay dirty when the change altered the tree shape (e.g. a new array item). Use this when something was added that the user should still be warned about losing on navigation, but whose internal fields shouldn't be marked dirty from the get-go.
161
+
162
+ ```typescript
163
+ const form = useForm({
164
+ initialData: { rows: [{ name: 'A' }] as { name: string }[] },
165
+ })
166
+
167
+ // Push a new row (array structure changes → form is dirty)
168
+ form.data.value.rows.push({ name: 'new' })
169
+
170
+ // Anchor the new row's subtree as its own baseline.
171
+ form.getField('rows.1').setInitialData({ name: 'new' }, { scope: 'subtree' })
172
+
173
+ form.getField('rows.1.name').dirty.value // false (reads the anchor)
174
+ form.getField('rows').dirty.value // true (array baseline unchanged)
175
+ form.isDirty.value // true
176
+ ```
177
+
178
+ ### Interaction with external `initialData` and other overrides
179
+ - **External reassignment wipes overrides.** When the `initialData` ref passed to `useForm` is reassigned, all overrides applied via `setInitialData` are dropped — the form's baseline goes back to the external source.
180
+ - **Cascade on ancestor write.** Calling `setInitialData` on a path drops any existing override on that path *or below* before applying the new one (e.g. setting an override on `user` clears a prior override on `user.name`).
181
+ - **`form.reset()` keeps overrides.** Reset rebuilds `data` from the merged tree (external `initialData` + active overrides), so a `setInitialData` call is treated as the new programmatic baseline.
182
+
183
+ ## Method `defineValidator`
184
+ The `defineValidator` method allows subcomponents to add validation rules to a form without needing access to the initial `useForm` call. The validator is automatically removed when the component unmounts.
185
+
186
+ This is particularly useful on subforms, where a reusable component can define its own validation:
187
+
188
+ ```vue
189
+ <!-- AddressFields.vue - a reusable address component -->
190
+ <script setup lang="ts">
191
+ import type { Form } from '@teamnovu/kit-vue-forms'
192
+ import { z } from 'zod'
193
+
194
+ const props = defineProps<{
195
+ form: Form<{ street: string; city: string; zip: string }>
196
+ }>()
197
+
198
+ // This validation only applies while AddressFields is mounted
199
+ props.form.defineValidator({
200
+ schema: z.object({
201
+ street: z.string().min(1, 'Street is required'),
202
+ city: z.string().min(1, 'City is required'),
203
+ zip: z.string().regex(/^\d{4}$/, 'Invalid zip code'),
204
+ }),
205
+ })
206
+ </script>
207
+ ```
208
+
209
+ ```vue
210
+ <!-- Parent component using FormPart -->
211
+ <FormPart :form="form" path="person.address" #="{ subform }">
212
+ <AddressFields :form="subform" />
213
+ </FormPart>
214
+ ```
215
+
119
216
  ## Type `ErrorBag`
120
217
  The errors in the form are structured in an `ErrorBag` object. Most errors are tied to properties in the form. However,
121
218
  there might be cases where there are errors, that cannot be tied to one property. To account for that the `ErrorBag` satisfies the following interface:
@@ -150,6 +247,21 @@ hobbies.remove(hobbies.items.value[0].id)
150
247
  </div>
151
248
  ```
152
249
 
250
+ ### `pushPristine` — add a new item without dirtying its subfields
251
+ `pushPristine(item)` works like `push`, but additionally anchors the new index's subtree as its own baseline (a `setInitialData(item, { scope: 'subtree' })` on the new index path). The new item's subfields start non-dirty, while the array field itself stays dirty — its baseline still reflects the external `initialData`, so navigation guards / dirty checks still warn that something was added.
252
+
253
+ ```typescript
254
+ const rows = form.getFieldArray('rows')
255
+
256
+ rows.pushPristine({ name: '', email: '' })
257
+
258
+ form.getField('rows.1.name').dirty.value // false (reads the subtree anchor)
259
+ rows.field.dirty.value // true (a new row exists)
260
+ form.isDirty.value // true
261
+ ```
262
+
263
+ Use `push` when the new item is a user edit you want flagged everywhere; use `pushPristine` when it's a blank/placeholder row whose internal fields shouldn't light up dirty/validation noise until the user actually touches them.
264
+
153
265
  For objects where identity should be based on a property (e.g. `id`) rather than reference equality, provide a `hashFn`:
154
266
  ```typescript
155
267
  const products = form.getFieldArray('products', {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teamnovu/kit-vue-forms",
3
- "version": "0.2.17",
3
+ "version": "0.3.0",
4
4
  "description": "",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -60,4 +60,6 @@ defineOptions({
60
60
  })
61
61
 
62
62
  defineProps<FormFieldWrapperProps<TData, TPath, TComponent, TDataOut>>()
63
+
64
+ defineSlots<Record<string, (props: any) => any>>()
63
65
  </script>
@@ -1,5 +1,5 @@
1
1
  import type { Awaitable } from '@vueuse/core'
2
- import { computed, reactive, shallowRef, toRefs, unref, watch, type MaybeRef, type MaybeRefOrGetter, type Ref } from 'vue'
2
+ import { computed, reactive, toRefs, unref, watch, type MaybeRef, type MaybeRefOrGetter, type Ref } from 'vue'
3
3
  import type { FormField } from '../types/form'
4
4
  import type { ValidationErrorMessage, ValidationErrors } from '../types/validation'
5
5
  import { cloneRefValue } from '../utils/general'
@@ -24,33 +24,20 @@ export function useField<T, K extends string>(fieldOptions: UseFieldOptions<T, K
24
24
  ...defaultOptions,
25
25
  ...fieldOptions,
26
26
  }
27
- const initialValue = shallowRef(Object.freeze(cloneRefValue(options.initialValue))) as Ref<Readonly<T | undefined>>
28
-
29
27
  const state = reactive({
30
28
  value: options.value,
31
29
  path: options.path,
32
- initialValue,
30
+ initialValue: options.initialValue,
33
31
  errors: options.errors,
34
32
  touched: false,
35
33
  })
36
34
 
37
- watch(
38
- shallowRef(options.initialValue),
39
- () => {
40
- initialValue.value = Object.freeze(cloneRefValue(options.initialValue))
41
- if (state.value !== unref(options.initialValue)) {
42
- state.value = cloneRefValue(options.initialValue)
43
- }
44
- },
45
- { flush: 'sync' },
46
- )
47
-
48
35
  watch(() => state.value, (newData) => {
49
36
  fieldOptions.onChange?.(newData as T)
50
37
  }, { deep: true })
51
38
 
52
39
  const dirty = computed(() => {
53
- return JSON.stringify(state.value) !== JSON.stringify(state.initialValue)
40
+ return JSON.stringify(state.value) !== JSON.stringify(cloneRefValue(state.initialValue as T))
54
41
  })
55
42
 
56
43
  const setData = (newData: T): void => {
@@ -71,17 +58,21 @@ export function useField<T, K extends string>(fieldOptions: UseFieldOptions<T, K
71
58
  const reset = (): void => {
72
59
  const lastPathPart = state.path.split('.').at(-1) || ''
73
60
  if (unref(options.existsInForm) && !/^\d+$/.test(lastPathPart)) {
74
- state.value = cloneRefValue(state.initialValue)
61
+ state.value = cloneRefValue(state.initialValue as T)
75
62
  }
76
63
  state.touched = false
77
64
  state.errors = []
78
65
  }
79
66
 
80
- const setInitialData = (newData: T): void => {
67
+ // This method is replaced by the registry and should never be called directly.
68
+ // This only acts as a stub.
69
+ const setInitialData = (newData: T, _options?: { replace?: boolean }): void => {
81
70
  if (!dirty.value) {
82
71
  setData(cloneRefValue(newData))
83
72
  }
84
- state.initialValue = newData
73
+ // Standalone-mode fallback: when no registry has replaced this method, keep
74
+ // the previous behavior of updating the local baseline directly.
75
+ state.initialValue = newData as typeof state.initialValue
85
76
  }
86
77
 
87
78
  const setErrors = (newErrors: ValidationErrorMessage[]): void => {
@@ -132,6 +132,21 @@ export function useFieldArray<T extends FormDataDefault, K extends Paths<T>, TOu
132
132
  return items.value.at(-1)!
133
133
  }
134
134
 
135
+ const pushPristine = (item: Item) => {
136
+ const current = (arrayField.data.value ?? []) as Item[]
137
+ const newIndex = current.length
138
+ arrayField.setData([...current, item] as Items)
139
+
140
+ // Anchor the new item's subtree as its own baseline. Subtree scope keeps
141
+ // the array field (and any further ancestors) dirty against the external
142
+ // initialData, while the new index and its descendants read the anchor.
143
+ const newItemPath = `${path}.${newIndex}` as Paths<T>
144
+ const newItemField = form.getField(newItemPath) as unknown as FormField<Item, string>
145
+ newItemField.setInitialData(item, { scope: 'subtree' })
146
+
147
+ return items.value.at(-1)!
148
+ }
149
+
135
150
  const remove = (id: Id) => {
136
151
  const currentData = (arrayField.data.value ?? []) as Item[]
137
152
  const currentItem = items.value.findIndex(
@@ -165,6 +180,7 @@ export function useFieldArray<T extends FormDataDefault, K extends Paths<T>, TOu
165
180
  return {
166
181
  items,
167
182
  push,
183
+ pushPristine,
168
184
  remove,
169
185
  insert,
170
186
  field: arrayField,
@@ -12,9 +12,14 @@ import {
12
12
  } from 'vue'
13
13
  import type { FieldsTuple, FormDataDefault, FormField } from '../types/form'
14
14
  import type { Paths, PickProps } from '../types/util'
15
- import { existsPath, getLens, getNestedValue } from '../utils/path'
15
+ import { cloneRefValue } from '../utils/general'
16
+ import { existsPath, getLens } from '../utils/path'
16
17
  import { Rc } from '../utils/rc'
17
18
  import { useField, type UseFieldOptions } from './useField'
19
+ import type {
20
+ InitialDataOverride,
21
+ InitialDataOverrideSetOptions,
22
+ } from './useInitialDataOverride'
18
23
  import type { ValidationState } from './useValidation'
19
24
 
20
25
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -56,17 +61,18 @@ const optionDefaults = {
56
61
 
57
62
  // A computed that always reflects the latest value from the getter
58
63
  // This computed forces updates even if the value is the same (to trigger watchers)
59
- function initialDataSync<T extends FormDataDefault>(
60
- formState: FormState<T>,
61
- path: Paths<T>,
64
+ function initialDataSync<T extends FormDataDefault, K extends Paths<T>>(
65
+ initialDataOverride: InitialDataOverride<T>,
66
+ path: K,
62
67
  ) {
63
- const getNewValue = () => getNestedValue(formState.initialData, path)
64
- const initialValueRef = shallowRef(getNewValue())
68
+ type FieldValue = PickProps<T, K>
69
+ const getNewValue = () => initialDataOverride.resolveAt(path) as FieldValue
70
+ const initialValueRef = shallowRef<FieldValue>(getNewValue())
65
71
 
66
72
  watch(
67
- () => formState.initialData,
68
- () => {
69
- initialValueRef.value = getNewValue()
73
+ () => initialDataOverride.resolveAt(path),
74
+ (newValue) => {
75
+ initialValueRef.value = newValue as FieldValue
70
76
  triggerRef(initialValueRef)
71
77
  },
72
78
  { flush: 'sync' },
@@ -78,6 +84,7 @@ function initialDataSync<T extends FormDataDefault>(
78
84
  export function useFieldRegistry<T extends FormDataDefault, TOut = T>(
79
85
  formState: FormState<T>,
80
86
  validationState: ValidationState<T, TOut>,
87
+ initialDataOverride: InitialDataOverride<T>,
81
88
  fieldRegistryOptions?: FieldRegistryOptions,
82
89
  ) {
83
90
  const fieldReferenceCounter = new Map<Paths<T>, Rc>()
@@ -124,7 +131,7 @@ export function useFieldRegistry<T extends FormDataDefault, TOut = T>(
124
131
  const field = useField({
125
132
  path,
126
133
  value: getLens(toRef(formState, 'data'), path),
127
- initialValue: initialDataSync(formState, path),
134
+ initialValue: initialDataSync(initialDataOverride, path),
128
135
  existsInForm: computed(() => existsPath(formState.data, unref(path))),
129
136
  errors: computed({
130
137
  get() {
@@ -154,6 +161,22 @@ export function useFieldRegistry<T extends FormDataDefault, TOut = T>(
154
161
  },
155
162
  })
156
163
 
164
+ // Replace setInitialData with a registry-provided version that writes to
165
+ // the override layer so subfields resolve their baseline from the merged
166
+ // tree.
167
+ field.setInitialData = (
168
+ newData: PickProps<T, K>,
169
+ setOptions?: InitialDataOverrideSetOptions,
170
+ ) => {
171
+ // Capture dirty state before the override write, since writing to the
172
+ // override layer synchronously updates this field's initialValue.
173
+ const wasDirty = field.dirty.value
174
+ initialDataOverride.set(path, newData, setOptions)
175
+ if (!wasDirty) {
176
+ field.setData(cloneRefValue(newData))
177
+ }
178
+ }
179
+
157
180
  registerField(field)
158
181
  }
159
182
 
@@ -15,6 +15,7 @@ import { makeSubmitHandler } from '../utils/submitHandler'
15
15
  import { useFieldArray } from './useFieldArray'
16
16
  import { useFieldRegistry } from './useFieldRegistry'
17
17
  import { useFormState } from './useFormState'
18
+ import { useInitialDataOverride } from './useInitialDataOverride'
18
19
  import { createSubformInterface } from './useSubform'
19
20
  import {
20
21
  useValidation,
@@ -60,6 +61,7 @@ export function useForm<T extends FormDataDefault, TOut = T>(
60
61
 
61
62
  /* eslint-enable no-redeclare */
62
63
  const initialData = computed(() => cloneRefValue(options.initialData))
64
+ const initialDataOverride = useInitialDataOverride(initialData)
63
65
 
64
66
  const data = ref<T>(cloneRefValue(initialData)) as Ref<T>
65
67
 
@@ -77,7 +79,7 @@ export function useForm<T extends FormDataDefault, TOut = T>(
77
79
  )
78
80
 
79
81
  const validationState = useValidation(state, options)
80
- const fieldRegistry = useFieldRegistry(state, validationState, {
82
+ const fieldRegistry = useFieldRegistry(state, validationState, initialDataOverride, {
81
83
  keepValuesOnUnmount: options.keepValuesOnUnmount,
82
84
  onBlur: async (path: string) => {
83
85
  validationState.validateStrategy('validateOnBlur', path)
@@ -92,7 +94,7 @@ export function useForm<T extends FormDataDefault, TOut = T>(
92
94
  const formState = useFormState(fieldRegistry)
93
95
 
94
96
  const reset = () => {
95
- data.value = cloneRefValue(initialData)
97
+ data.value = cloneRefValue(initialDataOverride.effectiveInitialData)
96
98
  validationState.reset()
97
99
  for (const field of fieldRegistry.fields.value) {
98
100
  field.reset()
@@ -113,7 +115,7 @@ export function useForm<T extends FormDataDefault, TOut = T>(
113
115
  validateForm: validationState.validateForm as Form<T, TOut>['validateForm'],
114
116
  submitHandler: onSubmit => makeSubmitHandler(form, validationState)(onSubmit),
115
117
  getSubForm: (path, subformOptions) => {
116
- return createSubformInterface(form, path, options, subformOptions)
118
+ return createSubformInterface(form, path, validationState, subformOptions)
117
119
  },
118
120
  getFieldArray: (path, fieldArrayOptions) => {
119
121
  return useFieldArray(form, path, fieldArrayOptions)
@@ -0,0 +1,130 @@
1
+ import { merge } from 'lodash-es'
2
+ import {
3
+ computed,
4
+ shallowReactive,
5
+ watch,
6
+ type ComputedRef,
7
+ type Ref,
8
+ } from 'vue'
9
+ import type { FormDataDefault } from '../types/form'
10
+ import type { Paths, PickProps } from '../types/util'
11
+ import { cloneRefValue } from '../utils/general'
12
+ import {
13
+ dropOverridesAtAndBelow,
14
+ getNestedValue,
15
+ setNestedValue,
16
+ } from '../utils/path'
17
+
18
+ export type InitialDataOverrideScope = 'tree' | 'subtree'
19
+
20
+ export interface InitialDataOverrideSetOptions {
21
+ replace?: boolean
22
+ scope?: InitialDataOverrideScope
23
+ }
24
+
25
+ export interface InitialDataOverride<T extends FormDataDefault> {
26
+ // Full merge of external initialData with all overrides regardless of scope.
27
+ // Used by form.reset() to rebuild the data tree.
28
+ effectiveInitialData: ComputedRef<T>
29
+ // Per-field baseline resolution. Honors scope: subtree-scoped overrides at
30
+ // paths strictly below `path` are invisible to that read, so ancestors keep
31
+ // their external baseline and stay dirty when descendants extend the tree.
32
+ resolveAt: (path: string) => unknown
33
+ set: (
34
+ path: string,
35
+ value: unknown,
36
+ options?: InitialDataOverrideSetOptions,
37
+ ) => void
38
+ }
39
+
40
+ interface OverrideEntry {
41
+ value: unknown
42
+ replace: boolean
43
+ scope: InitialDataOverrideScope
44
+ }
45
+
46
+ const isPlainObject = (v: unknown): v is Record<string, unknown> => {
47
+ if (v === null || typeof v !== 'object') return false
48
+ const proto = Object.getPrototypeOf(v)
49
+ // Only treat literal `{}` / `Object.create(null)` as mergeable. Class
50
+ // instances, Date, Map, Set, RegExp, etc. have no enumerable own props for
51
+ // lodash.merge to copy and would silently collapse to `{}`.
52
+ return proto === Object.prototype || proto === null
53
+ }
54
+
55
+ const isStrictDescendant = (candidate: string, ancestor: string): boolean => {
56
+ if (ancestor === '') return candidate !== ''
57
+ return candidate.startsWith(ancestor + '.')
58
+ }
59
+
60
+ export function useInitialDataOverride<T extends FormDataDefault>(
61
+ initialData: Ref<T> | ComputedRef<T>,
62
+ ): InitialDataOverride<T> {
63
+ const overrides = shallowReactive(new Map<string, OverrideEntry>())
64
+
65
+ const buildTree = (readingPath?: string): T => {
66
+ const result = cloneRefValue(initialData)
67
+ const sortedEntries = [...overrides.entries()].sort(
68
+ ([a], [b]) => a.split('.').length - b.split('.').length,
69
+ )
70
+
71
+ for (const [path, entry] of sortedEntries) {
72
+ // For a per-field read, skip subtree-scoped overrides that live strictly
73
+ // below the reading path — those anchors must remain invisible to
74
+ // ancestors of their own subtree.
75
+ if (
76
+ readingPath !== undefined
77
+ && entry.scope === 'subtree'
78
+ && isStrictDescendant(path, readingPath)
79
+ ) {
80
+ continue
81
+ }
82
+
83
+ const typedPath = path as Paths<T>
84
+ const existing = getNestedValue<T, Paths<T>>(result, typedPath)
85
+
86
+ const nextValue = entry.replace || !isPlainObject(entry.value) || !isPlainObject(existing)
87
+ ? entry.value
88
+ : merge({}, existing, entry.value)
89
+
90
+ setNestedValue<T, Paths<T>>(
91
+ result,
92
+ typedPath,
93
+ nextValue as PickProps<T, Paths<T>>,
94
+ )
95
+ }
96
+
97
+ return result
98
+ }
99
+
100
+ const effectiveInitialData = computed<T>(() => buildTree())
101
+
102
+ const resolveAt = (path: string): unknown => {
103
+ const tree = buildTree(path)
104
+ if (path === '') return tree
105
+ return getNestedValue<T, Paths<T>>(tree, path as Paths<T>)
106
+ }
107
+
108
+ // External initialData reassignment wipes all overrides. Programmatic
109
+ // overrides via setInitialData stay scoped to their own subtrees.
110
+ watch(
111
+ initialData,
112
+ () => {
113
+ overrides.clear()
114
+ },
115
+ { flush: 'sync' },
116
+ )
117
+
118
+ return {
119
+ effectiveInitialData,
120
+ resolveAt,
121
+ set(path, value, options) {
122
+ dropOverridesAtAndBelow(overrides, path)
123
+ overrides.set(path, {
124
+ value,
125
+ replace: options?.replace ?? false,
126
+ scope: options?.scope ?? 'tree',
127
+ })
128
+ },
129
+ }
130
+ }
@@ -16,10 +16,10 @@ import {
16
16
  import { makeSubmitHandler } from '../utils/submitHandler'
17
17
  import { useFieldArray } from './useFieldArray'
18
18
  import type { DefineFieldOptions } from './useFieldRegistry'
19
- import type { UseFormOptions } from './useForm'
20
19
  import {
21
20
  createValidator,
22
21
  SuccessValidationResult,
22
+ type ValidationState,
23
23
  type ValidatorOptions,
24
24
  } from './useValidation'
25
25
 
@@ -65,7 +65,7 @@ export function createSubformInterface<
65
65
  >(
66
66
  mainForm: Form<T, TOut>,
67
67
  path: K,
68
- formOptions?: UseFormOptions<T, TOut>,
68
+ validationState: ValidationState<T, TOut>,
69
69
  _options?: SubformOptions<PickEntity<T, K>>,
70
70
  ): Form<PickEntity<T, K>, PickEntity<TOut, K>> {
71
71
  type ST = PickEntity<T, K>
@@ -158,7 +158,10 @@ export function createSubformInterface<
158
158
  const errors = computed(() =>
159
159
  filterErrorsForPath(unref(mainForm.errors), path))
160
160
 
161
- const validateForm = (() => mainForm.validateForm()) as Form<ST, STOut>['validateForm']
161
+ const validateForm = (() => mainForm.validateForm()) as Form<
162
+ ST,
163
+ STOut
164
+ >['validateForm']
162
165
 
163
166
  // Nested subforms
164
167
  const getSubForm = <P extends EntityPaths<ST>>(
@@ -203,7 +206,8 @@ export function createSubformInterface<
203
206
  reset,
204
207
  validateForm,
205
208
  getSubForm,
206
- submitHandler: onSubmit => makeSubmitHandler(subForm, formOptions ?? {})(onSubmit),
209
+ submitHandler: onSubmit =>
210
+ makeSubmitHandler(subForm, validationState)(onSubmit),
207
211
  getFieldArray: (fieldArrayPath, fieldArrayOptions) => {
208
212
  return useFieldArray(subForm, fieldArrayPath, fieldArrayOptions)
209
213
  },