Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -390,28 +390,14 @@ let reducers: {
}
},
[ActionTypes.UnregisterOptions]: (state, action) => {
let options = state.options

let idxs = []
let ids = new Set(action.options)
for (let [idx, option] of options.entries()) {
if (ids.has(option.id)) {
idxs.push(idx)
ids.delete(option.id)
if (ids.size === 0) break
}
}

if (idxs.length > 0) {
options = options.slice()
for (let idx of idxs.reverse()) {
options.splice(idx, 1)
}
}
let adjustedState = adjustOrderedState(state, (options) => {
return options.filter((option) => !ids.has(option.id))
})

return {
...state,
options,
...adjustedState,
activationTrigger: ActivationTrigger.Other,
}
},
Expand Down Expand Up @@ -590,7 +576,9 @@ export class ListboxMachine<T> extends Machine<State<T>, Actions<T>> {

selectActiveOption: () => {
if (this.state.activeOptionIndex !== null) {
let { dataRef } = this.state.options[this.state.activeOptionIndex]
let activeOption = this.state.options[this.state.activeOptionIndex]
if (!activeOption) return
let { dataRef } = activeOption
this.actions.selectOption(dataRef.current.value)
} else if (this.state.dataRef.current.mode === ValueMode.Single) {
this.actions.closeListbox()
Expand Down
75 changes: 75 additions & 0 deletions packages/@headlessui-react/src/components/listbox/listbox.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4705,3 +4705,78 @@ describe('transitions', () => {
})
)
})

describe('Dynamic option removal', () => {
it(
'should not crash when the active option is removed',
suppressConsoleLogs(async () => {
function Example({ hide = false }) {
return (
<Listbox value={undefined} onChange={(x) => console.log(x)}>
<Listbox.Button>Trigger</Listbox.Button>
<Listbox.Options>
<Listbox.Option value="a">alice</Listbox.Option>
{!hide && <Listbox.Option value="b">bob</Listbox.Option>}
{!hide && <Listbox.Option value="c">charlie</Listbox.Option>}
</Listbox.Options>
</Listbox>
)
}

let { rerender } = render(<Example />)

// Open the listbox
await click(getListboxButton())
assertListbox({ state: ListboxState.Visible })

// Navigate to the last option
await press(Keys.End)
let options = getListboxOptions()
assertActiveListboxOption(options[2])

// Remove options while the listbox is open
rerender(<Example hide={true} />)

// Press Enter — should not crash, should close the listbox gracefully
await press(Keys.Enter)
assertListbox({ state: ListboxState.InvisibleUnmounted })
})
)

it(
'should adjust activeOptionIndex when options before the active option are removed',
suppressConsoleLogs(async () => {
function Example({ hide = false }) {
return (
<Listbox value={undefined} onChange={(x) => console.log(x)}>
<Listbox.Button>Trigger</Listbox.Button>
<Listbox.Options>
{!hide && <Listbox.Option value="a">alice</Listbox.Option>}
<Listbox.Option value="b">bob</Listbox.Option>
<Listbox.Option value="c">charlie</Listbox.Option>
</Listbox.Options>
</Listbox>
)
}

let { rerender } = render(<Example />)

// Open the listbox
await click(getListboxButton())
assertListbox({ state: ListboxState.Visible })

// Navigate to the last option ("charlie")
await press(Keys.End)
let options = getListboxOptions()
assertActiveListboxOption(options[2])

// Remove the first option ("alice") while "charlie" is active
rerender(<Example hide={true} />)

// "charlie" should still be the active option (now at index 1)
options = getListboxOptions()
expect(options).toHaveLength(2)
assertActiveListboxOption(options[1])
})
)
})
22 changes: 4 additions & 18 deletions packages/@headlessui-react/src/components/menu/menu-machine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,28 +305,14 @@ let reducers: {
}
},
[ActionTypes.UnregisterItems]: (state, action) => {
let items = state.items

let idxs = []
let ids = new Set(action.items)
for (let [idx, item] of items.entries()) {
if (ids.has(item.id)) {
idxs.push(idx)
ids.delete(item.id)
if (ids.size === 0) break
}
}

if (idxs.length > 0) {
items = items.slice()
for (let idx of idxs.reverse()) {
items.splice(idx, 1)
}
}
let adjustedState = adjustOrderedState(state, (items) => {
return items.filter((item) => !ids.has(item.id))
})

return {
...state,
items,
...adjustedState,
activationTrigger: ActivationTrigger.Other,
}
},
Expand Down
75 changes: 75 additions & 0 deletions packages/@headlessui-react/src/components/menu/menu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3604,3 +3604,78 @@ describe('transitions', () => {
})
)
})

describe('Dynamic item removal', () => {
it(
'should not crash when the active item is removed',
suppressConsoleLogs(async () => {
function Example({ hide = false }) {
return (
<Menu>
<Menu.Button>Trigger</Menu.Button>
<Menu.Items>
<Menu.Item as="button">alice</Menu.Item>
{!hide && <Menu.Item as="button">bob</Menu.Item>}
{!hide && <Menu.Item as="button">charlie</Menu.Item>}
</Menu.Items>
</Menu>
)
}

let { rerender } = render(<Example />)

// Open the menu
await click(getMenuButton())
assertMenu({ state: MenuState.Visible })

// Navigate to the last item
await press(Keys.End)
let items = getMenuItems()
assertMenuLinkedWithMenuItem(items[2])

// Remove items while the menu is open
rerender(<Example hide={true} />)

// Press Enter — should not crash, should close the menu gracefully
await press(Keys.Enter)
assertMenu({ state: MenuState.InvisibleUnmounted })
})
)

it(
'should adjust activeItemIndex when items before the active item are removed',
suppressConsoleLogs(async () => {
function Example({ hide = false }) {
return (
<Menu>
<Menu.Button>Trigger</Menu.Button>
<Menu.Items>
{!hide && <Menu.Item as="button">alice</Menu.Item>}
<Menu.Item as="button">bob</Menu.Item>
<Menu.Item as="button">charlie</Menu.Item>
</Menu.Items>
</Menu>
)
}

let { rerender } = render(<Example />)

// Open the menu
await click(getMenuButton())
assertMenu({ state: MenuState.Visible })

// Navigate to the last item ("charlie")
await press(Keys.End)
let items = getMenuItems()
assertMenuLinkedWithMenuItem(items[2])

// Remove the first item ("alice") while "charlie" is active
rerender(<Example hide={true} />)

// "charlie" should still be the active item (now at index 1)
items = getMenuItems()
expect(items).toHaveLength(2)
assertMenuLinkedWithMenuItem(items[1])
})
)
})
4 changes: 3 additions & 1 deletion packages/@headlessui-react/src/components/menu/menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -473,7 +473,9 @@ function ItemsFn<TTag extends ElementType = typeof DEFAULT_ITEMS_TAG>(
event.preventDefault()
event.stopPropagation()
if (machine.state.activeItemIndex !== null) {
let { dataRef } = machine.state.items[machine.state.activeItemIndex]
let activeItem = machine.state.items[machine.state.activeItemIndex]
if (!activeItem) break
let { dataRef } = activeItem
dataRef.current?.domRef.current?.click()
}
machine.send({ type: ActionTypes.CloseMenu })
Expand Down