Skip to content

bug(aria/listbox): Enter pressed while options change selects an option that is no longer rendered #34041

Closed

Description

@AwsIT

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)). Its items() therefore lag a DOM change until the observer fires.
  • The reset of an active item that is no longer in items() runs in a later afterRenderEffect (listbox.mjs, the effect that calls listBehavior.unfocus() and setDefaultState()).
  • 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 stale activeItem.

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/aria 22.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

  1. self-assigned this
    on Nov 2, 2026
  2. added
    P3An issue that is relevant to core functions, but does not impede progress. Important, but not urgent
    on Nov 2, 2026
  3. added a commit that references this issue on Nov 2, 2026
    e7dc325
  4. added a commit that references this issue on Nov 2, 2026
    9512d87
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

P3An issue that is relevant to core functions, but does not impede progress. Important, but not urgentarea: aria/listbox

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions