Skip to content

fix(select): make renderValue and placeholder work together#413

Merged
mattrothenberg merged 12 commits intomainfrom
fix/select-strictnullchecks-inference
Apr 15, 2026
Merged

fix(select): make renderValue and placeholder work together#413
mattrothenberg merged 12 commits intomainfrom
fix/select-strictnullchecks-inference

Conversation

@mattrothenberg
Copy link
Copy Markdown
Collaborator

@mattrothenberg mattrothenberg commented Apr 15, 2026

Summary

This PR fixes two issues with the Select component:

  1. TypeScript inference bug - Under strictNullChecks, using value={objectOrNull} would cause T to be inferred as never, making callback props like renderValue and onValueChange unusable.

  2. placeholder ignored when using renderValue - Base UI's design means the placeholder prop is completely ignored when you provide a children-as-function (which renderValue maps to). Users had to handle the null case inside renderValue, defeating the purpose of placeholder.

The Fix

Kumo now wraps the renderValue function internally to:

  • Show placeholder (with proper styling) when value is null
  • Only call user's renderValue with non-null values
  • If no placeholder is provided and value is null, return null (show nothing)
// Recommended pattern
<Select
  placeholder="Select a user..."
  value={user}
  renderValue={(user) => user.name}  // Only called when user is not null
/>

Changes

packages/kumo/src/components/select/select.tsx

  • Wrap renderValue to handle null internally, showing placeholder with proper styling
  • When no placeholder provided and value is null, return null (no empty span)
  • Simplify valueChildrenFn logic

packages/kumo-docs-astro/

  • Update demos to use placeholder + renderValue pattern
  • Add comprehensive stress test page at /tests/select with 12 scenarios
  • Update documentation

packages/kumo/tests/

  • Add TypeScript type tests with strictNullChecks and strictFunctionTypes enabled
  • Verify type inference works correctly for all use cases (including without explicit generic)

Stress Test Page

Visit /tests/select to see all API permutations:

  1. strictNullChecks with object values
  2. renderValue + placeholder interaction
  3. items array prop
  4. items object map with disabled options
  5. Multiple selection
  6. Controlled vs uncontrolled
  7. Groups and separators
  8. Field integration (label, error, description)
  9. Size variants
  10. Loading state
  11. Disabled options
  12. Type inference without explicit generic

Testing

  • TypeScript compiles in kumo
  • TypeScript compiles with stratus-equivalent strict settings
  • Type tests pass without explicit <Select<T>> generic syntax
  • Verified placeholder shows when value is null
  • Verified renderValue is only called with non-null values
  • Verified no empty span when placeholder is not provided
  • All 12 stress test scenarios work correctly

  • Reviews
  • bonk has reviewed the change
  • automated review not possible because: this is a behavioral fix that requires human judgment on the API design
  • Tests
  • Tests included/updated
  • Additional testing not necessary because: type tests and stress test page added

mattrothenberg and others added 2 commits April 14, 2026 22:48
## Problem

When using Select with object values and `strictNullChecks` enabled in
TypeScript, there were two issues:

1. **Type inference bug**: `T` would collapse to `never` due to
   covariant/contravariant conflicts between the `value` prop and
   callback props like `renderValue`.

2. **API confusion**: Base UI's `placeholder` prop is ignored when you
   provide a children-as-function (which `renderValue` maps to). This
   meant users had to handle the null case inside `renderValue`, making
   `placeholder` useless when customizing value display.

## Solution

Kumo now wraps the `renderValue` function internally:
- When value is `null`, we render the `placeholder` with appropriate styling
- When value is set, we call `renderValue` with the non-null value
- The type signature remains `(value: T) => ReactNode` (non-null)

This means `placeholder` and `renderValue` work together as users expect:

```tsx
<Select
  placeholder="Select a user..."
  value={user}
  renderValue={(user) => user.name}  // Only called when user is not null
/>
```

## Changes

### packages/kumo/src/components/select/select.tsx
- Add JSDoc explaining `renderValue` and `placeholder` interaction
- Wrap `renderValue` to handle null internally, showing placeholder

### packages/kumo-docs-astro/src/components/demos/SelectDemo.tsx
- Update SelectComplexDemo to use `placeholder` + `renderValue` pattern
- Update SelectDisabledOptionsDemo to use object values with `{ value, label }`
- Update SelectGroupedDemo to use object values
- Update SelectGroupedWithDisabledDemo to use object values
- Update SelectLongListDemo to use object values
- All demos now show the label (not raw value) when an item is selected

### packages/kumo-docs-astro/src/pages/components/select.mdx
- Update Placeholder section to explain `renderValue` interaction
- Update Custom Rendering section with clearer guidance
- Update Multiple Selection section with code example
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Apr 15, 2026

npm i https://pkg.pr.new/@cloudflare/kumo@413

commit: def1e4e

@mattrothenberg
Copy link
Copy Markdown
Collaborator Author

/bonk I need a thorough review of this one.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 15, 2026

Docs Preview

View docs preview

Commit: 53a8877

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 15, 2026

Visual Regression Report — 18 changed, 19 unchanged

18 screenshot(s) with visual changes:

Button / Variant: Primary

370 px (0.36%) changed

Before After Diff
Before After Diff

Button / Loading State

4 px (0%) changed

Before After Diff
Before After Diff

Dialog / Dialog With Actions

138 px (0.14%) changed

Before After Diff
Before After Diff

Dialog / Dialog Basic

197 px (0.19%) changed

Before After Diff
Before After Diff

Dialog / Dialog Confirmation

352 px (0.35%) changed

Before After Diff
Before After Diff

Dialog / Dialog With Select

225 px (0.22%) changed

Before After Diff
Before After Diff

Dialog (Open)

0 px (0%) changed

Before After Diff
Before After Diff

Select / Select Sizes

947 px (0.51%) changed

Before After Diff
Before After Diff

Select / Select Placeholder

418 px (0.41%) changed

Before After Diff
Before After Diff

Select / Select With Tooltip

500 px (0.49%) changed

Before After Diff
Before After Diff

Select / Select Custom Rendering

414 px (0.41%) changed

Before After Diff
Before After Diff

Select / Select Loading

0 px (0%) changed

Before After Diff
Before After Diff

Select / Select Multiple

0 px (0%) changed

Before After Diff
Before After Diff

Select / Select Complex

1,090 px (0.92%) changed

Before After Diff
Before After Diff

Select / Select Disabled Options

0 px (0%) changed

Before After Diff
Before After Diff

Select / Select Grouped

194 px (0.19%) changed

Before After Diff
Before After Diff

Select / Select Long List

1,428 px (1.21%) changed

Before After Diff
Before After Diff

Select (Open)

1,461,715 px (5.89%) changed

Before After Diff
Before After Diff
19 screenshot(s) unchanged
  • Button / Basic
  • Button / Variant: Secondary
  • Button / Variant: Ghost
  • Button / Variant: Destructive
  • Button / Variant: Outline
  • Button / Variant: Secondary Destructive
  • Button / Sizes
  • Button / With Icon
  • Button / Icon Only
  • Button / Disabled State
  • Button / Title
  • Dialog / Dialog Alert
  • Dialog / Dialog With Combobox
  • Dialog / Dialog With Dropdown
  • Select / Select Basic
  • Select / Select Without Label
  • Select / Select With Field
  • Select / Select Disabled Items
  • Select / Select Grouped With Disabled

Generated by Kumo Visual Regression

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk bot commented Apr 15, 2026

UnknownError: ProviderInitError

github run

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk bot commented Apr 15, 2026

@geoquant Bonk workflow failed. Check the logs for details.

View workflow run · To retry, trigger Bonk again.

mattrothenberg and others added 4 commits April 15, 2026 11:10
…tests

- Remove Select.Value compound component (renderValue prop covers all use cases)
- Simplify valueChildrenFn logic (extract to variable for clarity)
- Add stress test page at /tests/select with 12 test scenarios
- Add TypeScript type tests with strictNullChecks enabled
- All tests verify renderValue/placeholder interaction works correctly
@geoquant
Copy link
Copy Markdown
Collaborator

/bonk review

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk bot commented Apr 15, 2026

UnknownError: ProviderInitError

github run

@mattrothenberg
Copy link
Copy Markdown
Collaborator Author

/bigbonk

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

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

Review: fix(select): make renderValue and placeholder work together

Good fix for a real pain point. The core logic change is small and well-targeted. A few issues to address:

Issues

1. as never cast is a code smell

At select.tsx:409:

return renderValue(value as never);

never is assignable to anything, so this suppresses all type checking. The value flowing in is unknown (from Base UI's children callback), and renderValue expects Multiple extends true ? T[] : T. A safer cast would be:

return renderValue(value as Multiple extends true ? T[] : T);

Or since the generic isn't available at that scope, at minimum use as any which is more conventional for deliberate type boundary crossings. as never reads like an accident.

2. renderValue + no placeholder renders an empty span

When renderValue is provided but placeholder is not, and the value is null, line 407 renders:

<span className="text-kumo-placeholder">{undefined}</span>

This produces an empty <span> in the DOM where Base UI's native placeholder handling would have shown nothing. Consider falling back to Base UI's default behavior when no placeholder is specified:

if (value == null) {
  if (placeholder != null) {
    return <span className="text-kumo-placeholder">{placeholder}</span>;
  }
  return null; // Let Base UI handle it
}

Though returning null from the children function still bypasses Base UI's placeholder logic (since the function was called). You may need to return undefined or test what Base UI does when the function returns null. Worth verifying.

3. PR description claims "Remove Select.Value compound component" but it wasn't removed

The PR description says "Remove Select.Value compound component (renderValue prop covers all use cases)" but Select.Value was never a public compound component — it's Base UI's internal SelectBase.Value. The description is misleading here and should be corrected.

Type Tests

The type test file (select-strict-types.test-d.tsx) is well structured. One note:

At line 109, TestRenderValueNonNull uses implicit generic inference:

<Select
  value={country}
  onValueChange={setCountry}
  placeholder="Select..."
  renderValue={(c) => { ... }}
>

This is the scenario that originally broke. Can you confirm this actually compiles under the strict tsconfig? The onValueChange={setCountry} line is the one that would cause T to infer as never in the original bug — setCountry accepts { code: string; name: string } | null but renderValue expects T (not T | null). If the fix is purely at the runtime level (wrapping the function), the original TS inference issue with strictNullChecks may still exist for users who don't use explicit <Select<T>> syntax.

I'd want to see this confirmed: does TestRenderValueNonNull pass under tsconfig.strict.json without the explicit <Select<Country>> generic?

Stress Test Page

The stress test page at /tests/select is thorough — 12 scenarios cover the major API surface. Nice addition for manual verification.

Minor

  • The changeset is well-written and documents the behavioral contract clearly.
  • The renderedChildren refactor from ?? to ternary is fine but is a no-op change (same semantics). Not a problem, just noting it's cosmetic.

Comment thread packages/kumo/src/components/select/select.tsx Outdated
Comment thread packages/kumo/src/components/select/select.tsx
Co-authored-by: mattrothenberg <mattrothenberg@users.noreply.github.com>
@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk bot commented Apr 15, 2026

The review has been posted. Here's a summary of my review:

Review Summary for PR #413

What the PR does

Fixes two issues with the Select component:

  1. A TypeScript inference bug where value={objectOrNull} under strictNullChecks caused T to infer as never
  2. placeholder being ignored when renderValue is provided (because Base UI bypasses placeholder when children-as-function is used)

The fix wraps renderValue internally to intercept null values and show the placeholder, only calling the user's renderValue with non-null values.

Issues raised

  1. as never cast (select.tsx:409) - This is a stronger type suppression than needed. never is assignable to everything, killing type safety at this boundary. Should use as any or a proper type alias instead.

  2. Empty span when no placeholder (select.tsx:407) - When renderValue is provided without placeholder and value is null, an empty <span className="text-kumo-placeholder"> is rendered. Should guard against placeholder being undefined.

  3. Misleading PR description - Claims to "Remove Select.Value compound component" but Select.Value was never a public compound component (it's Base UI's internal).

  4. Type inference question - The TestRenderValueNonNull test uses implicit generic inference (no explicit <Select<T>>). Since the fix appears to be runtime-only (wrapping the function), the original TS inference issue may still exist without explicit generics. Requested confirmation.

github run

- Change 'as never' to 'as any' with comment explaining type boundary
- Return null instead of empty span when no placeholder provided
- Update PR description (removed incorrect claim about Select.Value)
Confirms that WITHOUT explicit <Select<T>> generic:
- onValueChange receives T | null
- renderValue receives T (non-null)
- isItemEqualToValue receives correct types

These replicate the exact pattern that broke in stratus.
@mattrothenberg
Copy link
Copy Markdown
Collaborator Author

/bigbonk OK I made the changes you requested

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

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

Review: fix(select): make renderValue and placeholder work together

I've reviewed the full PR including the core fix, type tests, stress test page, demos, and documentation. I also verified the type tests compile against the strict tsconfig.

Core Fix Assessment

The fix is well-targeted. The approach of wrapping renderValue to intercept null values is the right call given Base UI's design — their SelectValue explicitly documents that placeholder is "overridden by children if specified" (see SelectValue.d.ts:34). There's no way around this without wrapping.

The implementation at select.tsx:404-416 is clean:

  • placeholder == null → return null (no empty DOM node)
  • placeholder != null → render placeholder span with text-kumo-placeholder
  • Non-null value → delegate to user's renderValue

Bonk's Issues — Status

Both code-level issues from bonk's review have been addressed in commit 265b516:

  1. as neveras any: Fixed, with a comment explaining the deliberate type boundary. This is the right cast — as any is the conventional escape hatch for intentional generic boundaries, and the comment makes it clear this isn't accidental.
  2. Empty span when no placeholder: Fixed by adding the placeholder == null guard that returns null. No empty <span> will render.

Type Tests

I ran tsc --project packages/kumo/tests/tsconfig.strict.json --noEmit and confirmed zero type errors (excluding the known ai/schemas stub that's auto-generated at build time).

The critical test is stratus-scenario.test-d.tsx — it replicates the exact pattern that broke in production, without explicit <Select<T>> generics:

const [tunnel, setTunnel] = useState<Tunnel | null>(null);
<Select
  value={tunnel}
  onValueChange={setTunnel}
  renderValue={(t) => t.name}  // T inferred correctly, not never
>

This compiles cleanly under strictNullChecks + strictFunctionTypes. The inference works because renderValue's type is (value: Multiple extends true ? T[] : T) => ReactNode — when T is Tunnel, renderValue expects (value: Tunnel) => ReactNode, which doesn't conflict with onValueChange's (value: Tunnel | null) => void. The | null only appears on value and onValueChange, not on renderValue.

One Observation

The renderedChildren refactor from ?? to ternary (select.tsx:395-399) is semantically equivalent — ?? would have the same behavior here since children is ReactNode and 0/"" are valid ReactNode values that shouldn't fall through. The ternary is arguably more readable since it makes the three-way branch explicit, so this is fine.

Stress Test Page

The /tests/select page with 12 scenarios is a good addition for manual regression testing. Each scenario isolates a specific API interaction (strictNullChecks, renderValue+placeholder, items array, object map, multiple, controlled/uncontrolled, groups, field integration, sizes, loading, disabled, inference).

Summary

Approve the direction. The two bonk issues have been addressed. The type tests confirm the inference fix works under strict mode without explicit generics. The runtime wrapping correctly handles the Base UI placeholder limitation.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk bot commented Apr 15, 2026

Review posted on PR #413. Here's a summary of my findings:

The fix is solid. The core change wraps renderValue to intercept null values before they reach the user's callback, working around Base UI's documented behavior where placeholder is ignored when a children function is provided.

Both bonk review issues have been addressed in commit 265b516:

  • as never changed to as any with explanatory comment
  • Empty <span> when no placeholder fixed — now returns null

Type tests pass under strictNullChecks + strictFunctionTypes. I confirmed this by running tsc against the strict tsconfig. The critical stratus-scenario.test-d.tsx test compiles without explicit <Select<T>> generics, proving the inference fix works for the exact pattern that broke in production.

github run

@mattrothenberg mattrothenberg merged commit 98e3170 into main Apr 15, 2026
15 checks passed
@mattrothenberg mattrothenberg deleted the fix/select-strictnullchecks-inference branch April 15, 2026 18:26
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.

2 participants