Repository navigation
Description
Is this a regression?
- Yes, this behavior used to work in the previous version
The previous version in which this bug was not present was
No response
Description
A combobox that filters its listbox's options can select an option that the filter has just removed. If Enter is pressed in the same render as the change that removes the active option (a fast typist, a slow device, or tools that send keys in a burst), the listbox selects the removed option and emits it from valueChange. A render later the listbox's own cleanup drops the value again, so valueChange fires ['apple'] and then [].
For a consumer that acts on the first emission, the result is a choice the user never saw. In our app a command palette navigated to a page that was not in the list, and a form combobox saved a value that was not in the list (the user typed "ist" and got "Erbil central").
As far as we can tell from the 22.2.3 sources (the same code is in 22.2.4):
- The listbox learns its options through a
MutationObserver(_collection.startObserving(this.element)). Itsitems()therefore lag a DOM change until the observer fires. - The reset of an active item that is no longer in
items()runs in a laterafterRenderEffect(listbox.mjs, the effect that callslistBehavior.unfocus()andsetDefaultState()). - The combobox's relayed Enter is dispatched from its own
afterRenderEffect(keyboardEventRelayEffect). That happens in the render that removed the option, before either of the above has caught up, so the Enter acts on the staleactiveItem.
Pressing Enter after one more render selects the right option.
Related: #33973 (the same relay, keys merged within a render). It is a separate mechanism, but the two combine: a quick Down + Enter reaches the listbox as a lone Enter on the stale active item.
Reproduction
import { ApplicationRef, Component, computed, signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { Combobox, ComboboxPopup, ComboboxWidget } from '@angular/aria/combobox';
import { Listbox, Option } from '@angular/aria/listbox';
@Component({
imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option],
template: `
<input ngCombobox #combobox="ngCombobox" [alwaysExpanded]="true" [(value)]="query" />
<ng-template ngComboboxPopup [combobox]="combobox">
<div ngListbox ngComboboxWidget #listbox="ngListbox" focusMode="activedescendant"
selectionMode="explicit" [activeDescendant]="listbox.activeDescendant()"
(valueChange)="changes.push($event)">
@for (o of shown(); track o) {
<div ngOption [value]="o" [label]="o">{{ o }}</div>
}
</div>
</ng-template>
`,
})
class Repro {
readonly query = signal('');
readonly shown = computed(() => ['apple', 'banana', 'cherry'].filter((o) => o.includes(this.query())));
readonly changes: (readonly string[])[] = [];
}
it('Enter in the render that filters selects the removed option', async () => {
const fixture = TestBed.createComponent(Repro);
document.body.appendChild(fixture.nativeElement);
const stable = () => TestBed.inject(ApplicationRef).whenStable();
await stable();
const input = fixture.nativeElement.querySelector('input') as HTMLInputElement;
input.focus();
input.value = 'cherry'; // filters out apple, the active option
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); // same task
await stable();
await stable();
expect(fixture.componentInstance.changes).toEqual([['cherry']]);
// Actual: [['apple'], []]; the DOM shows only 'cherry'.
// With `await stable()` between the input event and Enter: [['cherry']].
});We saw it in Chromium too (Playwright, the last letter and Enter sent in one burst through CDP): 7 runs in 10 selected the removed option.
Expected behavior
Enter selects the option that is rendered and active, or does nothing when the active option has been removed. It never selects or emits an option that is no longer in the list.
Actual behavior
valueChange emits the removed option (['apple']), then [] a render later.
Environment
- Angular: 22.2.3 (
@angular/aria22.2.3; the relevant code is unchanged in 22.2.4) - Zoneless
- Chromium (Playwright 1.64) and jsdom (Vitest 5)
Workaround
Ignore a valueChange whose value is not among the options currently rendered.
Activity
fix(aria/combobox): prevent selecting filtered-out options on Enter (#…
fix(aria/combobox): prevent selecting filtered-out options on Enter (a…