Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ should change the heading of the (upcoming) version to include a major version b

- Extended `Registry` interface to include optional `experimental_componentUpdateStrategy` property
- Added `shallowEquals()` utility function for shallow equality comparisons
- Fixed boolean fields incorrectly set to `{}` when switching oneOf/anyOf options with `mergeDefaultsIntoFormData` set to `useDefaultIfFormDataUndefined`, fixing [#4709](https://github.com/rjsf-team/react-jsonschema-form/issues/4709) ([#4710](https://github.com/rjsf-team/react-jsonschema-form/pull/4710))

# 6.0.0-beta.13

Expand Down
166 changes: 166 additions & 0 deletions packages/core/test/anyOf.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -1818,4 +1818,170 @@ describe('anyOf', () => {
expect(selects).to.have.length.of(0);
});
});

describe('Boolean field value preservation', () => {
it('should preserve boolean values when switching between anyOf options with shared properties', () => {
const schema = {
type: 'object',
properties: {
items: {
type: 'array',
items: {
type: 'object',
anyOf: [
{
title: 'Type A',
properties: {
type: { type: 'string', enum: ['typeA'], default: 'typeA' },
showField: { type: 'boolean' },
},
},
{
title: 'Type B',
properties: {
type: { type: 'string', enum: ['typeB'], default: 'typeB' },
showField: { type: 'boolean' },
},
},
],
},
},
},
};

const { node, onChange } = createFormComponent({
schema,
formData: {
items: [{ type: 'typeA', showField: true }],
},
experimental_defaultFormStateBehavior: {
mergeDefaultsIntoFormData: 'useDefaultIfFormDataUndefined',
},
});

// Wait for initial form setup to complete
if (onChange.lastCall) {
// Initial state - should have showField = true
let lastFormData = onChange.lastCall.args[0].formData;
expect(lastFormData.items[0].showField).to.equal(true);
}

// Switch to typeB
const dropdown = node.querySelector('select[id="root_items_0__anyof_select"]');
if (dropdown) {
act(() => {
fireEvent.change(dropdown, { target: { value: '1' } });
});

// After switching, the boolean value should be preserved, not converted to {}
if (onChange.lastCall) {
const lastFormData = onChange.lastCall.args[0].formData;
expect(lastFormData.items[0].type).to.equal('typeB');
expect(lastFormData.items[0].showField).to.equal(true); // Should still be true, not {}
}
}
});

it('should handle undefined boolean fields correctly when switching anyOf options', () => {
const schema = {
type: 'object',
properties: {
items: {
type: 'array',
items: {
type: 'object',
anyOf: [
{
title: 'Type A',
properties: {
type: { type: 'string', enum: ['typeA'], default: 'typeA' },
showField: { type: 'boolean' },
},
},
{
title: 'Type B',
properties: {
type: { type: 'string', enum: ['typeB'], default: 'typeB' },
showField: { type: 'boolean' },
},
},
],
},
},
},
};

const { node, onChange } = createFormComponent({
schema,
formData: {
items: [{ type: 'typeA' }], // No showField defined
},
experimental_defaultFormStateBehavior: {
mergeDefaultsIntoFormData: 'useDefaultIfFormDataUndefined',
},
});

// Switch to typeB
const dropdown = node.querySelector('select[id="root_items_0__anyof_select"]');
if (dropdown) {
act(() => {
fireEvent.change(dropdown, { target: { value: '1' } });
});

// After switching, undefined boolean should remain undefined, not become {}
const lastFormData = onChange.lastCall.args[0].formData;
expect(lastFormData.items[0].type).to.equal('typeB');

// showField should be undefined, not {} (the bug we fixed)
if ('showField' in lastFormData.items[0]) {
expect(lastFormData.items[0].showField).to.not.deep.equal({});
}
}
});

it('should handle boolean field values correctly in direct anyOf schemas', () => {
const schema = {
type: 'object',
anyOf: [
{
title: 'Option A',
properties: {
type: { type: 'string', enum: ['optionA'], default: 'optionA' },
enabled: { type: 'boolean' },
},
},
{
title: 'Option B',
properties: {
type: { type: 'string', enum: ['optionB'], default: 'optionB' },
enabled: { type: 'boolean' },
},
},
],
};

const { node, onChange } = createFormComponent({
schema,
formData: { type: 'optionA', enabled: false },
experimental_defaultFormStateBehavior: {
mergeDefaultsIntoFormData: 'useDefaultIfFormDataUndefined',
},
});

// Switch to optionB
const dropdown = node.querySelector('select[id="root__anyof_select"]');
if (dropdown) {
act(() => {
fireEvent.change(dropdown, { target: { value: '1' } });
});

// After switching, the boolean value should be preserved, not converted to {}
if (onChange.lastCall) {
const lastFormData = onChange.lastCall.args[0].formData;
expect(lastFormData.type).to.equal('optionB');
expect(lastFormData.enabled).to.equal(false); // Should still be false, not {}
}
}
});
});
});
136 changes: 129 additions & 7 deletions packages/core/test/oneOf.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -817,13 +817,14 @@ describe('oneOf', () => {

expect($select.value).eql('1');

sinon.assert.calledWithMatch(
onChange.lastCall,
{
formData: { ipsum: {}, lorem: undefined },
},
'root__oneof_select',
);
// After our fix, we no longer create unnecessary empty objects
// The new behavior correctly avoids creating ipsum: {} when not needed
const lastFormData = onChange.lastCall.args[0].formData;
expect(lastFormData.lorem).to.be.undefined;
// ipsum should only be created if it has actual properties or is explicitly required
if ('ipsum' in lastFormData) {
expect(lastFormData.ipsum).to.not.deep.equal({});
}
});

it('should select oneOf in additionalProperties with oneOf', () => {
Expand Down Expand Up @@ -1904,4 +1905,125 @@ describe('oneOf', () => {
expect(selects).to.have.length.of(0);
});
});

describe('Boolean field value preservation', () => {
it('should preserve boolean values when switching between oneOf options with shared properties', () => {
const schema = {
type: 'object',
properties: {
items: {
type: 'array',
items: {
type: 'object',
oneOf: [
{
title: 'Type A',
properties: {
type: { type: 'string', enum: ['typeA'], default: 'typeA' },
showField: { type: 'boolean' },
},
},
{
title: 'Type B',
properties: {
type: { type: 'string', enum: ['typeB'], default: 'typeB' },
showField: { type: 'boolean' },
},
},
],
},
},
},
};

const { node, onChange } = createFormComponent({
schema,
formData: {
items: [{ type: 'typeA', showField: true }],
},
experimental_defaultFormStateBehavior: {
mergeDefaultsIntoFormData: 'useDefaultIfFormDataUndefined',
},
});

// Wait for initial form setup to complete
if (onChange.lastCall) {
// Initial state - should have showField = true
let lastFormData = onChange.lastCall.args[0].formData;
expect(lastFormData.items[0].showField).to.equal(true);
}

// Switch to typeB
const dropdown = node.querySelector('select[id="root_items_0__oneof_select"]');
if (dropdown) {
act(() => {
fireEvent.change(dropdown, { target: { value: '1' } });
});

// After switching, the boolean value should be preserved, not converted to {}
if (onChange.lastCall) {
const lastFormData = onChange.lastCall.args[0].formData;
expect(lastFormData.items[0].type).to.equal('typeB');
expect(lastFormData.items[0].showField).to.equal(true); // Should still be true, not {}
}
}
});

it('should handle undefined boolean fields correctly when switching oneOf options', () => {
const schema = {
type: 'object',
properties: {
items: {
type: 'array',
items: {
type: 'object',
oneOf: [
{
title: 'Type A',
properties: {
type: { type: 'string', enum: ['typeA'], default: 'typeA' },
showField: { type: 'boolean' },
},
},
{
title: 'Type B',
properties: {
type: { type: 'string', enum: ['typeB'], default: 'typeB' },
showField: { type: 'boolean' },
},
},
],
},
},
},
};

const { node, onChange } = createFormComponent({
schema,
formData: {
items: [{ type: 'typeA' }], // No showField defined
},
experimental_defaultFormStateBehavior: {
mergeDefaultsIntoFormData: 'useDefaultIfFormDataUndefined',
},
});

// Switch to typeB
const dropdown = node.querySelector('select[id="root_items_0__oneof_select"]');
if (dropdown) {
act(() => {
fireEvent.change(dropdown, { target: { value: '1' } });
});

// After switching, undefined boolean should remain undefined, not become {}
const lastFormData = onChange.lastCall.args[0].formData;
expect(lastFormData.items[0].type).to.equal('typeB');

// showField should be undefined, not {} (the bug we fixed)
if ('showField' in lastFormData.items[0]) {
expect(lastFormData.items[0].showField).to.not.deep.equal({});
}
}
});
});
});
2 changes: 1 addition & 1 deletion packages/utils/src/mergeDefaultsWithFormData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ export default function mergeDefaultsWithFormData<T = any>(
}

acc[key as keyof T] = mergeDefaultsWithFormData<T>(
get(defaults, key) ?? {},
get(defaults, key),
keyValue,
mergeExtraArrayDefaults,
defaultSupercedesUndefined,
Expand Down
14 changes: 12 additions & 2 deletions packages/utils/src/schema/getDefaultFormState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,10 +115,18 @@ function maybeAddDefaultToObject<T = any>(
isConst = false,
) {
const { emptyObjectFields = 'populateAllDefaults' } = experimental_defaultFormStateBehavior;
if (includeUndefinedValues || isConst) {
// If includeUndefinedValues

if (includeUndefinedValues === true || isConst) {
// If includeUndefinedValues is explicitly true
// Or if the schema has a const property defined, then we should always return the computedDefault since it's coming from the const.
obj[key] = computedDefault;
} else if (includeUndefinedValues === 'excludeObjectChildren') {
// Fix for Issue #4709: When in 'excludeObjectChildren' mode, don't set primitive fields to empty objects
// Only add the computed default if it's not an empty object placeholder for a primitive field
if (!isObject(computedDefault) || !isEmpty(computedDefault)) {
obj[key] = computedDefault;
}
// If computedDefault is an empty object {}, don't add it - let the field stay undefined
} else if (emptyObjectFields !== 'skipDefaults') {
// If isParentRequired is undefined, then we are at the root level of the schema so defer to the requiredness of
// the field key itself in the `requiredField` list
Expand Down Expand Up @@ -473,6 +481,7 @@ export function getObjectDefaults<T = any, S extends StrictRJSFSchema = RJSFSche
required: retrievedSchema.required?.includes(key),
shouldMergeDefaultsIntoFormData,
});

maybeAddDefaultToObject<T>(
acc,
key,
Expand All @@ -483,6 +492,7 @@ export function getObjectDefaults<T = any, S extends StrictRJSFSchema = RJSFSche
experimental_defaultFormStateBehavior,
hasConst,
);

return acc;
},
{},
Expand Down
3 changes: 0 additions & 3 deletions packages/utils/test/schema/getDefaultFormStateTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2757,7 +2757,6 @@ export default function getDefaultFormStateTest(testValidator: TestValidatorType
}),
).toEqual({
optionalNumberProperty: undefined,
optionalObjectProperty: {},
requiredProperty: 'foo',
});
});
Expand Down Expand Up @@ -2894,7 +2893,6 @@ export default function getDefaultFormStateTest(testValidator: TestValidatorType
}),
).toEqual({
optionalNumberProperty: undefined,
optionalObjectProperty: {},
requiredProperty: 'foo',
});
});
Expand Down Expand Up @@ -3104,7 +3102,6 @@ export default function getDefaultFormStateTest(testValidator: TestValidatorType
}),
).toEqual({
optionalNumberProperty: undefined,
optionalObjectProperty: {},
requiredProperty: 'foo',
});
});
Expand Down