[GH-ISSUE #346] Keypress event leaks causing unintended automatic ITEM_SELECTED #858

Closed
opened 2026-03-14 08:50:34 +03:00 by kerem · 2 comments
Owner

Originally created by @hsulab on GitHub (Nov 25, 2025).
Original GitHub issue: https://github.com/anomalyco/opentui/issues/346

Description

When a SelectRenderable (s0) handles ITEM_SELECTED, and the handler makes another selector (s1) visible and focuses it, the same keypress event leaks into s1, causing s1 to immediately fire its own ITEM_SELECTED event and automatically select its first option.

This happens without any user input.

Currently, the workaround is using setTimeout or queueMicrotask. I wonder if this can be fixed or there is better practice?

Expected behaviour

  • Only one widget should receive a keypress.
  • After s0 selection, s1 should become visible and focused.
  • s1 should not automatically select an item unless the user presses a new key.

Actual behaviour

  • The same keypress that selected an item in s0 is immediately routed into s1 as well.
  • s1 fires its own ITEM_SELECTED.
  • s1 selects the first item without user intention.
  • This creates broken UI flows (menus, dialogs, cascading selectors, etc.).

Reproduce

#!/usr/bin/env bun

import {
  CliRenderer,
  createCliRenderer,
  SelectRenderable,
  type SelectOption,
  type KeyEvent,
  SelectRenderableEvents,
} from "@opentui/core";

function run(renderer: CliRenderer) {
  const s0 = new SelectRenderable(renderer, {
    id: "select_0",
    position: "absolute",
    top: 0,
    left: 0,
    width: 30,
    height: 10,
    options: [
      { name: "a", value: "a", description: "a" },
      { name: "b", value: "b", description: "b" },
      { name: "c", value: "c", description: "c" },
    ],
    showDescription: false,
  });

  const s1 = new SelectRenderable(renderer, {
    id: "s1",
    position: "absolute",
    top: 2,
    left: 35,
    width: 30,
    height: 10,
    options: [
      {
        name: "I",
        value: "I",
        description: "I",
      },
      {
        name: "II",
        value: "II",
        description: "II",
      },
    ],
    showDescription: false,
  });

  s0.on(
    SelectRenderableEvents.ITEM_SELECTED,
    (idnex: number, option: SelectOption) => {
      console.log(`s0 selected index ${idnex} with option:`, option.value);
      switch (option.value) {
        case "a": {
          break;
        }
        case "b": {
          // TODO: ITEM_SELECTED leaks to s1?
          s1.visible = true;
          s1.focus();
          // FIX:
          // setTimeout(() => {
          //   s1.visible = true;
          //   s1.focus();
          // });
          // FIX:
          // queueMicrotask(() => {
          //  s1.visible = true;
          //  s1.focus();
          // });
          break;
        }
        case "c": {
          break;
        }
        default:
          break;
      }
    },
  );
  s0.visible = true;
  s0.focus();
  renderer.root.add(s0);

  s1.on(
    SelectRenderableEvents.ITEM_SELECTED,
    (index: number, option: SelectOption) => {
      console.log(`s1 selected index ${index} with option:`, option.value);
      switch (option.value) {
        case "a": {
          break;
        }
        case "b": {
          s1.visible = true;
          s1.focus();
          break;
        }
        default:
          break;
      }
    },
  );
  s1.visible = false;
  s1.blur();
  renderer.root.add(s1);
}

function setupKeybindings(renderer: CliRenderer) {
  renderer.keyInput.on("keypress", (key: KeyEvent) => {
    if (key.name === "`") {
      renderer.console.toggle();
    }
  });
}

if (import.meta.main) {
  const renderer = await createCliRenderer({
    exitOnCtrlC: true,
  });
  run(renderer);
  setupKeybindings(renderer);
  renderer.start();
}

Image
Originally created by @hsulab on GitHub (Nov 25, 2025). Original GitHub issue: https://github.com/anomalyco/opentui/issues/346 # Description When a `SelectRenderable` (s0) handles `ITEM_SELECTED`, and the handler makes another selector (s1) visible and focuses it, the same keypress event leaks into s1, causing s1 to immediately fire its own `ITEM_SELECTED` event and automatically select its first option. This happens without any user input. Currently, the workaround is using `setTimeout` or `queueMicrotask`. I wonder if this can be fixed or there is better practice? # Expected behaviour - Only one widget should receive a keypress. - After s0 selection, s1 should become visible and focused. - s1 should not automatically select an item unless the user presses a new key. # Actual behaviour - The same keypress that selected an item in s0 is immediately routed into s1 as well. - s1 fires its own ITEM_SELECTED. - s1 selects the first item without user intention. - This creates broken UI flows (menus, dialogs, cascading selectors, etc.). # Reproduce ``` #!/usr/bin/env bun import { CliRenderer, createCliRenderer, SelectRenderable, type SelectOption, type KeyEvent, SelectRenderableEvents, } from "@opentui/core"; function run(renderer: CliRenderer) { const s0 = new SelectRenderable(renderer, { id: "select_0", position: "absolute", top: 0, left: 0, width: 30, height: 10, options: [ { name: "a", value: "a", description: "a" }, { name: "b", value: "b", description: "b" }, { name: "c", value: "c", description: "c" }, ], showDescription: false, }); const s1 = new SelectRenderable(renderer, { id: "s1", position: "absolute", top: 2, left: 35, width: 30, height: 10, options: [ { name: "I", value: "I", description: "I", }, { name: "II", value: "II", description: "II", }, ], showDescription: false, }); s0.on( SelectRenderableEvents.ITEM_SELECTED, (idnex: number, option: SelectOption) => { console.log(`s0 selected index ${idnex} with option:`, option.value); switch (option.value) { case "a": { break; } case "b": { // TODO: ITEM_SELECTED leaks to s1? s1.visible = true; s1.focus(); // FIX: // setTimeout(() => { // s1.visible = true; // s1.focus(); // }); // FIX: // queueMicrotask(() => { // s1.visible = true; // s1.focus(); // }); break; } case "c": { break; } default: break; } }, ); s0.visible = true; s0.focus(); renderer.root.add(s0); s1.on( SelectRenderableEvents.ITEM_SELECTED, (index: number, option: SelectOption) => { console.log(`s1 selected index ${index} with option:`, option.value); switch (option.value) { case "a": { break; } case "b": { s1.visible = true; s1.focus(); break; } default: break; } }, ); s1.visible = false; s1.blur(); renderer.root.add(s1); } function setupKeybindings(renderer: CliRenderer) { renderer.keyInput.on("keypress", (key: KeyEvent) => { if (key.name === "`") { renderer.console.toggle(); } }); } if (import.meta.main) { const renderer = await createCliRenderer({ exitOnCtrlC: true, }); run(renderer); setupKeybindings(renderer); renderer.start(); } ``` <img width="912" height="778" alt="Image" src="https://github.com/user-attachments/assets/a8f5ad13-3b73-431d-894c-881ec3e79ced" />
Author
Owner

@kommander commented on GitHub (Nov 26, 2025):

Ah I think I see what's happening, the events are synchronous, so when calling s1.focus() it attaches the event listener immediately while still executing the key event, mutating the listeners array while iterating over it.

Should be a simple enough fix I think, in lib/KeyHandler.ts it needs to use [...listeners] for iteration so the listeners mutation does not execute newly added listeners in the same tick.

Needs a simple reproduction in a test to make sure it behaves.

<!-- gh-comment-id:3580427849 --> @kommander commented on GitHub (Nov 26, 2025): Ah I think I see what's happening, the events are synchronous, so when calling s1.focus() it attaches the event listener immediately while still executing the key event, mutating the listeners array while iterating over it. Should be a simple enough fix I think, in `lib/KeyHandler.ts` it needs to use `[...listeners]` for iteration so the listeners mutation does not execute newly added listeners in the same tick. Needs a simple reproduction in a test to make sure it behaves.
Author
Owner

@hsulab commented on GitHub (Nov 27, 2025):

Ah I think I see what's happening, the events are synchronous, so when calling s1.focus() it attaches the event listener immediately while still executing the key event, mutating the listeners array while iterating over it.

Should be a simple enough fix I think, in lib/KeyHandler.ts it needs to use [...listeners] for iteration so the listeners mutation does not execute newly added listeners in the same tick.

Needs a simple reproduction in a test to make sure it behaves.

Many thanks! I just made a pull request.

<!-- gh-comment-id:3584334862 --> @hsulab commented on GitHub (Nov 27, 2025): > Ah I think I see what's happening, the events are synchronous, so when calling s1.focus() it attaches the event listener immediately while still executing the key event, mutating the listeners array while iterating over it. > > Should be a simple enough fix I think, in `lib/KeyHandler.ts` it needs to use `[...listeners]` for iteration so the listeners mutation does not execute newly added listeners in the same tick. > > Needs a simple reproduction in a test to make sure it behaves. Many thanks! I just made a pull request.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
starred/opentui#858
No description provided.