Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/core/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ All notable changes to the library will be documented in this file.

## vX.X.X (Month DD, YYYY)

- Add `GetFieldInputOptions` and a corresponding `config` parameter to `getFieldInput` to support filtering by `isDirty`
- Fix `initializeFieldStore` to throw an error when `variant` or `union` branches initialize the same key with incompatible store kinds (pull request #94)
- Fix `ValidArrayPath` type to accept array fields reachable through unions (`variant` options, optional/nullish intermediates, and unions that include primitives) (pull request #89)
- Fix `PathValue` type to preserve `| undefined` when navigating to optional or nullish fields, so input types of methods like `setInput` are no longer narrowed away from `T | null | undefined` (issue #15, pull request #89)
Expand Down
199 changes: 199 additions & 0 deletions packages/core/src/field/getFieldInput/getFieldInput.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,4 +102,203 @@ describe('getFieldInput', () => {
});
});
});

describe('with dirtyOnly', () => {
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(getFieldInput(store, { dirtyOnly: true })).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(getFieldInput(store, { dirtyOnly: true })).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(getFieldInput(store, { dirtyOnly: true })).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(getFieldInput(store, { dirtyOnly: true })).toStrictEqual({
user: { email: 'b@example.com' },
});
});

test('should include a value field even when its dirty input 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(getFieldInput(store, { dirtyOnly: true })).toStrictEqual({
name: undefined,
});
});

test('should return all dirty branches across multiple keys and depths', () => {
const store = createTestStore(
v.object({
name: v.string(),
email: v.string(),
user: v.object({ first: v.string(), last: v.string() }),
}),
{
initialInput: {
name: 'John',
email: 'a@example.com',
user: { first: 'A', last: 'B' },
},
}
);
store.children.email.input.value = 'b@example.com';
store.children.email.isDirty.value = true;
const userStore = store.children.user;
expect(userStore.kind).toBe('object');
if (userStore.kind === 'object') {
userStore.children.first.input.value = 'C';
userStore.children.first.isDirty.value = true;
}
expect(getFieldInput(store, { dirtyOnly: true })).toStrictEqual({
email: 'b@example.com',
user: { first: 'C' },
});
});

test('should return full objects inside arrays when any 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(getFieldInput(store, { dirtyOnly: true })).toStrictEqual({
users: [
{ name: 'Johnny', age: 25 },
{ name: 'Jane', age: 30 },
],
});
});

test('should include an object that transitioned from null to set', () => {
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') {
userStore.input.value = true;
userStore.isDirty.value = true;
userStore.children.name.input.value = 'John';
userStore.children.name.isDirty.value = true;
}
expect(getFieldInput(store, { dirtyOnly: true })).toStrictEqual({
user: { name: 'John' },
});
});

test('should include an object that transitioned from set 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(getFieldInput(store, { dirtyOnly: true })).toStrictEqual({
user: null,
});
});

test('should return value for a dirty value field called directly', () => {
const store = createTestStore(v.object({ name: v.string() }), {
initialInput: { name: 'John' },
});
store.children.name.input.value = 'Jane';
store.children.name.isDirty.value = true;
expect(
getFieldInput(store.children.name, { dirtyOnly: true })
).toBe('Jane');
});

test('should return undefined when called directly on a clean value field', () => {
const store = createTestStore(v.object({ name: v.string() }), {
initialInput: { name: 'John' },
});
expect(
getFieldInput(store.children.name, { dirtyOnly: true })
).toBeUndefined();
});

test('should return undefined when called directly on a clean array', () => {
const store = createTestStore(v.object({ items: v.array(v.string()) }), {
initialInput: { items: ['a', 'b', 'c'] },
});
expect(
getFieldInput(store.children.items, { dirtyOnly: true })
).toBeUndefined();
});

test('should return undefined when called directly on a clean object subtree', () => {
const store = createTestStore(
v.object({ user: v.object({ name: v.string() }) }),
{ initialInput: { user: { name: 'John' } } }
);
expect(
getFieldInput(store.children.user, { dirtyOnly: true })
).toBeUndefined();
});
});
});
39 changes: 36 additions & 3 deletions packages/core/src/field/getFieldInput/getFieldInput.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,54 @@
import type { InternalFieldStore } from '../../types/index.ts';
import { getFieldBool } from '../getFieldBool/getFieldBool.ts';

/**
* Options for retrieving field input.
*/
export interface GetFieldInputOptions {
/**
* When true, fields whose subtree contains no dirty descendant return
* `undefined`. Object iterations omit clean keys; arrays are treated as
* atomic and are returned in full whenever any descendant is dirty.
*/
readonly dirtyOnly?: boolean;
}

/**
* Returns the current input of the field store. For arrays and objects,
* recursively collects input from all children. Returns `null` or `undefined`
* for nullish array/object inputs, or the primitive value for value fields.
*
* @param internalFieldStore The field store to get input from.
* @param config Options to filter the collected input (e.g. `dirtyOnly`).
*
* @returns The field input.
*/
// @__NO_SIDE_EFFECTS__
export function getFieldInput(internalFieldStore: InternalFieldStore): unknown {
export function getFieldInput(
internalFieldStore: InternalFieldStore,
config?: GetFieldInputOptions
): unknown {
// When `dirtyOnly`, bail with `undefined` for any subtree that contains
// no dirty descendant. This applies uniformly to values, arrays and
// objects. Inside arrays we still populate every item (atomic semantic)
// so undefined slots never appear in the output.
if (
config?.dirtyOnly &&
!getFieldBool(internalFieldStore, 'isDirty')
) {
return undefined;
}

// If field store is array, collect input from children
if (internalFieldStore.kind === 'array') {
// If array input is not nullish, build array from children
if (internalFieldStore.input.value) {
// Create output array
const value = [];

// Collect input from each array item
// Collect input from each array item. Once we enter an array, every
// item is fully populated regardless of dirty state — arrays are
// atomic in `dirtyOnly` mode.
for (
let index = 0;
index < internalFieldStore.items.value.length;
Expand All @@ -42,7 +72,10 @@ export function getFieldInput(internalFieldStore: InternalFieldStore): unknown {

// Collect input from each object property
for (const key in internalFieldStore.children) {
value[key] = getFieldInput(internalFieldStore.children[key]);
const child = internalFieldStore.children[key];
if (!config?.dirtyOnly || getFieldBool(child, 'isDirty')) {
value[key] = getFieldInput(child, config);
Comment on lines +75 to +77
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Traverse clean parents when filtering dirty fields

When dirtyOnly is enabled, recursion is gated by child.isDirty.value, so traversal stops at any clean parent. In this codebase, parent isDirty often stays false for nested edits (for example, updating user.email in a non-null object), which means dirty descendants are dropped and getInput({ dirtyOnly: true }) can return {} even though fields changed. This breaks the documented "submit changed values" use case for common nested object/array updates.

Useful? React with 👍 / 👎.

}
}
return value;
}
Expand Down
1 change: 1 addition & 0 deletions packages/methods/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ All notable changes to the library will be documented in this file.

## vX.X.X (Month DD, YYYY)

- Add `dirtyOnly` option to `getInput` to retrieve only fields whose `isDirty` flag is set (issue #21)
- Fix `reset` method to apply falsy and explicit `undefined` values when resetting the initial input of a specific field (issue #78)

## v0.7.1 (April 16, 2026)
Expand Down
Loading
Loading