diff --git a/CHANGELOG.md b/CHANGELOG.md index 422558cecc..6be34694d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/packages/core/test/anyOf.test.jsx b/packages/core/test/anyOf.test.jsx index 456c4fc3c5..34c7b3f102 100644 --- a/packages/core/test/anyOf.test.jsx +++ b/packages/core/test/anyOf.test.jsx @@ -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 {} + } + } + }); + }); }); diff --git a/packages/core/test/oneOf.test.jsx b/packages/core/test/oneOf.test.jsx index 63d8855719..0700f90f75 100644 --- a/packages/core/test/oneOf.test.jsx +++ b/packages/core/test/oneOf.test.jsx @@ -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', () => { @@ -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({}); + } + } + }); + }); }); diff --git a/packages/docs/docs/migration-guides/v6.x upgrade guide.md b/packages/docs/docs/migration-guides/v6.x upgrade guide.md index 64341d65e3..94d83db863 100644 --- a/packages/docs/docs/migration-guides/v6.x upgrade guide.md +++ b/packages/docs/docs/migration-guides/v6.x upgrade guide.md @@ -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 diff --git a/packages/utils/src/mergeDefaultsWithFormData.ts b/packages/utils/src/mergeDefaultsWithFormData.ts index d208589355..2922d3dc38 100644 --- a/packages/utils/src/mergeDefaultsWithFormData.ts +++ b/packages/utils/src/mergeDefaultsWithFormData.ts @@ -82,7 +82,7 @@ export default function mergeDefaultsWithFormData( } acc[key as keyof T] = mergeDefaultsWithFormData( - get(defaults, key) ?? {}, + get(defaults, key), keyValue, mergeExtraArrayDefaults, defaultSupercedesUndefined, diff --git a/packages/utils/src/schema/getDefaultFormState.ts b/packages/utils/src/schema/getDefaultFormState.ts index 388223218a..f6f9eed446 100644 --- a/packages/utils/src/schema/getDefaultFormState.ts +++ b/packages/utils/src/schema/getDefaultFormState.ts @@ -115,10 +115,18 @@ function maybeAddDefaultToObject( 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 @@ -473,6 +481,7 @@ export function getObjectDefaults( acc, key, @@ -483,6 +492,7 @@ export function getObjectDefaults