Skip to content
16 changes: 16 additions & 0 deletions packages/components/src/components/SelectNext/Select.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,22 @@ with `defaultInputValue`, and the `onInputChange` callback is called whenever th

<Story of={Stories.Searchable} />

### Dependencies

The collection caches a rendered item by the identity of its data object, so an item is not
re-rendered when a value used inside the render function changes. List such values in
`dependencies` to invalidate that cache — for example the search query when the options

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The search query is the one dependency the component already owns

Both new docs sections use the search query as the motivating — and only — example. But SelectInner holds inputValue itself via useControlledState, and TreeSelectInner does the same; the collection is built one level above, in SelectRender / TreeSelectRender, with <Collection {...props} />.

So for the canonical case the API asks the consumer to hand back a value the component already has, and anyone who combines isSearchable with per-item highlighting and forgets dependencies={[inputValue]} gets silently stale, unhighlighted options.

Worth considering lifting the search value so the root Collection gets dependencies={[...(props.dependencies ?? []), searchValue]} whenever isSearchable is set. That makes the common case correct by construction and leaves dependencies for genuinely external values — which is what the prop is actually for.

Not blocking; the prop as specified is still useful either way.

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.

Keeping it explicit. Filtering doesn't rebuild the collection today. An automatic dependency would re-render every item on each keystroke in every searchable Select, including ones that don't highlight. And a consumer who highlights already holds the query for Highlight, so it's one extra dependencies={[query]}.

highlight it.

Keep the length of the array the same between renders, otherwise the items are not
re-rendered. To depend on a list of values, wrap it: `dependencies={[filters]}`.

The same applies to `Select.Section`: it inherits `dependencies` from the Select, so the

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This paragraph has no example behind it

Stories.Dependencies, rendered three lines below, contains no sections — so the inheritance this promises is stated but never shown, and the docs page never exercises the SelectSection code path that commit b66e308 exists to fix. A regression there would surface only in the unit test, not on the docs site or in a visual review.

The section variant already exists as a test fixture (renderSectionsWithSuffix, Select.test.tsx:1232); promoting it to a story — or just adding sections to the existing one — would cover the claim and the code path at once.

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.

Leaving it as prose: it's a one-line rule. The section code path is covered by unit tests for both inherited and per-section dependencies, and that's where a regression would be caught.

prop only has to be set once, on the root. A section written out in JSX can also take its
own `dependencies`, for values only its items use.

<Story of={Stories.Dependencies} />

### Minimum options for search

Short lists don't need a search input. The `minOptionsThreshold` prop shows the search only when
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type { Meta, StoryObj } from '@storybook/react';

import { Button } from '../Button';
import { FlexBox } from '../FlexBox';
import { Highlight } from '../Highlight';
import { useAsyncList, useFilter } from '../index';
import { Typography } from '../Typography';

Expand All @@ -33,7 +34,7 @@ const meta = {
'Select.ItemAddon': Select.ItemAddon,
},
argTypes: {},
tags: ['status:updated', 'date:2026-07-30'],
tags: ['status:updated', 'date:2026-09-09'],
} satisfies Meta<typeof Select>;

export default meta;
Expand Down Expand Up @@ -543,6 +544,30 @@ export const Searchable: Story = {
},
};

export const Dependencies: Story = {
render: function Render() {
const [inputValue, setInputValue] = useState('');

return (
<Select
items={options}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Story data belongs inside render — and here it carries the point

AGENTS.md, Storybook Stories: "Define story data and helpers inside render so they appear in the Source panel (the docs page shows the raw text of the story export)."

The Source panel for this story shows items={options} with no definition in sight. That matters more than usual here: the surrounding prose explains that the collection caches by object identity, and a reader cannot see that options is a stable module-level array of stable objects — which is the entire reason dependencies is needed.

Same at TreeSelect.stories.tsx:610. (The neighbouring stories share the habit, so this is a judgement call on whether to fix it just for the new ones.)

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.

Keeping options module-level on purpose. Inside render, the array would be recreated on every keystroke and every item would re-render anyway. The story would then work without dependencies, hiding exactly what it demonstrates. Real data usually has a stable identity, and that's the case the story models.

label="Attack type"
dependencies={[inputValue]}
onInputChange={setInputValue}
style={{ inlineSize: 200 }}
placeholder="Select an option"
isSearchable
>
{(item) => (
<Select.Item id={item.id} textValue={item.name}>
<Highlight text={item.name} query={inputValue} />
</Select.Item>
)}
</Select>
);
},
};

export const SearchableMinOptionsThreshold: Story = {
render: function Render() {
return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1204,4 +1204,95 @@ describe('Select', () => {
expect(getRoot()).not.toHaveAttribute('data-disabled', 'true');
});
});

describe('dependencies', () => {
type Option = { id: string; name: string };

const items: Option[] = [{ id: '1', name: 'one' }];

const renderWithSuffix = (suffix: string) => (
<Select<Option>
label="label"
items={items}
dependencies={[suffix]}
defaultOpen
>
{(item) => (
<Select.Item id={item.id}>{`${item.name}-${suffix}`}</Select.Item>
)}
</Select>
);

type Group = { id: string; name: string; children: Option[] };

const groups: Group[] = [
{ id: 'group-1', name: 'Group 1', children: items },
];

const renderSectionsWithSuffix = (suffix: string) => (
<Select<Group>
label="label"
items={groups}
dependencies={[suffix]}
defaultOpen
>
{(group) => (
<Select.Section
id={group.id}
title={group.name}
items={group.children}
>
{(item) => (
<Select.Item id={item.id}>{`${item.name}-${suffix}`}</Select.Item>
)}
</Select.Section>
)}
</Select>
);

const renderSectionWithDependencies = (suffix: string) => (
<Select label="label" defaultOpen>
<Select.Section
id="group-1"
title="Group 1"
items={items}
dependencies={[suffix]}
>
{(item) => (
<Select.Item id={item.id}>{`${item.name}-${suffix}`}</Select.Item>
)}
</Select.Section>
</Select>
);

it('should re-render the options when a dependency changes', () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

test-coverage — the sectioned shape, which is the one that fails, is untested.

This test covers a flat top-level list only. Select.Section with items + a render function is a first-class documented API (Select.stories.tsx:156, Select.mdx### Sections) and it is where dependencies is silently ignored (see my comment on types.ts:157). Worth adding a case here so the suite doesn't report the feature as working across the whole API:

it('should re-render options inside a section when a dependency changes', () => {
  // <Select items={sections} dependencies={[suffix]}> with a
  // <Select.Section items={section.children}> render function
});

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.

Added in b66e308should re-render the options inside a section when a dependency changes, with Select.Section + items + a render function, exactly the documented shape.

Checked it is a real regression test: with the SelectSection fix stashed it fails, with it applied it passes.

const { rerender } = render(renderWithSuffix('a'));

expect(getOptions()[0]).toHaveTextContent('one-a');

rerender(renderWithSuffix('b'));

expect(getOptions()[0]).toHaveTextContent('one-b');
});

it('should re-render the options inside a section when a dependency changes', () => {
const { rerender } = render(renderSectionsWithSuffix('a'));

expect(getOptions()[0]).toHaveTextContent('one-a');

rerender(renderSectionsWithSuffix('b'));

expect(getOptions()[0]).toHaveTextContent('one-b');
});

it('should re-render the options when a dependency of the section changes', () => {
const { rerender } = render(renderSectionWithDependencies('a'));

expect(getOptions()[0]).toHaveTextContent('one-a');

rerender(renderSectionWithDependencies('b'));

expect(getOptions()[0]).toHaveTextContent('one-b');
});
});
});
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import type { ForwardedRef } from 'react';
import type { ForwardedRef, ReactElement } from 'react';
import { useContext } from 'react';

import type {
Expand All @@ -13,6 +13,7 @@ import { filterDOMProps, mergeProps } from '@koobiq/react-core';
import {
useListBoxSection,
createBranchComponent,
Collection,
SectionNode,
} from '@koobiq/react-primitives';

Expand All @@ -27,10 +28,20 @@ export type SelectSectionProps<T> = ExtendableComponentPropsWithRef<
SectionProps<T> & {
/** The unique id of the item. */
id?: Key;
/**
* Values the section's items depend on, in addition to the `dependencies`
* of the Select. Takes effect for a section written out in JSX; for
* sections rendered from the Select's `items`, list the values on the Select.
*/
dependencies?: ReadonlyArray<unknown>;
},
'section'
>;

export type SelectSectionComponent = <T extends object>(
props: SelectSectionProps<T>
) => ReactElement | null;

function SelectSectionInner<T extends object>(
props: SelectSectionProps<T>,
ref: ForwardedRef<HTMLElement>,
Expand Down Expand Up @@ -69,7 +80,19 @@ function SelectSectionInner<T extends object>(
);
}

export const SelectSection = createBranchComponent(
const SelectSectionRoot = createBranchComponent(
SectionNode,
SelectSectionInner
SelectSectionInner,
// Render the children through `Collection` rather than the built-in
// `useCollectionChildren`, so the section inherits `dependencies` from the
// Select it is rendered in and adds its own on top.
({ items, children, dependencies }) => (
<Collection items={items} dependencies={dependencies}>
{children}
</Collection>
)
);

// The type is spelled out: the inferred one leaks an unresolved type parameter
// into the declaration output, which API Extractor cannot follow.
export const SelectSection = SelectSectionRoot as SelectSectionComponent;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

displayName is still SelectSectionInner

createBranchComponent sets Result.displayName = render.name, so React DevTools, Storybook's subcomponents entry and any snapshot show SelectSectionInner rather than Select.Section. AGENTS.md, Component Anatomy: "Always set X.displayName" — and the sibling does (DropdownMenuSection.displayName = 'DropdownMenu.Section').

Pre-existing, but this change makes it slightly harder to fix later (the cast target has no displayName), and now that there is a named SelectSectionRoot it is a one-liner before the cast:

SelectSectionRoot.displayName = 'Select.Section';

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.

Pre-existing and out of scope here. No part of SelectNext or TreeSelect sets displayName yet, so I'd rather not fix one in isolation.

8 changes: 8 additions & 0 deletions packages/components/src/components/SelectNext/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,14 @@ export type SelectNextProps<
defaultInputValue?: string;
/** Handler that is called when the Select search input value changes. */
onInputChange?: (value: string) => void;
/**
Comment thread
lskramarov marked this conversation as resolved.
* Values the rendered items depend on. The collection caches an item by its
* object identity, so a value used inside the render function — a search
* query, for instance — has to be listed here for the items to re-render.
* The array must keep the same length between renders; to depend on a
* list, wrap it: `[filters]`.
*/
dependencies?: ReadonlyArray<unknown>;
Comment on lines +152 to +159

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.

Regenerated in b4a5834.

One correction: pnpm check-api would not have failed. SelectNext was missing from the components array in tools/api-extractor/config.json, so the report was never validated — that was the actual problem (see @lskramarov's comment on TreeSelect.api.md). SelectNext is now in the config and the report is regenerated, so from here on a stale report does fail CI.

Comment thread
lskramarov marked this conversation as resolved.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

dependencies must have a constant length — worth saying in the doc comment

This value is spliced straight into React useMemo dependency arrays by React Aria: useCachedChildren does useMemo(() => new WeakMap(), dependencies), and Collection does useMemo(..., [idScope, ...dependencies]). So the array has the same constraint as a hook dependency list — its length must not change between renders.

Nothing here, in the identical JSDoc at TreeSelect/types.ts:131, or in the two new MDX sections says so. A consumer who follows the docs and writes dependencies={activeFilters} will, the moment a second filter is added, get React's "The final argument passed to useMemo changed size between renders" warning — and, because React only compares up to the shorter array, the item cache is never invalidated. The options keep rendering the stale value with no error.

One extra sentence in the JSDoc (and the MDX) would close it.

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.

Added in c39a4af: one sentence in both JSDoc blocks and both MDX sections. The array must keep the same length between renders, and a list is passed wrapped: dependencies={[filters]}.

/** The filter function used to determine if an option should be included in the Select list. */
defaultFilter?: (textValue: string, inputValue: string) => boolean;
/** The props used for each slot inside. */
Expand Down
15 changes: 15 additions & 0 deletions packages/components/src/components/TreeSelect/TreeSelect.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,21 @@ and matches text anywhere in an item. To change how items match, pass your own

<Story of={Stories.Searchable} />

### Dependencies

The collection caches a rendered item by the identity of its data object, so an item is not
re-rendered when a value used inside the render function changes. List such values in
`dependencies` to invalidate that cache — for example the search query when the items
highlight it.

Keep the length of the array the same between renders, otherwise the items are not
re-rendered. To depend on a list of values, wrap it: `dependencies={[filters]}`.

Nested `Collection` elements inherit `dependencies` from the TreeSelect, so the prop only
has to be set once, on the root.

<Story of={Stories.Dependencies} />

### Open

Use `defaultOpen` to set the initial uncontrolled open state.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { Meta, StoryObj } from '@storybook/react';
import { useAsyncList } from '../../index';
import { Button } from '../Button';
import { FlexBox } from '../FlexBox';
import { Highlight } from '../Highlight';
import { Tree } from '../Tree';
import { Typography } from '../Typography';

Expand All @@ -26,7 +27,7 @@ const meta = {
'TreeSelect.Tag': TreeSelect.Tag,
},
argTypes: {},
tags: ['status:updated', 'date:2026-09-07'],
tags: ['status:updated', 'date:2026-09-09'],
} satisfies Meta<typeof TreeSelect>;

export default meta;
Expand Down Expand Up @@ -600,6 +601,35 @@ export const Searchable: Story = {
},
};

export const Dependencies: Story = {
render: function Render() {
const [inputValue, setInputValue] = useState('');

return (
<TreeSelect
items={items}
label="Project files"
dependencies={[inputValue]}
onInputChange={setInputValue}
style={{ inlineSize: 320 }}
placeholder="Select a file"
isSearchable
>
{function renderItem(item) {
return (
<Tree.Item key={item.id} textValue={item.title}>
<Tree.ItemContent>
<Highlight text={item.title} query={inputValue} />
</Tree.ItemContent>
<Collection items={item.test}>{renderItem}</Collection>
</Tree.Item>
);
}}
</TreeSelect>
);
},
};

export const Open: Story = {
render: function Render() {
const [isOpen, { toggle, set }] = useBoolean(false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,9 @@ const items: FileNode[] = [
];

function TreeSelectFixture<M extends SelectionMode = 'single'>(
props: Partial<TreeSelectProps<FileNode, M>> = {}
props: Partial<TreeSelectProps<FileNode, M>> & { suffix?: string } = {}
) {
const { slotProps, ...otherProps } = props;
const { slotProps, suffix = '', ...otherProps } = props;

return (
<Provider>
Expand Down Expand Up @@ -59,7 +59,7 @@ function TreeSelectFixture<M extends SelectionMode = 'single'>(
textValue={item.title}
data-testid={`item-${item.id}`}
>
<Tree.ItemContent>{item.title}</Tree.ItemContent>
<Tree.ItemContent>{`${item.title}${suffix}`}</Tree.ItemContent>
<Collection items={item.children}>{renderItem}</Collection>
</Tree.Item>
);
Expand All @@ -70,7 +70,7 @@ function TreeSelectFixture<M extends SelectionMode = 'single'>(
}

function renderTreeSelect<M extends SelectionMode = 'single'>(
props: Partial<TreeSelectProps<FileNode, M>> = {}
props: Partial<TreeSelectProps<FileNode, M>> & { suffix?: string } = {}
) {
return render(<TreeSelectFixture {...props} />);
}
Expand Down Expand Up @@ -998,4 +998,25 @@ describe('TreeSelect', () => {
expect(screen.getByTestId('item-7')).toBeInTheDocument();
});
});

describe('dependencies', () => {
const propsWithSuffix = (suffix: string) => ({
suffix,
dependencies: [suffix],
defaultExpandedKeys: [1],
defaultOpen: true,
});

it('should re-render the items when a dependency changes', () => {
const { rerender } = renderTreeSelect(propsWithSuffix('-a'));

expect(screen.getByTestId('item-7')).toHaveTextContent('README.md-a');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

test-coverage — only the root-level leaf is asserted.

item-7 (README.md) is top-level. The nested item (id: 2, child of app) is collapsed by default, never rendered, and never checked — so nested-branch invalidation, which goes through a second useCachedChildren inside the nested <Collection>, has no coverage.

I verified the assertion below passes today, so this is a free coverage gain rather than a bug: add defaultExpandedKeys={[1]} to the fixture and

expect(screen.getByTestId('item-2')).toHaveTextContent('Http-a');
// …after rerender
expect(screen.getByTestId('item-2')).toHaveTextContent('Http-b');

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.

Added in f78a02bdefaultExpandedKeys={[1]} on the fixture plus assertions on item-2 before and after the rerender, so the nested useCachedChildren is covered.

expect(screen.getByTestId('item-2')).toHaveTextContent('Http-a');

rerender(<TreeSelectFixture {...propsWithSuffix('-b')} />);

expect(screen.getByTestId('item-7')).toHaveTextContent('README.md-b');
expect(screen.getByTestId('item-2')).toHaveTextContent('Http-b');
});
});
});
11 changes: 10 additions & 1 deletion packages/components/src/components/TreeSelect/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,14 @@ export type TreeSelectProps<
defaultInputValue?: string;
/** Handler called when the search query changes. */
onInputChange?: (value: string) => void;
/**
Comment thread
lskramarov marked this conversation as resolved.
* Values the rendered items depend on. The collection caches an item by its
* object identity, so a value used inside the render function — a search
* query, for instance — has to be listed here for the items to re-render.
* The array must keep the same length between renders; to depend on a
* list, wrap it: `[filters]`.
*/
dependencies?: ReadonlyArray<unknown>;
Comment thread
lskramarov marked this conversation as resolved.
/** The filter function used to determine whether an item should be included in the search results. */
defaultFilter?: (textValue: string, inputValue: string) => boolean;
/** The props used for each slot inside. */
Expand All @@ -136,7 +144,8 @@ export type TreeSelectProps<
control?: FormFieldSelectProps;
popover?: PopoverProps;
dropdownFooter?: DropdownFooterProps & DataAttributeProps;
tree?: Omit<AriaTreeProps<T>, 'children' | 'items'> & DataAttributeProps;
tree?: Omit<AriaTreeProps<T>, 'children' | 'items' | 'dependencies'> &

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

idScope is dead here for exactly the same reason

dependencies and idScope both come from the CollectionProps base that RAC's TreeProps extends, and slotProps.tree is forwarded to TreeInner, which never builds a collection — it runs the props through filterDOMProps (TreeInner.tsx:337) and drops anything that is not a DOM prop.

So slotProps={{ tree: { idScope: 'x' } }} still type-checks and still does nothing: the same trap this change closes for dependencies.

Suggested change
tree?: Omit<AriaTreeProps<T>, 'children' | 'items' | 'dependencies'> &
tree?: Omit<
AriaTreeProps<T>,
'children' | 'items' | 'dependencies' | 'idScope'
> &

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.

This one doesn't reproduce. slotProps.tree is based on RAC's TreeProps, whose CollectionProps only adds children and dependencies. idScope lives on the CollectionProps from @react-aria/collections, which TreeProps doesn't extend. So slotProps={{ tree: { idScope: 'x' } }} is already a type error — checked with tsc.

DataAttributeProps;
'search-input'?: SearchInputProps;
};
} & Omit<AriaTreeSelectProps<T, M>, 'description' | 'validationState'>;
Expand Down
Loading
Loading