Skip to content

[select] SelectValue placeholder not rendered when items is a Record without a "null" key #4136

@vcode-sh

Description

@vcode-sh

Bug description

<Select.Value placeholder="..." /> renders an empty <span> when the items prop on <Select.Root> is a Record<string, ReactNode> that doesn't contain a "null" key. The placeholder text is completely absent from the DOM.

Steps to reproduce

const items = {
  sans: 'Sans-serif',
  serif: 'Serif',
  mono: 'Monospace',
};

<Select.Root items={items}>
  <Select.Trigger>
    <Select.Value placeholder="Choose a font..." />
  </Select.Trigger>
  <Select.Portal>
    <Select.Positioner>
      <Select.Popup>
        <Select.List>
          {Object.entries(items).map(([value, label]) => (
            <Select.Item key={value} value={value}>
              <Select.ItemText>{label}</Select.ItemText>
            </Select.Item>
          ))}
        </Select.List>
      </Select.Popup>
    </Select.Positioner>
  </Select.Portal>
</Select.Root>

Expected: The trigger shows "Choose a font..." as placeholder text.
Actual: The trigger renders an empty <span>.

Root cause

The logic in hasNullItemLabel() in packages/react/src/utils/resolveValueLabel.tsx is inverted for the Record branch:

export function hasNullItemLabel(items: ItemsInput): boolean {
  if (!Array.isArray(items)) {
    return items != null && !('null' in items);  // ← bug: `!` inverts the check
  }
  // ...
}

For a Record like { sans: "Sans-serif", serif: "Serif" }:

  • 'null' in itemsfalse (no "null" key)
  • !('null' in items)true
  • Function returns true, meaning "the null value has a label"

This causes SelectValue to skip the placeholder (line 52):

} else if (!hasSelectedValue && placeholder != null && !hasNullLabel) {
  children = placeholder;
}

Since hasNullLabel is true, the condition is false, and the placeholder is never rendered.

The fix should be removing the !:

return items != null && ('null' in items);

The existing tests in resolveValueLabel.test.ts only cover grouped array items — no test covers the Record branch.

Workaround

Use array format for items instead of Record:

const items = [
  { value: 'sans', label: 'Sans-serif' },
  { value: 'serif', label: 'Serif' },
  { value: 'mono', label: 'Monospace' },
];

The array path in hasNullItemLabel has correct logic (item.value == null check).

Environment

Metadata

Metadata

Assignees

No one assigned

    Labels

    component: selectChanges related to the select component.type: bugIt doesn't behave as expected.
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions