Skip to content

Commit 713fc47

Browse files
authored
fix: preserve empty strings in defaultPayload (#1235)
1 parent daee12e commit 713fc47

4 files changed

Lines changed: 66 additions & 21 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@conform-to/dom': patch
3+
'@conform-to/react': patch
4+
---
5+
6+
Preserve empty values in `defaultPayload` so custom controls can render the full default structure before user interaction.

packages/conform-dom/formdata.ts

Lines changed: 26 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -800,8 +800,8 @@ export function isDirty(
800800
};
801801

802802
return !deepEqual(
803-
normalize(formValue, serialize),
804-
normalize(options?.defaultValue, serialize),
803+
normalize(formValue, { serialize }),
804+
normalize(options?.defaultValue, { serialize }),
805805
);
806806
}
807807

@@ -891,21 +891,28 @@ export function defaultSerialize(value: unknown): ReturnType<Serialize> {
891891
}
892892

893893
/**
894-
* Recursively serializes a value using the provided serialize function,
895-
* collapsing empty leaves (`null`, `''`, empty files) to `undefined`
896-
* and removing empty containers (objects with no remaining keys, empty arrays).
894+
* Recursively serializes a value using the provided serialize function.
895+
* When `stripEmptyValue` is true, empty values, empty arrays, and empty objects
896+
* are removed, and single-string arrays are unwrapped to match single-value fields.
897897
*
898898
* When serialize returns `undefined` for a value (i.e. it can't be represented
899899
* as form data), the raw value is kept and recursed into if it's an object or array.
900900
*
901-
* Single-element arrays where the element is a string or undefined are unwrapped
902-
* to handle the case where a multi-value field (e.g. checkboxes) has only one value.
903901
*/
904902
export function normalize(
905903
value: unknown,
906-
serialize: Serialize = defaultSerialize,
907-
name?: string,
904+
options: {
905+
serialize?: Serialize;
906+
stripEmptyValue?: boolean;
907+
name?: string;
908+
} = {},
908909
): unknown {
910+
const {
911+
serialize = defaultSerialize,
912+
stripEmptyValue = true,
913+
name,
914+
} = options;
915+
909916
let data: unknown = serialize(value, {
910917
name,
911918
});
@@ -914,28 +921,29 @@ export function normalize(
914921
data = value;
915922
}
916923

917-
if (data === '' || data === null) {
924+
if (stripEmptyValue && (data === null || data === '')) {
918925
return undefined;
919926
}
920927

921928
if (isGlobalInstance(data, 'File')) {
922-
if (data.name === '' && data.size === 0) {
929+
if (stripEmptyValue && data.name === '' && data.size === 0) {
923930
return undefined;
924931
}
925932

926933
return data;
927934
}
928935

929936
if (Array.isArray(data)) {
930-
if (data.length === 0) {
937+
if (stripEmptyValue && data.length === 0) {
931938
return undefined;
932939
}
933940

934941
const array = data.map((item, index) =>
935-
normalize(item, serialize, appendPath(name, index)),
942+
normalize(item, { ...options, name: appendPath(name, index) }),
936943
);
937944

938945
if (
946+
stripEmptyValue &&
939947
array.length === 1 &&
940948
(typeof array[0] === 'string' || array[0] === undefined)
941949
) {
@@ -948,11 +956,10 @@ export function normalize(
948956
if (isPlainObject(data)) {
949957
const entries = Object.entries(data).reduce<Array<[string, unknown]>>(
950958
(list, [key, value]) => {
951-
const normalizedValue = normalize(
952-
value,
953-
serialize,
954-
appendPath(name, key),
955-
);
959+
const normalizedValue = normalize(value, {
960+
...options,
961+
name: appendPath(name, key),
962+
});
956963

957964
if (typeof normalizedValue !== 'undefined') {
958965
list.push([key, normalizedValue]);
@@ -963,7 +970,7 @@ export function normalize(
963970
[],
964971
);
965972

966-
if (entries.length === 0) {
973+
if (stripEmptyValue && entries.length === 0) {
967974
return undefined;
968975
}
969976

packages/conform-react/future/state.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -511,7 +511,11 @@ export function getDefaultPayload(
511511
return null;
512512
}
513513

514-
return normalize(value, context.serialize, name);
514+
return normalize(value, {
515+
serialize: context.serialize,
516+
stripEmptyValue: false,
517+
name,
518+
});
515519
}
516520

517521
export function getDefaultValue(

packages/conform-react/tests/state.test.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1388,7 +1388,7 @@ describe('form state', () => {
13881388

13891389
expect(getListKey(context, 'tasks')).toEqual(['0-tasks[0]']);
13901390
expect(getDefaultOptions(context, 'tasks')).toEqual(['Final 1']);
1391-
expect(getDefaultPayload(context, 'tasks')).toBe('Final 1');
1391+
expect(getDefaultPayload(context, 'tasks')).toEqual(['Final 1']);
13921392
expect(isTouched(context.state)).toBe(true);
13931393

13941394
context.state = updateState(
@@ -1603,6 +1603,34 @@ test('field metadata serialize override updates default values', () => {
16031603
expect(field.defaultPayload).toBe('{"foo":"bar"}');
16041604
});
16051605

1606+
test('getDefaultPayload', () => {
1607+
const context = createContext({
1608+
state: initializeState({
1609+
defaultValue: {
1610+
address: {
1611+
street: '',
1612+
postcode: null,
1613+
city: 'London',
1614+
},
1615+
tasks: ['', 'Buy groceries', ''],
1616+
username: '',
1617+
},
1618+
}),
1619+
});
1620+
1621+
expect(getDefaultPayload(context, 'address')).toEqual({
1622+
street: '',
1623+
postcode: null,
1624+
city: 'London',
1625+
});
1626+
expect(getDefaultPayload(context, 'tasks')).toEqual([
1627+
'',
1628+
'Buy groceries',
1629+
'',
1630+
]);
1631+
expect(getDefaultPayload(context, 'username')).toBe('');
1632+
});
1633+
16061634
test('getDefaultListKey', () => {
16071635
const prefix = 'test-prefix';
16081636
const initialValue = {

0 commit comments

Comments
 (0)