From d914e02e8633af143c49db57d2cf9b027632e7fd Mon Sep 17 00:00:00 2001 From: Fabian Hiller Date: Sun, 17 May 2026 23:31:48 -0400 Subject: [PATCH 01/10] Add getDirtyInput, getDirtyPaths and pickDirty method --- packages/core/CHANGELOG.md | 1 + .../getDirtyFieldInput.test.ts | 73 +++++++ .../getDirtyFieldInput/getDirtyFieldInput.ts | 61 ++++++ .../src/field/getDirtyFieldInput/index.ts | 1 + packages/core/src/field/index.ts | 1 + packages/methods/CHANGELOG.md | 3 + .../src/getDirtyInput/getDirtyInput.test.ts | 138 ++++++++++++ .../src/getDirtyInput/getDirtyInput.ts | 91 ++++++++ packages/methods/src/getDirtyInput/index.ts | 1 + .../src/getDirtyPaths/getDirtyPaths.test.ts | 197 ++++++++++++++++++ .../src/getDirtyPaths/getDirtyPaths.ts | 118 +++++++++++ packages/methods/src/getDirtyPaths/index.ts | 1 + packages/methods/src/index.ts | 3 + packages/methods/src/pickDirty/index.ts | 1 + .../methods/src/pickDirty/pickDirty.test.ts | 124 +++++++++++ packages/methods/src/pickDirty/pickDirty.ts | 95 +++++++++ .../api/(methods)/getDirtyInput/index.mdx | 52 +++++ .../api/(methods)/getDirtyInput/properties.ts | 105 ++++++++++ .../api/(methods)/getDirtyPaths/index.mdx | 51 +++++ .../api/(methods)/getDirtyPaths/properties.ts | 78 +++++++ .../methods/api/(methods)/pickDirty/index.mdx | 50 +++++ .../api/(methods)/pickDirty/properties.ts | 63 ++++++ .../GetFieldDirtyInputConfig/index.mdx | 30 +++ .../GetFieldDirtyInputConfig/properties.ts | 44 ++++ .../GetFieldDirtyPathsConfig/index.mdx | 30 +++ .../GetFieldDirtyPathsConfig/properties.ts | 44 ++++ .../(types)/GetFormDirtyInputConfig/index.mdx | 25 +++ .../GetFormDirtyInputConfig/properties.ts | 8 + .../(types)/GetFormDirtyPathsConfig/index.mdx | 25 +++ .../GetFormDirtyPathsConfig/properties.ts | 8 + .../api/(types)/PickDirtyConfig/index.mdx | 29 +++ .../api/(types)/PickDirtyConfig/properties.ts | 16 ++ website/src/routes/(docs)/preact/api/menu.md | 8 + .../(advanced-guides)/dirty-fields/index.mdx | 196 +++++++++++++++++ .../src/routes/(docs)/preact/guides/menu.md | 1 + website/src/routes/(docs)/qwik/api/menu.md | 8 + .../(advanced-guides)/dirty-fields/index.mdx | 196 +++++++++++++++++ website/src/routes/(docs)/qwik/guides/menu.md | 1 + website/src/routes/(docs)/react/api/menu.md | 8 + .../(advanced-guides)/dirty-fields/index.mdx | 196 +++++++++++++++++ .../src/routes/(docs)/react/guides/menu.md | 1 + website/src/routes/(docs)/solid/api/menu.md | 8 + .../(advanced-guides)/dirty-fields/index.mdx | 196 +++++++++++++++++ .../src/routes/(docs)/solid/guides/menu.md | 1 + website/src/routes/(docs)/svelte/api/menu.md | 8 + .../(advanced-guides)/dirty-fields/index.mdx | 189 +++++++++++++++++ .../src/routes/(docs)/svelte/guides/menu.md | 1 + website/src/routes/(docs)/vue/api/menu.md | 8 + .../(advanced-guides)/dirty-fields/index.mdx | 185 ++++++++++++++++ website/src/routes/(docs)/vue/guides/menu.md | 1 + 50 files changed, 2779 insertions(+) create mode 100644 packages/core/src/field/getDirtyFieldInput/getDirtyFieldInput.test.ts create mode 100644 packages/core/src/field/getDirtyFieldInput/getDirtyFieldInput.ts create mode 100644 packages/core/src/field/getDirtyFieldInput/index.ts create mode 100644 packages/methods/src/getDirtyInput/getDirtyInput.test.ts create mode 100644 packages/methods/src/getDirtyInput/getDirtyInput.ts create mode 100644 packages/methods/src/getDirtyInput/index.ts create mode 100644 packages/methods/src/getDirtyPaths/getDirtyPaths.test.ts create mode 100644 packages/methods/src/getDirtyPaths/getDirtyPaths.ts create mode 100644 packages/methods/src/getDirtyPaths/index.ts create mode 100644 packages/methods/src/pickDirty/index.ts create mode 100644 packages/methods/src/pickDirty/pickDirty.test.ts create mode 100644 packages/methods/src/pickDirty/pickDirty.ts create mode 100644 website/src/routes/(docs)/methods/api/(methods)/getDirtyInput/index.mdx create mode 100644 website/src/routes/(docs)/methods/api/(methods)/getDirtyInput/properties.ts create mode 100644 website/src/routes/(docs)/methods/api/(methods)/getDirtyPaths/index.mdx create mode 100644 website/src/routes/(docs)/methods/api/(methods)/getDirtyPaths/properties.ts create mode 100644 website/src/routes/(docs)/methods/api/(methods)/pickDirty/index.mdx create mode 100644 website/src/routes/(docs)/methods/api/(methods)/pickDirty/properties.ts create mode 100644 website/src/routes/(docs)/methods/api/(types)/GetFieldDirtyInputConfig/index.mdx create mode 100644 website/src/routes/(docs)/methods/api/(types)/GetFieldDirtyInputConfig/properties.ts create mode 100644 website/src/routes/(docs)/methods/api/(types)/GetFieldDirtyPathsConfig/index.mdx create mode 100644 website/src/routes/(docs)/methods/api/(types)/GetFieldDirtyPathsConfig/properties.ts create mode 100644 website/src/routes/(docs)/methods/api/(types)/GetFormDirtyInputConfig/index.mdx create mode 100644 website/src/routes/(docs)/methods/api/(types)/GetFormDirtyInputConfig/properties.ts create mode 100644 website/src/routes/(docs)/methods/api/(types)/GetFormDirtyPathsConfig/index.mdx create mode 100644 website/src/routes/(docs)/methods/api/(types)/GetFormDirtyPathsConfig/properties.ts create mode 100644 website/src/routes/(docs)/methods/api/(types)/PickDirtyConfig/index.mdx create mode 100644 website/src/routes/(docs)/methods/api/(types)/PickDirtyConfig/properties.ts create mode 100644 website/src/routes/(docs)/preact/guides/(advanced-guides)/dirty-fields/index.mdx create mode 100644 website/src/routes/(docs)/qwik/guides/(advanced-guides)/dirty-fields/index.mdx create mode 100644 website/src/routes/(docs)/react/guides/(advanced-guides)/dirty-fields/index.mdx create mode 100644 website/src/routes/(docs)/solid/guides/(advanced-guides)/dirty-fields/index.mdx create mode 100644 website/src/routes/(docs)/svelte/guides/(advanced-guides)/dirty-fields/index.mdx create mode 100644 website/src/routes/(docs)/vue/guides/(advanced-guides)/dirty-fields/index.mdx diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index bb0f76a7..38311a08 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to the library will be documented in this file. ## vX.X.X (Month DD, YYYY) - Add `FormSchema` type that constrains a form's root schema to object schemas (sync or async) and combinators (`intersect`, `union`, `variant`) +- Add `getDirtyFieldInput` to extract only the dirty input of a field store - Change `FormConfig`, `InternalFormStore`, `BaseFormStore`, `SubmitHandler` and `SubmitEventHandler` generic constraints from `Schema` to `FormSchema` ## v0.6.4 (May 17, 2026) diff --git a/packages/core/src/field/getDirtyFieldInput/getDirtyFieldInput.test.ts b/packages/core/src/field/getDirtyFieldInput/getDirtyFieldInput.test.ts new file mode 100644 index 00000000..ad6c592f --- /dev/null +++ b/packages/core/src/field/getDirtyFieldInput/getDirtyFieldInput.test.ts @@ -0,0 +1,73 @@ +import * as v from 'valibot'; +import { describe, expect, test } from 'vitest'; +import { createTestStore } from '../../vitest/index.ts'; +import { getDirtyFieldInput } from './getDirtyFieldInput.ts'; + +describe('getDirtyFieldInput', () => { + test('should return undefined when no field is dirty', () => { + const store = createTestStore( + v.object({ name: v.string(), age: v.number() }), + { initialInput: { name: 'John', age: 25 } } + ); + expect(getDirtyFieldInput(store)).toBeUndefined(); + }); + + test('should omit clean siblings of a dirty value', () => { + const store = createTestStore( + v.object({ name: v.string(), email: v.string() }), + { initialInput: { name: 'John', email: 'a@example.com' } } + ); + store.children.email.input.value = 'b@example.com'; + store.children.email.isDirty.value = true; + expect(getDirtyFieldInput(store)).toStrictEqual({ + email: 'b@example.com', + }); + }); + + test('should return the full current array when any item is dirty', () => { + const store = createTestStore(v.object({ items: v.array(v.string()) }), { + initialInput: { items: ['a', 'b', 'c'] }, + }); + const itemsStore = store.children.items; + expect(itemsStore.kind).toBe('array'); + if (itemsStore.kind === 'array') { + itemsStore.children[1].input.value = 'B'; + itemsStore.children[1].isDirty.value = true; + } + expect(getDirtyFieldInput(store)).toStrictEqual({ + items: ['a', 'B', 'c'], + }); + }); + + test('should include dirty leaves under a clean object parent', () => { + const store = createTestStore( + v.object({ user: v.object({ email: v.string(), name: v.string() }) }), + { initialInput: { user: { email: 'a@example.com', name: 'John' } } } + ); + const userStore = store.children.user; + expect(userStore.kind).toBe('object'); + if (userStore.kind === 'object') { + userStore.children.email.input.value = 'b@example.com'; + userStore.children.email.isDirty.value = true; + } + expect(getDirtyFieldInput(store)).toStrictEqual({ + user: { email: 'b@example.com' }, + }); + }); + + test('should return undefined when called directly on a clean value field', () => { + const store = createTestStore(v.object({ name: v.string() }), { + initialInput: { name: 'John' }, + }); + expect(getDirtyFieldInput(store.children.name)).toBeUndefined(); + }); + + test('should return the value when called directly on a dirty value field', () => { + const store = createTestStore(v.object({ name: v.string() }), { + initialInput: { name: 'John' }, + }); + store.children.name.input.value = 'Jane'; + store.children.name.isDirty.value = true; + expect(getDirtyFieldInput(store.children.name)).toBe('Jane'); + }); +}); diff --git a/packages/core/src/field/getDirtyFieldInput/getDirtyFieldInput.ts b/packages/core/src/field/getDirtyFieldInput/getDirtyFieldInput.ts new file mode 100644 index 00000000..02106495 --- /dev/null +++ b/packages/core/src/field/getDirtyFieldInput/getDirtyFieldInput.ts @@ -0,0 +1,61 @@ +import type { InternalFieldStore } from '../../types/index.ts'; +import { getFieldBool } from '../getFieldBool/getFieldBool.ts'; + +/** + * Returns only the dirty input of the field store. Object keys whose subtree + * contains no dirty descendant are omitted; arrays are treated as atomic and + * returned in full whenever any descendant is dirty. Returns `undefined` for + * subtrees that contain no dirty descendant. + * + * @param internalFieldStore The field store to get dirty input from. + * @param dirtyOnly Whether to filter to dirty fields only. Defaults to `true`. + * + * @returns The dirty input, or `undefined` if no descendant is dirty. + */ +// @__NO_SIDE_EFFECTS__ +export function getDirtyFieldInput( + internalFieldStore: InternalFieldStore, + dirtyOnly: boolean = true +): unknown { + // Bail with `undefined` if no descendant is dirty + if (dirtyOnly && !getFieldBool(internalFieldStore, 'isDirty')) { + return undefined; + } + + // If field store is array, return the full current array (atomic) + if (internalFieldStore.kind === 'array') { + if (internalFieldStore.input.value) { + const value = []; + for ( + let index = 0; + index < internalFieldStore.items.value.length; + index++ + ) { + value[index] = getDirtyFieldInput( + internalFieldStore.children[index], + false + ); + } + return value; + } + return internalFieldStore.input.value; + } + + // If field store is object, recurse only into dirty branches + if (internalFieldStore.kind === 'object') { + if (internalFieldStore.input.value) { + const value: Record = {}; + for (const key in internalFieldStore.children) { + const child = internalFieldStore.children[key]; + if (!dirtyOnly || getFieldBool(child, 'isDirty')) { + value[key] = getDirtyFieldInput(child, dirtyOnly); + } + } + return value; + } + return internalFieldStore.input.value; + } + + // Return primitive value input + return internalFieldStore.input.value; +} diff --git a/packages/core/src/field/getDirtyFieldInput/index.ts b/packages/core/src/field/getDirtyFieldInput/index.ts new file mode 100644 index 00000000..483cb317 --- /dev/null +++ b/packages/core/src/field/getDirtyFieldInput/index.ts @@ -0,0 +1 @@ +export * from './getDirtyFieldInput.ts'; diff --git a/packages/core/src/field/index.ts b/packages/core/src/field/index.ts index 48064b68..e99c215f 100644 --- a/packages/core/src/field/index.ts +++ b/packages/core/src/field/index.ts @@ -1,3 +1,4 @@ +export * from './getDirtyFieldInput/index.ts'; export * from './getElementInput/index.ts'; export * from './getFieldBool/index.ts'; export * from './getFieldInput/index.ts'; diff --git a/packages/methods/CHANGELOG.md b/packages/methods/CHANGELOG.md index ade496f6..f49fe210 100644 --- a/packages/methods/CHANGELOG.md +++ b/packages/methods/CHANGELOG.md @@ -4,6 +4,9 @@ All notable changes to the library will be documented in this file. ## vX.X.X (Month DD, YYYY) +- Add `pickDirty` method to filter an externally-supplied value down to its dirty parts using the form's dirty mask (issue #21) +- Add `getDirtyPaths` method to list the paths of dirty fields in a form or specific field (issue #21) +- Add `getDirtyInput` method to retrieve only the dirty input values of a form or specific field (issue #21) - Change `@formisch/core` to vX.X.X - Change method generic constraints from `Schema` to `FormSchema` so the form root must be an object schema (sync or async) or a combinator (`intersect`, `union`, `variant`) diff --git a/packages/methods/src/getDirtyInput/getDirtyInput.test.ts b/packages/methods/src/getDirtyInput/getDirtyInput.test.ts new file mode 100644 index 00000000..81c98978 --- /dev/null +++ b/packages/methods/src/getDirtyInput/getDirtyInput.test.ts @@ -0,0 +1,138 @@ +import * as v from 'valibot'; +import { describe, expect, test } from 'vitest'; +import { createTestStore } from '../vitest/index.ts'; +import { getDirtyInput } from './getDirtyInput.ts'; + +describe('getDirtyInput', () => { + test('should return undefined for a clean form', () => { + const store = createTestStore( + v.object({ name: v.string(), age: v.number() }), + { initialInput: { name: 'John', age: 25 } } + ); + + expect(getDirtyInput(store)).toBeUndefined(); + }); + + test('should return only the dirty key from a flat object', () => { + const store = createTestStore( + v.object({ name: v.string(), email: v.string() }), + { initialInput: { name: 'John', email: 'a@example.com' } } + ); + store.children.email.input.value = 'b@example.com'; + store.children.email.isDirty.value = true; + + expect(getDirtyInput(store)).toStrictEqual({ email: 'b@example.com' }); + }); + + test('should return the full current array when any item is dirty', () => { + const store = createTestStore(v.object({ items: v.array(v.string()) }), { + initialInput: { items: ['a', 'b', 'c'] }, + }); + const itemsStore = store.children.items; + expect(itemsStore.kind).toBe('array'); + if (itemsStore.kind === 'array') { + itemsStore.children[1].input.value = 'B'; + itemsStore.children[1].isDirty.value = true; + } + + expect(getDirtyInput(store)).toStrictEqual({ items: ['a', 'B', 'c'] }); + }); + + test('should preserve clean fields of array items when an item is dirty', () => { + const store = createTestStore( + v.object({ + users: v.array(v.object({ name: v.string(), age: v.number() })), + }), + { + initialInput: { + users: [ + { name: 'John', age: 25 }, + { name: 'Jane', age: 30 }, + ], + }, + } + ); + const usersStore = store.children.users; + expect(usersStore.kind).toBe('array'); + if (usersStore.kind === 'array') { + const user0 = usersStore.children[0]; + expect(user0.kind).toBe('object'); + if (user0.kind === 'object') { + user0.children.name.input.value = 'Johnny'; + user0.children.name.isDirty.value = true; + } + } + + expect(getDirtyInput(store)).toStrictEqual({ + users: [ + { name: 'Johnny', age: 25 }, + { name: 'Jane', age: 30 }, + ], + }); + }); + + test('should include dirty leaves under a clean object parent', () => { + const store = createTestStore( + v.object({ user: v.object({ email: v.string(), name: v.string() }) }), + { initialInput: { user: { email: 'a@example.com', name: 'John' } } } + ); + const userStore = store.children.user; + expect(userStore.kind).toBe('object'); + if (userStore.kind === 'object') { + userStore.children.email.input.value = 'b@example.com'; + userStore.children.email.isDirty.value = true; + } + + expect(getDirtyInput(store)).toStrictEqual({ + user: { email: 'b@example.com' }, + }); + }); + + test('should scope the dirty input to the given path', () => { + const store = createTestStore( + v.object({ + user: v.object({ email: v.string(), name: v.string() }), + }), + { initialInput: { user: { email: 'a@example.com', name: 'John' } } } + ); + const userStore = store.children.user; + expect(userStore.kind).toBe('object'); + if (userStore.kind === 'object') { + userStore.children.email.input.value = 'b@example.com'; + userStore.children.email.isDirty.value = true; + } + + expect(getDirtyInput(store, { path: ['user'] })).toStrictEqual({ + email: 'b@example.com', + }); + }); + + test('should return undefined for a path with a fully clean subtree', () => { + const store = createTestStore( + v.object({ + user: v.object({ email: v.string(), name: v.string() }), + }), + { initialInput: { user: { email: 'a@example.com', name: 'John' } } } + ); + + expect(getDirtyInput(store, { path: ['user'] })).toBeUndefined(); + }); + + test('should return the dirty value for a value path that is dirty', () => { + const store = createTestStore(v.object({ name: v.string() }), { + initialInput: { name: 'John' }, + }); + store.children.name.input.value = 'Jane'; + store.children.name.isDirty.value = true; + + expect(getDirtyInput(store, { path: ['name'] })).toBe('Jane'); + }); + + test('should return undefined for a value path that is clean', () => { + const store = createTestStore(v.object({ name: v.string() }), { + initialInput: { name: 'John' }, + }); + + expect(getDirtyInput(store, { path: ['name'] })).toBeUndefined(); + }); +}); diff --git a/packages/methods/src/getDirtyInput/getDirtyInput.ts b/packages/methods/src/getDirtyInput/getDirtyInput.ts new file mode 100644 index 00000000..7056907d --- /dev/null +++ b/packages/methods/src/getDirtyInput/getDirtyInput.ts @@ -0,0 +1,91 @@ +import { + type BaseFormStore, + type DeepPartial, + getDirtyFieldInput, + getFieldStore, + INTERNAL, + type PathValue, + type RequiredPath, + type Schema, + type ValidPath, +} from '@formisch/core'; +import type * as v from 'valibot'; + +/** + * Get form dirty input config interface. + */ +export interface GetFormDirtyInputConfig { + /** + * The path to a field. Leave undefined to get the dirty input of the entire + * form. + */ + readonly path?: undefined; +} + +/** + * Get field dirty input config interface. + */ +export interface GetFieldDirtyInputConfig< + TSchema extends Schema, + TFieldPath extends RequiredPath, +> { + /** + * The path to the field to retrieve the dirty input from. + */ + readonly path: ValidPath, TFieldPath>; +} + +/** + * Retrieves only the dirty input values of a specific field or the entire + * form. Object keys whose subtree contains no dirty descendant are omitted; + * arrays are treated as atomic and returned in full whenever any descendant + * is dirty. Returns `undefined` if no field in the inspected subtree is + * dirty. + * + * @param form The form store to retrieve dirty input from. + * + * @returns The dirty input of the form or specified field, or `undefined`. + */ +export function getDirtyInput( + form: BaseFormStore +): DeepPartial> | undefined; + +/** + * Retrieves only the dirty input values of a specific field or the entire + * form. Object keys whose subtree contains no dirty descendant are omitted; + * arrays are treated as atomic and returned in full whenever any descendant + * is dirty. Returns `undefined` if no field in the inspected subtree is + * dirty. + * + * @param form The form store to retrieve dirty input from. + * @param config The get dirty input configuration. + * + * @returns The dirty input of the form or specified field, or `undefined`. + */ +export function getDirtyInput< + TSchema extends Schema, + TFieldPath extends RequiredPath | undefined = undefined, +>( + form: BaseFormStore, + config: TFieldPath extends RequiredPath + ? GetFieldDirtyInputConfig + : GetFormDirtyInputConfig +): + | DeepPartial< + TFieldPath extends RequiredPath + ? PathValue, TFieldPath> + : v.InferInput + > + | undefined; + +// @__NO_SIDE_EFFECTS__ +export function getDirtyInput( + form: BaseFormStore, + config?: + | GetFormDirtyInputConfig + | GetFieldDirtyInputConfig +): unknown { + return getDirtyFieldInput( + config?.path ? getFieldStore(form[INTERNAL], config.path) : form[INTERNAL] + ); +} diff --git a/packages/methods/src/getDirtyInput/index.ts b/packages/methods/src/getDirtyInput/index.ts new file mode 100644 index 00000000..e13024fc --- /dev/null +++ b/packages/methods/src/getDirtyInput/index.ts @@ -0,0 +1 @@ +export * from './getDirtyInput.ts'; diff --git a/packages/methods/src/getDirtyPaths/getDirtyPaths.test.ts b/packages/methods/src/getDirtyPaths/getDirtyPaths.test.ts new file mode 100644 index 00000000..305e7746 --- /dev/null +++ b/packages/methods/src/getDirtyPaths/getDirtyPaths.test.ts @@ -0,0 +1,197 @@ +import type { InternalFieldStore } from '@formisch/core'; +import * as v from 'valibot'; +import { describe, expect, test } from 'vitest'; +import { createTestStore } from '../vitest/index.ts'; +import { getDirtyPaths } from './getDirtyPaths.ts'; + +describe('getDirtyPaths', () => { + test('should return empty array for a clean form', () => { + const store = createTestStore( + v.object({ name: v.string(), age: v.number() }), + { initialInput: { name: 'John', age: 25 } } + ); + + expect(getDirtyPaths(store)).toStrictEqual([]); + }); + + test('should return path to a dirty top-level value', () => { + const store = createTestStore( + v.object({ name: v.string(), email: v.string() }), + { initialInput: { name: 'John', email: 'a@example.com' } } + ); + store.children.email.input.value = 'b@example.com'; + store.children.email.isDirty.value = true; + + expect(getDirtyPaths(store)).toStrictEqual([['email']]); + }); + + test('should return paths to multiple dirty values', () => { + const store = createTestStore( + v.object({ name: v.string(), email: v.string(), age: v.number() }), + { initialInput: { name: 'John', email: 'a@example.com', age: 25 } } + ); + store.children.email.input.value = 'b@example.com'; + store.children.email.isDirty.value = true; + store.children.age.input.value = 26; + store.children.age.isDirty.value = true; + + expect(getDirtyPaths(store)).toStrictEqual([['email'], ['age']]); + }); + + test('should return path to a nested dirty value', () => { + const store = createTestStore( + v.object({ user: v.object({ email: v.string(), name: v.string() }) }), + { initialInput: { user: { email: 'a@example.com', name: 'John' } } } + ); + const userStore = store.children.user; + expect(userStore.kind).toBe('object'); + if (userStore.kind === 'object') { + userStore.children.email.input.value = 'b@example.com'; + userStore.children.email.isDirty.value = true; + } + + expect(getDirtyPaths(store)).toStrictEqual([['user', 'email']]); + }); + + test('should return the array path when any item is dirty', () => { + const store = createTestStore(v.object({ items: v.array(v.string()) }), { + initialInput: { items: ['a', 'b', 'c'] }, + }); + const itemsStore = store.children.items; + expect(itemsStore.kind).toBe('array'); + if (itemsStore.kind === 'array') { + itemsStore.children[1].input.value = 'B'; + itemsStore.children[1].isDirty.value = true; + } + + expect(getDirtyPaths(store)).toStrictEqual([['items']]); + }); + + test('should return only the array path when a nested object field inside an array is dirty', () => { + const store = createTestStore( + v.object({ + users: v.array(v.object({ name: v.string(), age: v.number() })), + }), + { + initialInput: { + users: [ + { name: 'John', age: 25 }, + { name: 'Jane', age: 30 }, + ], + }, + } + ); + const usersStore = store.children.users; + expect(usersStore.kind).toBe('array'); + if (usersStore.kind === 'array') { + const user0 = usersStore.children[0]; + expect(user0.kind).toBe('object'); + if (user0.kind === 'object') { + user0.children.name.input.value = 'Johnny'; + user0.children.name.isDirty.value = true; + } + } + + expect(getDirtyPaths(store)).toStrictEqual([['users']]); + }); + + test('should return the object path when an object was cleared to null', () => { + const store = createTestStore( + v.object({ user: v.nullish(v.object({ name: v.string() })) }), + { initialInput: { user: { name: 'John' } } } + ); + const userStore = store.children.user; + expect(userStore.kind).toBe('object'); + if (userStore.kind === 'object') { + userStore.input.value = null; + userStore.isDirty.value = true; + } + + expect(getDirtyPaths(store)).toStrictEqual([['user']]); + }); + + test('should scope to the given path', () => { + const store = createTestStore( + v.object({ + user: v.object({ email: v.string(), name: v.string() }), + meta: v.object({ visits: v.number() }), + }), + { + initialInput: { + user: { email: 'a@example.com', name: 'John' }, + meta: { visits: 0 }, + }, + } + ); + const userStore = store.children.user; + expect(userStore.kind).toBe('object'); + if (userStore.kind === 'object') { + userStore.children.email.input.value = 'b@example.com'; + userStore.children.email.isDirty.value = true; + } + const metaStore = store.children.meta; + expect(metaStore.kind).toBe('object'); + if (metaStore.kind === 'object') { + metaStore.children.visits.input.value = 1; + metaStore.children.visits.isDirty.value = true; + } + + expect(getDirtyPaths(store, { path: ['user'] })).toStrictEqual([ + ['user', 'email'], + ]); + }); + + test('should return empty array when scoped to a clean subtree', () => { + const store = createTestStore( + v.object({ + user: v.object({ email: v.string() }), + other: v.string(), + }), + { initialInput: { user: { email: 'a@example.com' }, other: 'x' } } + ); + store.children.other.input.value = 'y'; + store.children.other.isDirty.value = true; + + expect(getDirtyPaths(store, { path: ['user'] })).toStrictEqual([]); + }); + + test('should return the path when scoped to a dirty value field', () => { + const store = createTestStore(v.object({ name: v.string() }), { + initialInput: { name: 'John' }, + }); + store.children.name.input.value = 'Jane'; + store.children.name.isDirty.value = true; + + expect(getDirtyPaths(store, { path: ['name'] })).toStrictEqual([['name']]); + }); + + test('should return the path when scoped to an array with a dirty item', () => { + const store = createTestStore(v.object({ items: v.array(v.string()) }), { + initialInput: { items: ['a', 'b', 'c'] }, + }); + const itemsStore = store.children.items; + expect(itemsStore.kind).toBe('array'); + if (itemsStore.kind === 'array') { + itemsStore.children[1].input.value = 'B'; + itemsStore.children[1].isDirty.value = true; + } + + expect(getDirtyPaths(store, { path: ['items'] })).toStrictEqual([ + ['items'], + ]); + }); + + test('should return the root path when the form schema is an array and any item is dirty', () => { + const store = createTestStore(v.array(v.string()), { + initialInput: ['a', 'b', 'c'], + }); + const rootStore = store as unknown as InternalFieldStore; // TODO: Fix typing of `createTestStore` + expect(rootStore.kind).toBe('array'); + if (rootStore.kind === 'array') { + rootStore.children[1].input.value = 'B'; + rootStore.children[1].isDirty.value = true; + } + + expect(getDirtyPaths(store)).toStrictEqual([[]]); + }); +}); diff --git a/packages/methods/src/getDirtyPaths/getDirtyPaths.ts b/packages/methods/src/getDirtyPaths/getDirtyPaths.ts new file mode 100644 index 00000000..cb61eb95 --- /dev/null +++ b/packages/methods/src/getDirtyPaths/getDirtyPaths.ts @@ -0,0 +1,118 @@ +import { + type BaseFormStore, + getFieldBool, + getFieldStore, + INTERNAL, + type InternalFieldStore, + type Path, + type RequiredPath, + type Schema, + type ValidPath, +} from '@formisch/core'; +import type * as v from 'valibot'; + +/** + * Get form dirty paths config interface. + */ +export interface GetFormDirtyPathsConfig { + /** + * The path to a field. Leave undefined to inspect the entire form. + */ + readonly path?: undefined; +} + +/** + * Get field dirty paths config interface. + */ +export interface GetFieldDirtyPathsConfig< + TSchema extends Schema, + TFieldPath extends RequiredPath, +> { + /** + * The path to the field to inspect. + */ + readonly path: ValidPath, TFieldPath>; +} + +/** + * Returns a list of paths to dirty fields. Object branches are recursed into; + * arrays are treated as atomic — when any descendant of an array is dirty, + * only the array's own path is returned. Returns an empty array if no field + * in the inspected subtree is dirty. + * + * @param form The form store to inspect. + * + * @returns The list of paths to dirty fields. + */ +export function getDirtyPaths( + form: BaseFormStore +): Path[]; + +/** + * Returns a list of paths to dirty fields. Object branches are recursed into; + * arrays are treated as atomic — when any descendant of an array is dirty, + * only the array's own path is returned. Returns an empty array if no field + * in the inspected subtree is dirty. + * + * @param form The form store to inspect. + * @param config The configuration with a `path` to scope the search. + * + * @returns The list of paths to dirty fields. + */ +export function getDirtyPaths< + TSchema extends Schema, + TFieldPath extends RequiredPath | undefined = undefined, +>( + form: BaseFormStore, + config: TFieldPath extends RequiredPath + ? GetFieldDirtyPathsConfig + : GetFormDirtyPathsConfig +): Path[]; + +// @__NO_SIDE_EFFECTS__ +export function getDirtyPaths( + form: BaseFormStore, + config?: + | GetFormDirtyPathsConfig + | GetFieldDirtyPathsConfig +): Path[] { + const target = config?.path + ? getFieldStore(form[INTERNAL], config.path) + : form[INTERNAL]; + + // Bail with an empty list if no descendant is dirty + if (!getFieldBool(target, 'isDirty')) { + return []; + } + + return collect(target, config?.path ? [...config.path] : []); +} + +function collect( + internalFieldStore: InternalFieldStore, + currentPath: Path +): Path[] { + // Arrays are atomic — emit the array's own path + if (internalFieldStore.kind === 'array') { + return [currentPath]; + } + + // For objects: if input is null/undefined, treat as atomic (the whole + // container changed). Otherwise recurse into dirty children. + if (internalFieldStore.kind === 'object') { + if (!internalFieldStore.input.value) { + return [currentPath]; + } + const paths: Path[] = []; + for (const key in internalFieldStore.children) { + const child = internalFieldStore.children[key]; + if (getFieldBool(child, 'isDirty')) { + paths.push(...collect(child, [...currentPath, key])); + } + } + return paths; + } + + // Value field — emit its path + return [currentPath]; +} diff --git a/packages/methods/src/getDirtyPaths/index.ts b/packages/methods/src/getDirtyPaths/index.ts new file mode 100644 index 00000000..0f77fa9e --- /dev/null +++ b/packages/methods/src/getDirtyPaths/index.ts @@ -0,0 +1 @@ +export * from './getDirtyPaths.ts'; diff --git a/packages/methods/src/index.ts b/packages/methods/src/index.ts index 9b31bac4..3f42868c 100644 --- a/packages/methods/src/index.ts +++ b/packages/methods/src/index.ts @@ -1,10 +1,13 @@ export * from './focus/index.ts'; export * from './getAllErrors/index.ts'; +export * from './getDirtyInput/index.ts'; +export * from './getDirtyPaths/index.ts'; export * from './getErrors/index.ts'; export * from './getInput/index.ts'; export * from './handleSubmit/index.ts'; export * from './insert/index.ts'; export * from './move/index.ts'; +export * from './pickDirty/index.ts'; export * from './remove/index.ts'; export * from './replace/index.ts'; export * from './reset/index.ts'; diff --git a/packages/methods/src/pickDirty/index.ts b/packages/methods/src/pickDirty/index.ts new file mode 100644 index 00000000..e28ec6d9 --- /dev/null +++ b/packages/methods/src/pickDirty/index.ts @@ -0,0 +1 @@ +export * from './pickDirty.ts'; diff --git a/packages/methods/src/pickDirty/pickDirty.test.ts b/packages/methods/src/pickDirty/pickDirty.test.ts new file mode 100644 index 00000000..83ecaa63 --- /dev/null +++ b/packages/methods/src/pickDirty/pickDirty.test.ts @@ -0,0 +1,124 @@ +import * as v from 'valibot'; +import { describe, expect, test } from 'vitest'; +import { createTestStore } from '../vitest/index.ts'; +import { pickDirty } from './pickDirty.ts'; + +describe('pickDirty', () => { + test('should return undefined for a clean form', () => { + const store = createTestStore( + v.object({ name: v.string(), age: v.number() }), + { initialInput: { name: 'John', age: 25 } } + ); + + expect( + pickDirty(store, { from: { name: 'John', age: 25 } }) + ).toBeUndefined(); + }); + + test('should return only the dirty key from a flat object', () => { + const store = createTestStore( + v.object({ name: v.string(), email: v.string() }), + { initialInput: { name: 'John', email: 'a@example.com' } } + ); + store.children.email.input.value = 'b@example.com'; + store.children.email.isDirty.value = true; + + expect( + pickDirty(store, { + from: { name: 'John', email: 'b@example.com' }, + }) + ).toStrictEqual({ email: 'b@example.com' }); + }); + + test('should pull values from the supplied value, not the form', () => { + const store = createTestStore(v.object({ age: v.string() }), { + initialInput: { age: '25' }, + }); + store.children.age.input.value = '30'; + store.children.age.isDirty.value = true; + + expect(pickDirty(store, { from: { age: 30 } })).toStrictEqual({ + age: 30, + }); + }); + + test('should return the full current array when any item is dirty', () => { + const store = createTestStore(v.object({ items: v.array(v.string()) }), { + initialInput: { items: ['a', 'b', 'c'] }, + }); + const itemsStore = store.children.items; + expect(itemsStore.kind).toBe('array'); + if (itemsStore.kind === 'array') { + itemsStore.children[1].input.value = 'B'; + itemsStore.children[1].isDirty.value = true; + } + + expect( + pickDirty(store, { from: { items: ['a', 'B', 'c'] } }) + ).toStrictEqual({ items: ['a', 'B', 'c'] }); + }); + + test('should include dirty leaves under a clean object parent', () => { + const store = createTestStore( + v.object({ user: v.object({ email: v.string(), name: v.string() }) }), + { initialInput: { user: { email: 'a@example.com', name: 'John' } } } + ); + const userStore = store.children.user; + expect(userStore.kind).toBe('object'); + if (userStore.kind === 'object') { + userStore.children.email.input.value = 'b@example.com'; + userStore.children.email.isDirty.value = true; + } + + expect( + pickDirty(store, { + from: { user: { email: 'b@example.com', name: 'John' } }, + }) + ).toStrictEqual({ user: { email: 'b@example.com' } }); + }); + + test('should include a dirty leaf whose value is undefined', () => { + const store = createTestStore( + v.object({ name: v.optional(v.string()) }), + { initialInput: { name: 'John' } } + ); + store.children.name.input.value = undefined; + store.children.name.isDirty.value = true; + + expect(pickDirty(store, { from: { name: undefined } })).toStrictEqual({ + name: undefined, + }); + }); + + test('should return undefined when the root value shape diverges', () => { + const store = createTestStore(v.object({ name: v.string() }), { + initialInput: { name: 'John' }, + }); + store.children.name.input.value = 'Jane'; + store.children.name.isDirty.value = true; + + expect(pickDirty(store, { from: 'transformed-string' })).toBeUndefined(); + }); + + test('should skip keys where the value shape diverges and keep aligned siblings', () => { + const store = createTestStore( + v.object({ + name: v.string(), + user: v.object({ email: v.string() }), + }), + { initialInput: { name: 'John', user: { email: 'a@example.com' } } } + ); + store.children.name.input.value = 'Jane'; + store.children.name.isDirty.value = true; + const userStore = store.children.user; + expect(userStore.kind).toBe('object'); + if (userStore.kind === 'object') { + userStore.children.email.input.value = 'b@example.com'; + userStore.children.email.isDirty.value = true; + } + + expect( + pickDirty(store, { from: { name: 'Jane', user: 'reshaped' } }) + ).toStrictEqual({ name: 'Jane' }); + }); +}); diff --git a/packages/methods/src/pickDirty/pickDirty.ts b/packages/methods/src/pickDirty/pickDirty.ts new file mode 100644 index 00000000..d597e7cc --- /dev/null +++ b/packages/methods/src/pickDirty/pickDirty.ts @@ -0,0 +1,95 @@ +import { + type BaseFormStore, + type DeepPartial, + getFieldBool, + INTERNAL, + type InternalFieldStore, + type Schema, +} from '@formisch/core'; + +/** + * Pick dirty config interface. + */ +export interface PickDirtyConfig { + /** + * The value to filter down to its dirty parts. + */ + readonly from: TValue; +} + +/** + * Picks only the dirty parts of the given value, using the form's dirty + * tree as a structural mask. Object keys whose subtree contains no dirty + * descendant are omitted; arrays are treated as atomic and returned in full + * whenever any descendant is dirty. Returns `undefined` if no field is dirty + * or if the value's shape does not align with the form. Useful for filtering + * a validated output to just the changed parts before submitting. + * + * @param form The form store providing the dirty mask. + * @param config The pick dirty configuration. + * + * @returns The dirty parts of the value, or `undefined`. + */ +// @__NO_SIDE_EFFECTS__ +export function pickDirty( + form: BaseFormStore, + config: PickDirtyConfig +): DeepPartial | undefined { + const result = pickFromField(form[INTERNAL], config.from); + return result === SKIP ? undefined : (result as DeepPartial); +} + +// Sentinel returned when a subtree contributes nothing to the result. +// Distinct from `undefined` so that a dirty leaf whose value is `undefined` +// is still included rather than skipped. +const SKIP = Symbol(); + +// Recursively walks the form's dirty tree alongside the supplied value, +// plucking only the parts that correspond to dirty fields and whose shape +// aligns with the form. Returns `SKIP` when nothing should be included. +function pickFromField( + internalFieldStore: InternalFieldStore, + value: unknown +): unknown { + // Bail with sentinel if no descendant is dirty + if (!getFieldBool(internalFieldStore, 'isDirty')) { + return SKIP; + } + + // If field store is array, return the value if it is an array (atomic). + // Otherwise the shapes diverged and there is nothing safe to pluck. + if (internalFieldStore.kind === 'array') { + return Array.isArray(value) ? value : SKIP; + } + + // If field store is object, recurse only into dirty branches when the + // value is a non-array object. Skip when shapes diverge. + if (internalFieldStore.kind === 'object') { + if ( + value === null || + typeof value !== 'object' || + Array.isArray(value) + ) { + return SKIP; + } + const result: Record = {}; + let added = false; + for (const key in internalFieldStore.children) { + const child = internalFieldStore.children[key]; + if (getFieldBool(child, 'isDirty')) { + const childResult = pickFromField( + child, + (value as Record)[key] + ); + if (childResult !== SKIP) { + result[key] = childResult; + added = true; + } + } + } + return added ? result : SKIP; + } + + // Return value as-is for primitive value field + return value; +} diff --git a/website/src/routes/(docs)/methods/api/(methods)/getDirtyInput/index.mdx b/website/src/routes/(docs)/methods/api/(methods)/getDirtyInput/index.mdx new file mode 100644 index 00000000..7cad46cc --- /dev/null +++ b/website/src/routes/(docs)/methods/api/(methods)/getDirtyInput/index.mdx @@ -0,0 +1,52 @@ +--- +title: getDirtyInput +description: Retrieves only the dirty input values of a specific field or the entire form. +source: /packages/methods/src/getDirtyInput/getDirtyInput.ts +contributors: + - fabian-hiller +--- + +import { ApiList, Property } from '~/components'; +import { properties } from './properties'; + +# getDirtyInput + +Retrieves only the dirty input values of a specific field or the entire form. Object keys whose subtree contains no dirty descendant are omitted; arrays are treated as atomic and returned in full whenever any descendant is dirty. Returns `undefined` if no field in the inspected subtree is dirty. + +```ts +const input = getDirtyInput(form); +const input = getDirtyInput(form, config); +``` + +## Generics + +- `TSchema` +- `TFieldPath` + +## Parameters + +- `form` +- `config` + +### Explanation + +The `form` parameter is the form store to retrieve dirty input from. The optional `config` parameter scopes the result to a specific field path - if omitted, returns dirty input from the entire form. The returned value is a partial of the inspected input shape containing only the fields whose `isDirty` flag is set, which is useful for submitting only the values that changed since the start input. Arrays are treated as atomic — when any descendant of an array is dirty, the full current array is returned. + +Internally, the function walks the form's field tree and asks `getFieldBool` whether each branch contains a dirty descendant. Because that check is itself recursive, the cost is effectively linear in field count for typical balanced forms (shallow and wide) and degrades toward `O(N²)` for deeply nested trees. Call this method from submit or blur handlers rather than from tight reactive loops on large forms. + +## Returns + +- `result` + +## Related + +### Methods + + diff --git a/website/src/routes/(docs)/methods/api/(methods)/getDirtyInput/properties.ts b/website/src/routes/(docs)/methods/api/(methods)/getDirtyInput/properties.ts new file mode 100644 index 00000000..b4a632d6 --- /dev/null +++ b/website/src/routes/(docs)/methods/api/(methods)/getDirtyInput/properties.ts @@ -0,0 +1,105 @@ +import type { PropertyProps } from '~/components'; + +export const properties: Record = { + TSchema: { + modifier: 'extends', + type: { + type: 'custom', + name: 'Schema', + href: '/core/api/Schema/', + }, + }, + TFieldPath: { + modifier: 'extends', + type: { + type: 'union', + options: [ + { + type: 'custom', + name: 'RequiredPath', + href: '/core/api/RequiredPath/', + }, + 'undefined', + ], + }, + default: 'undefined', + }, + form: { + type: { + type: 'custom', + name: 'BaseFormStore', + href: '/core/api/BaseFormStore/', + generics: [ + { + type: 'custom', + name: 'TSchema', + }, + ], + }, + }, + config: { + type: { + type: 'union', + options: [ + { + type: 'custom', + name: 'GetFormDirtyInputConfig', + href: '../GetFormDirtyInputConfig/', + }, + { + type: 'custom', + name: 'GetFieldDirtyInputConfig', + href: '../GetFieldDirtyInputConfig/', + generics: [ + { + type: 'custom', + name: 'TSchema', + }, + { + type: 'custom', + name: 'RequiredPath', + }, + ], + }, + 'undefined', + ], + }, + }, + result: { + type: { + type: 'union', + options: [ + { + type: 'custom', + name: 'DeepPartial', + href: '/core/api/DeepPartial/', + generics: [ + { + type: 'custom', + name: 'PathValue', + href: '/core/api/PathValue/', + generics: [ + { + type: 'custom', + name: 'v.InferInput', + href: 'https://valibot.dev/api/InferInput/', + generics: [ + { + type: 'custom', + name: 'TSchema', + }, + ], + }, + { + type: 'custom', + name: 'TFieldPath', + }, + ], + }, + ], + }, + 'undefined', + ], + }, + }, +}; diff --git a/website/src/routes/(docs)/methods/api/(methods)/getDirtyPaths/index.mdx b/website/src/routes/(docs)/methods/api/(methods)/getDirtyPaths/index.mdx new file mode 100644 index 00000000..0a1b00b6 --- /dev/null +++ b/website/src/routes/(docs)/methods/api/(methods)/getDirtyPaths/index.mdx @@ -0,0 +1,51 @@ +--- +title: getDirtyPaths +description: Returns the paths of dirty fields in a form. +source: /packages/methods/src/getDirtyPaths/getDirtyPaths.ts +contributors: + - fabian-hiller +--- + +import { ApiList, Property } from '~/components'; +import { properties } from './properties'; + +# getDirtyPaths + +Returns a list of paths to dirty fields in the form. Object branches are recursed into; arrays are treated as atomic — when any descendant of an array is dirty, only the array's own path is returned. + +```ts +const paths = getDirtyPaths(form); +const paths = getDirtyPaths(form, config); +``` + +## Generics + +- `TSchema` +- `TFieldPath` + +## Parameters + +- `form` +- `config` + +### Explanation + +The `form` parameter is the form store to inspect. The optional `config` parameter scopes the search to a single subtree via its `path` property. Object branches are recursed into and clean keys are omitted; arrays are atomic, so when any descendant of an array is dirty, the result includes the array's own path rather than the paths of its individual items. Returns an empty array if the inspected subtree contains no dirty fields. When the inspected target is itself a dirty array or value (for example a form whose root schema is `v.array(...)`), the result contains its path — which is the empty array for the form root and so cannot be passed back into another path-scoped method without a length check. + +Internally, the function walks the form's field tree and asks `getFieldBool` whether each branch contains a dirty descendant. Because that check is itself recursive, the cost is effectively linear in field count for typical balanced forms (shallow and wide) and degrades toward `O(N²)` for deeply nested trees. Call this method from submit or blur handlers rather than from tight reactive loops on large forms. + +## Returns + +- `result` + +## Related + +### Methods + + diff --git a/website/src/routes/(docs)/methods/api/(methods)/getDirtyPaths/properties.ts b/website/src/routes/(docs)/methods/api/(methods)/getDirtyPaths/properties.ts new file mode 100644 index 00000000..eaaacb6b --- /dev/null +++ b/website/src/routes/(docs)/methods/api/(methods)/getDirtyPaths/properties.ts @@ -0,0 +1,78 @@ +import type { PropertyProps } from '~/components'; + +export const properties: Record = { + TSchema: { + modifier: 'extends', + type: { + type: 'custom', + name: 'Schema', + href: '/core/api/Schema/', + }, + }, + TFieldPath: { + modifier: 'extends', + type: { + type: 'union', + options: [ + { + type: 'custom', + name: 'RequiredPath', + href: '/core/api/RequiredPath/', + }, + 'undefined', + ], + }, + default: 'undefined', + }, + form: { + type: { + type: 'custom', + name: 'BaseFormStore', + href: '/core/api/BaseFormStore/', + generics: [ + { + type: 'custom', + name: 'TSchema', + }, + ], + }, + }, + config: { + type: { + type: 'union', + options: [ + { + type: 'custom', + name: 'GetFormDirtyPathsConfig', + href: '../GetFormDirtyPathsConfig/', + }, + { + type: 'custom', + name: 'GetFieldDirtyPathsConfig', + href: '../GetFieldDirtyPathsConfig/', + generics: [ + { + type: 'custom', + name: 'TSchema', + }, + { + type: 'custom', + name: 'RequiredPath', + }, + ], + }, + 'undefined', + ], + }, + }, + result: { + type: { + type: 'array', + item: { + type: 'custom', + name: 'RequiredPath', + href: '/core/api/RequiredPath/', + }, + }, + }, +}; diff --git a/website/src/routes/(docs)/methods/api/(methods)/pickDirty/index.mdx b/website/src/routes/(docs)/methods/api/(methods)/pickDirty/index.mdx new file mode 100644 index 00000000..6cb004a5 --- /dev/null +++ b/website/src/routes/(docs)/methods/api/(methods)/pickDirty/index.mdx @@ -0,0 +1,50 @@ +--- +title: pickDirty +description: Filters an externally-supplied value down to its dirty parts using the form's dirty mask. +source: /packages/methods/src/pickDirty/pickDirty.ts +contributors: + - fabian-hiller +--- + +import { ApiList, Property } from '~/components'; +import { properties } from './properties'; + +# pickDirty + +Picks only the dirty parts of the given value, using the form's dirty tree as a structural mask. Object keys whose subtree contains no dirty descendant are omitted; arrays are treated as atomic and returned in full whenever any descendant is dirty. Returns `undefined` if no field is dirty or if the value's shape does not align with the form. Useful for filtering a validated output to just the changed parts before submitting. + +```ts +const dirty = pickDirty(form, config); +``` + +## Generics + +- `TSchema` +- `TValue` + +## Parameters + +- `form` +- `config` + +### Explanation + +The `form` parameter is the form store providing the dirty mask. The `config` parameter must include a `from` value to filter. The value's shape is expected to match the form's input shape — wherever the shapes diverge (e.g. the value's object key is missing, or an array key holds a non-array), that branch is skipped. Use this when a Valibot schema transformation produces a value of a different shape than the form's input and you want to ship only the dirty parts of that output. + +Internally, the function walks the form's field tree alongside the supplied value, using `getFieldBool` to skip clean subtrees and a constant-time check at each node to verify shape alignment. Because the dirty check is itself recursive, the cost is effectively linear in field count for typical balanced forms (shallow and wide) and degrades toward `O(N²)` for deeply nested trees. Call this method from submit or blur handlers rather than from tight reactive loops on large forms. + +## Returns + +- `result` + +## Related + +### Methods + + diff --git a/website/src/routes/(docs)/methods/api/(methods)/pickDirty/properties.ts b/website/src/routes/(docs)/methods/api/(methods)/pickDirty/properties.ts new file mode 100644 index 00000000..b98d57ae --- /dev/null +++ b/website/src/routes/(docs)/methods/api/(methods)/pickDirty/properties.ts @@ -0,0 +1,63 @@ +import type { PropertyProps } from '~/components'; + +export const properties: Record = { + TSchema: { + modifier: 'extends', + type: { + type: 'custom', + name: 'Schema', + href: '/core/api/Schema/', + }, + }, + TValue: { + type: { + type: 'custom', + name: 'unknown', + }, + }, + form: { + type: { + type: 'custom', + name: 'BaseFormStore', + href: '/core/api/BaseFormStore/', + generics: [ + { + type: 'custom', + name: 'TSchema', + }, + ], + }, + }, + config: { + type: { + type: 'custom', + name: 'PickDirtyConfig', + href: '../PickDirtyConfig/', + generics: [ + { + type: 'custom', + name: 'TValue', + }, + ], + }, + }, + result: { + type: { + type: 'union', + options: [ + { + type: 'custom', + name: 'DeepPartial', + href: '/core/api/DeepPartial/', + generics: [ + { + type: 'custom', + name: 'TValue', + }, + ], + }, + 'undefined', + ], + }, + }, +}; diff --git a/website/src/routes/(docs)/methods/api/(types)/GetFieldDirtyInputConfig/index.mdx b/website/src/routes/(docs)/methods/api/(types)/GetFieldDirtyInputConfig/index.mdx new file mode 100644 index 00000000..efe44f76 --- /dev/null +++ b/website/src/routes/(docs)/methods/api/(types)/GetFieldDirtyInputConfig/index.mdx @@ -0,0 +1,30 @@ +--- +title: GetFieldDirtyInputConfig +description: Configuration interface for retrieving field-scoped dirty input. +source: /packages/methods/src/getDirtyInput/getDirtyInput.ts +contributors: + - fabian-hiller +--- + +import { ApiList, Property } from '~/components'; +import { properties } from './properties'; + +# GetFieldDirtyInputConfig + +Configuration interface for retrieving field-scoped dirty input. Used by the `getDirtyInput` method when a specific field path is provided. + +## Generics + +- `TSchema` +- `TFieldPath` + +## Definition + +- `GetFieldDirtyInputConfig` + - `path` + +## Related + +### Methods + + diff --git a/website/src/routes/(docs)/methods/api/(types)/GetFieldDirtyInputConfig/properties.ts b/website/src/routes/(docs)/methods/api/(types)/GetFieldDirtyInputConfig/properties.ts new file mode 100644 index 00000000..56155efd --- /dev/null +++ b/website/src/routes/(docs)/methods/api/(types)/GetFieldDirtyInputConfig/properties.ts @@ -0,0 +1,44 @@ +import type { PropertyProps } from '~/components'; + +export const properties: Record = { + TSchema: { + modifier: 'extends', + type: { + type: 'custom', + name: 'Schema', + href: '/core/api/Schema/', + }, + }, + TFieldPath: { + modifier: 'extends', + type: { + type: 'custom', + name: 'RequiredPath', + href: '/core/api/RequiredPath/', + }, + }, + path: { + type: { + type: 'custom', + name: 'ValidPath', + href: '/core/api/ValidPath/', + generics: [ + { + type: 'custom', + name: 'v.InferInput', + href: 'https://valibot.dev/api/InferInput/', + generics: [ + { + type: 'custom', + name: 'TSchema', + }, + ], + }, + { + type: 'custom', + name: 'TFieldPath', + }, + ], + }, + }, +}; diff --git a/website/src/routes/(docs)/methods/api/(types)/GetFieldDirtyPathsConfig/index.mdx b/website/src/routes/(docs)/methods/api/(types)/GetFieldDirtyPathsConfig/index.mdx new file mode 100644 index 00000000..1de4bb92 --- /dev/null +++ b/website/src/routes/(docs)/methods/api/(types)/GetFieldDirtyPathsConfig/index.mdx @@ -0,0 +1,30 @@ +--- +title: GetFieldDirtyPathsConfig +description: Configuration interface for inspecting field-scoped dirty paths. +source: /packages/methods/src/getDirtyPaths/getDirtyPaths.ts +contributors: + - fabian-hiller +--- + +import { ApiList, Property } from '~/components'; +import { properties } from './properties'; + +# GetFieldDirtyPathsConfig + +Configuration interface for inspecting field-scoped dirty paths. Used by the `getDirtyPaths` method when a specific field path is provided. + +## Generics + +- `TSchema` +- `TFieldPath` + +## Definition + +- `GetFieldDirtyPathsConfig` + - `path` + +## Related + +### Methods + + diff --git a/website/src/routes/(docs)/methods/api/(types)/GetFieldDirtyPathsConfig/properties.ts b/website/src/routes/(docs)/methods/api/(types)/GetFieldDirtyPathsConfig/properties.ts new file mode 100644 index 00000000..56155efd --- /dev/null +++ b/website/src/routes/(docs)/methods/api/(types)/GetFieldDirtyPathsConfig/properties.ts @@ -0,0 +1,44 @@ +import type { PropertyProps } from '~/components'; + +export const properties: Record = { + TSchema: { + modifier: 'extends', + type: { + type: 'custom', + name: 'Schema', + href: '/core/api/Schema/', + }, + }, + TFieldPath: { + modifier: 'extends', + type: { + type: 'custom', + name: 'RequiredPath', + href: '/core/api/RequiredPath/', + }, + }, + path: { + type: { + type: 'custom', + name: 'ValidPath', + href: '/core/api/ValidPath/', + generics: [ + { + type: 'custom', + name: 'v.InferInput', + href: 'https://valibot.dev/api/InferInput/', + generics: [ + { + type: 'custom', + name: 'TSchema', + }, + ], + }, + { + type: 'custom', + name: 'TFieldPath', + }, + ], + }, + }, +}; diff --git a/website/src/routes/(docs)/methods/api/(types)/GetFormDirtyInputConfig/index.mdx b/website/src/routes/(docs)/methods/api/(types)/GetFormDirtyInputConfig/index.mdx new file mode 100644 index 00000000..586d1486 --- /dev/null +++ b/website/src/routes/(docs)/methods/api/(types)/GetFormDirtyInputConfig/index.mdx @@ -0,0 +1,25 @@ +--- +title: GetFormDirtyInputConfig +description: Configuration interface for retrieving form-level dirty input. +source: /packages/methods/src/getDirtyInput/getDirtyInput.ts +contributors: + - fabian-hiller +--- + +import { ApiList, Property } from '~/components'; +import { properties } from './properties'; + +# GetFormDirtyInputConfig + +Configuration interface for retrieving form-level dirty input. Used by the `getDirtyInput` method when no specific field path is provided. + +## Definition + +- `GetFormDirtyInputConfig` + - `path` + +## Related + +### Methods + + diff --git a/website/src/routes/(docs)/methods/api/(types)/GetFormDirtyInputConfig/properties.ts b/website/src/routes/(docs)/methods/api/(types)/GetFormDirtyInputConfig/properties.ts new file mode 100644 index 00000000..ed1e77e3 --- /dev/null +++ b/website/src/routes/(docs)/methods/api/(types)/GetFormDirtyInputConfig/properties.ts @@ -0,0 +1,8 @@ +import type { PropertyProps } from '~/components'; + +export const properties: Record = { + path: { + type: 'undefined', + default: 'undefined', + }, +}; diff --git a/website/src/routes/(docs)/methods/api/(types)/GetFormDirtyPathsConfig/index.mdx b/website/src/routes/(docs)/methods/api/(types)/GetFormDirtyPathsConfig/index.mdx new file mode 100644 index 00000000..e9373457 --- /dev/null +++ b/website/src/routes/(docs)/methods/api/(types)/GetFormDirtyPathsConfig/index.mdx @@ -0,0 +1,25 @@ +--- +title: GetFormDirtyPathsConfig +description: Configuration interface for inspecting form-level dirty paths. +source: /packages/methods/src/getDirtyPaths/getDirtyPaths.ts +contributors: + - fabian-hiller +--- + +import { ApiList, Property } from '~/components'; +import { properties } from './properties'; + +# GetFormDirtyPathsConfig + +Configuration interface for inspecting form-level dirty paths. Used by the `getDirtyPaths` method when no specific field path is provided. + +## Definition + +- `GetFormDirtyPathsConfig` + - `path` + +## Related + +### Methods + + diff --git a/website/src/routes/(docs)/methods/api/(types)/GetFormDirtyPathsConfig/properties.ts b/website/src/routes/(docs)/methods/api/(types)/GetFormDirtyPathsConfig/properties.ts new file mode 100644 index 00000000..ed1e77e3 --- /dev/null +++ b/website/src/routes/(docs)/methods/api/(types)/GetFormDirtyPathsConfig/properties.ts @@ -0,0 +1,8 @@ +import type { PropertyProps } from '~/components'; + +export const properties: Record = { + path: { + type: 'undefined', + default: 'undefined', + }, +}; diff --git a/website/src/routes/(docs)/methods/api/(types)/PickDirtyConfig/index.mdx b/website/src/routes/(docs)/methods/api/(types)/PickDirtyConfig/index.mdx new file mode 100644 index 00000000..85164cfb --- /dev/null +++ b/website/src/routes/(docs)/methods/api/(types)/PickDirtyConfig/index.mdx @@ -0,0 +1,29 @@ +--- +title: PickDirtyConfig +description: Configuration interface for picking dirty parts of a value. +source: /packages/methods/src/pickDirty/pickDirty.ts +contributors: + - fabian-hiller +--- + +import { ApiList, Property } from '~/components'; +import { properties } from './properties'; + +# PickDirtyConfig + +Configuration interface for picking dirty parts of a value. Used by the `pickDirty` method. + +## Generics + +- `TValue` + +## Definition + +- `PickDirtyConfig` + - `from` + +## Related + +### Methods + + diff --git a/website/src/routes/(docs)/methods/api/(types)/PickDirtyConfig/properties.ts b/website/src/routes/(docs)/methods/api/(types)/PickDirtyConfig/properties.ts new file mode 100644 index 00000000..e98615b1 --- /dev/null +++ b/website/src/routes/(docs)/methods/api/(types)/PickDirtyConfig/properties.ts @@ -0,0 +1,16 @@ +import type { PropertyProps } from '~/components'; + +export const properties: Record = { + TValue: { + type: { + type: 'custom', + name: 'unknown', + }, + }, + from: { + type: { + type: 'custom', + name: 'TValue', + }, + }, +}; diff --git a/website/src/routes/(docs)/preact/api/menu.md b/website/src/routes/(docs)/preact/api/menu.md index 8a9eb521..9efe75f9 100644 --- a/website/src/routes/(docs)/preact/api/menu.md +++ b/website/src/routes/(docs)/preact/api/menu.md @@ -16,11 +16,14 @@ - [focus](/methods/api/focus/) - [getAllErrors](/methods/api/getAllErrors/) +- [getDirtyInput](/methods/api/getDirtyInput/) +- [getDirtyPaths](/methods/api/getDirtyPaths/) - [getErrors](/methods/api/getErrors/) - [getInput](/methods/api/getInput/) - [handleSubmit](/methods/api/handleSubmit/) - [insert](/methods/api/insert/) - [move](/methods/api/move/) +- [pickDirty](/methods/api/pickDirty/) - [remove](/methods/api/remove/) - [replace](/methods/api/replace/) - [reset](/methods/api/reset/) @@ -41,14 +44,19 @@ - [FormConfig](/preact/api/FormConfig/) - [FormSchema](/core/api/FormSchema/) - [FormStore](/preact/api/FormStore/) +- [GetFieldDirtyInputConfig](/methods/api/GetFieldDirtyInputConfig/) +- [GetFieldDirtyPathsConfig](/methods/api/GetFieldDirtyPathsConfig/) - [GetFieldErrorsConfig](/methods/api/GetFieldErrorsConfig/) - [GetFieldInputConfig](/methods/api/GetFieldInputConfig/) +- [GetFormDirtyInputConfig](/methods/api/GetFormDirtyInputConfig/) +- [GetFormDirtyPathsConfig](/methods/api/GetFormDirtyPathsConfig/) - [GetFormErrorsConfig](/methods/api/GetFormErrorsConfig/) - [GetFormInputConfig](/methods/api/GetFormInputConfig/) - [InsertConfig](/methods/api/InsertConfig/) - [PartialValues](/core/api/PartialValues/) - [Path](/core/api/Path/) - [PathKey](/core/api/PathKey/) +- [PickDirtyConfig](/methods/api/PickDirtyConfig/) - [MoveConfig](/methods/api/MoveConfig/) - [RemoveConfig](/methods/api/RemoveConfig/) - [ReplaceConfig](/methods/api/ReplaceConfig/) diff --git a/website/src/routes/(docs)/preact/guides/(advanced-guides)/dirty-fields/index.mdx b/website/src/routes/(docs)/preact/guides/(advanced-guides)/dirty-fields/index.mdx new file mode 100644 index 00000000..41a94ac8 --- /dev/null +++ b/website/src/routes/(docs)/preact/guides/(advanced-guides)/dirty-fields/index.mdx @@ -0,0 +1,196 @@ +--- +title: Dirty fields +description: >- + Learn how to work with dirty form state in Formisch. This guide covers the + three dirty-extraction methods, when to reach for each, and why pickDirty + exists alongside getDirtyInput. +contributors: + - fabian-hiller +--- + +import { Link } from '~/components'; + +# Dirty fields + +When a user edits a form, Formisch tracks which fields have changed since they were initialized. The library exposes three methods for working with that dirty state: `getDirtyInput`, `getDirtyPaths`, and `pickDirty`. This guide explains what each one is for, when to reach for it, and why `pickDirty` exists alongside the others. + +## What "dirty" means + +A field is dirty when its current input differs from its start input. The form starts clean. As the user types, only the fields they touch flip to dirty. Resetting a field (or resetting the form with a new `initialInput`) clears the dirty flag. + +> The dirty flag is per-field. Editing a deeply nested value does not flip its ancestors' flags. The dirty-extraction methods walk the tree internally to find dirty descendants, which is why all three methods do the same kind of work under the hood. + +## The three methods + +### `getDirtyInput` + +Returns the dirty subtree of the form's input. Use it when you want to ship the values the user typed — typically for a PATCH endpoint that accepts the same shape as your form input. + +```tsx +import { + Form, + getDirtyInput, + useForm, + type SubmitHandler, +} from '@formisch/preact'; +import * as v from 'valibot'; + +const UserSchema = v.object({ + name: v.pipe(v.string(), v.nonEmpty()), + email: v.pipe(v.string(), v.email()), +}); + +export default function EditProfile({ user }) { + const profileForm = useForm({ + schema: UserSchema, + initialInput: user, + }); + + const onSubmit: SubmitHandler = async () => { + const dirty = getDirtyInput(profileForm); + if (dirty) { + await api.patchUser(user.id, dirty); + } + }; + + return ( +
+ {/* fields */} +
+ ); +} +``` + +If only `email` was edited, `dirty` is `{ email: 'new@example.com' }`. If nothing was edited, it is `undefined`. + +Because dirty state is bound to the form input, `getDirtyInput` always returns the raw user input. Schema transformations such as `v.trim()` or `v.toNumber()` are not applied to the returned values. When you need the validated and transformed output instead, reach for `pickDirty` (see below). + +### `getDirtyPaths` + +Returns a list of paths to dirty fields. Use it when you want to drive your own walker — for example, generating a patch payload or computing a structural diff. + +```tsx +import { getDirtyPaths, getInput } from '@formisch/preact'; + +const patch = getDirtyPaths(profileForm).map((path) => ({ + operation: 'replace', + path, + value: getInput(profileForm, { path }), +})); +``` + +Arrays are atomic: only the array's own path is returned, never the paths of individual items. + +### `pickDirty` + +Filters a user-supplied value down to its dirty parts using the form's dirty mask. Use it when you have a separately-derived value — most commonly the validated and transformed output from a submit handler — and you want only its dirty parts. + +```tsx +import { pickDirty, type SubmitHandler } from '@formisch/preact'; + +const onSubmit: SubmitHandler = async (output) => { + const dirty = pickDirty(profileForm, { from: output }); + if (dirty) { + await api.update(dirty); + } +}; +``` + +## Why `pickDirty` exists + +Dirty state is tracked against the form **input** — the raw values the user typed. But Valibot schemas can transform that input into a different output shape before it reaches your submit handler: + +```tsx +import * as v from 'valibot'; + +const Schema = v.object({ + name: v.pipe(v.string(), v.trim()), + age: v.pipe(v.string(), v.toNumber()), +}); +``` + +After validation, `output.name` has whitespace trimmed and `output.age` is a number. `getDirtyInput` would give you the raw strings, because the dirty state is bound to the input form — not to the validated output. + +`pickDirty(form, { from: output })` is the bridge: it walks the form's dirty tree as a structural mask and reads the corresponding values from the output you supply. The result preserves the transformed types. + +```tsx +import { pickDirty, type SubmitHandler } from '@formisch/preact'; + +const onSubmit: SubmitHandler = async (output) => { + // output.age is a number — pickDirty preserves that. + const dirty = pickDirty(form, { from: output }); + if (dirty) { + await api.update(dirty); + } +}; +``` + +If the schema reshapes the output entirely — for example, by combining several input fields into a single output value — `pickDirty` returns `undefined` because the shape no longer aligns with the form's input shape. + +## Atomic arrays + +All three methods treat arrays as atomic. When any descendant of an array is dirty, the full array is returned (or its own path, for `getDirtyPaths`). The methods never produce sparse arrays. + +Sparse arrays don't round-trip safely. Serializers compact them, `JSON.stringify` writes `null` for holes, and indices lose positional meaning. Returning the full current array preserves order and lets the server treat the array as a complete replacement. + +This differs from objects, where clean keys are omitted. Objects model keyed dictionaries; arrays model ordered sequences. + +## Common patterns + +### Skip submission when nothing changed + +The form's `isDirty` flag is the cheapest way to ask "is anything dirty?" — it short-circuits on the first dirty field it finds and doesn't allocate. Use it when all you need is the yes/no answer. If you actually consume the output of `getDirtyInput`, `getDirtyPaths`, or `pickDirty`, call that method directly — its return value already signals "nothing dirty", so checking `form.isDirty` first would just walk the tree twice. + +```tsx +import type { SubmitHandler } from '@formisch/preact'; + +const onSubmit: SubmitHandler = async (output) => { + if (!form.isDirty) { + return; + } + await api.update(output); +}; +``` + +### Send only the dirty raw input + +```tsx +import { getDirtyInput } from '@formisch/preact'; + +const dirty = getDirtyInput(form); +if (dirty) { + await api.patch(dirty); +} +``` + +### Send only the dirty validated output + +```tsx +import { pickDirty, type SubmitHandler } from '@formisch/preact'; + +const onSubmit: SubmitHandler = async (output) => { + const dirty = pickDirty(form, { from: output }); + if (dirty) { + await api.update(dirty); + } +}; +``` + +### Build a patch payload + +```tsx +import { getDirtyPaths, getInput } from '@formisch/preact'; + +const patch = getDirtyPaths(form).map((path) => ({ + operation: 'replace', + path, + value: getInput(form, { path }), +})); +await api.patch(patch); +``` + +## Performance + +All three methods walk the form's field tree and call `getFieldBool` recursively to skip clean subtrees. Cost is effectively linear in field count for typical balanced forms (shallow and wide) and degrades toward `O(N²)` for deeply nested trees with few siblings at each level. + +Call these methods from submit or blur handlers — not from tight reactive loops on every keystroke. For very large or deeply nested forms (thousands of fields), profile before binding them to high-frequency events. diff --git a/website/src/routes/(docs)/preact/guides/menu.md b/website/src/routes/(docs)/preact/guides/menu.md index b359313d..77868632 100644 --- a/website/src/routes/(docs)/preact/guides/menu.md +++ b/website/src/routes/(docs)/preact/guides/menu.md @@ -20,6 +20,7 @@ - [Special inputs](/preact/guides/special-inputs/) - [Controlled fields](/preact/guides/controlled-fields/) - [Nested fields](/preact/guides/nested-fields/) +- [Dirty fields](/preact/guides/dirty-fields/) - [Field arrays](/preact/guides/field-arrays/) - [TypeScript](/preact/guides/typescript/) - [Architecture](/preact/guides/architecture/) diff --git a/website/src/routes/(docs)/qwik/api/menu.md b/website/src/routes/(docs)/qwik/api/menu.md index 74ae2323..d47e835f 100644 --- a/website/src/routes/(docs)/qwik/api/menu.md +++ b/website/src/routes/(docs)/qwik/api/menu.md @@ -16,11 +16,14 @@ - [focus](/methods/api/focus/) - [getAllErrors](/methods/api/getAllErrors/) +- [getDirtyInput](/methods/api/getDirtyInput/) +- [getDirtyPaths](/methods/api/getDirtyPaths/) - [getErrors](/methods/api/getErrors/) - [getInput](/methods/api/getInput/) - [handleSubmit](/methods/api/handleSubmit/) - [insert](/methods/api/insert/) - [move](/methods/api/move/) +- [pickDirty](/methods/api/pickDirty/) - [remove](/methods/api/remove/) - [replace](/methods/api/replace/) - [reset](/methods/api/reset/) @@ -41,14 +44,19 @@ - [FormConfig](/qwik/api/FormConfig/) - [FormSchema](/core/api/FormSchema/) - [FormStore](/qwik/api/FormStore/) +- [GetFieldDirtyInputConfig](/methods/api/GetFieldDirtyInputConfig/) +- [GetFieldDirtyPathsConfig](/methods/api/GetFieldDirtyPathsConfig/) - [GetFieldErrorsConfig](/methods/api/GetFieldErrorsConfig/) - [GetFieldInputConfig](/methods/api/GetFieldInputConfig/) +- [GetFormDirtyInputConfig](/methods/api/GetFormDirtyInputConfig/) +- [GetFormDirtyPathsConfig](/methods/api/GetFormDirtyPathsConfig/) - [GetFormErrorsConfig](/methods/api/GetFormErrorsConfig/) - [GetFormInputConfig](/methods/api/GetFormInputConfig/) - [InsertConfig](/methods/api/InsertConfig/) - [PartialValues](/core/api/PartialValues/) - [Path](/core/api/Path/) - [PathKey](/core/api/PathKey/) +- [PickDirtyConfig](/methods/api/PickDirtyConfig/) - [MoveConfig](/methods/api/MoveConfig/) - [RemoveConfig](/methods/api/RemoveConfig/) - [ReplaceConfig](/methods/api/ReplaceConfig/) diff --git a/website/src/routes/(docs)/qwik/guides/(advanced-guides)/dirty-fields/index.mdx b/website/src/routes/(docs)/qwik/guides/(advanced-guides)/dirty-fields/index.mdx new file mode 100644 index 00000000..a5f3eddb --- /dev/null +++ b/website/src/routes/(docs)/qwik/guides/(advanced-guides)/dirty-fields/index.mdx @@ -0,0 +1,196 @@ +--- +title: Dirty fields +description: >- + Learn how to work with dirty form state in Formisch. This guide covers the + three dirty-extraction methods, when to reach for each, and why pickDirty + exists alongside getDirtyInput. +contributors: + - fabian-hiller +--- + +import { Link } from '~/components'; + +# Dirty fields + +When a user edits a form, Formisch tracks which fields have changed since they were initialized. The library exposes three methods for working with that dirty state: `getDirtyInput`, `getDirtyPaths`, and `pickDirty`. This guide explains what each one is for, when to reach for it, and why `pickDirty` exists alongside the others. + +## What "dirty" means + +A field is dirty when its current input differs from its start input. The form starts clean. As the user types, only the fields they touch flip to dirty. Resetting a field (or resetting the form with a new `initialInput`) clears the dirty flag. + +> The dirty flag is per-field. Editing a deeply nested value does not flip its ancestors' flags. The dirty-extraction methods walk the tree internally to find dirty descendants, which is why all three methods do the same kind of work under the hood. + +## The three methods + +### `getDirtyInput` + +Returns the dirty subtree of the form's input. Use it when you want to ship the values the user typed — typically for a PATCH endpoint that accepts the same shape as your form input. + +```tsx +import { + Form, + getDirtyInput, + useForm$, + type SubmitHandler, +} from '@formisch/qwik'; +import * as v from 'valibot'; + +const UserSchema = v.object({ + name: v.pipe(v.string(), v.nonEmpty()), + email: v.pipe(v.string(), v.email()), +}); + +export default function EditProfile({ user }) { + const profileForm = useForm$({ + schema: UserSchema, + initialInput: user, + }); + + const onSubmit: SubmitHandler = async () => { + const dirty = getDirtyInput(profileForm); + if (dirty) { + await api.patchUser(user.id, dirty); + } + }; + + return ( +
+ {/* fields */} +
+ ); +} +``` + +If only `email` was edited, `dirty` is `{ email: 'new@example.com' }`. If nothing was edited, it is `undefined`. + +Because dirty state is bound to the form input, `getDirtyInput` always returns the raw user input. Schema transformations such as `v.trim()` or `v.toNumber()` are not applied to the returned values. When you need the validated and transformed output instead, reach for `pickDirty` (see below). + +### `getDirtyPaths` + +Returns a list of paths to dirty fields. Use it when you want to drive your own walker — for example, generating a patch payload or computing a structural diff. + +```tsx +import { getDirtyPaths, getInput } from '@formisch/qwik'; + +const patch = getDirtyPaths(profileForm).map((path) => ({ + operation: 'replace', + path, + value: getInput(profileForm, { path }), +})); +``` + +Arrays are atomic: only the array's own path is returned, never the paths of individual items. + +### `pickDirty` + +Filters a user-supplied value down to its dirty parts using the form's dirty mask. Use it when you have a separately-derived value — most commonly the validated and transformed output from a submit handler — and you want only its dirty parts. + +```tsx +import { pickDirty, type SubmitHandler } from '@formisch/qwik'; + +const onSubmit: SubmitHandler = async (output) => { + const dirty = pickDirty(profileForm, { from: output }); + if (dirty) { + await api.update(dirty); + } +}; +``` + +## Why `pickDirty` exists + +Dirty state is tracked against the form **input** — the raw values the user typed. But Valibot schemas can transform that input into a different output shape before it reaches your submit handler: + +```tsx +import * as v from 'valibot'; + +const Schema = v.object({ + name: v.pipe(v.string(), v.trim()), + age: v.pipe(v.string(), v.toNumber()), +}); +``` + +After validation, `output.name` has whitespace trimmed and `output.age` is a number. `getDirtyInput` would give you the raw strings, because the dirty state is bound to the input form — not to the validated output. + +`pickDirty(form, { from: output })` is the bridge: it walks the form's dirty tree as a structural mask and reads the corresponding values from the output you supply. The result preserves the transformed types. + +```tsx +import { pickDirty, type SubmitHandler } from '@formisch/qwik'; + +const onSubmit: SubmitHandler = async (output) => { + // output.age is a number — pickDirty preserves that. + const dirty = pickDirty(form, { from: output }); + if (dirty) { + await api.update(dirty); + } +}; +``` + +If the schema reshapes the output entirely — for example, by combining several input fields into a single output value — `pickDirty` returns `undefined` because the shape no longer aligns with the form's input shape. + +## Atomic arrays + +All three methods treat arrays as atomic. When any descendant of an array is dirty, the full array is returned (or its own path, for `getDirtyPaths`). The methods never produce sparse arrays. + +Sparse arrays don't round-trip safely. Serializers compact them, `JSON.stringify` writes `null` for holes, and indices lose positional meaning. Returning the full current array preserves order and lets the server treat the array as a complete replacement. + +This differs from objects, where clean keys are omitted. Objects model keyed dictionaries; arrays model ordered sequences. + +## Common patterns + +### Skip submission when nothing changed + +The form's `isDirty` flag is the cheapest way to ask "is anything dirty?" — it short-circuits on the first dirty field it finds and doesn't allocate. Use it when all you need is the yes/no answer. If you actually consume the output of `getDirtyInput`, `getDirtyPaths`, or `pickDirty`, call that method directly — its return value already signals "nothing dirty", so checking `form.isDirty` first would just walk the tree twice. + +```tsx +import type { SubmitHandler } from '@formisch/qwik'; + +const onSubmit: SubmitHandler = async (output) => { + if (!form.isDirty) { + return; + } + await api.update(output); +}; +``` + +### Send only the dirty raw input + +```tsx +import { getDirtyInput } from '@formisch/qwik'; + +const dirty = getDirtyInput(form); +if (dirty) { + await api.patch(dirty); +} +``` + +### Send only the dirty validated output + +```tsx +import { pickDirty, type SubmitHandler } from '@formisch/qwik'; + +const onSubmit: SubmitHandler = async (output) => { + const dirty = pickDirty(form, { from: output }); + if (dirty) { + await api.update(dirty); + } +}; +``` + +### Build a patch payload + +```tsx +import { getDirtyPaths, getInput } from '@formisch/qwik'; + +const patch = getDirtyPaths(form).map((path) => ({ + operation: 'replace', + path, + value: getInput(form, { path }), +})); +await api.patch(patch); +``` + +## Performance + +All three methods walk the form's field tree and call `getFieldBool` recursively to skip clean subtrees. Cost is effectively linear in field count for typical balanced forms (shallow and wide) and degrades toward `O(N²)` for deeply nested trees with few siblings at each level. + +Call these methods from submit or blur handlers — not from tight reactive loops on every keystroke. For very large or deeply nested forms (thousands of fields), profile before binding them to high-frequency events. diff --git a/website/src/routes/(docs)/qwik/guides/menu.md b/website/src/routes/(docs)/qwik/guides/menu.md index 7e593560..1dfe9768 100644 --- a/website/src/routes/(docs)/qwik/guides/menu.md +++ b/website/src/routes/(docs)/qwik/guides/menu.md @@ -20,6 +20,7 @@ - [Special inputs](/qwik/guides/special-inputs/) - [Controlled fields](/qwik/guides/controlled-fields/) - [Nested fields](/qwik/guides/nested-fields/) +- [Dirty fields](/qwik/guides/dirty-fields/) - [Field arrays](/qwik/guides/field-arrays/) - [TypeScript](/qwik/guides/typescript/) - [Architecture](/qwik/guides/architecture/) diff --git a/website/src/routes/(docs)/react/api/menu.md b/website/src/routes/(docs)/react/api/menu.md index 6641f6fb..e1811408 100644 --- a/website/src/routes/(docs)/react/api/menu.md +++ b/website/src/routes/(docs)/react/api/menu.md @@ -16,11 +16,14 @@ - [focus](/methods/api/focus/) - [getAllErrors](/methods/api/getAllErrors/) +- [getDirtyInput](/methods/api/getDirtyInput/) +- [getDirtyPaths](/methods/api/getDirtyPaths/) - [getErrors](/methods/api/getErrors/) - [getInput](/methods/api/getInput/) - [handleSubmit](/methods/api/handleSubmit/) - [insert](/methods/api/insert/) - [move](/methods/api/move/) +- [pickDirty](/methods/api/pickDirty/) - [remove](/methods/api/remove/) - [replace](/methods/api/replace/) - [reset](/methods/api/reset/) @@ -41,14 +44,19 @@ - [FormConfig](/react/api/FormConfig/) - [FormSchema](/core/api/FormSchema/) - [FormStore](/react/api/FormStore/) +- [GetFieldDirtyInputConfig](/methods/api/GetFieldDirtyInputConfig/) +- [GetFieldDirtyPathsConfig](/methods/api/GetFieldDirtyPathsConfig/) - [GetFieldErrorsConfig](/methods/api/GetFieldErrorsConfig/) - [GetFieldInputConfig](/methods/api/GetFieldInputConfig/) +- [GetFormDirtyInputConfig](/methods/api/GetFormDirtyInputConfig/) +- [GetFormDirtyPathsConfig](/methods/api/GetFormDirtyPathsConfig/) - [GetFormErrorsConfig](/methods/api/GetFormErrorsConfig/) - [GetFormInputConfig](/methods/api/GetFormInputConfig/) - [InsertConfig](/methods/api/InsertConfig/) - [PartialValues](/core/api/PartialValues/) - [Path](/core/api/Path/) - [PathKey](/core/api/PathKey/) +- [PickDirtyConfig](/methods/api/PickDirtyConfig/) - [MoveConfig](/methods/api/MoveConfig/) - [RemoveConfig](/methods/api/RemoveConfig/) - [ReplaceConfig](/methods/api/ReplaceConfig/) diff --git a/website/src/routes/(docs)/react/guides/(advanced-guides)/dirty-fields/index.mdx b/website/src/routes/(docs)/react/guides/(advanced-guides)/dirty-fields/index.mdx new file mode 100644 index 00000000..98fecb49 --- /dev/null +++ b/website/src/routes/(docs)/react/guides/(advanced-guides)/dirty-fields/index.mdx @@ -0,0 +1,196 @@ +--- +title: Dirty fields +description: >- + Learn how to work with dirty form state in Formisch. This guide covers the + three dirty-extraction methods, when to reach for each, and why pickDirty + exists alongside getDirtyInput. +contributors: + - fabian-hiller +--- + +import { Link } from '~/components'; + +# Dirty fields + +When a user edits a form, Formisch tracks which fields have changed since they were initialized. The library exposes three methods for working with that dirty state: `getDirtyInput`, `getDirtyPaths`, and `pickDirty`. This guide explains what each one is for, when to reach for it, and why `pickDirty` exists alongside the others. + +## What "dirty" means + +A field is dirty when its current input differs from its start input. The form starts clean. As the user types, only the fields they touch flip to dirty. Resetting a field (or resetting the form with a new `initialInput`) clears the dirty flag. + +> The dirty flag is per-field. Editing a deeply nested value does not flip its ancestors' flags. The dirty-extraction methods walk the tree internally to find dirty descendants, which is why all three methods do the same kind of work under the hood. + +## The three methods + +### `getDirtyInput` + +Returns the dirty subtree of the form's input. Use it when you want to ship the values the user typed — typically for a PATCH endpoint that accepts the same shape as your form input. + +```tsx +import { + Form, + getDirtyInput, + useForm, + type SubmitHandler, +} from '@formisch/react'; +import * as v from 'valibot'; + +const UserSchema = v.object({ + name: v.pipe(v.string(), v.nonEmpty()), + email: v.pipe(v.string(), v.email()), +}); + +export default function EditProfile({ user }) { + const profileForm = useForm({ + schema: UserSchema, + initialInput: user, + }); + + const onSubmit: SubmitHandler = async () => { + const dirty = getDirtyInput(profileForm); + if (dirty) { + await api.patchUser(user.id, dirty); + } + }; + + return ( +
+ {/* fields */} +
+ ); +} +``` + +If only `email` was edited, `dirty` is `{ email: 'new@example.com' }`. If nothing was edited, it is `undefined`. + +Because dirty state is bound to the form input, `getDirtyInput` always returns the raw user input. Schema transformations such as `v.trim()` or `v.toNumber()` are not applied to the returned values. When you need the validated and transformed output instead, reach for `pickDirty` (see below). + +### `getDirtyPaths` + +Returns a list of paths to dirty fields. Use it when you want to drive your own walker — for example, generating a patch payload or computing a structural diff. + +```tsx +import { getDirtyPaths, getInput } from '@formisch/react'; + +const patch = getDirtyPaths(profileForm).map((path) => ({ + operation: 'replace', + path, + value: getInput(profileForm, { path }), +})); +``` + +Arrays are atomic: only the array's own path is returned, never the paths of individual items. + +### `pickDirty` + +Filters a user-supplied value down to its dirty parts using the form's dirty mask. Use it when you have a separately-derived value — most commonly the validated and transformed output from a submit handler — and you want only its dirty parts. + +```tsx +import { pickDirty, type SubmitHandler } from '@formisch/react'; + +const onSubmit: SubmitHandler = async (output) => { + const dirty = pickDirty(profileForm, { from: output }); + if (dirty) { + await api.update(dirty); + } +}; +``` + +## Why `pickDirty` exists + +Dirty state is tracked against the form **input** — the raw values the user typed. But Valibot schemas can transform that input into a different output shape before it reaches your submit handler: + +```tsx +import * as v from 'valibot'; + +const Schema = v.object({ + name: v.pipe(v.string(), v.trim()), + age: v.pipe(v.string(), v.toNumber()), +}); +``` + +After validation, `output.name` has whitespace trimmed and `output.age` is a number. `getDirtyInput` would give you the raw strings, because the dirty state is bound to the input form — not to the validated output. + +`pickDirty(form, { from: output })` is the bridge: it walks the form's dirty tree as a structural mask and reads the corresponding values from the output you supply. The result preserves the transformed types. + +```tsx +import { pickDirty, type SubmitHandler } from '@formisch/react'; + +const onSubmit: SubmitHandler = async (output) => { + // output.age is a number — pickDirty preserves that. + const dirty = pickDirty(form, { from: output }); + if (dirty) { + await api.update(dirty); + } +}; +``` + +If the schema reshapes the output entirely — for example, by combining several input fields into a single output value — `pickDirty` returns `undefined` because the shape no longer aligns with the form's input shape. + +## Atomic arrays + +All three methods treat arrays as atomic. When any descendant of an array is dirty, the full array is returned (or its own path, for `getDirtyPaths`). The methods never produce sparse arrays. + +Sparse arrays don't round-trip safely. Serializers compact them, `JSON.stringify` writes `null` for holes, and indices lose positional meaning. Returning the full current array preserves order and lets the server treat the array as a complete replacement. + +This differs from objects, where clean keys are omitted. Objects model keyed dictionaries; arrays model ordered sequences. + +## Common patterns + +### Skip submission when nothing changed + +The form's `isDirty` flag is the cheapest way to ask "is anything dirty?" — it short-circuits on the first dirty field it finds and doesn't allocate. Use it when all you need is the yes/no answer. If you actually consume the output of `getDirtyInput`, `getDirtyPaths`, or `pickDirty`, call that method directly — its return value already signals "nothing dirty", so checking `form.isDirty` first would just walk the tree twice. + +```tsx +import type { SubmitHandler } from '@formisch/react'; + +const onSubmit: SubmitHandler = async (output) => { + if (!form.isDirty) { + return; + } + await api.update(output); +}; +``` + +### Send only the dirty raw input + +```tsx +import { getDirtyInput } from '@formisch/react'; + +const dirty = getDirtyInput(form); +if (dirty) { + await api.patch(dirty); +} +``` + +### Send only the dirty validated output + +```tsx +import { pickDirty, type SubmitHandler } from '@formisch/react'; + +const onSubmit: SubmitHandler = async (output) => { + const dirty = pickDirty(form, { from: output }); + if (dirty) { + await api.update(dirty); + } +}; +``` + +### Build a patch payload + +```tsx +import { getDirtyPaths, getInput } from '@formisch/react'; + +const patch = getDirtyPaths(form).map((path) => ({ + operation: 'replace', + path, + value: getInput(form, { path }), +})); +await api.patch(patch); +``` + +## Performance + +All three methods walk the form's field tree and call `getFieldBool` recursively to skip clean subtrees. Cost is effectively linear in field count for typical balanced forms (shallow and wide) and degrades toward `O(N²)` for deeply nested trees with few siblings at each level. + +Call these methods from submit or blur handlers — not from tight reactive loops on every keystroke. For very large or deeply nested forms (thousands of fields), profile before binding them to high-frequency events. diff --git a/website/src/routes/(docs)/react/guides/menu.md b/website/src/routes/(docs)/react/guides/menu.md index f8e10d9c..2e70687c 100644 --- a/website/src/routes/(docs)/react/guides/menu.md +++ b/website/src/routes/(docs)/react/guides/menu.md @@ -21,6 +21,7 @@ - [Special inputs](/react/guides/special-inputs/) - [Controlled fields](/react/guides/controlled-fields/) - [Nested fields](/react/guides/nested-fields/) +- [Dirty fields](/react/guides/dirty-fields/) - [Field arrays](/react/guides/field-arrays/) - [TypeScript](/react/guides/typescript/) - [Architecture](/react/guides/architecture/) diff --git a/website/src/routes/(docs)/solid/api/menu.md b/website/src/routes/(docs)/solid/api/menu.md index d14828e5..daaa9365 100644 --- a/website/src/routes/(docs)/solid/api/menu.md +++ b/website/src/routes/(docs)/solid/api/menu.md @@ -16,11 +16,14 @@ - [focus](/methods/api/focus/) - [getAllErrors](/methods/api/getAllErrors/) +- [getDirtyInput](/methods/api/getDirtyInput/) +- [getDirtyPaths](/methods/api/getDirtyPaths/) - [getErrors](/methods/api/getErrors/) - [getInput](/methods/api/getInput/) - [handleSubmit](/methods/api/handleSubmit/) - [insert](/methods/api/insert/) - [move](/methods/api/move/) +- [pickDirty](/methods/api/pickDirty/) - [remove](/methods/api/remove/) - [replace](/methods/api/replace/) - [reset](/methods/api/reset/) @@ -41,8 +44,12 @@ - [FormConfig](/solid/api/FormConfig/) - [FormSchema](/core/api/FormSchema/) - [FormStore](/solid/api/FormStore/) +- [GetFieldDirtyInputConfig](/methods/api/GetFieldDirtyInputConfig/) +- [GetFieldDirtyPathsConfig](/methods/api/GetFieldDirtyPathsConfig/) - [GetFieldErrorsConfig](/methods/api/GetFieldErrorsConfig/) - [GetFieldInputConfig](/methods/api/GetFieldInputConfig/) +- [GetFormDirtyInputConfig](/methods/api/GetFormDirtyInputConfig/) +- [GetFormDirtyPathsConfig](/methods/api/GetFormDirtyPathsConfig/) - [GetFormErrorsConfig](/methods/api/GetFormErrorsConfig/) - [GetFormInputConfig](/methods/api/GetFormInputConfig/) - [InsertConfig](/methods/api/InsertConfig/) @@ -50,6 +57,7 @@ - [PartialValues](/core/api/PartialValues/) - [Path](/core/api/Path/) - [PathKey](/core/api/PathKey/) +- [PickDirtyConfig](/methods/api/PickDirtyConfig/) - [MoveConfig](/methods/api/MoveConfig/) - [RemoveConfig](/methods/api/RemoveConfig/) - [ReplaceConfig](/methods/api/ReplaceConfig/) diff --git a/website/src/routes/(docs)/solid/guides/(advanced-guides)/dirty-fields/index.mdx b/website/src/routes/(docs)/solid/guides/(advanced-guides)/dirty-fields/index.mdx new file mode 100644 index 00000000..dc72e594 --- /dev/null +++ b/website/src/routes/(docs)/solid/guides/(advanced-guides)/dirty-fields/index.mdx @@ -0,0 +1,196 @@ +--- +title: Dirty fields +description: >- + Learn how to work with dirty form state in Formisch. This guide covers the + three dirty-extraction methods, when to reach for each, and why pickDirty + exists alongside getDirtyInput. +contributors: + - fabian-hiller +--- + +import { Link } from '~/components'; + +# Dirty fields + +When a user edits a form, Formisch tracks which fields have changed since they were initialized. The library exposes three methods for working with that dirty state: `getDirtyInput`, `getDirtyPaths`, and `pickDirty`. This guide explains what each one is for, when to reach for it, and why `pickDirty` exists alongside the others. + +## What "dirty" means + +A field is dirty when its current input differs from its start input. The form starts clean. As the user types, only the fields they touch flip to dirty. Resetting a field (or resetting the form with a new `initialInput`) clears the dirty flag. + +> The dirty flag is per-field. Editing a deeply nested value does not flip its ancestors' flags. The dirty-extraction methods walk the tree internally to find dirty descendants, which is why all three methods do the same kind of work under the hood. + +## The three methods + +### `getDirtyInput` + +Returns the dirty subtree of the form's input. Use it when you want to ship the values the user typed — typically for a PATCH endpoint that accepts the same shape as your form input. + +```tsx +import { + createForm, + Form, + getDirtyInput, + type SubmitHandler, +} from '@formisch/solid'; +import * as v from 'valibot'; + +const UserSchema = v.object({ + name: v.pipe(v.string(), v.nonEmpty()), + email: v.pipe(v.string(), v.email()), +}); + +export default function EditProfile(props) { + const profileForm = createForm({ + schema: UserSchema, + initialInput: props.user, + }); + + const onSubmit: SubmitHandler = async () => { + const dirty = getDirtyInput(profileForm); + if (dirty) { + await api.patchUser(props.user.id, dirty); + } + }; + + return ( +
+ {/* fields */} +
+ ); +} +``` + +If only `email` was edited, `dirty` is `{ email: 'new@example.com' }`. If nothing was edited, it is `undefined`. + +Because dirty state is bound to the form input, `getDirtyInput` always returns the raw user input. Schema transformations such as `v.trim()` or `v.toNumber()` are not applied to the returned values. When you need the validated and transformed output instead, reach for `pickDirty` (see below). + +### `getDirtyPaths` + +Returns a list of paths to dirty fields. Use it when you want to drive your own walker — for example, generating a patch payload or computing a structural diff. + +```tsx +import { getDirtyPaths, getInput } from '@formisch/solid'; + +const patch = getDirtyPaths(profileForm).map((path) => ({ + operation: 'replace', + path, + value: getInput(profileForm, { path }), +})); +``` + +Arrays are atomic: only the array's own path is returned, never the paths of individual items. + +### `pickDirty` + +Filters a user-supplied value down to its dirty parts using the form's dirty mask. Use it when you have a separately-derived value — most commonly the validated and transformed output from a submit handler — and you want only its dirty parts. + +```tsx +import { pickDirty, type SubmitHandler } from '@formisch/solid'; + +const onSubmit: SubmitHandler = async (output) => { + const dirty = pickDirty(profileForm, { from: output }); + if (dirty) { + await api.update(dirty); + } +}; +``` + +## Why `pickDirty` exists + +Dirty state is tracked against the form **input** — the raw values the user typed. But Valibot schemas can transform that input into a different output shape before it reaches your submit handler: + +```tsx +import * as v from 'valibot'; + +const Schema = v.object({ + name: v.pipe(v.string(), v.trim()), + age: v.pipe(v.string(), v.toNumber()), +}); +``` + +After validation, `output.name` has whitespace trimmed and `output.age` is a number. `getDirtyInput` would give you the raw strings, because the dirty state is bound to the input form — not to the validated output. + +`pickDirty(form, { from: output })` is the bridge: it walks the form's dirty tree as a structural mask and reads the corresponding values from the output you supply. The result preserves the transformed types. + +```tsx +import { pickDirty, type SubmitHandler } from '@formisch/solid'; + +const onSubmit: SubmitHandler = async (output) => { + // output.age is a number — pickDirty preserves that. + const dirty = pickDirty(form, { from: output }); + if (dirty) { + await api.update(dirty); + } +}; +``` + +If the schema reshapes the output entirely — for example, by combining several input fields into a single output value — `pickDirty` returns `undefined` because the shape no longer aligns with the form's input shape. + +## Atomic arrays + +All three methods treat arrays as atomic. When any descendant of an array is dirty, the full array is returned (or its own path, for `getDirtyPaths`). The methods never produce sparse arrays. + +Sparse arrays don't round-trip safely. Serializers compact them, `JSON.stringify` writes `null` for holes, and indices lose positional meaning. Returning the full current array preserves order and lets the server treat the array as a complete replacement. + +This differs from objects, where clean keys are omitted. Objects model keyed dictionaries; arrays model ordered sequences. + +## Common patterns + +### Skip submission when nothing changed + +The form's `isDirty` flag is the cheapest way to ask "is anything dirty?" — it short-circuits on the first dirty field it finds and doesn't allocate. Use it when all you need is the yes/no answer. If you actually consume the output of `getDirtyInput`, `getDirtyPaths`, or `pickDirty`, call that method directly — its return value already signals "nothing dirty", so checking `form.isDirty` first would just walk the tree twice. + +```tsx +import type { SubmitHandler } from '@formisch/solid'; + +const onSubmit: SubmitHandler = async (output) => { + if (!form.isDirty) { + return; + } + await api.update(output); +}; +``` + +### Send only the dirty raw input + +```tsx +import { getDirtyInput } from '@formisch/solid'; + +const dirty = getDirtyInput(form); +if (dirty) { + await api.patch(dirty); +} +``` + +### Send only the dirty validated output + +```tsx +import { pickDirty, type SubmitHandler } from '@formisch/solid'; + +const onSubmit: SubmitHandler = async (output) => { + const dirty = pickDirty(form, { from: output }); + if (dirty) { + await api.update(dirty); + } +}; +``` + +### Build a patch payload + +```tsx +import { getDirtyPaths, getInput } from '@formisch/solid'; + +const patch = getDirtyPaths(form).map((path) => ({ + operation: 'replace', + path, + value: getInput(form, { path }), +})); +await api.patch(patch); +``` + +## Performance + +All three methods walk the form's field tree and call `getFieldBool` recursively to skip clean subtrees. Cost is effectively linear in field count for typical balanced forms (shallow and wide) and degrades toward `O(N²)` for deeply nested trees with few siblings at each level. + +Call these methods from submit or blur handlers — not from tight reactive loops on every keystroke. For very large or deeply nested forms (thousands of fields), profile before binding them to high-frequency events. diff --git a/website/src/routes/(docs)/solid/guides/menu.md b/website/src/routes/(docs)/solid/guides/menu.md index cd5576c7..4cb48ea0 100644 --- a/website/src/routes/(docs)/solid/guides/menu.md +++ b/website/src/routes/(docs)/solid/guides/menu.md @@ -21,6 +21,7 @@ - [Special inputs](/solid/guides/special-inputs/) - [Controlled fields](/solid/guides/controlled-fields/) - [Nested fields](/solid/guides/nested-fields/) +- [Dirty fields](/solid/guides/dirty-fields/) - [Field arrays](/solid/guides/field-arrays/) - [TypeScript](/solid/guides/typescript/) - [Architecture](/solid/guides/architecture/) diff --git a/website/src/routes/(docs)/svelte/api/menu.md b/website/src/routes/(docs)/svelte/api/menu.md index 1759af7e..a24769a4 100644 --- a/website/src/routes/(docs)/svelte/api/menu.md +++ b/website/src/routes/(docs)/svelte/api/menu.md @@ -16,11 +16,14 @@ - [focus](/methods/api/focus/) - [getAllErrors](/methods/api/getAllErrors/) +- [getDirtyInput](/methods/api/getDirtyInput/) +- [getDirtyPaths](/methods/api/getDirtyPaths/) - [getErrors](/methods/api/getErrors/) - [getInput](/methods/api/getInput/) - [handleSubmit](/methods/api/handleSubmit/) - [insert](/methods/api/insert/) - [move](/methods/api/move/) +- [pickDirty](/methods/api/pickDirty/) - [remove](/methods/api/remove/) - [replace](/methods/api/replace/) - [reset](/methods/api/reset/) @@ -41,8 +44,12 @@ - [FormConfig](/svelte/api/FormConfig/) - [FormSchema](/core/api/FormSchema/) - [FormStore](/svelte/api/FormStore/) +- [GetFieldDirtyInputConfig](/methods/api/GetFieldDirtyInputConfig/) +- [GetFieldDirtyPathsConfig](/methods/api/GetFieldDirtyPathsConfig/) - [GetFieldErrorsConfig](/methods/api/GetFieldErrorsConfig/) - [GetFieldInputConfig](/methods/api/GetFieldInputConfig/) +- [GetFormDirtyInputConfig](/methods/api/GetFormDirtyInputConfig/) +- [GetFormDirtyPathsConfig](/methods/api/GetFormDirtyPathsConfig/) - [GetFormErrorsConfig](/methods/api/GetFormErrorsConfig/) - [GetFormInputConfig](/methods/api/GetFormInputConfig/) - [InsertConfig](/methods/api/InsertConfig/) @@ -50,6 +57,7 @@ - [PartialValues](/core/api/PartialValues/) - [Path](/core/api/Path/) - [PathKey](/core/api/PathKey/) +- [PickDirtyConfig](/methods/api/PickDirtyConfig/) - [MoveConfig](/methods/api/MoveConfig/) - [RemoveConfig](/methods/api/RemoveConfig/) - [ReplaceConfig](/methods/api/ReplaceConfig/) diff --git a/website/src/routes/(docs)/svelte/guides/(advanced-guides)/dirty-fields/index.mdx b/website/src/routes/(docs)/svelte/guides/(advanced-guides)/dirty-fields/index.mdx new file mode 100644 index 00000000..d50b949f --- /dev/null +++ b/website/src/routes/(docs)/svelte/guides/(advanced-guides)/dirty-fields/index.mdx @@ -0,0 +1,189 @@ +--- +title: Dirty fields +description: >- + Learn how to work with dirty form state in Formisch. This guide covers the + three dirty-extraction methods, when to reach for each, and why pickDirty + exists alongside getDirtyInput. +contributors: + - fabian-hiller +--- + +import { Link } from '~/components'; + +# Dirty fields + +When a user edits a form, Formisch tracks which fields have changed since they were initialized. The library exposes three methods for working with that dirty state: `getDirtyInput`, `getDirtyPaths`, and `pickDirty`. This guide explains what each one is for, when to reach for it, and why `pickDirty` exists alongside the others. + +## What "dirty" means + +A field is dirty when its current input differs from its start input. The form starts clean. As the user types, only the fields they touch flip to dirty. Resetting a field (or resetting the form with a new `initialInput`) clears the dirty flag. + +> The dirty flag is per-field. Editing a deeply nested value does not flip its ancestors' flags. The dirty-extraction methods walk the tree internally to find dirty descendants, which is why all three methods do the same kind of work under the hood. + +## The three methods + +### `getDirtyInput` + +Returns the dirty subtree of the form's input. Use it when you want to ship the values the user typed — typically for a PATCH endpoint that accepts the same shape as your form input. + +```ts +import { + createForm, + getDirtyInput, + type SubmitHandler, +} from '@formisch/svelte'; +import * as v from 'valibot'; + +const UserSchema = v.object({ + name: v.pipe(v.string(), v.nonEmpty()), + email: v.pipe(v.string(), v.email()), +}); + +const { user } = $props(); + +const profileForm = createForm({ + schema: UserSchema, + initialInput: user, +}); + +const onSubmit: SubmitHandler = async () => { + const dirty = getDirtyInput(profileForm); + if (dirty) { + await api.patchUser(user.id, dirty); + } +}; +``` + +If only `email` was edited, `dirty` is `{ email: 'new@example.com' }`. If nothing was edited, it is `undefined`. + +Because dirty state is bound to the form input, `getDirtyInput` always returns the raw user input. Schema transformations such as `v.trim()` or `v.toNumber()` are not applied to the returned values. When you need the validated and transformed output instead, reach for `pickDirty` (see below). + +### `getDirtyPaths` + +Returns a list of paths to dirty fields. Use it when you want to drive your own walker — for example, generating a patch payload or computing a structural diff. + +```ts +import { getDirtyPaths, getInput } from '@formisch/svelte'; + +const patch = getDirtyPaths(profileForm).map((path) => ({ + operation: 'replace', + path, + value: getInput(profileForm, { path }), +})); +``` + +Arrays are atomic: only the array's own path is returned, never the paths of individual items. + +### `pickDirty` + +Filters a user-supplied value down to its dirty parts using the form's dirty mask. Use it when you have a separately-derived value — most commonly the validated and transformed output from a submit handler — and you want only its dirty parts. + +```ts +import { pickDirty, type SubmitHandler } from '@formisch/svelte'; + +const onSubmit: SubmitHandler = async (output) => { + const dirty = pickDirty(profileForm, { from: output }); + if (dirty) { + await api.update(dirty); + } +}; +``` + +## Why `pickDirty` exists + +Dirty state is tracked against the form **input** — the raw values the user typed. But Valibot schemas can transform that input into a different output shape before it reaches your submit handler: + +```ts +import * as v from 'valibot'; + +const Schema = v.object({ + name: v.pipe(v.string(), v.trim()), + age: v.pipe(v.string(), v.toNumber()), +}); +``` + +After validation, `output.name` has whitespace trimmed and `output.age` is a number. `getDirtyInput` would give you the raw strings, because the dirty state is bound to the input form — not to the validated output. + +`pickDirty(form, { from: output })` is the bridge: it walks the form's dirty tree as a structural mask and reads the corresponding values from the output you supply. The result preserves the transformed types. + +```ts +import { pickDirty, type SubmitHandler } from '@formisch/svelte'; + +const onSubmit: SubmitHandler = async (output) => { + // output.age is a number — pickDirty preserves that. + const dirty = pickDirty(form, { from: output }); + if (dirty) { + await api.update(dirty); + } +}; +``` + +If the schema reshapes the output entirely — for example, by combining several input fields into a single output value — `pickDirty` returns `undefined` because the shape no longer aligns with the form's input shape. + +## Atomic arrays + +All three methods treat arrays as atomic. When any descendant of an array is dirty, the full array is returned (or its own path, for `getDirtyPaths`). The methods never produce sparse arrays. + +Sparse arrays don't round-trip safely. Serializers compact them, `JSON.stringify` writes `null` for holes, and indices lose positional meaning. Returning the full current array preserves order and lets the server treat the array as a complete replacement. + +This differs from objects, where clean keys are omitted. Objects model keyed dictionaries; arrays model ordered sequences. + +## Common patterns + +### Skip submission when nothing changed + +The form's `isDirty` flag is the cheapest way to ask "is anything dirty?" — it short-circuits on the first dirty field it finds and doesn't allocate. Use it when all you need is the yes/no answer. If you actually consume the output of `getDirtyInput`, `getDirtyPaths`, or `pickDirty`, call that method directly — its return value already signals "nothing dirty", so checking `form.isDirty` first would just walk the tree twice. + +```ts +import type { SubmitHandler } from '@formisch/svelte'; + +const onSubmit: SubmitHandler = async (output) => { + if (!form.isDirty) { + return; + } + await api.update(output); +}; +``` + +### Send only the dirty raw input + +```ts +import { getDirtyInput } from '@formisch/svelte'; + +const dirty = getDirtyInput(form); +if (dirty) { + await api.patch(dirty); +} +``` + +### Send only the dirty validated output + +```ts +import { pickDirty, type SubmitHandler } from '@formisch/svelte'; + +const onSubmit: SubmitHandler = async (output) => { + const dirty = pickDirty(form, { from: output }); + if (dirty) { + await api.update(dirty); + } +}; +``` + +### Build a patch payload + +```ts +import { getDirtyPaths, getInput } from '@formisch/svelte'; + +const patch = getDirtyPaths(form).map((path) => ({ + operation: 'replace', + path, + value: getInput(form, { path }), +})); +await api.patch(patch); +``` + +## Performance + +All three methods walk the form's field tree and call `getFieldBool` recursively to skip clean subtrees. Cost is effectively linear in field count for typical balanced forms (shallow and wide) and degrades toward `O(N²)` for deeply nested trees with few siblings at each level. + +Call these methods from submit or blur handlers — not from tight reactive loops on every keystroke. For very large or deeply nested forms (thousands of fields), profile before binding them to high-frequency events. diff --git a/website/src/routes/(docs)/svelte/guides/menu.md b/website/src/routes/(docs)/svelte/guides/menu.md index 725b47b4..b22c3882 100644 --- a/website/src/routes/(docs)/svelte/guides/menu.md +++ b/website/src/routes/(docs)/svelte/guides/menu.md @@ -21,6 +21,7 @@ - [Special inputs](/svelte/guides/special-inputs/) - [Controlled fields](/svelte/guides/controlled-fields/) - [Nested fields](/svelte/guides/nested-fields/) +- [Dirty fields](/svelte/guides/dirty-fields/) - [Field arrays](/svelte/guides/field-arrays/) - [TypeScript](/svelte/guides/typescript/) - [Architecture](/svelte/guides/architecture/) diff --git a/website/src/routes/(docs)/vue/api/menu.md b/website/src/routes/(docs)/vue/api/menu.md index 2c82d34c..8e9bf8bf 100644 --- a/website/src/routes/(docs)/vue/api/menu.md +++ b/website/src/routes/(docs)/vue/api/menu.md @@ -16,11 +16,14 @@ - [focus](/methods/api/focus/) - [getAllErrors](/methods/api/getAllErrors/) +- [getDirtyInput](/methods/api/getDirtyInput/) +- [getDirtyPaths](/methods/api/getDirtyPaths/) - [getErrors](/methods/api/getErrors/) - [getInput](/methods/api/getInput/) - [handleSubmit](/methods/api/handleSubmit/) - [insert](/methods/api/insert/) - [move](/methods/api/move/) +- [pickDirty](/methods/api/pickDirty/) - [remove](/methods/api/remove/) - [replace](/methods/api/replace/) - [reset](/methods/api/reset/) @@ -41,14 +44,19 @@ - [FormConfig](/vue/api/FormConfig/) - [FormSchema](/core/api/FormSchema/) - [FormStore](/vue/api/FormStore/) +- [GetFieldDirtyInputConfig](/methods/api/GetFieldDirtyInputConfig/) +- [GetFieldDirtyPathsConfig](/methods/api/GetFieldDirtyPathsConfig/) - [GetFieldErrorsConfig](/methods/api/GetFieldErrorsConfig/) - [GetFieldInputConfig](/methods/api/GetFieldInputConfig/) +- [GetFormDirtyInputConfig](/methods/api/GetFormDirtyInputConfig/) +- [GetFormDirtyPathsConfig](/methods/api/GetFormDirtyPathsConfig/) - [GetFormErrorsConfig](/methods/api/GetFormErrorsConfig/) - [GetFormInputConfig](/methods/api/GetFormInputConfig/) - [InsertConfig](/methods/api/InsertConfig/) - [PartialValues](/core/api/PartialValues/) - [Path](/core/api/Path/) - [PathKey](/core/api/PathKey/) +- [PickDirtyConfig](/methods/api/PickDirtyConfig/) - [MoveConfig](/methods/api/MoveConfig/) - [RemoveConfig](/methods/api/RemoveConfig/) - [ReplaceConfig](/methods/api/ReplaceConfig/) diff --git a/website/src/routes/(docs)/vue/guides/(advanced-guides)/dirty-fields/index.mdx b/website/src/routes/(docs)/vue/guides/(advanced-guides)/dirty-fields/index.mdx new file mode 100644 index 00000000..678af7aa --- /dev/null +++ b/website/src/routes/(docs)/vue/guides/(advanced-guides)/dirty-fields/index.mdx @@ -0,0 +1,185 @@ +--- +title: Dirty fields +description: >- + Learn how to work with dirty form state in Formisch. This guide covers the + three dirty-extraction methods, when to reach for each, and why pickDirty + exists alongside getDirtyInput. +contributors: + - fabian-hiller +--- + +import { Link } from '~/components'; + +# Dirty fields + +When a user edits a form, Formisch tracks which fields have changed since they were initialized. The library exposes three methods for working with that dirty state: `getDirtyInput`, `getDirtyPaths`, and `pickDirty`. This guide explains what each one is for, when to reach for it, and why `pickDirty` exists alongside the others. + +## What "dirty" means + +A field is dirty when its current input differs from its start input. The form starts clean. As the user types, only the fields they touch flip to dirty. Resetting a field (or resetting the form with a new `initialInput`) clears the dirty flag. + +> The dirty flag is per-field. Editing a deeply nested value does not flip its ancestors' flags. The dirty-extraction methods walk the tree internally to find dirty descendants, which is why all three methods do the same kind of work under the hood. + +## The three methods + +### `getDirtyInput` + +Returns the dirty subtree of the form's input. Use it when you want to ship the values the user typed — typically for a PATCH endpoint that accepts the same shape as your form input. + +```ts +import { getDirtyInput, useForm, type SubmitHandler } from '@formisch/vue'; +import * as v from 'valibot'; + +const UserSchema = v.object({ + name: v.pipe(v.string(), v.nonEmpty()), + email: v.pipe(v.string(), v.email()), +}); + +const props = defineProps<{ user: User }>(); + +const profileForm = useForm({ + schema: UserSchema, + initialInput: props.user, +}); + +const onSubmit: SubmitHandler = async () => { + const dirty = getDirtyInput(profileForm); + if (dirty) { + await api.patchUser(props.user.id, dirty); + } +}; +``` + +If only `email` was edited, `dirty` is `{ email: 'new@example.com' }`. If nothing was edited, it is `undefined`. + +Because dirty state is bound to the form input, `getDirtyInput` always returns the raw user input. Schema transformations such as `v.trim()` or `v.toNumber()` are not applied to the returned values. When you need the validated and transformed output instead, reach for `pickDirty` (see below). + +### `getDirtyPaths` + +Returns a list of paths to dirty fields. Use it when you want to drive your own walker — for example, generating a patch payload or computing a structural diff. + +```ts +import { getDirtyPaths, getInput } from '@formisch/vue'; + +const patch = getDirtyPaths(profileForm).map((path) => ({ + operation: 'replace', + path, + value: getInput(profileForm, { path }), +})); +``` + +Arrays are atomic: only the array's own path is returned, never the paths of individual items. + +### `pickDirty` + +Filters a user-supplied value down to its dirty parts using the form's dirty mask. Use it when you have a separately-derived value — most commonly the validated and transformed output from a submit handler — and you want only its dirty parts. + +```ts +import { pickDirty, type SubmitHandler } from '@formisch/vue'; + +const onSubmit: SubmitHandler = async (output) => { + const dirty = pickDirty(profileForm, { from: output }); + if (dirty) { + await api.update(dirty); + } +}; +``` + +## Why `pickDirty` exists + +Dirty state is tracked against the form **input** — the raw values the user typed. But Valibot schemas can transform that input into a different output shape before it reaches your submit handler: + +```ts +import * as v from 'valibot'; + +const Schema = v.object({ + name: v.pipe(v.string(), v.trim()), + age: v.pipe(v.string(), v.toNumber()), +}); +``` + +After validation, `output.name` has whitespace trimmed and `output.age` is a number. `getDirtyInput` would give you the raw strings, because the dirty state is bound to the input form — not to the validated output. + +`pickDirty(form, { from: output })` is the bridge: it walks the form's dirty tree as a structural mask and reads the corresponding values from the output you supply. The result preserves the transformed types. + +```ts +import { pickDirty, type SubmitHandler } from '@formisch/vue'; + +const onSubmit: SubmitHandler = async (output) => { + // output.age is a number — pickDirty preserves that. + const dirty = pickDirty(form, { from: output }); + if (dirty) { + await api.update(dirty); + } +}; +``` + +If the schema reshapes the output entirely — for example, by combining several input fields into a single output value — `pickDirty` returns `undefined` because the shape no longer aligns with the form's input shape. + +## Atomic arrays + +All three methods treat arrays as atomic. When any descendant of an array is dirty, the full array is returned (or its own path, for `getDirtyPaths`). The methods never produce sparse arrays. + +Sparse arrays don't round-trip safely. Serializers compact them, `JSON.stringify` writes `null` for holes, and indices lose positional meaning. Returning the full current array preserves order and lets the server treat the array as a complete replacement. + +This differs from objects, where clean keys are omitted. Objects model keyed dictionaries; arrays model ordered sequences. + +## Common patterns + +### Skip submission when nothing changed + +The form's `isDirty` flag is the cheapest way to ask "is anything dirty?" — it short-circuits on the first dirty field it finds and doesn't allocate. Use it when all you need is the yes/no answer. If you actually consume the output of `getDirtyInput`, `getDirtyPaths`, or `pickDirty`, call that method directly — its return value already signals "nothing dirty", so checking `form.isDirty` first would just walk the tree twice. + +```ts +import type { SubmitHandler } from '@formisch/vue'; + +const onSubmit: SubmitHandler = async (output) => { + if (!form.isDirty) { + return; + } + await api.update(output); +}; +``` + +### Send only the dirty raw input + +```ts +import { getDirtyInput } from '@formisch/vue'; + +const dirty = getDirtyInput(form); +if (dirty) { + await api.patch(dirty); +} +``` + +### Send only the dirty validated output + +```ts +import { pickDirty, type SubmitHandler } from '@formisch/vue'; + +const onSubmit: SubmitHandler = async (output) => { + const dirty = pickDirty(form, { from: output }); + if (dirty) { + await api.update(dirty); + } +}; +``` + +### Build a patch payload + +```ts +import { getDirtyPaths, getInput } from '@formisch/vue'; + +const patch = getDirtyPaths(form).map((path) => ({ + operation: 'replace', + path, + value: getInput(form, { path }), +})); +await api.patch(patch); +``` + +## Performance + +All three methods walk the form's field tree and call `getFieldBool` recursively to skip clean subtrees. Cost is effectively linear in field count for typical balanced forms (shallow and wide) and degrades toward `O(N²)` for deeply nested trees with few siblings at each level. + +Call these methods from submit or blur handlers — not from tight reactive loops on every keystroke. For very large or deeply nested forms (thousands of fields), profile before binding them to high-frequency events. diff --git a/website/src/routes/(docs)/vue/guides/menu.md b/website/src/routes/(docs)/vue/guides/menu.md index 8a277ee3..9f301af0 100644 --- a/website/src/routes/(docs)/vue/guides/menu.md +++ b/website/src/routes/(docs)/vue/guides/menu.md @@ -21,6 +21,7 @@ - [Special inputs](/vue/guides/special-inputs/) - [Controlled fields](/vue/guides/controlled-fields/) - [Nested fields](/vue/guides/nested-fields/) +- [Dirty fields](/vue/guides/dirty-fields/) - [Field arrays](/vue/guides/field-arrays/) - [TypeScript](/vue/guides/typescript/) - [Architecture](/vue/guides/architecture/) From 8e52487edc83d6b5484b99362befaed23a30147f Mon Sep 17 00:00:00 2001 From: Fabian Hiller Date: Sun, 17 May 2026 23:34:47 -0400 Subject: [PATCH 02/10] Fix fomatting issues of dirty fields guide and pickDirty method --- packages/methods/src/pickDirty/pickDirty.test.ts | 7 +++---- packages/methods/src/pickDirty/pickDirty.ts | 6 +----- .../preact/guides/(advanced-guides)/dirty-fields/index.mdx | 2 +- .../qwik/guides/(advanced-guides)/dirty-fields/index.mdx | 2 +- .../react/guides/(advanced-guides)/dirty-fields/index.mdx | 2 +- .../vue/guides/(advanced-guides)/dirty-fields/index.mdx | 2 +- 6 files changed, 8 insertions(+), 13 deletions(-) diff --git a/packages/methods/src/pickDirty/pickDirty.test.ts b/packages/methods/src/pickDirty/pickDirty.test.ts index 83ecaa63..786dfc44 100644 --- a/packages/methods/src/pickDirty/pickDirty.test.ts +++ b/packages/methods/src/pickDirty/pickDirty.test.ts @@ -78,10 +78,9 @@ describe('pickDirty', () => { }); test('should include a dirty leaf whose value is undefined', () => { - const store = createTestStore( - v.object({ name: v.optional(v.string()) }), - { initialInput: { name: 'John' } } - ); + const store = createTestStore(v.object({ name: v.optional(v.string()) }), { + initialInput: { name: 'John' }, + }); store.children.name.input.value = undefined; store.children.name.isDirty.value = true; diff --git a/packages/methods/src/pickDirty/pickDirty.ts b/packages/methods/src/pickDirty/pickDirty.ts index d597e7cc..57330791 100644 --- a/packages/methods/src/pickDirty/pickDirty.ts +++ b/packages/methods/src/pickDirty/pickDirty.ts @@ -65,11 +65,7 @@ function pickFromField( // If field store is object, recurse only into dirty branches when the // value is a non-array object. Skip when shapes diverge. if (internalFieldStore.kind === 'object') { - if ( - value === null || - typeof value !== 'object' || - Array.isArray(value) - ) { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { return SKIP; } const result: Record = {}; diff --git a/website/src/routes/(docs)/preact/guides/(advanced-guides)/dirty-fields/index.mdx b/website/src/routes/(docs)/preact/guides/(advanced-guides)/dirty-fields/index.mdx index 41a94ac8..94947f9d 100644 --- a/website/src/routes/(docs)/preact/guides/(advanced-guides)/dirty-fields/index.mdx +++ b/website/src/routes/(docs)/preact/guides/(advanced-guides)/dirty-fields/index.mdx @@ -30,8 +30,8 @@ Returns the dirty subtree of the form's input. Use it when you want to ship the import { Form, getDirtyInput, - useForm, type SubmitHandler, + useForm, } from '@formisch/preact'; import * as v from 'valibot'; diff --git a/website/src/routes/(docs)/qwik/guides/(advanced-guides)/dirty-fields/index.mdx b/website/src/routes/(docs)/qwik/guides/(advanced-guides)/dirty-fields/index.mdx index a5f3eddb..519b44ec 100644 --- a/website/src/routes/(docs)/qwik/guides/(advanced-guides)/dirty-fields/index.mdx +++ b/website/src/routes/(docs)/qwik/guides/(advanced-guides)/dirty-fields/index.mdx @@ -30,8 +30,8 @@ Returns the dirty subtree of the form's input. Use it when you want to ship the import { Form, getDirtyInput, - useForm$, type SubmitHandler, + useForm$, } from '@formisch/qwik'; import * as v from 'valibot'; diff --git a/website/src/routes/(docs)/react/guides/(advanced-guides)/dirty-fields/index.mdx b/website/src/routes/(docs)/react/guides/(advanced-guides)/dirty-fields/index.mdx index 98fecb49..39227ffe 100644 --- a/website/src/routes/(docs)/react/guides/(advanced-guides)/dirty-fields/index.mdx +++ b/website/src/routes/(docs)/react/guides/(advanced-guides)/dirty-fields/index.mdx @@ -30,8 +30,8 @@ Returns the dirty subtree of the form's input. Use it when you want to ship the import { Form, getDirtyInput, - useForm, type SubmitHandler, + useForm, } from '@formisch/react'; import * as v from 'valibot'; diff --git a/website/src/routes/(docs)/vue/guides/(advanced-guides)/dirty-fields/index.mdx b/website/src/routes/(docs)/vue/guides/(advanced-guides)/dirty-fields/index.mdx index 678af7aa..92ccc249 100644 --- a/website/src/routes/(docs)/vue/guides/(advanced-guides)/dirty-fields/index.mdx +++ b/website/src/routes/(docs)/vue/guides/(advanced-guides)/dirty-fields/index.mdx @@ -27,7 +27,7 @@ A field is dirty when its current input differs from its start input. The form s Returns the dirty subtree of the form's input. Use it when you want to ship the values the user typed — typically for a PATCH endpoint that accepts the same shape as your form input. ```ts -import { getDirtyInput, useForm, type SubmitHandler } from '@formisch/vue'; +import { getDirtyInput, type SubmitHandler, useForm } from '@formisch/vue'; import * as v from 'valibot'; const UserSchema = v.object({ From df2e4bdf2c5e0e43a4c87a2c2305a876e0fd20f5 Mon Sep 17 00:00:00 2001 From: Fabian Hiller Date: Sun, 24 May 2026 18:59:45 -0400 Subject: [PATCH 03/10] Refactor type definitions to use FormSchema in getDirtyInput, getDirtyPaths, and pickDirty methods --- .../src/getDirtyInput/getDirtyInput.ts | 10 +++---- .../src/getDirtyPaths/getDirtyPaths.test.ts | 15 ---------- .../src/getDirtyPaths/getDirtyPaths.ts | 30 ++++++++++--------- packages/methods/src/pickDirty/pickDirty.ts | 4 +-- 4 files changed, 23 insertions(+), 36 deletions(-) diff --git a/packages/methods/src/getDirtyInput/getDirtyInput.ts b/packages/methods/src/getDirtyInput/getDirtyInput.ts index 7056907d..30144817 100644 --- a/packages/methods/src/getDirtyInput/getDirtyInput.ts +++ b/packages/methods/src/getDirtyInput/getDirtyInput.ts @@ -6,7 +6,7 @@ import { INTERNAL, type PathValue, type RequiredPath, - type Schema, + type FormSchema, type ValidPath, } from '@formisch/core'; import type * as v from 'valibot'; @@ -26,7 +26,7 @@ export interface GetFormDirtyInputConfig { * Get field dirty input config interface. */ export interface GetFieldDirtyInputConfig< - TSchema extends Schema, + TSchema extends FormSchema, TFieldPath extends RequiredPath, > { /** @@ -46,7 +46,7 @@ export interface GetFieldDirtyInputConfig< * * @returns The dirty input of the form or specified field, or `undefined`. */ -export function getDirtyInput( +export function getDirtyInput( form: BaseFormStore ): DeepPartial> | undefined; @@ -63,7 +63,7 @@ export function getDirtyInput( * @returns The dirty input of the form or specified field, or `undefined`. */ export function getDirtyInput< - TSchema extends Schema, + TSchema extends FormSchema, TFieldPath extends RequiredPath | undefined = undefined, >( form: BaseFormStore, @@ -83,7 +83,7 @@ export function getDirtyInput( form: BaseFormStore, config?: | GetFormDirtyInputConfig - | GetFieldDirtyInputConfig + | GetFieldDirtyInputConfig ): unknown { return getDirtyFieldInput( config?.path ? getFieldStore(form[INTERNAL], config.path) : form[INTERNAL] diff --git a/packages/methods/src/getDirtyPaths/getDirtyPaths.test.ts b/packages/methods/src/getDirtyPaths/getDirtyPaths.test.ts index 305e7746..7099179c 100644 --- a/packages/methods/src/getDirtyPaths/getDirtyPaths.test.ts +++ b/packages/methods/src/getDirtyPaths/getDirtyPaths.test.ts @@ -1,4 +1,3 @@ -import type { InternalFieldStore } from '@formisch/core'; import * as v from 'valibot'; import { describe, expect, test } from 'vitest'; import { createTestStore } from '../vitest/index.ts'; @@ -180,18 +179,4 @@ describe('getDirtyPaths', () => { ['items'], ]); }); - - test('should return the root path when the form schema is an array and any item is dirty', () => { - const store = createTestStore(v.array(v.string()), { - initialInput: ['a', 'b', 'c'], - }); - const rootStore = store as unknown as InternalFieldStore; // TODO: Fix typing of `createTestStore` - expect(rootStore.kind).toBe('array'); - if (rootStore.kind === 'array') { - rootStore.children[1].input.value = 'B'; - rootStore.children[1].isDirty.value = true; - } - - expect(getDirtyPaths(store)).toStrictEqual([[]]); - }); }); diff --git a/packages/methods/src/getDirtyPaths/getDirtyPaths.ts b/packages/methods/src/getDirtyPaths/getDirtyPaths.ts index cb61eb95..f24fd818 100644 --- a/packages/methods/src/getDirtyPaths/getDirtyPaths.ts +++ b/packages/methods/src/getDirtyPaths/getDirtyPaths.ts @@ -6,7 +6,7 @@ import { type InternalFieldStore, type Path, type RequiredPath, - type Schema, + type FormSchema, type ValidPath, } from '@formisch/core'; import type * as v from 'valibot'; @@ -25,7 +25,7 @@ export interface GetFormDirtyPathsConfig { * Get field dirty paths config interface. */ export interface GetFieldDirtyPathsConfig< - TSchema extends Schema, + TSchema extends FormSchema, TFieldPath extends RequiredPath, > { /** @@ -44,9 +44,9 @@ export interface GetFieldDirtyPathsConfig< * * @returns The list of paths to dirty fields. */ -export function getDirtyPaths( +export function getDirtyPaths( form: BaseFormStore -): Path[]; +): RequiredPath[]; /** * Returns a list of paths to dirty fields. Object branches are recursed into; @@ -60,22 +60,22 @@ export function getDirtyPaths( * @returns The list of paths to dirty fields. */ export function getDirtyPaths< - TSchema extends Schema, + TSchema extends FormSchema, TFieldPath extends RequiredPath | undefined = undefined, >( form: BaseFormStore, config: TFieldPath extends RequiredPath ? GetFieldDirtyPathsConfig : GetFormDirtyPathsConfig -): Path[]; +): RequiredPath[]; // @__NO_SIDE_EFFECTS__ export function getDirtyPaths( form: BaseFormStore, config?: | GetFormDirtyPathsConfig - | GetFieldDirtyPathsConfig -): Path[] { + | GetFieldDirtyPathsConfig +): RequiredPath[] { const target = config?.path ? getFieldStore(form[INTERNAL], config.path) : form[INTERNAL]; @@ -91,19 +91,21 @@ export function getDirtyPaths( function collect( internalFieldStore: InternalFieldStore, currentPath: Path -): Path[] { - // Arrays are atomic — emit the array's own path +): RequiredPath[] { + // Arrays are atomic — emit the array's own path. With `FormSchema` + // enforcing object roots, `currentPath` is always non-empty here, but the + // guard keeps the cast honest. if (internalFieldStore.kind === 'array') { - return [currentPath]; + return currentPath.length > 0 ? [currentPath as RequiredPath] : []; } // For objects: if input is null/undefined, treat as atomic (the whole // container changed). Otherwise recurse into dirty children. if (internalFieldStore.kind === 'object') { if (!internalFieldStore.input.value) { - return [currentPath]; + return currentPath.length > 0 ? [currentPath as RequiredPath] : []; } - const paths: Path[] = []; + const paths: RequiredPath[] = []; for (const key in internalFieldStore.children) { const child = internalFieldStore.children[key]; if (getFieldBool(child, 'isDirty')) { @@ -114,5 +116,5 @@ function collect( } // Value field — emit its path - return [currentPath]; + return currentPath.length > 0 ? [currentPath as RequiredPath] : []; } diff --git a/packages/methods/src/pickDirty/pickDirty.ts b/packages/methods/src/pickDirty/pickDirty.ts index 57330791..55d6ddea 100644 --- a/packages/methods/src/pickDirty/pickDirty.ts +++ b/packages/methods/src/pickDirty/pickDirty.ts @@ -4,7 +4,7 @@ import { getFieldBool, INTERNAL, type InternalFieldStore, - type Schema, + type FormSchema, } from '@formisch/core'; /** @@ -31,7 +31,7 @@ export interface PickDirtyConfig { * @returns The dirty parts of the value, or `undefined`. */ // @__NO_SIDE_EFFECTS__ -export function pickDirty( +export function pickDirty( form: BaseFormStore, config: PickDirtyConfig ): DeepPartial | undefined { From d6ff21e6ffb68a6d56d6064e725fdcac0f6f87ec Mon Sep 17 00:00:00 2001 From: Fabian Hiller Date: Sun, 24 May 2026 19:04:25 -0400 Subject: [PATCH 04/10] Add tests for pickDirty to handle null values and absent keys --- .../methods/src/pickDirty/pickDirty.test.ts | 34 +++++++++++++++++++ packages/methods/src/pickDirty/pickDirty.ts | 23 ++++++++++--- .../api/(methods)/getDirtyInput/properties.ts | 21 +++--------- .../methods/api/(methods)/pickDirty/index.mdx | 2 +- .../(advanced-guides)/dirty-fields/index.mdx | 4 ++- .../(advanced-guides)/dirty-fields/index.mdx | 4 ++- .../(advanced-guides)/dirty-fields/index.mdx | 4 ++- .../(advanced-guides)/dirty-fields/index.mdx | 4 ++- .../(advanced-guides)/dirty-fields/index.mdx | 4 ++- .../(advanced-guides)/dirty-fields/index.mdx | 4 ++- 10 files changed, 76 insertions(+), 28 deletions(-) diff --git a/packages/methods/src/pickDirty/pickDirty.test.ts b/packages/methods/src/pickDirty/pickDirty.test.ts index 786dfc44..805fbe74 100644 --- a/packages/methods/src/pickDirty/pickDirty.test.ts +++ b/packages/methods/src/pickDirty/pickDirty.test.ts @@ -99,6 +99,40 @@ describe('pickDirty', () => { expect(pickDirty(store, { from: 'transformed-string' })).toBeUndefined(); }); + test('should pass through the supplied value when an object was cleared to null', () => { + const store = createTestStore( + v.object({ user: v.nullish(v.object({ name: v.string() })) }), + { initialInput: { user: { name: 'John' } } } + ); + const userStore = store.children.user; + expect(userStore.kind).toBe('object'); + if (userStore.kind === 'object') { + userStore.input.value = null; + userStore.isDirty.value = true; + } + + expect(pickDirty(store, { from: { user: null } })).toStrictEqual({ + user: null, + }); + }); + + test('should skip a dirty key that is absent from the supplied value', () => { + const store = createTestStore( + v.object({ name: v.string(), email: v.string() }), + { initialInput: { name: 'John', email: 'a@example.com' } } + ); + store.children.name.input.value = 'Jane'; + store.children.name.isDirty.value = true; + store.children.email.input.value = 'b@example.com'; + store.children.email.isDirty.value = true; + + // `email` is dirty in the form but absent from `from` — it should be + // skipped rather than included as `undefined`. + expect(pickDirty(store, { from: { name: 'Jane' } })).toStrictEqual({ + name: 'Jane', + }); + }); + test('should skip keys where the value shape diverges and keep aligned siblings', () => { const store = createTestStore( v.object({ diff --git a/packages/methods/src/pickDirty/pickDirty.ts b/packages/methods/src/pickDirty/pickDirty.ts index 55d6ddea..3f1cc085 100644 --- a/packages/methods/src/pickDirty/pickDirty.ts +++ b/packages/methods/src/pickDirty/pickDirty.ts @@ -1,10 +1,10 @@ import { type BaseFormStore, type DeepPartial, + type FormSchema, getFieldBool, INTERNAL, type InternalFieldStore, - type FormSchema, } from '@formisch/core'; /** @@ -21,9 +21,10 @@ export interface PickDirtyConfig { * Picks only the dirty parts of the given value, using the form's dirty * tree as a structural mask. Object keys whose subtree contains no dirty * descendant are omitted; arrays are treated as atomic and returned in full - * whenever any descendant is dirty. Returns `undefined` if no field is dirty - * or if the value's shape does not align with the form. Useful for filtering - * a validated output to just the changed parts before submitting. + * whenever any descendant is dirty. Returns `undefined` if no field is + * dirty or if the root shape diverges; per-branch shape divergence is + * silently skipped. Useful for filtering a validated output to just the + * changed parts before submitting. * * @param form The form store providing the dirty mask. * @param config The pick dirty configuration. @@ -59,12 +60,22 @@ function pickFromField( // If field store is array, return the value if it is an array (atomic). // Otherwise the shapes diverged and there is nothing safe to pluck. if (internalFieldStore.kind === 'array') { + // Array was cleared to null/undefined — pass through whatever the + // supplied value holds at this path. + if (!internalFieldStore.input.value) { + return value; + } return Array.isArray(value) ? value : SKIP; } // If field store is object, recurse only into dirty branches when the // value is a non-array object. Skip when shapes diverge. if (internalFieldStore.kind === 'object') { + // Object was cleared to null/undefined — pass through whatever the + // supplied value holds at this path. + if (!internalFieldStore.input.value) { + return value; + } if (value === null || typeof value !== 'object' || Array.isArray(value)) { return SKIP; } @@ -72,7 +83,9 @@ function pickFromField( let added = false; for (const key in internalFieldStore.children) { const child = internalFieldStore.children[key]; - if (getFieldBool(child, 'isDirty')) { + // Skip absent keys so a transformed value that omits a dirty key + // doesn't get an unintended `undefined` written into the result. + if (getFieldBool(child, 'isDirty') && key in value) { const childResult = pickFromField( child, (value as Record)[key] diff --git a/website/src/routes/(docs)/methods/api/(methods)/getDirtyInput/properties.ts b/website/src/routes/(docs)/methods/api/(methods)/getDirtyInput/properties.ts index b4a632d6..66da643d 100644 --- a/website/src/routes/(docs)/methods/api/(methods)/getDirtyInput/properties.ts +++ b/website/src/routes/(docs)/methods/api/(methods)/getDirtyInput/properties.ts @@ -5,8 +5,8 @@ export const properties: Record = { modifier: 'extends', type: { type: 'custom', - name: 'Schema', - href: '/core/api/Schema/', + name: 'FormSchema', + href: '/core/api/FormSchema/', }, }, TFieldPath: { @@ -76,23 +76,12 @@ export const properties: Record = { generics: [ { type: 'custom', - name: 'PathValue', - href: '/core/api/PathValue/', + name: 'v.InferInput', + href: 'https://valibot.dev/api/InferInput/', generics: [ { type: 'custom', - name: 'v.InferInput', - href: 'https://valibot.dev/api/InferInput/', - generics: [ - { - type: 'custom', - name: 'TSchema', - }, - ], - }, - { - type: 'custom', - name: 'TFieldPath', + name: 'TSchema', }, ], }, diff --git a/website/src/routes/(docs)/methods/api/(methods)/pickDirty/index.mdx b/website/src/routes/(docs)/methods/api/(methods)/pickDirty/index.mdx index 6cb004a5..67b6155d 100644 --- a/website/src/routes/(docs)/methods/api/(methods)/pickDirty/index.mdx +++ b/website/src/routes/(docs)/methods/api/(methods)/pickDirty/index.mdx @@ -11,7 +11,7 @@ import { properties } from './properties'; # pickDirty -Picks only the dirty parts of the given value, using the form's dirty tree as a structural mask. Object keys whose subtree contains no dirty descendant are omitted; arrays are treated as atomic and returned in full whenever any descendant is dirty. Returns `undefined` if no field is dirty or if the value's shape does not align with the form. Useful for filtering a validated output to just the changed parts before submitting. +Picks only the dirty parts of the given value, using the form's dirty tree as a structural mask. Object keys whose subtree contains no dirty descendant are omitted; arrays are treated as atomic and returned in full whenever any descendant is dirty. Returns `undefined` if no field is dirty or if the root shape diverges; per-branch shape divergence is silently skipped. Useful for filtering a validated output to just the changed parts before submitting. ```ts const dirty = pickDirty(form, config); diff --git a/website/src/routes/(docs)/preact/guides/(advanced-guides)/dirty-fields/index.mdx b/website/src/routes/(docs)/preact/guides/(advanced-guides)/dirty-fields/index.mdx index 94947f9d..264675e1 100644 --- a/website/src/routes/(docs)/preact/guides/(advanced-guides)/dirty-fields/index.mdx +++ b/website/src/routes/(docs)/preact/guides/(advanced-guides)/dirty-fields/index.mdx @@ -109,7 +109,7 @@ const Schema = v.object({ }); ``` -After validation, `output.name` has whitespace trimmed and `output.age` is a number. `getDirtyInput` would give you the raw strings, because the dirty state is bound to the input form — not to the validated output. +After validation, `output.name` has whitespace trimmed and `output.age` is a number. `getDirtyInput` would give you the raw strings, because the dirty state is bound to the form input — not to the validated output. `pickDirty(form, { from: output })` is the bridge: it walks the form's dirty tree as a structural mask and reads the corresponding values from the output you supply. The result preserves the transformed types. @@ -137,6 +137,8 @@ This differs from objects, where clean keys are omitted. Objects model keyed dic ## Common patterns +The snippets below assume `form` is your form store (the result of `useForm`). + ### Skip submission when nothing changed The form's `isDirty` flag is the cheapest way to ask "is anything dirty?" — it short-circuits on the first dirty field it finds and doesn't allocate. Use it when all you need is the yes/no answer. If you actually consume the output of `getDirtyInput`, `getDirtyPaths`, or `pickDirty`, call that method directly — its return value already signals "nothing dirty", so checking `form.isDirty` first would just walk the tree twice. diff --git a/website/src/routes/(docs)/qwik/guides/(advanced-guides)/dirty-fields/index.mdx b/website/src/routes/(docs)/qwik/guides/(advanced-guides)/dirty-fields/index.mdx index 519b44ec..7e8d8be3 100644 --- a/website/src/routes/(docs)/qwik/guides/(advanced-guides)/dirty-fields/index.mdx +++ b/website/src/routes/(docs)/qwik/guides/(advanced-guides)/dirty-fields/index.mdx @@ -109,7 +109,7 @@ const Schema = v.object({ }); ``` -After validation, `output.name` has whitespace trimmed and `output.age` is a number. `getDirtyInput` would give you the raw strings, because the dirty state is bound to the input form — not to the validated output. +After validation, `output.name` has whitespace trimmed and `output.age` is a number. `getDirtyInput` would give you the raw strings, because the dirty state is bound to the form input — not to the validated output. `pickDirty(form, { from: output })` is the bridge: it walks the form's dirty tree as a structural mask and reads the corresponding values from the output you supply. The result preserves the transformed types. @@ -137,6 +137,8 @@ This differs from objects, where clean keys are omitted. Objects model keyed dic ## Common patterns +The snippets below assume `form` is your form store (the result of `useForm$`). + ### Skip submission when nothing changed The form's `isDirty` flag is the cheapest way to ask "is anything dirty?" — it short-circuits on the first dirty field it finds and doesn't allocate. Use it when all you need is the yes/no answer. If you actually consume the output of `getDirtyInput`, `getDirtyPaths`, or `pickDirty`, call that method directly — its return value already signals "nothing dirty", so checking `form.isDirty` first would just walk the tree twice. diff --git a/website/src/routes/(docs)/react/guides/(advanced-guides)/dirty-fields/index.mdx b/website/src/routes/(docs)/react/guides/(advanced-guides)/dirty-fields/index.mdx index 39227ffe..ca81e7ed 100644 --- a/website/src/routes/(docs)/react/guides/(advanced-guides)/dirty-fields/index.mdx +++ b/website/src/routes/(docs)/react/guides/(advanced-guides)/dirty-fields/index.mdx @@ -109,7 +109,7 @@ const Schema = v.object({ }); ``` -After validation, `output.name` has whitespace trimmed and `output.age` is a number. `getDirtyInput` would give you the raw strings, because the dirty state is bound to the input form — not to the validated output. +After validation, `output.name` has whitespace trimmed and `output.age` is a number. `getDirtyInput` would give you the raw strings, because the dirty state is bound to the form input — not to the validated output. `pickDirty(form, { from: output })` is the bridge: it walks the form's dirty tree as a structural mask and reads the corresponding values from the output you supply. The result preserves the transformed types. @@ -137,6 +137,8 @@ This differs from objects, where clean keys are omitted. Objects model keyed dic ## Common patterns +The snippets below assume `form` is your form store (the result of `useForm`). + ### Skip submission when nothing changed The form's `isDirty` flag is the cheapest way to ask "is anything dirty?" — it short-circuits on the first dirty field it finds and doesn't allocate. Use it when all you need is the yes/no answer. If you actually consume the output of `getDirtyInput`, `getDirtyPaths`, or `pickDirty`, call that method directly — its return value already signals "nothing dirty", so checking `form.isDirty` first would just walk the tree twice. diff --git a/website/src/routes/(docs)/solid/guides/(advanced-guides)/dirty-fields/index.mdx b/website/src/routes/(docs)/solid/guides/(advanced-guides)/dirty-fields/index.mdx index dc72e594..40734020 100644 --- a/website/src/routes/(docs)/solid/guides/(advanced-guides)/dirty-fields/index.mdx +++ b/website/src/routes/(docs)/solid/guides/(advanced-guides)/dirty-fields/index.mdx @@ -109,7 +109,7 @@ const Schema = v.object({ }); ``` -After validation, `output.name` has whitespace trimmed and `output.age` is a number. `getDirtyInput` would give you the raw strings, because the dirty state is bound to the input form — not to the validated output. +After validation, `output.name` has whitespace trimmed and `output.age` is a number. `getDirtyInput` would give you the raw strings, because the dirty state is bound to the form input — not to the validated output. `pickDirty(form, { from: output })` is the bridge: it walks the form's dirty tree as a structural mask and reads the corresponding values from the output you supply. The result preserves the transformed types. @@ -137,6 +137,8 @@ This differs from objects, where clean keys are omitted. Objects model keyed dic ## Common patterns +The snippets below assume `form` is your form store (the result of `createForm`). + ### Skip submission when nothing changed The form's `isDirty` flag is the cheapest way to ask "is anything dirty?" — it short-circuits on the first dirty field it finds and doesn't allocate. Use it when all you need is the yes/no answer. If you actually consume the output of `getDirtyInput`, `getDirtyPaths`, or `pickDirty`, call that method directly — its return value already signals "nothing dirty", so checking `form.isDirty` first would just walk the tree twice. diff --git a/website/src/routes/(docs)/svelte/guides/(advanced-guides)/dirty-fields/index.mdx b/website/src/routes/(docs)/svelte/guides/(advanced-guides)/dirty-fields/index.mdx index d50b949f..0b029417 100644 --- a/website/src/routes/(docs)/svelte/guides/(advanced-guides)/dirty-fields/index.mdx +++ b/website/src/routes/(docs)/svelte/guides/(advanced-guides)/dirty-fields/index.mdx @@ -102,7 +102,7 @@ const Schema = v.object({ }); ``` -After validation, `output.name` has whitespace trimmed and `output.age` is a number. `getDirtyInput` would give you the raw strings, because the dirty state is bound to the input form — not to the validated output. +After validation, `output.name` has whitespace trimmed and `output.age` is a number. `getDirtyInput` would give you the raw strings, because the dirty state is bound to the form input — not to the validated output. `pickDirty(form, { from: output })` is the bridge: it walks the form's dirty tree as a structural mask and reads the corresponding values from the output you supply. The result preserves the transformed types. @@ -130,6 +130,8 @@ This differs from objects, where clean keys are omitted. Objects model keyed dic ## Common patterns +The snippets below assume `form` is your form store (the result of `createForm`). + ### Skip submission when nothing changed The form's `isDirty` flag is the cheapest way to ask "is anything dirty?" — it short-circuits on the first dirty field it finds and doesn't allocate. Use it when all you need is the yes/no answer. If you actually consume the output of `getDirtyInput`, `getDirtyPaths`, or `pickDirty`, call that method directly — its return value already signals "nothing dirty", so checking `form.isDirty` first would just walk the tree twice. diff --git a/website/src/routes/(docs)/vue/guides/(advanced-guides)/dirty-fields/index.mdx b/website/src/routes/(docs)/vue/guides/(advanced-guides)/dirty-fields/index.mdx index 92ccc249..0abd202c 100644 --- a/website/src/routes/(docs)/vue/guides/(advanced-guides)/dirty-fields/index.mdx +++ b/website/src/routes/(docs)/vue/guides/(advanced-guides)/dirty-fields/index.mdx @@ -98,7 +98,7 @@ const Schema = v.object({ }); ``` -After validation, `output.name` has whitespace trimmed and `output.age` is a number. `getDirtyInput` would give you the raw strings, because the dirty state is bound to the input form — not to the validated output. +After validation, `output.name` has whitespace trimmed and `output.age` is a number. `getDirtyInput` would give you the raw strings, because the dirty state is bound to the form input — not to the validated output. `pickDirty(form, { from: output })` is the bridge: it walks the form's dirty tree as a structural mask and reads the corresponding values from the output you supply. The result preserves the transformed types. @@ -126,6 +126,8 @@ This differs from objects, where clean keys are omitted. Objects model keyed dic ## Common patterns +The snippets below assume `form` is your form store (the result of `useForm`). + ### Skip submission when nothing changed The form's `isDirty` flag is the cheapest way to ask "is anything dirty?" — it short-circuits on the first dirty field it finds and doesn't allocate. Use it when all you need is the yes/no answer. If you actually consume the output of `getDirtyInput`, `getDirtyPaths`, or `pickDirty`, call that method directly — its return value already signals "nothing dirty", so checking `form.isDirty` first would just walk the tree twice. From c6793a2bd29c5a269e575f1396d83bb316cf0726 Mon Sep 17 00:00:00 2001 From: Fabian Hiller Date: Sun, 24 May 2026 21:12:26 -0400 Subject: [PATCH 05/10] Further improve implementation of new dirty methods --- .../getDirtyFieldInput/getDirtyFieldInput.ts | 28 ++- packages/core/src/types/path/index.ts | 1 + packages/core/src/types/path/path.test-d.ts | 174 ++++++++++++++++++ packages/core/src/types/path/path.ts | 42 +++++ packages/eslint-config/index.js | 24 +++ packages/methods/eslint.config.js | 4 +- packages/methods/package.json | 2 +- .../src/getDirtyInput/getDirtyInput.ts | 16 +- .../src/getDirtyPaths/getDirtyPaths.ts | 100 +++++----- .../methods/src/pickDirty/pickDirty.test-d.ts | 52 ++++++ .../methods/src/pickDirty/pickDirty.test.ts | 95 ++++++++-- packages/methods/src/pickDirty/pickDirty.ts | 107 +++++------ .../(advanced-guides)/dirty-fields/index.mdx | 24 +-- .../(advanced-guides)/dirty-fields/index.mdx | 24 +-- .../(advanced-guides)/dirty-fields/index.mdx | 24 +-- .../(advanced-guides)/dirty-fields/index.mdx | 24 +-- .../(advanced-guides)/dirty-fields/index.mdx | 24 +-- .../(advanced-guides)/dirty-fields/index.mdx | 24 +-- 18 files changed, 534 insertions(+), 255 deletions(-) create mode 100644 packages/methods/src/pickDirty/pickDirty.test-d.ts diff --git a/packages/core/src/field/getDirtyFieldInput/getDirtyFieldInput.ts b/packages/core/src/field/getDirtyFieldInput/getDirtyFieldInput.ts index 02106495..1f0d20e2 100644 --- a/packages/core/src/field/getDirtyFieldInput/getDirtyFieldInput.ts +++ b/packages/core/src/field/getDirtyFieldInput/getDirtyFieldInput.ts @@ -2,13 +2,13 @@ import type { InternalFieldStore } from '../../types/index.ts'; import { getFieldBool } from '../getFieldBool/getFieldBool.ts'; /** - * Returns only the dirty input of the field store. Object keys whose subtree - * contains no dirty descendant are omitted; arrays are treated as atomic and - * returned in full whenever any descendant is dirty. Returns `undefined` for - * subtrees that contain no dirty descendant. + * Returns only the dirty input of the field store. Arrays are treated as + * atomic and returned in full if any item is dirty, while object keys without + * a dirty descendant are omitted. Returns `undefined` if no descendant is + * dirty. * * @param internalFieldStore The field store to get dirty input from. - * @param dirtyOnly Whether to filter to dirty fields only. Defaults to `true`. + * @param dirtyOnly Whether to only include dirty fields. Defaults to `true`. * * @returns The dirty input, or `undefined` if no descendant is dirty. */ @@ -17,15 +17,19 @@ export function getDirtyFieldInput( internalFieldStore: InternalFieldStore, dirtyOnly: boolean = true ): unknown { - // Bail with `undefined` if no descendant is dirty + // If field has no dirty descendant, return undefined if (dirtyOnly && !getFieldBool(internalFieldStore, 'isDirty')) { return undefined; } - // If field store is array, return the full current array (atomic) + // If field store is array, collect input from children if (internalFieldStore.kind === 'array') { + // If array input is not nullish, build full array from children if (internalFieldStore.input.value) { + // Create output array const value = []; + + // Collect input from each array item for ( let index = 0; index < internalFieldStore.items.value.length; @@ -38,13 +42,19 @@ export function getDirtyFieldInput( } return value; } + + // Otherwise, return nullish input as-is return internalFieldStore.input.value; } - // If field store is object, recurse only into dirty branches + // If field store is object, recurse only into dirty children if (internalFieldStore.kind === 'object') { + // If object input is not nullish, build object from children if (internalFieldStore.input.value) { + // Create output object const value: Record = {}; + + // Collect input from each dirty object property for (const key in internalFieldStore.children) { const child = internalFieldStore.children[key]; if (!dirtyOnly || getFieldBool(child, 'isDirty')) { @@ -53,6 +63,8 @@ export function getDirtyFieldInput( } return value; } + + // Otherwise, return nullish input as-is return internalFieldStore.input.value; } diff --git a/packages/core/src/types/path/index.ts b/packages/core/src/types/path/index.ts index 9bddca42..01d28615 100644 --- a/packages/core/src/types/path/index.ts +++ b/packages/core/src/types/path/index.ts @@ -1,4 +1,5 @@ export type { + DirtyPath, Path, PathValue, PathKey, diff --git a/packages/core/src/types/path/path.test-d.ts b/packages/core/src/types/path/path.test-d.ts index 0882c980..72f39171 100644 --- a/packages/core/src/types/path/path.test-d.ts +++ b/packages/core/src/types/path/path.test-d.ts @@ -1,11 +1,15 @@ import { describe, expectTypeOf, test } from 'vitest'; import type { + DirtyPath, ExactKeysOf, ExactKeysOfArrayPath, ExactRequired, + Path, + PathKey, PathValue, PropertiesOf, PropertiesOfArrayPath, + RequiredPath, ValidArrayPath, ValidPath, } from './path.ts'; @@ -606,3 +610,173 @@ describe('ValidArrayPath', () => { >().toEqualTypeOf<['rows', number, 'tags']>(); }); }); + +describe('DirtyPath', () => { + test('should return the keys of a flat object as single-segment paths', () => { + expectTypeOf>().toEqualTypeOf< + readonly ['name'] | readonly ['age'] + >(); + }); + + test('should include both the parent path and nested paths for object children', () => { + expectTypeOf< + DirtyPath<{ user: { email: string; name: string } }> + >().toEqualTypeOf< + readonly ['user'] | readonly ['user', 'email'] | readonly ['user', 'name'] + >(); + }); + + test('should not recurse into arrays of objects', () => { + expectTypeOf>().toEqualTypeOf< + readonly ['users'] + >(); + }); + + test('should return `never` for non-object roots', () => { + expectTypeOf>().toBeNever(); + expectTypeOf>().toBeNever(); + expectTypeOf>().toBeNever(); + }); + + test('should narrow paths up to the configured depth', () => { + expectTypeOf< + DirtyPath<{ a: { b: { c: { d: { e: string } } } } }> + >().toEqualTypeOf< + | readonly ['a'] + | readonly ['a', 'b'] + | readonly ['a', 'b', 'c'] + | readonly ['a', 'b', 'c', 'd'] + | readonly ['a', 'b', 'c', 'd', 'e'] + >(); + }); + + test('should fall back to RequiredPath for paths deeper than the depth limit', () => { + type Result = DirtyPath<{ + a: { b: { c: { d: { e: { f: string } } } } }; + }>; + // The path `['a', 'b', 'c', 'd', 'e', 'f']` is past depth 5, so the last + // segments collapse to `PathKey, ...Path` (RequiredPath catch-all). + expectTypeOf< + readonly ['a', 'b', 'c', 'd', 'e', PathKey, ...Path] + >().toMatchTypeOf(); + }); + + test('should merge keys across object union members', () => { + expectTypeOf< + DirtyPath<{ shared: string } & ({ a: string } | { b: number })> + >().toEqualTypeOf(); + }); + + test('should be a subtype of RequiredPath', () => { + expectTypeOf< + DirtyPath<{ name: string; user: { email: string } }> + >().toMatchTypeOf(); + }); + + test('should not emit number-indexed paths into tuple items', () => { + type Result = DirtyPath<{ coords: [number, number, number] }>; + expectTypeOf().toEqualTypeOf(); + expectTypeOf().not.toMatchTypeOf(); + expectTypeOf().not.toMatchTypeOf(); + }); + + test('should not emit number-indexed paths into array items', () => { + type Result = DirtyPath<{ items: string[] }>; + expectTypeOf().toEqualTypeOf(); + expectTypeOf().not.toMatchTypeOf(); + }); + + test('should treat arrays of tuples as atomic at the array level', () => { + type Result = DirtyPath<{ matrix: [number, number][] }>; + expectTypeOf().toEqualTypeOf(); + expectTypeOf().not.toMatchTypeOf(); + expectTypeOf().not.toMatchTypeOf(); + }); + + test('should mix tuple and array fields with object fields without descending into either', () => { + expectTypeOf< + DirtyPath<{ + pos: [number, number]; + items: string[]; + user: { name: string }; + }> + >().toEqualTypeOf< + | readonly ['pos'] + | readonly ['items'] + | readonly ['user'] + | readonly ['user', 'name'] + >(); + }); + + test('should treat tuple-of-objects as atomic without recursing into items', () => { + expectTypeOf< + DirtyPath<{ pair: [{ x: number }, { y: number }] }> + >().toEqualTypeOf(); + }); + + test('should treat nested arrays as atomic at the outer level', () => { + expectTypeOf< + DirtyPath<{ + data: { + rows: string[][]; + }; + }> + >().toEqualTypeOf(); + }); + + test('should still emit sibling object paths when one sibling is an array', () => { + expectTypeOf< + DirtyPath<{ + profile: { + name: string; + items: string[]; + nested: { x: number }; + }; + }> + >().toEqualTypeOf< + | readonly ['profile'] + | readonly ['profile', 'name'] + | readonly ['profile', 'items'] + | readonly ['profile', 'nested'] + | readonly ['profile', 'nested', 'x'] + >(); + }); + + test('should support deep object nesting interspersed with an array sibling', () => { + expectTypeOf< + DirtyPath<{ + a: { + b: { + c: { + arr: string[]; + d: { e: number }; + }; + }; + }; + }> + >().toEqualTypeOf< + | readonly ['a'] + | readonly ['a', 'b'] + | readonly ['a', 'b', 'c'] + | readonly ['a', 'b', 'c', 'arr'] + | readonly ['a', 'b', 'c', 'd'] + | readonly ['a', 'b', 'c', 'd', 'e'] + >(); + }); + + test('should reach a deeply nested object beside a tuple sibling', () => { + expectTypeOf< + DirtyPath<{ + user: { + coords: [number, number]; + contact: { email: string }; + }; + }> + >().toEqualTypeOf< + | readonly ['user'] + | readonly ['user', 'coords'] + | readonly ['user', 'contact'] + | readonly ['user', 'contact', 'email'] + >(); + }); +}); diff --git a/packages/core/src/types/path/path.ts b/packages/core/src/types/path/path.ts index 9d62e080..61f4a064 100644 --- a/packages/core/src/types/path/path.ts +++ b/packages/core/src/types/path/path.ts @@ -245,3 +245,45 @@ export type ValidArrayPath = TPath extends LazyArrayPath, TPath> ? TPath : LazyArrayPath, TPath>; + +/** + * Recursive helper for `DirtyPath` that prepends `TKey` to each deeper path, + * or falls through to `never` when the child is not an object. + */ +type DeepDirtyPath = + TChild extends Record + ? readonly [TKey, ...DirtyPath] + : never; + +/** + * Returns the union of all `RequiredPath`s that `getDirtyPaths` can emit + * for a given input type. Object fields contribute their own path and the + * paths of their descendants; arrays and tuples are atomic and contribute + * only their own path, because dirty arrays are returned as complete units. + * + * Narrowing is exact for the first 5 levels of nesting; deeper paths fall + * back to `RequiredPath` to keep the result a complete superset of any + * path the runtime can address. + * + * Hint: Arrays and tuples are atomic because they don't structurally + * extend `Record` and so fall through to `never` + * via `DeepDirtyPath` — no explicit array check is needed. `TDepth` is + * a tuple-length counter capped at 5 to bound TypeScript instantiation + * cost. + */ +export type DirtyPath< + TValue, + TDepth extends 0[] = [], +> = TDepth['length'] extends 5 + ? RequiredPath + : TValue extends Record + ? { + [TKey in ExactKeysOf]: + | readonly [TKey] + | DeepDirtyPath< + NonNullable[TKey]>, + TKey, + TDepth + >; + }[ExactKeysOf] + : never; diff --git a/packages/eslint-config/index.js b/packages/eslint-config/index.js index da6945be..9cc85f15 100644 --- a/packages/eslint-config/index.js +++ b/packages/eslint-config/index.js @@ -119,5 +119,29 @@ export function createSourceConfig(options = {}) { }; } +/** + * Creates a test file configuration object that relaxes rules which are + * impractical in tests (e.g. local `type` aliases for type assertions). + * + * @param options Configuration options. + * @param options.files The file patterns to match (default: src/**\/*.{test,test-d}.{ts,tsx}). + * @param options.extraRules Additional rules to merge. + * + * @returns The ESLint configuration object for test files. + */ +export function createTestConfig(options = {}) { + const { + files = ['src/**/*.{test,test-d}.{ts,tsx}'], + extraRules = {}, + } = options; + return { + files, + rules: { + '@typescript-eslint/consistent-type-definitions': 'off', + ...extraRules, + }, + }; +} + // Re-export plugins and tseslint for framework configs export { eslint, importPlugin, jsdoc, pluginSecurity, tseslint }; diff --git a/packages/methods/eslint.config.js b/packages/methods/eslint.config.js index 5b4ec513..7bfc36cd 100644 --- a/packages/methods/eslint.config.js +++ b/packages/methods/eslint.config.js @@ -1,6 +1,7 @@ import { baseConfigs, createSourceConfig, + createTestConfig, tseslint, } from '@formisch/eslint-config'; @@ -14,5 +15,6 @@ export default tseslint.config( // Methods-specific rules '@typescript-eslint/no-empty-object-type': 'off', }, - }) + }), + createTestConfig({ files: ['src/**/*.{test,test-d}.ts'] }) ); diff --git a/packages/methods/package.json b/packages/methods/package.json index 9653a207..8d5d8a2a 100644 --- a/packages/methods/package.json +++ b/packages/methods/package.json @@ -53,7 +53,7 @@ "access": "public" }, "scripts": { - "test": "vitest run", + "test": "vitest run --typecheck", "coverage": "vitest run --coverage --isolate", "lint": "eslint \"src/**/*.ts*\" && tsc --noEmit", "lint.fix": "eslint \"src/**/*.ts*\" --fix", diff --git a/packages/methods/src/getDirtyInput/getDirtyInput.ts b/packages/methods/src/getDirtyInput/getDirtyInput.ts index 30144817..c2a036a2 100644 --- a/packages/methods/src/getDirtyInput/getDirtyInput.ts +++ b/packages/methods/src/getDirtyInput/getDirtyInput.ts @@ -1,12 +1,12 @@ import { type BaseFormStore, type DeepPartial, + type FormSchema, getDirtyFieldInput, getFieldStore, INTERNAL, type PathValue, type RequiredPath, - type FormSchema, type ValidPath, } from '@formisch/core'; import type * as v from 'valibot'; @@ -37,10 +37,9 @@ export interface GetFieldDirtyInputConfig< /** * Retrieves only the dirty input values of a specific field or the entire - * form. Object keys whose subtree contains no dirty descendant are omitted; - * arrays are treated as atomic and returned in full whenever any descendant - * is dirty. Returns `undefined` if no field in the inspected subtree is - * dirty. + * form. Arrays are treated as atomic and returned in full if any item is + * dirty, while object keys without a dirty descendant are omitted. Returns + * `undefined` if no field in the inspected subtree is dirty. * * @param form The form store to retrieve dirty input from. * @@ -52,10 +51,9 @@ export function getDirtyInput( /** * Retrieves only the dirty input values of a specific field or the entire - * form. Object keys whose subtree contains no dirty descendant are omitted; - * arrays are treated as atomic and returned in full whenever any descendant - * is dirty. Returns `undefined` if no field in the inspected subtree is - * dirty. + * form. Arrays are treated as atomic and returned in full if any item is + * dirty, while object keys without a dirty descendant are omitted. Returns + * `undefined` if no field in the inspected subtree is dirty. * * @param form The form store to retrieve dirty input from. * @param config The get dirty input configuration. diff --git a/packages/methods/src/getDirtyPaths/getDirtyPaths.ts b/packages/methods/src/getDirtyPaths/getDirtyPaths.ts index f24fd818..3ad07aae 100644 --- a/packages/methods/src/getDirtyPaths/getDirtyPaths.ts +++ b/packages/methods/src/getDirtyPaths/getDirtyPaths.ts @@ -1,12 +1,13 @@ import { type BaseFormStore, + type DirtyPath, + type FormSchema, getFieldBool, getFieldStore, INTERNAL, type InternalFieldStore, - type Path, + type PathKey, type RequiredPath, - type FormSchema, type ValidPath, } from '@formisch/core'; import type * as v from 'valibot'; @@ -35,29 +36,29 @@ export interface GetFieldDirtyPathsConfig< } /** - * Returns a list of paths to dirty fields. Object branches are recursed into; - * arrays are treated as atomic — when any descendant of an array is dirty, - * only the array's own path is returned. Returns an empty array if no field - * in the inspected subtree is dirty. + * Returns a list of paths to the dirty fields of a specific field or the + * entire form. Arrays are treated as atomic and contribute only their own + * path if any item is dirty, while object branches are recursed into. Returns + * an empty list if no field in the inspected subtree is dirty. * * @param form The form store to inspect. * - * @returns The list of paths to dirty fields. + * @returns The list of paths to the dirty fields. */ export function getDirtyPaths( form: BaseFormStore -): RequiredPath[]; +): DirtyPath>[]; /** - * Returns a list of paths to dirty fields. Object branches are recursed into; - * arrays are treated as atomic — when any descendant of an array is dirty, - * only the array's own path is returned. Returns an empty array if no field - * in the inspected subtree is dirty. + * Returns a list of paths to the dirty fields of a specific field or the + * entire form. Arrays are treated as atomic and contribute only their own + * path if any item is dirty, while object branches are recursed into. Returns + * an empty list if no field in the inspected subtree is dirty. * * @param form The form store to inspect. - * @param config The configuration with a `path` to scope the search. + * @param config The get dirty paths configuration. * - * @returns The list of paths to dirty fields. + * @returns The list of paths to the dirty fields. */ export function getDirtyPaths< TSchema extends FormSchema, @@ -67,7 +68,7 @@ export function getDirtyPaths< config: TFieldPath extends RequiredPath ? GetFieldDirtyPathsConfig : GetFormDirtyPathsConfig -): RequiredPath[]; +): DirtyPath>[]; // @__NO_SIDE_EFFECTS__ export function getDirtyPaths( @@ -76,45 +77,52 @@ export function getDirtyPaths( | GetFormDirtyPathsConfig | GetFieldDirtyPathsConfig ): RequiredPath[] { - const target = config?.path + // Get field store of form or specified field + const internalFieldStore = config?.path ? getFieldStore(form[INTERNAL], config.path) : form[INTERNAL]; - // Bail with an empty list if no descendant is dirty - if (!getFieldBool(target, 'isDirty')) { - return []; - } + // Collect paths of dirty fields via a single recursive walk + const paths: RequiredPath[] = []; + collectDirtyPaths( + internalFieldStore, + config?.path ? [...config.path] : [], + paths + ); - return collect(target, config?.path ? [...config.path] : []); + // Return collected paths + return paths; } -function collect( +// @__NO_SIDE_EFFECTS__ +function collectDirtyPaths( internalFieldStore: InternalFieldStore, - currentPath: Path -): RequiredPath[] { - // Arrays are atomic — emit the array's own path. With `FormSchema` - // enforcing object roots, `currentPath` is always non-empty here, but the - // guard keeps the cast honest. - if (internalFieldStore.kind === 'array') { - return currentPath.length > 0 ? [currentPath as RequiredPath] : []; - } - - // For objects: if input is null/undefined, treat as atomic (the whole - // container changed). Otherwise recurse into dirty children. - if (internalFieldStore.kind === 'object') { - if (!internalFieldStore.input.value) { - return currentPath.length > 0 ? [currentPath as RequiredPath] : []; - } - const paths: RequiredPath[] = []; + currentPath: PathKey[], + paths: RequiredPath[] +): void { + // If field store is object with non-nullish input, recurse into children + if (internalFieldStore.kind === 'object' && internalFieldStore.input.value) { + // Hint: We skip a per-child `getFieldBool` pre-check because the + // recursion already prunes clean subtrees. Pre-checking would walk + // every dirty subtree twice. for (const key in internalFieldStore.children) { - const child = internalFieldStore.children[key]; - if (getFieldBool(child, 'isDirty')) { - paths.push(...collect(child, [...currentPath, key])); - } + currentPath.push(key); + collectDirtyPaths(internalFieldStore.children[key], currentPath, paths); + currentPath.pop(); } - return paths; - } - // Value field — emit its path - return currentPath.length > 0 ? [currentPath as RequiredPath] : []; + // Otherwise, if field store is a value, emit its path if dirty + } else if (internalFieldStore.kind === 'value') { + if (internalFieldStore.isDirty.value && currentPath.length > 0) { + paths.push([...currentPath] as unknown as RequiredPath); + } + + // Otherwise, field is atomic (array or cleared object) — emit its path + // if any dirty content exists + } else if ( + getFieldBool(internalFieldStore, 'isDirty') && + currentPath.length > 0 + ) { + paths.push([...currentPath] as unknown as RequiredPath); + } } diff --git a/packages/methods/src/pickDirty/pickDirty.test-d.ts b/packages/methods/src/pickDirty/pickDirty.test-d.ts new file mode 100644 index 00000000..fb46ccc7 --- /dev/null +++ b/packages/methods/src/pickDirty/pickDirty.test-d.ts @@ -0,0 +1,52 @@ +import type { DeepPartial } from '@formisch/core'; +import * as v from 'valibot'; +import { describe, expectTypeOf, test } from 'vitest'; +import { createTestStore } from '../vitest/index.ts'; +import { pickDirty } from './pickDirty.ts'; + +describe('pickDirty', () => { + const store = createTestStore( + v.object({ + name: v.string(), + address: v.object({ street: v.string(), zip: v.number() }), + }) + ); + + test('should return a deep partial of the supplied value or undefined', () => { + type Value = { name: string; age: number }; + const from: Value = { name: 'John', age: 30 }; + + expectTypeOf(pickDirty(store, { from })).toEqualTypeOf< + DeepPartial | undefined + >(); + }); + + test('should deeply partialize nested objects and arrays', () => { + type Value = { user: { email: string; tags: string[] } }; + const from: Value = { user: { email: 'a@example.com', tags: ['x'] } }; + + expectTypeOf(pickDirty(store, { from })).toEqualTypeOf< + DeepPartial | undefined + >(); + }); + + test('should infer the value type from `from`, independent of the schema', () => { + type Value = { anything: boolean }; + const from: Value = { anything: true }; + + expectTypeOf(pickDirty(store, { from })).toEqualTypeOf< + DeepPartial | undefined + >(); + }); + + test('should require `from` to be an object', () => { + // @ts-expect-error string is not an object + pickDirty(store, { from: 'oops' }); + // @ts-expect-error number is not an object + pickDirty(store, { from: 42 }); + // @ts-expect-error null is not an object + pickDirty(store, { from: null }); + // @ts-expect-error undefined is not an object + pickDirty(store, { from: undefined }); + }); +}); diff --git a/packages/methods/src/pickDirty/pickDirty.test.ts b/packages/methods/src/pickDirty/pickDirty.test.ts index 805fbe74..bc5feed7 100644 --- a/packages/methods/src/pickDirty/pickDirty.test.ts +++ b/packages/methods/src/pickDirty/pickDirty.test.ts @@ -89,16 +89,6 @@ describe('pickDirty', () => { }); }); - test('should return undefined when the root value shape diverges', () => { - const store = createTestStore(v.object({ name: v.string() }), { - initialInput: { name: 'John' }, - }); - store.children.name.input.value = 'Jane'; - store.children.name.isDirty.value = true; - - expect(pickDirty(store, { from: 'transformed-string' })).toBeUndefined(); - }); - test('should pass through the supplied value when an object was cleared to null', () => { const store = createTestStore( v.object({ user: v.nullish(v.object({ name: v.string() })) }), @@ -133,7 +123,7 @@ describe('pickDirty', () => { }); }); - test('should skip keys where the value shape diverges and keep aligned siblings', () => { + test('should pass a diverging value through without throwing when an object is expected', () => { const store = createTestStore( v.object({ name: v.string(), @@ -150,8 +140,89 @@ describe('pickDirty', () => { userStore.children.email.isDirty.value = true; } + // `user` expects an object, but a primitive, `null` or array is passed — + // the value is returned as-is rather than crashing on `key in value`. expect( pickDirty(store, { from: { name: 'Jane', user: 'reshaped' } }) - ).toStrictEqual({ name: 'Jane' }); + ).toStrictEqual({ name: 'Jane', user: 'reshaped' }); + expect( + pickDirty(store, { from: { name: 'Jane', user: null } }) + ).toStrictEqual({ name: 'Jane', user: null }); + expect( + pickDirty(store, { from: { name: 'Jane', user: ['reshaped'] } }) + ).toStrictEqual({ name: 'Jane', user: ['reshaped'] }); + }); + + test('should return undefined when all dirty keys are absent from the supplied value', () => { + const store = createTestStore( + v.object({ name: v.string(), email: v.string() }), + { initialInput: { name: 'John', email: 'a@example.com' } } + ); + store.children.email.input.value = 'b@example.com'; + store.children.email.isDirty.value = true; + + // The form is dirty, but the only dirty key (`email`) is absent from + // `from`, so the root result is empty and `undefined` is returned. + expect(pickDirty(store, { from: { name: 'John' } })).toBeUndefined(); + }); + + test('should keep an empty object for a nested object whose dirty key is absent', () => { + const store = createTestStore( + v.object({ user: v.object({ email: v.string(), name: v.string() }) }), + { initialInput: { user: { email: 'a@example.com', name: 'John' } } } + ); + const userStore = store.children.user; + expect(userStore.kind).toBe('object'); + if (userStore.kind === 'object') { + userStore.children.email.input.value = 'b@example.com'; + userStore.children.email.isDirty.value = true; + } + + // `user` is dirty via `email`, but `email` is absent from the supplied + // `user` object, so an empty object is kept rather than omitted. + expect( + pickDirty(store, { from: { user: { name: 'John' } } }) + ).toStrictEqual({ user: {} }); + }); + + test('should return the full array atomically when a nested item field is dirty', () => { + const store = createTestStore( + v.object({ users: v.array(v.object({ name: v.string() })) }), + { initialInput: { users: [{ name: 'John' }, { name: 'Jane' }] } } + ); + const usersStore = store.children.users; + expect(usersStore.kind).toBe('array'); + if (usersStore.kind === 'array') { + const firstItem = usersStore.children[0]; + if (firstItem.kind === 'object') { + firstItem.children.name.input.value = 'Johnny'; + firstItem.children.name.isDirty.value = true; + } + } + + // Arrays are atomic: the whole supplied array is returned, including the + // clean second item. + expect( + pickDirty(store, { + from: { users: [{ name: 'Johnny' }, { name: 'Jane' }] }, + }) + ).toStrictEqual({ users: [{ name: 'Johnny' }, { name: 'Jane' }] }); + }); + + test('should pass through an array that was cleared to nullish', () => { + const store = createTestStore( + v.object({ tags: v.nullish(v.array(v.string())) }), + { initialInput: { tags: ['a', 'b'] } } + ); + const tagsStore = store.children.tags; + expect(tagsStore.kind).toBe('array'); + if (tagsStore.kind === 'array') { + tagsStore.input.value = null; + tagsStore.isDirty.value = true; + } + + expect(pickDirty(store, { from: { tags: null } })).toStrictEqual({ + tags: null, + }); }); }); diff --git a/packages/methods/src/pickDirty/pickDirty.ts b/packages/methods/src/pickDirty/pickDirty.ts index 3f1cc085..98a592f2 100644 --- a/packages/methods/src/pickDirty/pickDirty.ts +++ b/packages/methods/src/pickDirty/pickDirty.ts @@ -10,21 +10,20 @@ import { /** * Pick dirty config interface. */ -export interface PickDirtyConfig { +export interface PickDirtyConfig { /** - * The value to filter down to its dirty parts. + * The value to filter down to its dirty parts. Must be structurally + * compatible with the form's schema. */ readonly from: TValue; } /** - * Picks only the dirty parts of the given value, using the form's dirty - * tree as a structural mask. Object keys whose subtree contains no dirty - * descendant are omitted; arrays are treated as atomic and returned in full - * whenever any descendant is dirty. Returns `undefined` if no field is - * dirty or if the root shape diverges; per-branch shape divergence is - * silently skipped. Useful for filtering a validated output to just the - * changed parts before submitting. + * Picks only the dirty parts of the given value, using the form's dirty fields + * as a structural mask. Arrays are treated as atomic and object keys without a + * dirty descendant are omitted. Returns `undefined` if no field is dirty. + * Useful for filtering a validated output down to its changed parts before + * submitting. * * @param form The form store providing the dirty mask. * @param config The pick dirty configuration. @@ -32,73 +31,65 @@ export interface PickDirtyConfig { * @returns The dirty parts of the value, or `undefined`. */ // @__NO_SIDE_EFFECTS__ -export function pickDirty( +export function pickDirty( form: BaseFormStore, config: PickDirtyConfig ): DeepPartial | undefined { - const result = pickFromField(form[INTERNAL], config.from); - return result === SKIP ? undefined : (result as DeepPartial); -} + // If no field is dirty, return undefined + if (!getFieldBool(form[INTERNAL], 'isDirty')) { + return undefined; + } -// Sentinel returned when a subtree contributes nothing to the result. -// Distinct from `undefined` so that a dirty leaf whose value is `undefined` -// is still included rather than skipped. -const SKIP = Symbol(); + // Pick the dirty parts of the value using the form as a mask + const result = pickFieldValue(form[INTERNAL], config.from); + + // Return undefined if no dirty property ended up in the result, which can + // happen when every dirty key is absent from the supplied value + return Object.keys(result as object).length + ? (result as DeepPartial) + : undefined; +} -// Recursively walks the form's dirty tree alongside the supplied value, -// plucking only the parts that correspond to dirty fields and whose shape -// aligns with the form. Returns `SKIP` when nothing should be included. -function pickFromField( +/** + * Recursively picks the dirty parts of a value using the field store as a + * structural mask, reading from the supplied value rather than the form's own + * input. Objects with non-nullish input recurse into their dirty children that + * are present in the value, while arrays, primitives, nullish-cleared fields + * and shape-diverging values are returned as-is. + * + * @param internalFieldStore The field store used as the dirty mask. + * @param value The value to pick the dirty parts from. + * + * @returns The dirty parts of the value. + */ +// @__NO_SIDE_EFFECTS__ +function pickFieldValue( internalFieldStore: InternalFieldStore, value: unknown ): unknown { - // Bail with sentinel if no descendant is dirty - if (!getFieldBool(internalFieldStore, 'isDirty')) { - return SKIP; - } - - // If field store is array, return the value if it is an array (atomic). - // Otherwise the shapes diverged and there is nothing safe to pluck. - if (internalFieldStore.kind === 'array') { - // Array was cleared to null/undefined — pass through whatever the - // supplied value holds at this path. - if (!internalFieldStore.input.value) { - return value; - } - return Array.isArray(value) ? value : SKIP; - } - - // If field store is object, recurse only into dirty branches when the - // value is a non-array object. Skip when shapes diverge. - if (internalFieldStore.kind === 'object') { - // Object was cleared to null/undefined — pass through whatever the - // supplied value holds at this path. - if (!internalFieldStore.input.value) { - return value; - } - if (value === null || typeof value !== 'object' || Array.isArray(value)) { - return SKIP; - } + // If field store is object with non-nullish input and the value is a + // matching (non-array) object, recurse into children + if ( + internalFieldStore.kind === 'object' && + internalFieldStore.input.value && + value && + typeof value === 'object' && + !Array.isArray(value) + ) { + // Collect dirty parts from each dirty property present in value const result: Record = {}; - let added = false; for (const key in internalFieldStore.children) { const child = internalFieldStore.children[key]; - // Skip absent keys so a transformed value that omits a dirty key - // doesn't get an unintended `undefined` written into the result. if (getFieldBool(child, 'isDirty') && key in value) { - const childResult = pickFromField( + result[key] = pickFieldValue( child, (value as Record)[key] ); - if (childResult !== SKIP) { - result[key] = childResult; - added = true; - } } } - return added ? result : SKIP; + return result; } - // Return value as-is for primitive value field + // Otherwise, field is atomic or its shape diverges, so return as-is return value; } diff --git a/website/src/routes/(docs)/preact/guides/(advanced-guides)/dirty-fields/index.mdx b/website/src/routes/(docs)/preact/guides/(advanced-guides)/dirty-fields/index.mdx index 264675e1..bb74327f 100644 --- a/website/src/routes/(docs)/preact/guides/(advanced-guides)/dirty-fields/index.mdx +++ b/website/src/routes/(docs)/preact/guides/(advanced-guides)/dirty-fields/index.mdx @@ -67,16 +67,13 @@ Because dirty state is bound to the form input, `getDirtyInput` always returns t ### `getDirtyPaths` -Returns a list of paths to dirty fields. Use it when you want to drive your own walker — for example, generating a patch payload or computing a structural diff. +Returns a list of paths to dirty fields. Use it for logging, telemetry, or driving a custom walker over the form's dirty state. ```tsx -import { getDirtyPaths, getInput } from '@formisch/preact'; +import { getDirtyPaths } from '@formisch/preact'; -const patch = getDirtyPaths(profileForm).map((path) => ({ - operation: 'replace', - path, - value: getInput(profileForm, { path }), -})); +const dirtyPaths = getDirtyPaths(profileForm); +// e.g. [['email'], ['user', 'name']] ``` Arrays are atomic: only the array's own path is returned, never the paths of individual items. @@ -178,19 +175,6 @@ const onSubmit: SubmitHandler = async (output) => { }; ``` -### Build a patch payload - -```tsx -import { getDirtyPaths, getInput } from '@formisch/preact'; - -const patch = getDirtyPaths(form).map((path) => ({ - operation: 'replace', - path, - value: getInput(form, { path }), -})); -await api.patch(patch); -``` - ## Performance All three methods walk the form's field tree and call `getFieldBool` recursively to skip clean subtrees. Cost is effectively linear in field count for typical balanced forms (shallow and wide) and degrades toward `O(N²)` for deeply nested trees with few siblings at each level. diff --git a/website/src/routes/(docs)/qwik/guides/(advanced-guides)/dirty-fields/index.mdx b/website/src/routes/(docs)/qwik/guides/(advanced-guides)/dirty-fields/index.mdx index 7e8d8be3..8ee4616d 100644 --- a/website/src/routes/(docs)/qwik/guides/(advanced-guides)/dirty-fields/index.mdx +++ b/website/src/routes/(docs)/qwik/guides/(advanced-guides)/dirty-fields/index.mdx @@ -67,16 +67,13 @@ Because dirty state is bound to the form input, `getDirtyInput` always returns t ### `getDirtyPaths` -Returns a list of paths to dirty fields. Use it when you want to drive your own walker — for example, generating a patch payload or computing a structural diff. +Returns a list of paths to dirty fields. Use it for logging, telemetry, or driving a custom walker over the form's dirty state. ```tsx -import { getDirtyPaths, getInput } from '@formisch/qwik'; +import { getDirtyPaths } from '@formisch/qwik'; -const patch = getDirtyPaths(profileForm).map((path) => ({ - operation: 'replace', - path, - value: getInput(profileForm, { path }), -})); +const dirtyPaths = getDirtyPaths(profileForm); +// e.g. [['email'], ['user', 'name']] ``` Arrays are atomic: only the array's own path is returned, never the paths of individual items. @@ -178,19 +175,6 @@ const onSubmit: SubmitHandler = async (output) => { }; ``` -### Build a patch payload - -```tsx -import { getDirtyPaths, getInput } from '@formisch/qwik'; - -const patch = getDirtyPaths(form).map((path) => ({ - operation: 'replace', - path, - value: getInput(form, { path }), -})); -await api.patch(patch); -``` - ## Performance All three methods walk the form's field tree and call `getFieldBool` recursively to skip clean subtrees. Cost is effectively linear in field count for typical balanced forms (shallow and wide) and degrades toward `O(N²)` for deeply nested trees with few siblings at each level. diff --git a/website/src/routes/(docs)/react/guides/(advanced-guides)/dirty-fields/index.mdx b/website/src/routes/(docs)/react/guides/(advanced-guides)/dirty-fields/index.mdx index ca81e7ed..2093eac4 100644 --- a/website/src/routes/(docs)/react/guides/(advanced-guides)/dirty-fields/index.mdx +++ b/website/src/routes/(docs)/react/guides/(advanced-guides)/dirty-fields/index.mdx @@ -67,16 +67,13 @@ Because dirty state is bound to the form input, `getDirtyInput` always returns t ### `getDirtyPaths` -Returns a list of paths to dirty fields. Use it when you want to drive your own walker — for example, generating a patch payload or computing a structural diff. +Returns a list of paths to dirty fields. Use it for logging, telemetry, or driving a custom walker over the form's dirty state. ```tsx -import { getDirtyPaths, getInput } from '@formisch/react'; +import { getDirtyPaths } from '@formisch/react'; -const patch = getDirtyPaths(profileForm).map((path) => ({ - operation: 'replace', - path, - value: getInput(profileForm, { path }), -})); +const dirtyPaths = getDirtyPaths(profileForm); +// e.g. [['email'], ['user', 'name']] ``` Arrays are atomic: only the array's own path is returned, never the paths of individual items. @@ -178,19 +175,6 @@ const onSubmit: SubmitHandler = async (output) => { }; ``` -### Build a patch payload - -```tsx -import { getDirtyPaths, getInput } from '@formisch/react'; - -const patch = getDirtyPaths(form).map((path) => ({ - operation: 'replace', - path, - value: getInput(form, { path }), -})); -await api.patch(patch); -``` - ## Performance All three methods walk the form's field tree and call `getFieldBool` recursively to skip clean subtrees. Cost is effectively linear in field count for typical balanced forms (shallow and wide) and degrades toward `O(N²)` for deeply nested trees with few siblings at each level. diff --git a/website/src/routes/(docs)/solid/guides/(advanced-guides)/dirty-fields/index.mdx b/website/src/routes/(docs)/solid/guides/(advanced-guides)/dirty-fields/index.mdx index 40734020..c410e932 100644 --- a/website/src/routes/(docs)/solid/guides/(advanced-guides)/dirty-fields/index.mdx +++ b/website/src/routes/(docs)/solid/guides/(advanced-guides)/dirty-fields/index.mdx @@ -67,16 +67,13 @@ Because dirty state is bound to the form input, `getDirtyInput` always returns t ### `getDirtyPaths` -Returns a list of paths to dirty fields. Use it when you want to drive your own walker — for example, generating a patch payload or computing a structural diff. +Returns a list of paths to dirty fields. Use it for logging, telemetry, or driving a custom walker over the form's dirty state. ```tsx -import { getDirtyPaths, getInput } from '@formisch/solid'; +import { getDirtyPaths } from '@formisch/solid'; -const patch = getDirtyPaths(profileForm).map((path) => ({ - operation: 'replace', - path, - value: getInput(profileForm, { path }), -})); +const dirtyPaths = getDirtyPaths(profileForm); +// e.g. [['email'], ['user', 'name']] ``` Arrays are atomic: only the array's own path is returned, never the paths of individual items. @@ -178,19 +175,6 @@ const onSubmit: SubmitHandler = async (output) => { }; ``` -### Build a patch payload - -```tsx -import { getDirtyPaths, getInput } from '@formisch/solid'; - -const patch = getDirtyPaths(form).map((path) => ({ - operation: 'replace', - path, - value: getInput(form, { path }), -})); -await api.patch(patch); -``` - ## Performance All three methods walk the form's field tree and call `getFieldBool` recursively to skip clean subtrees. Cost is effectively linear in field count for typical balanced forms (shallow and wide) and degrades toward `O(N²)` for deeply nested trees with few siblings at each level. diff --git a/website/src/routes/(docs)/svelte/guides/(advanced-guides)/dirty-fields/index.mdx b/website/src/routes/(docs)/svelte/guides/(advanced-guides)/dirty-fields/index.mdx index 0b029417..73e178fd 100644 --- a/website/src/routes/(docs)/svelte/guides/(advanced-guides)/dirty-fields/index.mdx +++ b/website/src/routes/(docs)/svelte/guides/(advanced-guides)/dirty-fields/index.mdx @@ -60,16 +60,13 @@ Because dirty state is bound to the form input, `getDirtyInput` always returns t ### `getDirtyPaths` -Returns a list of paths to dirty fields. Use it when you want to drive your own walker — for example, generating a patch payload or computing a structural diff. +Returns a list of paths to dirty fields. Use it for logging, telemetry, or driving a custom walker over the form's dirty state. ```ts -import { getDirtyPaths, getInput } from '@formisch/svelte'; +import { getDirtyPaths } from '@formisch/svelte'; -const patch = getDirtyPaths(profileForm).map((path) => ({ - operation: 'replace', - path, - value: getInput(profileForm, { path }), -})); +const dirtyPaths = getDirtyPaths(profileForm); +// e.g. [['email'], ['user', 'name']] ``` Arrays are atomic: only the array's own path is returned, never the paths of individual items. @@ -171,19 +168,6 @@ const onSubmit: SubmitHandler = async (output) => { }; ``` -### Build a patch payload - -```ts -import { getDirtyPaths, getInput } from '@formisch/svelte'; - -const patch = getDirtyPaths(form).map((path) => ({ - operation: 'replace', - path, - value: getInput(form, { path }), -})); -await api.patch(patch); -``` - ## Performance All three methods walk the form's field tree and call `getFieldBool` recursively to skip clean subtrees. Cost is effectively linear in field count for typical balanced forms (shallow and wide) and degrades toward `O(N²)` for deeply nested trees with few siblings at each level. diff --git a/website/src/routes/(docs)/vue/guides/(advanced-guides)/dirty-fields/index.mdx b/website/src/routes/(docs)/vue/guides/(advanced-guides)/dirty-fields/index.mdx index 0abd202c..10f8e00a 100644 --- a/website/src/routes/(docs)/vue/guides/(advanced-guides)/dirty-fields/index.mdx +++ b/website/src/routes/(docs)/vue/guides/(advanced-guides)/dirty-fields/index.mdx @@ -56,16 +56,13 @@ Because dirty state is bound to the form input, `getDirtyInput` always returns t ### `getDirtyPaths` -Returns a list of paths to dirty fields. Use it when you want to drive your own walker — for example, generating a patch payload or computing a structural diff. +Returns a list of paths to dirty fields. Use it for logging, telemetry, or driving a custom walker over the form's dirty state. ```ts -import { getDirtyPaths, getInput } from '@formisch/vue'; +import { getDirtyPaths } from '@formisch/vue'; -const patch = getDirtyPaths(profileForm).map((path) => ({ - operation: 'replace', - path, - value: getInput(profileForm, { path }), -})); +const dirtyPaths = getDirtyPaths(profileForm); +// e.g. [['email'], ['user', 'name']] ``` Arrays are atomic: only the array's own path is returned, never the paths of individual items. @@ -167,19 +164,6 @@ const onSubmit: SubmitHandler = async (output) => { }; ``` -### Build a patch payload - -```ts -import { getDirtyPaths, getInput } from '@formisch/vue'; - -const patch = getDirtyPaths(form).map((path) => ({ - operation: 'replace', - path, - value: getInput(form, { path }), -})); -await api.patch(patch); -``` - ## Performance All three methods walk the form's field tree and call `getFieldBool` recursively to skip clean subtrees. Cost is effectively linear in field count for typical balanced forms (shallow and wide) and degrades toward `O(N²)` for deeply nested trees with few siblings at each level. From 1fa0f88ec2567962d941797ad60d4df24cc960ae Mon Sep 17 00:00:00 2001 From: Fabian Hiller Date: Sun, 24 May 2026 21:32:38 -0400 Subject: [PATCH 06/10] Simplify DirtyPath tests --- packages/core/src/types/path/path.test-d.ts | 144 +++++--------------- 1 file changed, 34 insertions(+), 110 deletions(-) diff --git a/packages/core/src/types/path/path.test-d.ts b/packages/core/src/types/path/path.test-d.ts index 72f39171..74fa9a1a 100644 --- a/packages/core/src/types/path/path.test-d.ts +++ b/packages/core/src/types/path/path.test-d.ts @@ -626,9 +626,40 @@ describe('DirtyPath', () => { >(); }); - test('should not recurse into arrays of objects', () => { - expectTypeOf>().toEqualTypeOf< - readonly ['users'] + test('should treat arrays and tuples of every shape as atomic', () => { + expectTypeOf< + DirtyPath<{ + tags: string[]; + users: { name: string }[]; + matrix: number[][]; + pairs: [number, number][]; + coords: [number, number]; + nodes: [{ x: number }, { y: number }]; + }> + >().toEqualTypeOf< + | readonly ['tags'] + | readonly ['users'] + | readonly ['matrix'] + | readonly ['pairs'] + | readonly ['coords'] + | readonly ['nodes'] + >(); + }); + + test('should recurse into object siblings while array and tuple siblings stay atomic', () => { + expectTypeOf< + DirtyPath<{ + items: string[]; + coords: [number, number]; + user: { name: string; address: { city: string } }; + }> + >().toEqualTypeOf< + | readonly ['items'] + | readonly ['coords'] + | readonly ['user'] + | readonly ['user', 'name'] + | readonly ['user', 'address'] + | readonly ['user', 'address', 'city'] >(); }); @@ -672,111 +703,4 @@ describe('DirtyPath', () => { DirtyPath<{ name: string; user: { email: string } }> >().toMatchTypeOf(); }); - - test('should not emit number-indexed paths into tuple items', () => { - type Result = DirtyPath<{ coords: [number, number, number] }>; - expectTypeOf().toEqualTypeOf(); - expectTypeOf().not.toMatchTypeOf(); - expectTypeOf().not.toMatchTypeOf(); - }); - - test('should not emit number-indexed paths into array items', () => { - type Result = DirtyPath<{ items: string[] }>; - expectTypeOf().toEqualTypeOf(); - expectTypeOf().not.toMatchTypeOf(); - }); - - test('should treat arrays of tuples as atomic at the array level', () => { - type Result = DirtyPath<{ matrix: [number, number][] }>; - expectTypeOf().toEqualTypeOf(); - expectTypeOf().not.toMatchTypeOf(); - expectTypeOf().not.toMatchTypeOf(); - }); - - test('should mix tuple and array fields with object fields without descending into either', () => { - expectTypeOf< - DirtyPath<{ - pos: [number, number]; - items: string[]; - user: { name: string }; - }> - >().toEqualTypeOf< - | readonly ['pos'] - | readonly ['items'] - | readonly ['user'] - | readonly ['user', 'name'] - >(); - }); - - test('should treat tuple-of-objects as atomic without recursing into items', () => { - expectTypeOf< - DirtyPath<{ pair: [{ x: number }, { y: number }] }> - >().toEqualTypeOf(); - }); - - test('should treat nested arrays as atomic at the outer level', () => { - expectTypeOf< - DirtyPath<{ - data: { - rows: string[][]; - }; - }> - >().toEqualTypeOf(); - }); - - test('should still emit sibling object paths when one sibling is an array', () => { - expectTypeOf< - DirtyPath<{ - profile: { - name: string; - items: string[]; - nested: { x: number }; - }; - }> - >().toEqualTypeOf< - | readonly ['profile'] - | readonly ['profile', 'name'] - | readonly ['profile', 'items'] - | readonly ['profile', 'nested'] - | readonly ['profile', 'nested', 'x'] - >(); - }); - - test('should support deep object nesting interspersed with an array sibling', () => { - expectTypeOf< - DirtyPath<{ - a: { - b: { - c: { - arr: string[]; - d: { e: number }; - }; - }; - }; - }> - >().toEqualTypeOf< - | readonly ['a'] - | readonly ['a', 'b'] - | readonly ['a', 'b', 'c'] - | readonly ['a', 'b', 'c', 'arr'] - | readonly ['a', 'b', 'c', 'd'] - | readonly ['a', 'b', 'c', 'd', 'e'] - >(); - }); - - test('should reach a deeply nested object beside a tuple sibling', () => { - expectTypeOf< - DirtyPath<{ - user: { - coords: [number, number]; - contact: { email: string }; - }; - }> - >().toEqualTypeOf< - | readonly ['user'] - | readonly ['user', 'coords'] - | readonly ['user', 'contact'] - | readonly ['user', 'contact', 'email'] - >(); - }); }); From ab2e37cacd8de89ceea09782bb78f3bc81b39151 Mon Sep 17 00:00:00 2001 From: Fabian Hiller Date: Sun, 24 May 2026 21:32:46 -0400 Subject: [PATCH 07/10] Update changelog to include pull request references for dirty methods --- packages/core/CHANGELOG.md | 2 +- packages/methods/CHANGELOG.md | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 38311a08..0f79d185 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to the library will be documented in this file. ## vX.X.X (Month DD, YYYY) - Add `FormSchema` type that constrains a form's root schema to object schemas (sync or async) and combinators (`intersect`, `union`, `variant`) -- Add `getDirtyFieldInput` to extract only the dirty input of a field store +- Add `getDirtyFieldInput` to extract only the dirty input of a field store (issue #21, pull request #98) - Change `FormConfig`, `InternalFormStore`, `BaseFormStore`, `SubmitHandler` and `SubmitEventHandler` generic constraints from `Schema` to `FormSchema` ## v0.6.4 (May 17, 2026) diff --git a/packages/methods/CHANGELOG.md b/packages/methods/CHANGELOG.md index f49fe210..6a6d031c 100644 --- a/packages/methods/CHANGELOG.md +++ b/packages/methods/CHANGELOG.md @@ -4,9 +4,9 @@ All notable changes to the library will be documented in this file. ## vX.X.X (Month DD, YYYY) -- Add `pickDirty` method to filter an externally-supplied value down to its dirty parts using the form's dirty mask (issue #21) -- Add `getDirtyPaths` method to list the paths of dirty fields in a form or specific field (issue #21) -- Add `getDirtyInput` method to retrieve only the dirty input values of a form or specific field (issue #21) +- Add `pickDirty` method to filter an externally-supplied value down to its dirty parts using the form's dirty mask (issue #21, pull request #98) +- Add `getDirtyPaths` method to list the paths of dirty fields in a form or specific field (issue #21, pull request #98) +- Add `getDirtyInput` method to retrieve only the dirty input values of a form or specific field (issue #21, pull request #98) - Change `@formisch/core` to vX.X.X - Change method generic constraints from `Schema` to `FormSchema` so the form root must be an object schema (sync or async) or a combinator (`intersect`, `union`, `variant`) From b7b5dc688a4268681e1a3c0484da244e73a40096 Mon Sep 17 00:00:00 2001 From: Fabian Hiller Date: Sun, 24 May 2026 21:32:54 -0400 Subject: [PATCH 08/10] Add documentation for dirty state methods in form methods guides --- .../guides/(main-concepts)/form-methods/index.mdx | 13 +++++++++++++ .../guides/(main-concepts)/form-methods/index.mdx | 13 +++++++++++++ .../guides/(main-concepts)/form-methods/index.mdx | 13 +++++++++++++ .../guides/(main-concepts)/form-methods/index.mdx | 13 +++++++++++++ .../guides/(main-concepts)/form-methods/index.mdx | 13 +++++++++++++ .../guides/(main-concepts)/form-methods/index.mdx | 13 +++++++++++++ 6 files changed, 78 insertions(+) diff --git a/website/src/routes/(docs)/preact/guides/(main-concepts)/form-methods/index.mdx b/website/src/routes/(docs)/preact/guides/(main-concepts)/form-methods/index.mdx index fe1f6de3..3a11c8a2 100644 --- a/website/src/routes/(docs)/preact/guides/(main-concepts)/form-methods/index.mdx +++ b/website/src/routes/(docs)/preact/guides/(main-concepts)/form-methods/index.mdx @@ -27,6 +27,19 @@ To retrieve values from your form, you can use: Formisch uses Preact signals internally which means that reading values with these methods is reactive. When the form state changes, any component that uses these methods will automatically update to reflect the new state. +## Dirty state + +To work with the dirty state of your form, Formisch provides three methods: + +- `getDirtyInput`: Get the dirty + parts of the form input +- `getDirtyPaths`: Get the paths + of dirty fields +- `pickDirty`: Filter an + externally-supplied value down to its dirty parts using the form's dirty mask + +See the dirty fields guide for when to reach for each. + ## Setting values To manually update form values or errors, use: diff --git a/website/src/routes/(docs)/qwik/guides/(main-concepts)/form-methods/index.mdx b/website/src/routes/(docs)/qwik/guides/(main-concepts)/form-methods/index.mdx index 7e3ca04e..4868dbc5 100644 --- a/website/src/routes/(docs)/qwik/guides/(main-concepts)/form-methods/index.mdx +++ b/website/src/routes/(docs)/qwik/guides/(main-concepts)/form-methods/index.mdx @@ -27,6 +27,19 @@ To retrieve values from your form, you can use: Formisch uses Qwik's signals internally which means that reading values with these methods is reactive. When the form state changes, any component or computation that uses these methods will automatically update to reflect the new state. +## Dirty state + +To work with the dirty state of your form, Formisch provides three methods: + +- `getDirtyInput`: Get the dirty + parts of the form input +- `getDirtyPaths`: Get the paths + of dirty fields +- `pickDirty`: Filter an + externally-supplied value down to its dirty parts using the form's dirty mask + +See the dirty fields guide for when to reach for each. + ## Setting values To manually update form values or errors, use: diff --git a/website/src/routes/(docs)/react/guides/(main-concepts)/form-methods/index.mdx b/website/src/routes/(docs)/react/guides/(main-concepts)/form-methods/index.mdx index 1e0d1883..7b7cc369 100644 --- a/website/src/routes/(docs)/react/guides/(main-concepts)/form-methods/index.mdx +++ b/website/src/routes/(docs)/react/guides/(main-concepts)/form-methods/index.mdx @@ -27,6 +27,19 @@ To retrieve values from your form, you can use: Formisch plugs into React's render cycle, so reading values with these methods inside components stays reactive. When the form state changes, components that rely on these values automatically re-render without manual subscriptions. +## Dirty state + +To work with the dirty state of your form, Formisch provides three methods: + +- `getDirtyInput`: Get the dirty + parts of the form input +- `getDirtyPaths`: Get the paths + of dirty fields +- `pickDirty`: Filter an + externally-supplied value down to its dirty parts using the form's dirty mask + +See the dirty fields guide for when to reach for each. + ## Setting values To manually update form values or errors, use: diff --git a/website/src/routes/(docs)/solid/guides/(main-concepts)/form-methods/index.mdx b/website/src/routes/(docs)/solid/guides/(main-concepts)/form-methods/index.mdx index fd7926f1..9f6ffaad 100644 --- a/website/src/routes/(docs)/solid/guides/(main-concepts)/form-methods/index.mdx +++ b/website/src/routes/(docs)/solid/guides/(main-concepts)/form-methods/index.mdx @@ -27,6 +27,19 @@ To retrieve values from your form, you can use: Formisch uses SolidJS's signals internally which means that reading values with these methods is reactive. When the form state changes, any component or computation that uses these methods will automatically update to reflect the new state. +## Dirty state + +To work with the dirty state of your form, Formisch provides three methods: + +- `getDirtyInput`: Get the dirty + parts of the form input +- `getDirtyPaths`: Get the paths + of dirty fields +- `pickDirty`: Filter an + externally-supplied value down to its dirty parts using the form's dirty mask + +See the dirty fields guide for when to reach for each. + ## Setting values To manually update form values or errors, use: diff --git a/website/src/routes/(docs)/svelte/guides/(main-concepts)/form-methods/index.mdx b/website/src/routes/(docs)/svelte/guides/(main-concepts)/form-methods/index.mdx index 3fada3b4..d4d21f74 100644 --- a/website/src/routes/(docs)/svelte/guides/(main-concepts)/form-methods/index.mdx +++ b/website/src/routes/(docs)/svelte/guides/(main-concepts)/form-methods/index.mdx @@ -27,6 +27,19 @@ To retrieve values from your form, you can use: Formisch uses reactive stores internally which means that reading values with these methods is reactive. When the form state changes, any component or subscription that uses these methods will automatically update to reflect the new state. +## Dirty state + +To work with the dirty state of your form, Formisch provides three methods: + +- `getDirtyInput`: Get the dirty + parts of the form input +- `getDirtyPaths`: Get the paths + of dirty fields +- `pickDirty`: Filter an + externally-supplied value down to its dirty parts using the form's dirty mask + +See the dirty fields guide for when to reach for each. + ## Setting values To manually update form values or errors, use: diff --git a/website/src/routes/(docs)/vue/guides/(main-concepts)/form-methods/index.mdx b/website/src/routes/(docs)/vue/guides/(main-concepts)/form-methods/index.mdx index 88dcaccd..5c632ea0 100644 --- a/website/src/routes/(docs)/vue/guides/(main-concepts)/form-methods/index.mdx +++ b/website/src/routes/(docs)/vue/guides/(main-concepts)/form-methods/index.mdx @@ -27,6 +27,19 @@ To retrieve values from your form, you can use: Formisch uses Vue's reactivity system internally which means that reading values with these methods is reactive. When the form state changes, any component or computation that uses these methods will automatically update to reflect the new state. +## Dirty state + +To work with the dirty state of your form, Formisch provides three methods: + +- `getDirtyInput`: Get the dirty + parts of the form input +- `getDirtyPaths`: Get the paths + of dirty fields +- `pickDirty`: Filter an + externally-supplied value down to its dirty parts using the form's dirty mask + +See the dirty fields guide for when to reach for each. + ## Setting values To manually update form values or errors, use: From 199cf9b959f3a351573c681fb7ca6c031f032ba0 Mon Sep 17 00:00:00 2001 From: Fabian Hiller Date: Sun, 24 May 2026 21:56:53 -0400 Subject: [PATCH 09/10] Address reveiw feedback of PR #98 --- .../src/getDirtyPaths/getDirtyPaths.test.ts | 17 +++++++++++++++++ .../methods/src/getDirtyPaths/getDirtyPaths.ts | 12 ++++++++++++ .../api/(methods)/getDirtyPaths/index.mdx | 2 +- .../api/(methods)/getDirtyPaths/properties.ts | 4 ++-- .../methods/api/(methods)/pickDirty/index.mdx | 4 ++-- .../api/(methods)/pickDirty/properties.ts | 4 ++-- .../GetFieldDirtyInputConfig/properties.ts | 4 ++-- .../GetFieldDirtyPathsConfig/properties.ts | 4 ++-- 8 files changed, 40 insertions(+), 11 deletions(-) diff --git a/packages/methods/src/getDirtyPaths/getDirtyPaths.test.ts b/packages/methods/src/getDirtyPaths/getDirtyPaths.test.ts index 7099179c..2b229518 100644 --- a/packages/methods/src/getDirtyPaths/getDirtyPaths.test.ts +++ b/packages/methods/src/getDirtyPaths/getDirtyPaths.test.ts @@ -109,6 +109,23 @@ describe('getDirtyPaths', () => { expect(getDirtyPaths(store)).toStrictEqual([['user']]); }); + test('should return the object path when an object transitioned from nullish without a dirty descendant', () => { + const store = createTestStore( + v.object({ user: v.nullish(v.object({ name: v.string() })) }), + { initialInput: { user: null } } + ); + const userStore = store.children.user; + expect(userStore.kind).toBe('object'); + if (userStore.kind === 'object') { + // Simulate `setInput(form, { path: ['user'], input: {} })`: the object's + // own dirty flag flips, but the child `name` stays at its initial value. + userStore.input.value = true; + userStore.isDirty.value = true; + } + + expect(getDirtyPaths(store)).toStrictEqual([['user']]); + }); + test('should scope to the given path', () => { const store = createTestStore( v.object({ diff --git a/packages/methods/src/getDirtyPaths/getDirtyPaths.ts b/packages/methods/src/getDirtyPaths/getDirtyPaths.ts index 3ad07aae..ae8a843f 100644 --- a/packages/methods/src/getDirtyPaths/getDirtyPaths.ts +++ b/packages/methods/src/getDirtyPaths/getDirtyPaths.ts @@ -105,12 +105,24 @@ function collectDirtyPaths( // Hint: We skip a per-child `getFieldBool` pre-check because the // recursion already prunes clean subtrees. Pre-checking would walk // every dirty subtree twice. + const lengthBefore = paths.length; for (const key in internalFieldStore.children) { currentPath.push(key); collectDirtyPaths(internalFieldStore.children[key], currentPath, paths); currentPath.pop(); } + // If no descendant emitted a path but the object itself flipped dirty + // (e.g. transitioned from nullish to a non-nullish object), emit the + // object's own path so the change isn't silently dropped. + if ( + paths.length === lengthBefore && + internalFieldStore.isDirty.value && + currentPath.length > 0 + ) { + paths.push([...currentPath] as unknown as RequiredPath); + } + // Otherwise, if field store is a value, emit its path if dirty } else if (internalFieldStore.kind === 'value') { if (internalFieldStore.isDirty.value && currentPath.length > 0) { diff --git a/website/src/routes/(docs)/methods/api/(methods)/getDirtyPaths/index.mdx b/website/src/routes/(docs)/methods/api/(methods)/getDirtyPaths/index.mdx index 0a1b00b6..8003caf5 100644 --- a/website/src/routes/(docs)/methods/api/(methods)/getDirtyPaths/index.mdx +++ b/website/src/routes/(docs)/methods/api/(methods)/getDirtyPaths/index.mdx @@ -30,7 +30,7 @@ const paths = getDirtyPaths(form, config); ### Explanation -The `form` parameter is the form store to inspect. The optional `config` parameter scopes the search to a single subtree via its `path` property. Object branches are recursed into and clean keys are omitted; arrays are atomic, so when any descendant of an array is dirty, the result includes the array's own path rather than the paths of its individual items. Returns an empty array if the inspected subtree contains no dirty fields. When the inspected target is itself a dirty array or value (for example a form whose root schema is `v.array(...)`), the result contains its path — which is the empty array for the form root and so cannot be passed back into another path-scoped method without a length check. +The `form` parameter is the form store to inspect. The optional `config` parameter scopes the search to a single subtree via its `path` property. Object branches are recursed into and clean keys are omitted; arrays are atomic, so when any descendant of an array is dirty, the result includes the array's own path rather than the paths of its individual items. Returns an empty array if the inspected subtree contains no dirty fields. When `path` targets a dirty array or value field, the result contains that path. Internally, the function walks the form's field tree and asks `getFieldBool` whether each branch contains a dirty descendant. Because that check is itself recursive, the cost is effectively linear in field count for typical balanced forms (shallow and wide) and degrades toward `O(N²)` for deeply nested trees. Call this method from submit or blur handlers rather than from tight reactive loops on large forms. diff --git a/website/src/routes/(docs)/methods/api/(methods)/getDirtyPaths/properties.ts b/website/src/routes/(docs)/methods/api/(methods)/getDirtyPaths/properties.ts index eaaacb6b..ec1e8014 100644 --- a/website/src/routes/(docs)/methods/api/(methods)/getDirtyPaths/properties.ts +++ b/website/src/routes/(docs)/methods/api/(methods)/getDirtyPaths/properties.ts @@ -5,8 +5,8 @@ export const properties: Record = { modifier: 'extends', type: { type: 'custom', - name: 'Schema', - href: '/core/api/Schema/', + name: 'FormSchema', + href: '/core/api/FormSchema/', }, }, TFieldPath: { diff --git a/website/src/routes/(docs)/methods/api/(methods)/pickDirty/index.mdx b/website/src/routes/(docs)/methods/api/(methods)/pickDirty/index.mdx index 67b6155d..d4b92960 100644 --- a/website/src/routes/(docs)/methods/api/(methods)/pickDirty/index.mdx +++ b/website/src/routes/(docs)/methods/api/(methods)/pickDirty/index.mdx @@ -11,7 +11,7 @@ import { properties } from './properties'; # pickDirty -Picks only the dirty parts of the given value, using the form's dirty tree as a structural mask. Object keys whose subtree contains no dirty descendant are omitted; arrays are treated as atomic and returned in full whenever any descendant is dirty. Returns `undefined` if no field is dirty or if the root shape diverges; per-branch shape divergence is silently skipped. Useful for filtering a validated output to just the changed parts before submitting. +Picks only the dirty parts of the given value, using the form's dirty tree as a structural mask. Object keys whose subtree contains no dirty descendant are omitted; arrays are treated as atomic and returned in full whenever any descendant is dirty. Returns `undefined` if no field is dirty. Where the supplied value's shape diverges from the form's input shape — for example, a field expected to be an object holds a primitive, `null` or array — that branch is returned as-is. Useful for filtering a validated output to just the changed parts before submitting. ```ts const dirty = pickDirty(form, config); @@ -29,7 +29,7 @@ const dirty = pickDirty(form, config); ### Explanation -The `form` parameter is the form store providing the dirty mask. The `config` parameter must include a `from` value to filter. The value's shape is expected to match the form's input shape — wherever the shapes diverge (e.g. the value's object key is missing, or an array key holds a non-array), that branch is skipped. Use this when a Valibot schema transformation produces a value of a different shape than the form's input and you want to ship only the dirty parts of that output. +The `form` parameter is the form store providing the dirty mask. The `config` parameter must include a `from` value to filter. The value's shape is expected to match the form's input shape — wherever the shapes diverge (e.g. an object key in the form holds a primitive, `null` or array in the supplied value), that branch is returned as-is so the divergence is preserved in the result. Use this when a Valibot schema transformation produces a value of a different shape than the form's input and you want to ship only the dirty parts of that output. Internally, the function walks the form's field tree alongside the supplied value, using `getFieldBool` to skip clean subtrees and a constant-time check at each node to verify shape alignment. Because the dirty check is itself recursive, the cost is effectively linear in field count for typical balanced forms (shallow and wide) and degrades toward `O(N²)` for deeply nested trees. Call this method from submit or blur handlers rather than from tight reactive loops on large forms. diff --git a/website/src/routes/(docs)/methods/api/(methods)/pickDirty/properties.ts b/website/src/routes/(docs)/methods/api/(methods)/pickDirty/properties.ts index b98d57ae..a58dfa57 100644 --- a/website/src/routes/(docs)/methods/api/(methods)/pickDirty/properties.ts +++ b/website/src/routes/(docs)/methods/api/(methods)/pickDirty/properties.ts @@ -5,8 +5,8 @@ export const properties: Record = { modifier: 'extends', type: { type: 'custom', - name: 'Schema', - href: '/core/api/Schema/', + name: 'FormSchema', + href: '/core/api/FormSchema/', }, }, TValue: { diff --git a/website/src/routes/(docs)/methods/api/(types)/GetFieldDirtyInputConfig/properties.ts b/website/src/routes/(docs)/methods/api/(types)/GetFieldDirtyInputConfig/properties.ts index 56155efd..d09e9b07 100644 --- a/website/src/routes/(docs)/methods/api/(types)/GetFieldDirtyInputConfig/properties.ts +++ b/website/src/routes/(docs)/methods/api/(types)/GetFieldDirtyInputConfig/properties.ts @@ -5,8 +5,8 @@ export const properties: Record = { modifier: 'extends', type: { type: 'custom', - name: 'Schema', - href: '/core/api/Schema/', + name: 'FormSchema', + href: '/core/api/FormSchema/', }, }, TFieldPath: { diff --git a/website/src/routes/(docs)/methods/api/(types)/GetFieldDirtyPathsConfig/properties.ts b/website/src/routes/(docs)/methods/api/(types)/GetFieldDirtyPathsConfig/properties.ts index 56155efd..d09e9b07 100644 --- a/website/src/routes/(docs)/methods/api/(types)/GetFieldDirtyPathsConfig/properties.ts +++ b/website/src/routes/(docs)/methods/api/(types)/GetFieldDirtyPathsConfig/properties.ts @@ -5,8 +5,8 @@ export const properties: Record = { modifier: 'extends', type: { type: 'custom', - name: 'Schema', - href: '/core/api/Schema/', + name: 'FormSchema', + href: '/core/api/FormSchema/', }, }, TFieldPath: { From ed354add95e09f9049c43d5405bab14b7b226998 Mon Sep 17 00:00:00 2001 From: Fabian Hiller Date: Sun, 24 May 2026 22:11:05 -0400 Subject: [PATCH 10/10] Add test for emitting leaf path when both object and descendant are dirty --- .../src/getDirtyPaths/getDirtyPaths.test.ts | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/methods/src/getDirtyPaths/getDirtyPaths.test.ts b/packages/methods/src/getDirtyPaths/getDirtyPaths.test.ts index 2b229518..52756364 100644 --- a/packages/methods/src/getDirtyPaths/getDirtyPaths.test.ts +++ b/packages/methods/src/getDirtyPaths/getDirtyPaths.test.ts @@ -126,6 +126,27 @@ describe('getDirtyPaths', () => { expect(getDirtyPaths(store)).toStrictEqual([['user']]); }); + test('should emit only the leaf path when both the object and a descendant are dirty', () => { + const store = createTestStore( + v.object({ user: v.nullish(v.object({ name: v.string() })) }), + { initialInput: { user: null } } + ); + const userStore = store.children.user; + expect(userStore.kind).toBe('object'); + if (userStore.kind === 'object') { + // Simulate `setInput(form, { path: ['user'], input: { name: 'John' } })`: + // both the object itself (null → object) and the `name` child flip dirty. + userStore.input.value = true; + userStore.isDirty.value = true; + userStore.children.name.input.value = 'John'; + userStore.children.name.isDirty.value = true; + } + + // Only the leaf path is emitted — the parent's dirty state is implied by + // the descendant path and should not double-emit. + expect(getDirtyPaths(store)).toStrictEqual([['user', 'name']]); + }); + test('should scope to the given path', () => { const store = createTestStore( v.object({