From 204a008e3e3932f375f5f4109c2e790f7d557927 Mon Sep 17 00:00:00 2001 From: Solant Date: Thu, 28 May 2026 23:53:07 +0200 Subject: [PATCH 1/9] feat: set object values, cleanup metadata on object set --- packages/varden/src/lib.ts | 18 +++++- packages/varden/tests/field-value.spec.ts | 74 +++++++++++++++++++++++ 2 files changed, 90 insertions(+), 2 deletions(-) create mode 100644 packages/varden/tests/field-value.spec.ts diff --git a/packages/varden/src/lib.ts b/packages/varden/src/lib.ts index a70bd3e..78f34bf 100644 --- a/packages/varden/src/lib.ts +++ b/packages/varden/src/lib.ts @@ -35,7 +35,7 @@ export interface FormContext { resetField>(path: Path): void; setValue, Value extends Get>( path: Path | CompiledPath, - value: Value, + value: Value | undefined, ): void; getValue, Value extends Get>(path: Path | CompiledPath): Value; setTouched>(path: Path, flag?: boolean): void; @@ -185,7 +185,7 @@ export function useForm(props: FormProps): FormContext { resetField, setValue, Value extends Get>( path: Path | CompiledPath, - value: Value, + value: Value | undefined, ) { const compiledPath = Array.isArray(path) ? path : toCompiledPath(path); const stringPath = typeof path === 'string' ? path : compiledPath.join('.'); @@ -199,6 +199,20 @@ export function useForm(props: FormProps): FormContext { } else { fields.set(stringPath, createFieldMeta(false, isDirty, '', 0)); } + + // cleanup child fields + for (const [nestedPath, nestedMeta] of fields) { + if (nestedPath.startsWith(`${path}.`)) { + if (nestedMeta !== undefined) { + if (nestedMeta.refCount === 0) { + fields.delete(nestedPath); + } else { + nestedMeta.dirty = !equalsFn(get(initialValues, toCompiledPath(nestedPath)), value); + } + } + } + } + applyValidation(); }, getValue, Value extends Get>(path: Path | CompiledPath): Value { diff --git a/packages/varden/tests/field-value.spec.ts b/packages/varden/tests/field-value.spec.ts new file mode 100644 index 0000000..c799bd7 --- /dev/null +++ b/packages/varden/tests/field-value.spec.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest'; +import * as v from 'valibot'; + +import { useForm } from '../src/lib'; +import { useFieldValue } from '../src/composables'; + +describe('form.setValue plain', () => { + it('should set the value of a field', () => { + const form = useForm({ + onSubmit: () => { }, + schema: v.object({ name: v.string() }), + }); + + form.setValue('name', 'foo'); + expect(form.values.value.name).toBe('foo'); + expect(form.isDirty('name')).toBe(true); + }); +}); + +describe('form.setValue object', () => { + it('should set the value of an object field', () => { + const form = useForm({ + onSubmit: () => { }, + schema: v.object({ user: v.object({ name: v.string() }) }), + }); + + form.setValue('user', { name: 'foo' }); + expect(form.values.value?.user).toEqual({ name: 'foo' }); + expect(form.isDirty('user')).toBe(true); + }); + + it('should set copied value of an object field', () => { + const form = useForm({ + onSubmit: () => { }, + schema: v.object({ user: v.object({ name: v.string() }) }), + }); + + const value = { name: 'foo' }; + form.setValue('user', value); + value.name = 'bar'; + + expect(form.values.value?.user).toEqual({ name: 'foo' }); + expect(form.isDirty('user')).toBe(true); + }); + + it('should reset child metadata', () => { + const form = useForm({ + onSubmit: () => { }, + schema: v.object({ user: v.object({ name: v.string() }) }), + }); + + form.setValue('user.name', 'foo'); + form.setValue('user', undefined); + + expect(form.values.value?.user?.name).toBeUndefined(); + expect(form.isDirty('user.name')).toBe(false); + }); + + it('should keep child metadata for registered fields', () => { + const form = useForm({ + onSubmit: () => { }, + schema: v.object({ user: v.object({ name: v.string() }) }), + }); + + const name = useFieldValue(form, 'user.name'); + name.value = 'foo'; + form.setTouched('user.name'); + form.setValue('user', undefined); + + expect(form.values.value?.user?.name).toBeUndefined(); + expect(form.isDirty('user.name')).toBe(false); + expect(form.isTouched('user.name')).toBe(true); + }); +}); From 735dd32c2aa5e82844d96a6512290cf0ac7a9e40 Mon Sep 17 00:00:00 2001 From: Solant Date: Fri, 29 May 2026 00:52:55 +0200 Subject: [PATCH 2/9] fix: handle long paths in flat objects --- packages/varden/src/path.spec.ts | 5 +++++ packages/varden/src/path.ts | 5 ++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/varden/src/path.spec.ts b/packages/varden/src/path.spec.ts index 9b92fb1..6fec6c2 100644 --- a/packages/varden/src/path.spec.ts +++ b/packages/varden/src/path.spec.ts @@ -34,6 +34,11 @@ describe('path utilities', () => { expect(get({ foo: { bar: '' } }, toCompiledPath('foo.bar'), 'TEST')).toBe(''); }); + it('path is longer than object', () => { + expect(get({ foo: undefined }, toCompiledPath('foo.bar.baz'), 'TEST')).toBe('TEST'); + expect(get({ foo: undefined }, toCompiledPath('foo.bar'))).toBe(undefined); + }); + it('should delete value by path', () => { const value = { foo: { bar: 2, baz: 3 } }; del(value, toCompiledPath('foo.bar')); diff --git a/packages/varden/src/path.ts b/packages/varden/src/path.ts index 7737498..f5b6896 100644 --- a/packages/varden/src/path.ts +++ b/packages/varden/src/path.ts @@ -45,10 +45,13 @@ export function get( if (typeof object === 'object' && object !== null && path[i]! in object) { object = object[path[i]!]; } else { - return object[path[i]!] ?? defaultValue; + // target path is longer than the object, return defaultValue + return object?.[path[i]!] ?? defaultValue; } } + // early return for null/undefined objects + if (!object) return object; return object[path[limit]!]; } From fd31e0de2f29fde0c35385714f0e93c194621be5 Mon Sep 17 00:00:00 2001 From: Solant Date: Fri, 29 May 2026 00:58:35 +0200 Subject: [PATCH 3/9] fix: set proper dirty flag --- packages/varden/src/lib.ts | 8 ++++-- packages/varden/tests/field-value.spec.ts | 33 +++++++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/packages/varden/src/lib.ts b/packages/varden/src/lib.ts index 78f34bf..b3db20f 100644 --- a/packages/varden/src/lib.ts +++ b/packages/varden/src/lib.ts @@ -202,12 +202,16 @@ export function useForm(props: FormProps): FormContext { // cleanup child fields for (const [nestedPath, nestedMeta] of fields) { - if (nestedPath.startsWith(`${path}.`)) { + if (nestedPath.startsWith(`${stringPath}.`)) { if (nestedMeta !== undefined) { if (nestedMeta.refCount === 0) { fields.delete(nestedPath); } else { - nestedMeta.dirty = !equalsFn(get(initialValues, toCompiledPath(nestedPath)), value); + const compiledNestedPath = toCompiledPath(nestedPath); + nestedMeta.dirty = !equalsFn( + get(initialValues, compiledNestedPath), + get(currentValues.value, compiledNestedPath), + ); } } } diff --git a/packages/varden/tests/field-value.spec.ts b/packages/varden/tests/field-value.spec.ts index c799bd7..74eb714 100644 --- a/packages/varden/tests/field-value.spec.ts +++ b/packages/varden/tests/field-value.spec.ts @@ -71,4 +71,37 @@ describe('form.setValue object', () => { expect(form.isDirty('user.name')).toBe(false); expect(form.isTouched('user.name')).toBe(true); }); + + it('should mark child as not dirty when parent is set to initial values', () => { + const form = useForm({ + onSubmit: () => { }, + initial: { user: { name: 'initial' } }, + schema: v.object({ user: v.object({ name: v.string() }) }), + }); + + const name = useFieldValue(form, 'user.name'); + name.value = 'changed'; + expect(form.isDirty('user.name')).toBe(true); + + form.setValue('user', { name: 'initial' }); + + expect(form.values.value?.user?.name).toBe('initial'); + expect(form.isDirty('user.name')).toBe(false); + }); + + it('should mark child as dirty when parent is set to different values', () => { + const form = useForm({ + onSubmit: () => { }, + initial: { user: { name: 'initial' } }, + schema: v.object({ user: v.object({ name: v.string() }) }), + }); + + useFieldValue(form, 'user.name'); + expect(form.isDirty('user.name')).toBe(false); + + form.setValue('user', { name: 'changed' }); + + expect(form.values.value?.user?.name).toBe('changed'); + expect(form.isDirty('user.name')).toBe(true); + }); }); From 59396ef021df72857e3f3c5ed39733fad9707712 Mon Sep 17 00:00:00 2001 From: Solant Date: Fri, 29 May 2026 01:38:20 +0200 Subject: [PATCH 4/9] fix: edge case with untracked parent and dirty child fields --- packages/varden/src/lib.ts | 7 ++++++- packages/varden/tests/field-value.spec.ts | 2 ++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/varden/src/lib.ts b/packages/varden/src/lib.ts index b3db20f..831e7ee 100644 --- a/packages/varden/src/lib.ts +++ b/packages/varden/src/lib.ts @@ -238,7 +238,12 @@ export function useForm(props: FormProps): FormContext { onSubmit(cloneFn(currentValues.value)); }, isDirty>(path: Path): boolean { - return fields.get(path)?.dirty ?? false; + for (const [field, meta] of fields) { + if (field === path) return meta.dirty; + if (field.startsWith(`${path}.`) && meta.dirty === true) return true; + } + + return false; }, isTouched>(path: Path): boolean { return fields.get(path)?.touched ?? false; diff --git a/packages/varden/tests/field-value.spec.ts b/packages/varden/tests/field-value.spec.ts index 74eb714..f6539c9 100644 --- a/packages/varden/tests/field-value.spec.ts +++ b/packages/varden/tests/field-value.spec.ts @@ -4,6 +4,8 @@ import * as v from 'valibot'; import { useForm } from '../src/lib'; import { useFieldValue } from '../src/composables'; +const onSubmit = () => { }; + describe('form.setValue plain', () => { it('should set the value of a field', () => { const form = useForm({ From 963a833b2b51ef5663f15ed6336d91be03795ae7 Mon Sep 17 00:00:00 2001 From: Solant Date: Fri, 29 May 2026 01:46:31 +0200 Subject: [PATCH 5/9] fix: distinguish non-existent and undefined fields --- packages/varden/src/lib.ts | 2 +- packages/varden/src/path.ts | 2 +- packages/varden/tests/field-value.spec.ts | 32 +++++++++++++++++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/packages/varden/src/lib.ts b/packages/varden/src/lib.ts index 831e7ee..dd07c84 100644 --- a/packages/varden/src/lib.ts +++ b/packages/varden/src/lib.ts @@ -193,7 +193,7 @@ export function useForm(props: FormProps): FormContext { set(currentValues.value, compiledPath, cloneFn(value)); const meta = fields.get(stringPath); - const isDirty = !equalsFn(get(initialValues, compiledPath), value); + const isDirty = !equalsFn(get(initialValues, compiledPath, Empty), value); if (meta) { meta.dirty = isDirty; } else { diff --git a/packages/varden/src/path.ts b/packages/varden/src/path.ts index f5b6896..f10802f 100644 --- a/packages/varden/src/path.ts +++ b/packages/varden/src/path.ts @@ -52,7 +52,7 @@ export function get( // early return for null/undefined objects if (!object) return object; - return object[path[limit]!]; + return path[limit]! in object ? object[path[limit]!] : defaultValue; } // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/packages/varden/tests/field-value.spec.ts b/packages/varden/tests/field-value.spec.ts index f6539c9..abd4453 100644 --- a/packages/varden/tests/field-value.spec.ts +++ b/packages/varden/tests/field-value.spec.ts @@ -17,6 +17,28 @@ describe('form.setValue plain', () => { expect(form.values.value.name).toBe('foo'); expect(form.isDirty('name')).toBe(true); }); + + it('should treat undefined and missing field as dirty (shallow)', () => { + const form = useForm({ + onSubmit, + schema: v.object({ foo: v.string() }), + }); + + form.setValue('foo', undefined); + expect(form.values.value?.foo).toBe(undefined); + expect(form.isDirty('foo')).toBe(true); + }); + + it('should treat undefined and missing field as dirty (deep)', () => { + const form = useForm({ + onSubmit, + schema: v.object({ foo: v.object({ bar: v.string() }) }), + }); + + form.setValue('foo.bar', undefined); + expect(form.values.value?.foo?.bar).toBe(undefined); + expect(form.isDirty('foo.bar')).toBe(true); + }); }); describe('form.setValue object', () => { @@ -106,4 +128,14 @@ describe('form.setValue object', () => { expect(form.values.value?.user?.name).toBe('changed'); expect(form.isDirty('user.name')).toBe(true); }); + + it('should treat untracked parent as dirty if child is dirty', () => { + const form = useForm({ + onSubmit, + schema: v.object({ user: v.object({ name: v.string() }) }), + }); + + form.setValue('user.name', 'test'); + expect(form.isDirty('user')).toBe(true); + }); }); From 49cd380b683ce6c4ae13bf0f58851b160100f7a2 Mon Sep 17 00:00:00 2001 From: Solant Date: Fri, 29 May 2026 19:06:50 +0200 Subject: [PATCH 6/9] fix: add `get` test case --- packages/varden/src/path.spec.ts | 7 ++++++- packages/varden/src/path.ts | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/varden/src/path.spec.ts b/packages/varden/src/path.spec.ts index 6fec6c2..44663f1 100644 --- a/packages/varden/src/path.spec.ts +++ b/packages/varden/src/path.spec.ts @@ -1,7 +1,7 @@ import { expect, it, describe } from 'vitest'; import { - del, get, isArrayIndex, set, toCompiledPath, + del, Empty, get, isArrayIndex, set, toCompiledPath, } from './path'; describe('path utilities', () => { @@ -74,4 +74,9 @@ describe('path utilities', () => { // @ts-expect-error test case expect(test.foo.bar[0].baz).toBe(4); }); + + it('should return Empty for nested path under undefined field', () => { + const test = { user: undefined }; + expect(get(test, toCompiledPath('user.name'), Empty)).toBe(Empty); + }); }); diff --git a/packages/varden/src/path.ts b/packages/varden/src/path.ts index f10802f..49f6396 100644 --- a/packages/varden/src/path.ts +++ b/packages/varden/src/path.ts @@ -51,7 +51,7 @@ export function get( } // early return for null/undefined objects - if (!object) return object; + if (!object) return defaultValue; return path[limit]! in object ? object[path[limit]!] : defaultValue; } From b1d9ee37c58eb2cb61d5e2115495d97743798d2e Mon Sep 17 00:00:00 2001 From: Solant Date: Fri, 29 May 2026 19:09:09 +0200 Subject: [PATCH 7/9] fix: fix dirty check for child value that was affected by the parent --- packages/varden/src/lib.ts | 7 ++++++- packages/varden/tests/field-value.spec.ts | 10 ++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/varden/src/lib.ts b/packages/varden/src/lib.ts index dd07c84..96bae26 100644 --- a/packages/varden/src/lib.ts +++ b/packages/varden/src/lib.ts @@ -243,7 +243,12 @@ export function useForm(props: FormProps): FormContext { if (field.startsWith(`${path}.`) && meta.dirty === true) return true; } - return false; + // TODO: should be tracked after first check? + const compiledPath = toCompiledPath(path); + return !equalsFn( + get(currentValues.value, compiledPath, Empty), + get(initialValues, compiledPath, Empty), + ); }, isTouched>(path: Path): boolean { return fields.get(path)?.touched ?? false; diff --git a/packages/varden/tests/field-value.spec.ts b/packages/varden/tests/field-value.spec.ts index abd4453..e14dac5 100644 --- a/packages/varden/tests/field-value.spec.ts +++ b/packages/varden/tests/field-value.spec.ts @@ -138,4 +138,14 @@ describe('form.setValue object', () => { form.setValue('user.name', 'test'); expect(form.isDirty('user')).toBe(true); }); + + it('should treat child as dirty when parent affects child field state', () => { + const form = useForm({ + onSubmit, + schema: v.object({ user: v.object({ name: v.string() }) }), + }); + + form.setValue('user', { name: 'Jack' }); + expect(form.isDirty('user.name')).toBe(true); + }); }); From 7b1200812bc222f47a0c8342d2e7eaaf9a81a759 Mon Sep 17 00:00:00 2001 From: Solant Date: Sat, 30 May 2026 14:15:05 +0200 Subject: [PATCH 8/9] fix: use custom equalsFn for isDirty comparison when provided --- packages/varden/src/lib.ts | 19 +++++++++++++++++-- packages/varden/tests/field-value.spec.ts | 14 ++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/packages/varden/src/lib.ts b/packages/varden/src/lib.ts index 96bae26..0880c31 100644 --- a/packages/varden/src/lib.ts +++ b/packages/varden/src/lib.ts @@ -50,8 +50,13 @@ export interface _FormContext extends FormContext { __meta: Map, FieldMeta>; } +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function strictEqualsFn(a: any, b: any): boolean { + return a === b; +} + // eslint-disable-next-line no-underscore-dangle -let _equalsFn: FormProps['equalsFn'] & {} = (a, b) => a === b; +let _equalsFn: FormProps['equalsFn'] & {} = strictEqualsFn; // eslint-disable-next-line no-underscore-dangle let _cloneFn: FormProps['cloneFn'] & {} = structuredClone; @@ -61,7 +66,7 @@ export function defineVardenConfig(config: { equalsFn?: FormProps['equa } export function resetVardenConfig() { - _equalsFn = (a, b) => a === b; + _equalsFn = strictEqualsFn; _cloneFn = structuredClone; } @@ -238,6 +243,16 @@ export function useForm(props: FormProps): FormContext { onSubmit(cloneFn(currentValues.value)); }, isDirty>(path: Path): boolean { + // TODO: consider shipping dequal for deep equality + // this case is only possible if equalsFn is not strict equality + if (equalsFn !== strictEqualsFn) { + const compiledPath = toCompiledPath(path); + return !equalsFn( + get(currentValues.value, compiledPath, Empty), + get(initialValues, compiledPath, Empty), + ); + } + for (const [field, meta] of fields) { if (field === path) return meta.dirty; if (field.startsWith(`${path}.`) && meta.dirty === true) return true; diff --git a/packages/varden/tests/field-value.spec.ts b/packages/varden/tests/field-value.spec.ts index e14dac5..e0c1605 100644 --- a/packages/varden/tests/field-value.spec.ts +++ b/packages/varden/tests/field-value.spec.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import * as v from 'valibot'; +import { dequal } from 'dequal'; import { useForm } from '../src/lib'; import { useFieldValue } from '../src/composables'; @@ -148,4 +149,17 @@ describe('form.setValue object', () => { form.setValue('user', { name: 'Jack' }); expect(form.isDirty('user.name')).toBe(true); }); + + it('should mark parent dirty if child is changed back to original value', () => { + const form = useForm({ + onSubmit, + initial: { user: { name: 'initial' } }, + equalsFn: dequal, + schema: v.object({ user: v.object({ name: v.string() }) }), + }); + + form.setValue('user', { name: 'changed' }); + form.setValue('user.name', 'initial'); + expect(form.isDirty('user')).toBe(false); + }); }); From bfe3378950412badc148f5bd4be343aef0f5b0be Mon Sep 17 00:00:00 2001 From: Solant Date: Sat, 30 May 2026 14:38:43 +0200 Subject: [PATCH 9/9] fix: return a safe copy from getValue to prevent external mutation --- packages/varden/src/lib.ts | 4 +++- packages/varden/tests/field-value.spec.ts | 11 +++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/varden/src/lib.ts b/packages/varden/src/lib.ts index 0880c31..85efe34 100644 --- a/packages/varden/src/lib.ts +++ b/packages/varden/src/lib.ts @@ -6,6 +6,7 @@ import { type ComputedRef, type Ref, type DeepReadonly, + toRaw, } from 'vue'; import { getIssuePath, type StandardSchemaV1 } from './standard-schema'; @@ -225,7 +226,8 @@ export function useForm(props: FormProps): FormContext { applyValidation(); }, getValue, Value extends Get>(path: Path | CompiledPath): Value { - return get(currentValues.value, Array.isArray(path) ? path : toCompiledPath(path)); + const a = get(currentValues.value, Array.isArray(path) ? path : toCompiledPath(path)); + return cloneFn(toRaw(a)); }, setTouched>(path: Path, flag = true) { fields.get(path)!.touched = flag; diff --git a/packages/varden/tests/field-value.spec.ts b/packages/varden/tests/field-value.spec.ts index e0c1605..e4f48da 100644 --- a/packages/varden/tests/field-value.spec.ts +++ b/packages/varden/tests/field-value.spec.ts @@ -162,4 +162,15 @@ describe('form.setValue object', () => { form.setValue('user.name', 'initial'); expect(form.isDirty('user')).toBe(false); }); + + it('should return a safe copy of the value', () => { + const form = useForm({ + onSubmit, + schema: v.object({ user: v.object({ name: v.string() }) }), + }); + form.setValue('user', { name: 'initial' }); + const a = form.getValue('user'); + a.name = 'changed'; + expect(form.values.value.user?.name).toBe('initial'); + }); });