Skip to content

Conversation

@TkDodo
Copy link
Collaborator

@TkDodo TkDodo commented Nov 20, 2025

This PR does a couple of things (it got away from me a bit), but they all belong together:

  • remove onClear callback of compactSelect
    instead, we are “just” firing an onChange event with undefined for single selection or [] for multi selection.
  • the types for compactSelect were adjusted accordingly (+ type tests): if you pass multiple: true, you’ll always get an array into onChange. But for single selects, we have another distributed union now so that you get SelectOption | undefined if clearable is true and SelectOption if clearable is false.
  • remove the disallowEmptySelection prop
    this is a feature react-aria uses internally on collections; if set to false, it means you can click the current selection you have for single selections to get back to “no value”. This doesn’t make any sense if clearable is false, because it’s just another way to clear values.
    I have now coupled this value to the clearable prop to get consistent behavior: If clearable is set, you can also click the current selection to “unselect” it. If you’re not clearable, you can’t do this. For multiple, nothing changes.
  • streamline null and undefined when a value gets cleared
    it’s now consistently undefined, because we also have value={undefined} and it’s easier to instantiate useState with undefined. Note that we used to send null when clearing. If we want null here, I would also disallow undefined for value and do value={null} instead. I don’t really care and typescript should guard us in either way, unless we have a lot of any.

Technical Note: Omit became a problem because Omit on discriminated unions flattens the union and ruins the distribution. I’ve had to use DistributiveOmit a bunch of times. I think it mostly shows that building types based on “I want all the types of this underlying thing except those 5” is really messy. The type hierarchy is very hard to understand. We sometimes even omitted things that weren’t any longer part of the union because they got removed. TypeScript doesn’t report on this. I also had to convert a couple of interfaces to types to be able to use DistributiveOmit.

Also: I had to update some tests that weren’t really managing any state and relied on the state being handled internally. This is not what happens in the product, because each instance manages state (as it should)

@github-actions github-actions bot added the Scope: Frontend Automatically applied to PRs that change frontend components label Nov 20, 2025
@codecov
Copy link

codecov bot commented Nov 20, 2025

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@           Coverage Diff            @@
##           master   #103720   +/-   ##
========================================
  Coverage   80.62%    80.62%           
========================================
  Files        9281      9281           
  Lines      396217    396190   -27     
  Branches    25252     25246    -6     
========================================
- Hits       319442    319424   -18     
+ Misses      76315     76306    -9     
  Partials      460       460           

since both navigate and localStorage are mocked, and the internal state of the component was removed, we no longer see a real update reflected in the test; this works fine in the product
if you mock `onChange`, the label doesn't magically update anymore
@TkDodo TkDodo marked this pull request as ready for review November 20, 2025 16:08
@TkDodo TkDodo requested review from a team as code owners November 20, 2025 16:08
Comment on lines -150 to -165
const listProps = useMemo(() => {
if (multiple) {
return {
multiple,
value,
closeOnSelect,
onChange,
};
}
return {
multiple,
value,
closeOnSelect,
onChange,
};
}, [multiple, value, onChange, closeOnSelect]);
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

note: we can just keep everything in props and forward it as-is to List

const wrapperRef = useRef<HTMLDivElement>(null);
// Set up list states (in composite selects, each region has its own state, that way
// selection values are contained within each region).
const [listStates, setListStates] = useState<Array<ListState<any>>>([]);
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

this was only used for clear, which now works by triggering onChange with an empty value (undefined for single select and [] for multi select)

// react-aria turns all keys into strings
selectedKeys: defined(value) ? [getEscapedKey(value)] : undefined,
disallowEmptySelection: disallowEmptySelection ?? true,
selectedKeys: defined(value) ? [getEscapedKey(value)] : [],
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

note: this was a crucial fix - using [] for selctedKeys instead of undefined. Somehow, react-aria is not “reactive” when it sees undefined as value, I think it switches to an uncontrolled state.

since we want to be fully controlled, we never pass undefined to it.

It might make sense to switch to null for that reason, I haven’t tested if that would make it better, but since selectedKeys wants an array in any case, an empty array will always do what we want.

Copy link
Member

Choose a reason for hiding this comment

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

Makes sense—would you mind adding this as an inline comment so we don't accidentally regress? Seems like an easy mistake to make.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

good idea: 3b7a44c

clearable
disallowEmptySelection={false}
menuTitle={t('Filter by category')}
onClear={() => setSelectedCategory(undefined)}
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

onChange does this automatically now

Copy link
Member

@natemoo-re natemoo-re left a comment

Choose a reason for hiding this comment

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

Amazing cleanup, nice work! Just a few small suggestions for clarity but won't block.

// react-aria turns all keys into strings
selectedKeys: defined(value) ? [getEscapedKey(value)] : undefined,
disallowEmptySelection: disallowEmptySelection ?? true,
selectedKeys: defined(value) ? [getEscapedKey(value)] : [],
Copy link
Member

Choose a reason for hiding this comment

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

Makes sense—would you mind adding this as an inline comment so we don't accidentally regress? Seems like an easy mistake to make.

Copy link
Contributor

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

Bug: Optional template selector cannot be cleared

The disallowEmptySelection={false} prop was removed, but clearable was not added to this optional selector. This causes disallowEmptySelection to default to true, effectively preventing users from deselecting the template by clicking it, with no clear button available as a fallback.

static/app/views/detectors/components/forms/metric/templateSection.tsx#L119-L123

<Heading as="h3">{t('Choose Your Metric')}</Heading>
<CompactSelect
options={templateOptions}
value={currentTemplateValue}
trigger={(triggerProps, isOpen) => {

Fix in Cursor Fix in Web


Bug: Optional template selector cannot be cleared

The disallowEmptySelection={false} prop was removed, but clearable was not added to this optional selector. Since disallowEmptySelection now defaults to !clearable (true), users can no longer deselect the template by clicking it, and no clear button is rendered as a fallback.

static/app/views/detectors/components/forms/metric/templateSection.tsx#L119-L123

<Heading as="h3">{t('Choose Your Metric')}</Heading>
<CompactSelect
options={templateOptions}
value={currentTemplateValue}
trigger={(triggerProps, isOpen) => {

Fix in Cursor Fix in Web


@TkDodo
Copy link
Collaborator Author

TkDodo commented Nov 21, 2025

Bug: Optional template selector cannot be cleared

The disallowEmptySelection={false} prop was removed, but clearable was not added to this optional selector. This causes disallowEmptySelection to default to true, effectively preventing users from deselecting the template by clicking it, with no clear button available as a fallback.

static/app/views/detectors/components/forms/metric/templateSection.tsx#L119-L123

@scttcper I think the bot is right, but I double checked and I’m not sure why disallowEmptySelection={false} is here in the first place, because it doesn’t do anything at runtime. What it used to do is call the onChange handler with null, but there’s an early bail-out built in:

onChange={option => {
if (!option) {
return;
}

Since the types are now better, this can actually be removed because without clearable:true, we never call onChange with a falsy value: cf7e9e2

I’ve compared the behavior on master and on this PR and the select behaves the same, which means: you can’t clear the value. I guess this makes sense because you need to Choose Your Metric. I’m just wondering why disallowEmptySelection was put there to begin with 🤔

Copy link
Contributor

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

Bug: Removal of empty selection support in template selector

disallowEmptySelection={false} was removed, but clearable was not added. This causes the component to default to disallowEmptySelection=true, making it impossible for users to clear the template selection despite the field being labeled as "optional".

static/app/views/detectors/components/forms/metric/templateSection.tsx#L121-L122

options={templateOptions}
value={currentTemplateValue}

Fix in Cursor Fix in Web


@TkDodo TkDodo merged commit 98b67ef into master Nov 21, 2025
48 checks passed
@TkDodo TkDodo deleted the tkdodo/ref/compactSelect-onClear branch November 21, 2025 13:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Scope: Frontend Automatically applied to PRs that change frontend components

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants