Skip to content

fix(SelectWidget): use real enum values for option values when nameGenerator is active#5004

Merged
heath-freenome merged 22 commits into
rjsf-team:mainfrom
nlemoine:fix/select-enum-values
Apr 17, 2026
Merged

fix(SelectWidget): use real enum values for option values when nameGenerator is active#5004
heath-freenome merged 22 commits into
rjsf-team:mainfrom
nlemoine:fix/select-enum-values

Conversation

@nlemoine

@nlemoine nlemoine commented Mar 26, 2026

Copy link
Copy Markdown
Contributor

Reasons for making this change

When htmlName is present (nameGenerator enabled), SelectWidget rendered option values as array indices (0, 1, 2) instead of real enum values (e.g. "active", "inactive"). This broke native form submission since the POSTed value was the index, not the actual value. Fixes all 10 themes.

Related to nameGenerator introduced in #4773

Checklist

  • I'm updating documentation
  • I'm adding or updating code
    • I've added and/or updated tests. I've run npx nx run-many --target=build --exclude=@rjsf/docs && npm run test:update to update snapshots, if needed.
    • I've updated docs if needed
    • I've updated the changelog with a description of the PR
  • I'm adding a new feature
    • I've updated the playground with an example use of the feature

@heath-freenome

Copy link
Copy Markdown
Member

@nlemoine Help me understand the problem here. How does the name generator break things? The select widget converts to/from the index value upon selection. so the caller is still given the proper value.

@nlemoine

nlemoine commented Mar 27, 2026

Copy link
Copy Markdown
Contributor Author

@heath-freenome Sorry, I should have included a playground example.

See the option values:

<select id="root_status" name="root_status" role="combobox" class="form-control" aria-describedby="root_status__error root_status__description root_status__help">
  <option value=""></option>
  <option value="0">active</option>
  <option value="1">inactive</option>
  <option value="2">archived</option>
</select>

When submitting the traditional way (the whole PR I worked on), the submitted values are wrong.

<select id="root_status" name="root_status" role="combobox" class="form-control" aria-describedby="root_status__error root_status__description root_status__help">
  <option value=""></option>
  <option value="active">active</option>
  <option value="inactive">inactive</option>
  <option value="archived">archived</option>
</select>

Let me know if you need more details.

@nlemoine nlemoine force-pushed the fix/select-enum-values branch from e2adff1 to 0a412e9 Compare March 27, 2026 16:26
@heath-freenome

Copy link
Copy Markdown
Member

@heath-freenome Sorry, I should have included a playground example.

See the option values:

❌
<select id="root_status" name="root_status" role="combobox" class="form-control" aria-describedby="root_status__error root_status__description root_status__help">
  <option value=""></option>
  <option value="0">active</option>
  <option value="1">inactive</option>
  <option value="2">archived</option>
</select>

When submitting the traditional way (the whole PR I worked on), the submitted values are wrong.

✅
<select id="root_status" name="root_status" role="combobox" class="form-control" aria-describedby="root_status__error root_status__description root_status__help">
  <option value=""></option>
  <option value="active">active</option>
  <option value="inactive">inactive</option>
  <option value="archived">archived</option>
</select>

Let me know if you need more details.

But the onChange handler should do the conversion so that the onSubmit will get the proper data in the formData. Or are you trying to work with pure HTML and avoiding the onSubmit callback?

@nlemoine

nlemoine commented Mar 27, 2026

Copy link
Copy Markdown
Contributor Author

Or are you trying to work with pure HTML and avoiding the onSubmit callback?

All the work done in #4773 has been made to that only purpose: be able to submit a form using standard HTML. I know people forget about it now everything is JS based :)

In a classic form submission (no JS involved): no formData, no onSubmit, etc. Everything works pretty great right now. Except that one. Note that the current PR changes only affect nameGenerator path (e.g. no changes when no nameGenerator is undefined).

@heath-freenome

Copy link
Copy Markdown
Member

Or are you trying to work with pure HTML and avoiding the onSubmit callback?

All the work done in #4773 has been made to that only purpose: be able to submit a form using standard HTML. I know people forget about it now everything is JS based :)

In a classic form submission (no JS involved): no formData, no onSubmit, etc. Everything works pretty great right now. Except that one. Note that the current PR changes only affect nameGenerator path (e.g. no changes when no nameGenerator is undefined).

AHHH.... Got it. Let me take a closer look then :)

Comment thread packages/chakra-ui/src/SelectWidget/SelectWidget.tsx Outdated
Comment thread packages/chakra-ui/src/SelectWidget/SelectWidget.tsx Outdated
Comment thread packages/chakra-ui/src/SelectWidget/SelectWidget.tsx Outdated
Comment thread packages/core/src/components/widgets/SelectWidget.tsx Outdated
@nlemoine

nlemoine commented Mar 27, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for your feedback and comments @heath-freenome, I addressed them all. There's probably some room for DRYness here too. I'll handle all this in #5000

@x0k

x0k commented Mar 28, 2026

Copy link
Copy Markdown
Contributor

Mapping via String(value) introduces ambiguity between 123 and '123', and only supports primitive values.

If you use JSON.stringify, you will also need to handle parsing from FormData. In that case, it’s simpler to use the index and restore the selected value with structuredClone(options[index]).

Also, if someone already relies on the current behavior, this would be a breaking change.

@nlemoine nlemoine force-pushed the fix/select-enum-values branch from 676ba57 to b9bf68d Compare March 29, 2026 18:51
@heath-freenome

Copy link
Copy Markdown
Member

@nlemoine Given that the name generator works across all themes, I'm wondering whether it makes sense to expand this set of changes across them all. Also @x0k makes a few interesting points that I'm curious about your response to them

@nlemoine

nlemoine commented Mar 30, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the feedback @x0k, that's a good catch and I reworked the approach.

Right now, <option value> stores the array index, handlers read it back and resolve the real typed value through enumOptionsValueForIndex(). That works fine on the JS side, but is meaningless for native form submission and a bit unusual from a classic submission POV. That would mean resolving the index server side again.

I realized it not only affects <select> but every choices widget like radio, checkboxes, etc. that use enumOptionsValueForIndex() for value resolution. This is more related to web standards than nameGenerator itself, although they share the same purpose.

Rather than overloading value for both jobs, I split them:

  • value="real_value": the actual enum value as a string, so native form payload contains the actual value
  • data-index="0": stores the array index for internal resolution

Handlers read data-index and call enumOptionsValueForIndex() same as before. So:

  1. No String(value) ambiguity: resolution is still index-based
  2. No FormData parsing: the index lives in data-index, the real value lives where the browser expects it
  3. Not breaking: onChange/onBlur/onFocus return the same typed values as before

This restores value purpose instead of using it as an index store and defer index storage to data-index (which is fine reading from in JS context). Only changed core and react-bootstrap since they use native <select>. Other packages are untouched.

What do you think?

If you're ok with this, I can spread this approach to RadioWidget and CheckboxesWidget.

@x0k

x0k commented Mar 30, 2026

Copy link
Copy Markdown
Contributor

resolving the index server side

This is what I meant. With the current approach on the server:

  • You won’t be able to distinguish primitive values from their string representations (123 vs '123')
  • Arrays and objects will be sent as [object Object] (example)

If this approach is acceptable for your project, implement it as a custom widget.

In my opinion, to properly support using RJSF with native forms, it’s better to approach this differently - namely, implement FormData parsing based on the schema, taking into account form submission specifics (empty text fields are not sent, only checked checkboxes are submitted with value on, files are sent even for empty inputs, ...) and RJSF-specific behavior (indexes instead of real values in enum fields, presence of a selector value for oneOf/anyOf fields, ability to rename keys when using additionalProperties and patternProperties, ...).

@nlemoine

nlemoine commented Mar 30, 2026

Copy link
Copy Markdown
Contributor Author

For objects/arrays, native form submission relies on proper field naming conventions. That's what nameGenerator and #4773 are about. The structure is expressed through field names (location[name], location[lat]), not serialized into a single value attribute.

<input type="checkbox" name="pets[]" value="dog" />
<input type="checkbox" name="pets[]" value="cat" />

Will generate a PHP array:

$_POST['pets'] = ['cat', 'dog'];

For the 123 vs '123' distinction, this is how HTML forms have always worked. Every value hits the server as a string. The server parses based on the expected type. That's true for too, it sends "123", not the number 123.

The data-index approach in this PR keeps the JS-side typed resolution intact (no behavior change for existing RJSF users) while making the HTML output correct for standard form submission.

There's also an accessibility/UX angle. Browser autofill depends on <option value> being meaningful. A <select autocomplete="country"> with <option value="FR">France</option> can be autofilled. With France, the browser can't match anything. Same for honorifics, card types, languages, etc. Index-based values break autofill.

@x0k

x0k commented Mar 30, 2026

Copy link
Copy Markdown
Contributor

The example above with your changes - try modifying the Location and Locations values:

image

this is how HTML forms have always worked

This is not the issue; the loss of type information is caused by the encoding method (String(value)). As mentioned earlier, you could use JSON.stringify, which would allow distinguishing values ('123' vs '"123"' on the server), but this approach requires decoding and is not significantly different from handling index-based values.

no behavior change for existing RJSF users

This is a breaking change for those already using RJSF with native forms.

Index-based values break autofill

This makes sense; perhaps this behavior should be configurable (via UI Schema / Form options)?

@heath-freenome

heath-freenome commented Mar 31, 2026

Copy link
Copy Markdown
Member

I would hope that people who are using the nameGenerator aren't dealing with complex objects like @x0k has in his example. @nlemoine I mostly agree with the approach you are taking and hope that the code can be a DRY as possible (maybe a new utility method or 2 would be helpful?). I would also love to have this solution working across all themes and places where we did the index replacement (to solve the issues with typing). And I am intrigued by the suggestion of adding a Form/UiSchema prop that controls whether JSON.stringify/parse is used to render/select values in the components. Maybe an optional prop on the GlobalFormOptions that controls this?

@nlemoine

nlemoine commented Mar 31, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the clarification @x0k and the example. Sorry, I didn't spot the object enum right away.

The example above with your changes - try modifying the Location and Locations values

<option value> is a string attribute meant for short identifiers: country codes, honorific prefixes, rating values. Although not technically incorrect, It was never designed to hold serialized objects. If someone has object enums and needs native form submission, nameGenerator decomposes the structure into properly named fields, that's its purpose.

That said, you're right that the current index-based approach is at least harmless for object enums, while String(value) would produce "[object Object]". We could fall back to the index/stringyfied for non-primitive values to avoid that regression.

the loss of type information is caused by the encoding method (String(value))

Every HTML form input sends strings. <input type="number" value="123"> submits "123". Server frameworks parse it based on the expected type. JSON.stringify would work technically (123 vs '"123"'), but no server framework expects that encoding out of the box. String(value) matches how every other HTML form element behaves.

This is a breaking change for those already using RJSF with native forms.

Fair but honestly, very unlikely.

perhaps this behavior should be configurable

Open to that, though I wonder if it adds complexity for a niche case. The data-index attribute is always present, so anyone who needs index-based resolution can access it. Making real values the default aligns with how HTML forms work everywhere else.


To summarize:

  • Meaningless values (browser autocomplete, a11y, etc.) is a real problem, I think we all agree about this?
  • Changing value attribute is a breaking change for users that handle submission the native way
  • Behavior should be optin

Proposed alternative:

  • Something like useRealValuesInChoices (or whatever name) in GlobalFormOptions as proposed by @heath-freenome enable the native behavior
  • When enabled: primitive values are printed as srings instead of index, more complex ones are stringyfied
  • When enabled: typed values are still resolved through data-index, formData stays unchanged

What do you think?

(@heath-freenome, I'll refactor the logic, just need feedback and approval from both of you about the behavior)

@nlemoine nlemoine force-pushed the fix/select-enum-values branch from b9bf68d to ab86c06 Compare March 31, 2026 19:47
@x0k

x0k commented Mar 31, 2026

Copy link
Copy Markdown
Contributor

Implementing this as an opt-in feature (with the option to switch to opt-out in v7) is a good solution.

more complex ones are stringyfied

I don’t think this is a good idea, as it’s unclear how to handle this on the server. For an opt-in feature, String(value) is sufficient.

in GlobalFormOptions

I think using the UI schema would result in a more flexible solution, as it would allow controlling behavior both for individual fields and for the entire form (ui:globalOptions).

through data-index

This doesn’t seem like a universal solution. It’s easy to imagine UI libraries that don’t allow passing data attributes or fully handle option rendering themselves (e.g., <Select options={options} />). In my library, I introduced the following entity:

interface EnumValueMapper {
  fromValue: (value: JSONSchema7Type | undefined) => string;
  toValue: (value: string) => JSONSchema7Type | undefined;
}

Using it, you can create options with the desired encoding (indices, String(value), JSON.stringify, etc.) and retrieve the original value in onChange.

@heath-freenome

Copy link
Copy Markdown
Member

Implementing this as an opt-in feature (with the option to switch to opt-out in v7) is a good solution.

more complex ones are stringyfied

I don’t think this is a good idea, as it’s unclear how to handle this on the server. For an opt-in feature, String(value) is sufficient.

in GlobalFormOptions

I think using the UI schema would result in a more flexible solution, as it would allow controlling behavior both for individual fields and for the entire form (ui:globalOptions).

through data-index

This doesn’t seem like a universal solution. It’s easy to imagine UI libraries that don’t allow passing data attributes or fully handle option rendering themselves (e.g., <Select options={options} />). In my library, I introduced the following entity:

interface EnumValueMapper {
  fromValue: (value: JSONSchema7Type | undefined) => string;
  toValue: (value: string) => JSONSchema7Type | undefined;
}

Using it, you can create options with the desired encoding (indices, String(value), JSON.stringify, etc.) and retrieve the original value in onChange.

@x0k Can you point to how your library is defining and using it by providing links for us to checkout?

@x0k

x0k commented Apr 1, 2026

Copy link
Copy Markdown
Contributor

@nlemoine

nlemoine commented Apr 2, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the links @x0k, very helpful to see how you approached this in your Svelte library.

Our current implementation follows a very similar pattern:

  • enumOptionValueEncoder(value, index, useRealValues) → maps typed values to strings for DOM attributes (equivalent to your fromValue)
  • enumOptionValueDecoder(value, enumOptions, useRealValues, emptyValue) → reverse lookup from string back to typed value (equivalent to your toValue)

Both are standalone utility functions in @rjsf/utils, used across all 10 theme packages in SelectWidget, RadioWidget, and CheckboxesWidget.

Configuration is via ui:globalOptions as you suggested, works both per-field and form-wide:

// Form-wide
uiSchema: {
  "ui:globalOptions": { useRealOptionValues: true }
}

// Per-field
uiSchema: {
  myField: { "ui:options": { useRealOptionValues: true } }
}

When disabled (default): index-based encoding, fully backward compatible.
When enabled: String(value) for primitives, falls back to index for objects/arrays.

Decoder does a reverse lookup by matching String(option.value) against the DOM value, with index fallback. No data-index dependency, the resolution is self-contained in the decoder, so it works with any UI library regardless of attribute support.

All existing tests pass across all packages. Will push the updated branch shortly.

@nlemoine nlemoine force-pushed the fix/select-enum-values branch from 1ab5c5c to 8a331f9 Compare April 2, 2026 18:36
@nlemoine

nlemoine commented Apr 2, 2026

Copy link
Copy Markdown
Contributor Author

Branch updated, I'll let you check it out and give me your feedback!

@heath-freenome heath-freenome left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@nlemoine Great work in responding to the feedback and improving your code. I've made note of a few more places where you can DRY up repetitive code. Also some of the snapshots seems like things were incorrectly changed. Plus your tests are failing in several themes.

Comment thread CHANGELOG.md Outdated
Comment thread packages/utils/src/enumOptionValueDecoder.ts Outdated
Comment thread packages/utils/src/enumOptionValueEncoder.ts Outdated
Comment thread packages/utils/test/enumOptionValueDecoder.test.ts Outdated
Comment thread packages/utils/test/enumOptionValueEncoder.test.ts Outdated
Comment thread packages/daisyui/src/widgets/SelectWidget/SelectWidget.tsx Outdated
Comment thread packages/core/src/components/widgets/SelectWidget.tsx Outdated
Comment thread packages/chakra-ui/test/__snapshots__/Grid.test.tsx.snap
Comment thread packages/chakra-ui/src/SelectWidget/SelectWidget.tsx Outdated
Comment thread packages/mui/test/__snapshots__/Form.test.tsx.snap Outdated
Comment thread packages/core/src/components/widgets/SelectWidget.tsx Outdated
@nlemoine nlemoine force-pushed the fix/select-enum-values branch 2 times, most recently from 95abee6 to d95118c Compare April 3, 2026 05:56
@nlemoine

nlemoine commented Apr 3, 2026

Copy link
Copy Markdown
Contributor Author

This should be good now, CI is green.

@heath-freenome heath-freenome left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@nlemoine Great work here. Thanks for being patient with all the suggested changes. I have a few more suggestions to help maintainability.

Also, it also looks like you skipped over converting the antd theme?

Comment thread packages/utils/src/types.ts Outdated
Comment thread packages/utils/src/enumOptionValueEncoder.ts Outdated
Comment thread packages/utils/src/enumOptionValueDecoder.ts Outdated
Comment thread packages/utils/src/enumOptionSelectedValue.ts Outdated
Comment thread packages/chakra-ui/src/CheckboxesWidget/CheckboxesWidget.tsx Outdated
Comment thread packages/chakra-ui/src/RadioWidget/RadioWidget.tsx Outdated
Comment thread packages/chakra-ui/src/SelectWidget/SelectWidget.tsx Outdated
Comment thread packages/daisyui/src/widgets/SelectWidget/SelectWidget.tsx
Comment thread packages/mantine/src/widgets/SelectWidget.tsx Outdated
Comment thread packages/mui/src/RadioWidget/RadioWidget.tsx Outdated
nlemoine and others added 11 commits April 11, 2026 09:34
…ndex for typed resolution

Replace index-based <option value="0"> with real enum values <option value="real_value"> so that native form submission sends meaningful values. Add data-index attribute to preserve typed value resolution via enumOptionsValueForIndex in JS handlers.

Scope limited to core and react-bootstrap (native <select> packages). Custom component packages (chakra, mui, mantine, etc.) are unchanged as they don't participate in native form submission.
Add enumOptionValueEncoder/Decoder utilities to @rjsf/utils that allow rendering real enum values in option attributes instead of array indices. Controlled via ui:globalOptions or per-field ui:options.

When enabled, primitives use String(value) for DOM attributes with reverse lookup decoding. Objects/arrays fall back to index encoding. Default behavior (index-based) is fully preserved.

Applied across SelectWidget, RadioWidget, and CheckboxesWidget in all 10 theme packages.
Co-authored-by: Heath C <51679588+heath-freenome@users.noreply.github.com>
Co-authored-by: Heath C <51679588+heath-freenome@users.noreply.github.com>
Co-authored-by: Heath C <51679588+heath-freenome@users.noreply.github.com>
@nlemoine nlemoine force-pushed the fix/select-enum-values branch from 23dfb16 to 9b31c5a Compare April 11, 2026 07:49
Replace the boolean flag with a 'indexed' | 'realValue'  union on GlobalUISchemaOptions for consistency with other option-style APIs in RJSF. Add an OptionValueFormat type and update the encoder, decoder, and selectedValue utilities to take a format parameter that defaults to 'indexed'.
@nlemoine

nlemoine commented Apr 11, 2026

Copy link
Copy Markdown
Contributor Author

#5004 (comment)

Done. Both paths go through one enumOptionSelectedValue call now. While I was in there I noticed the old indexed path was building {label, value} objects in getMultiValue/getSingleValue and then throwing the labels away on the next line with .map(item => item.value), so valueLabelMap in the useMemo wasn't actually used anywhere. I dropped that too. The formValue block and the helpers it fed went from about 28 lines down to 5, and the tests still pass without any snapshot updates.

Replace the `Component = multiple ? MultiSelect : Select` pattern with two explicit JSX branches sharing a common props object. Each branch passes its own narrow value type (`string[]` for MultiSelect, `string | null` for Select), dropping both the `as any` cast and the `multiple ? [] : null` conditional on enumOptionSelectedValue's emptyValue argument.
@nlemoine

Copy link
Copy Markdown
Contributor Author

@heath-freenome I addressed all comments from your review. With slightly different approaches on some of them. I posted the details to show the reasoning behind decisions.

Supporting so much UI libs, with all their distinctive implementation details, is quite a challenge 😅

Let me know if you spot something in the updated code.

@heath-freenome heath-freenome left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@nlemoine Nice work! I have a couple of questions/suggestions for you. Plus there are conflicts in the CHANGELOG.md again (sorry, merging other fixes).

<Select
{...sharedProps}
value={enumOptionSelectedValue<S>(value, enumOptions, false, optionValueFormat, null) as string | null}
onChange={!readonly ? handleChange : undefined}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I'm curious if there was a reason the onChange() handler is not in the sharedProps?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yeah, no real reason. I just missed it when I split the JSX. onChange has the same !readonly ? handleChange : undefined gate as onBlur and onFocus, so it belongs in sharedProps with them. Moved it in.

Comment thread packages/semantic-ui/src/SelectWidget/SelectWidget.tsx Outdated
Comment thread packages/utils/src/enumOptionValueDecoder.ts
@heath-freenome

Copy link
Copy Markdown
Member

@nlemoine Also, a new conflict in the MUI select widget due to another PR merging

@heath-freenome

Copy link
Copy Markdown
Member

@nlemoine Just a few conflict resolutions and a small change and this PR is good to go. How can I help?

@nlemoine

Copy link
Copy Markdown
Contributor Author

@heath-freenome Should hopefully be all good now :)

I'll be off next week and won't be able to make any more changes until April 27 at best. Feel free to take over if changes are needed and the PR blocks next release.

@heath-freenome heath-freenome merged commit e744e73 into rjsf-team:main Apr 17, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants