Skip to content
Merged
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 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({});
}
}
});
});
});
12 changes: 12 additions & 0 deletions packages/docs/docs/migration-guides/v6.x upgrade guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,18 @@ Use the `ui:enumNames` in the `UiSchema` instead.

### Other BREAKING CHANGES

#### Primitive field handling in oneOf/anyOf schemas

A bug fix was implemented that changes how primitive fields (boolean, string, number, etc.) are handled when switching between oneOf/anyOf schema options with `mergeDefaultsIntoFormData: "useDefaultIfFormDataUndefined"`.

**Previous (buggy) behavior**: Undefined primitive fields were incorrectly set to empty objects `{}` when switching between schema variants.

**New (correct) behavior**: Undefined primitive fields now remain `undefined` or receive proper default values according to their type when switching between schema variants.

This change fixes [#4709](https://github.com/rjsf-team/react-jsonschema-form/issues/4709) and was implemented in [#4710](https://github.com/rjsf-team/react-jsonschema-form/pull/4710).

**Impact**: If your application was incorrectly relying on undefined primitive fields becoming `{}` objects, you may need to update your form validation or data processing logic to handle proper primitive values or `undefined` instead.

#### SchemaField removed Bootstrap 3 classes

In fixing [#2280](https://github.com/rjsf-team/react-jsonschema-form/issues/2280), the following `Bootstrap 3` classes
Expand Down
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
Loading