Skip to content

Commit d324276

Browse files
committed
fix(tui): drop the keymap listener when the renderer is destroyed
1 parent b0b8cd6 commit d324276

2 files changed

Lines changed: 104 additions & 5 deletions

File tree

‎src/tui/list-modal.test.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { afterEach, describe, expect, test } from "bun:test";
33
import { defined } from "../../testkit/defined.js";
44
import { createHarness, type Harness } from "./harness.js";
55
import { runListModal, type ListModalConfig } from "./list-modal.js";
6+
import { createAppShell } from "./shell/index.js";
7+
import { pokeShellKeymapPress, shellKeymapListening } from "./shell/keymap.js";
68

79
let harness: Harness | undefined;
810

@@ -30,6 +32,28 @@ async function mountModal(overrides: Partial<ListModalConfig> = {}): Promise<{
3032
}
3133

3234
describe("runListModal", () => {
35+
test("renderer destroy detaches the keymap before a later keypress", async () => {
36+
const h = await createHarness({ width: 40, height: 12 });
37+
try {
38+
const shell = createAppShell(h.renderer, {
39+
title: "keymap",
40+
run: "idle",
41+
});
42+
expect(shellKeymapListening(h.renderer)).toBe(true);
43+
const before = h.renderer.keyInput.listenerCount("keypress");
44+
expect(before).toBeGreaterThan(0);
45+
shell.dispose();
46+
h.renderer.destroy();
47+
expect(shellKeymapListening(h.renderer)).toBe(false);
48+
expect(h.renderer.keyInput.listenerCount("keypress")).toBeLessThan(
49+
before,
50+
);
51+
expect(() => pokeShellKeymapPress(h.renderer)).not.toThrow();
52+
} finally {
53+
h.destroy();
54+
}
55+
});
56+
3357
test("resolves the id of the accepted row", async () => {
3458
const { choice, harness } = await mountModal();
3559
harness.pressKey("Enter");

‎src/tui/shell/keymap.ts‎

Lines changed: 80 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,20 @@
11
/**
22
* Registers `APP_CHORDS` on `@opentui/keymap`. Lookup belongs to the package.
33
* There is no second switch from key name to handler.
4+
*
5+
* The default host keeps its keypress listener until the destroy event, but
6+
* `isDestroyed` flips first and leftover stdin then throws inside
7+
* `getLiveHost`. This host unsubscribes inside `destroy()` and ignores any
8+
* keypress already queued for this turn.
49
*/
510
import type { CliRenderer, KeyEvent, Renderable } from "@opentui/core";
6-
import type { Keymap } from "@opentui/keymap";
7-
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui";
11+
import { Keymap, type KeymapHost } from "@opentui/keymap";
12+
import {
13+
registerDefaultKeys,
14+
registerEnabledFields,
15+
registerMetadataFields,
16+
} from "@opentui/keymap/addons";
17+
import { createOpenTuiKeymapHost } from "@opentui/keymap/opentui";
818

919
import { bindAppHelp, setAppShortcutRows } from "../keybindings.js";
1020
import type { AppShell } from "./internals.js";
@@ -21,13 +31,78 @@ bindAppHelp(appHelpLines);
2131
type ShellKeymap = Keymap<Renderable, KeyEvent>;
2232

2333
const keymaps = new WeakMap<CliRenderer, ShellKeymap>();
34+
const detached = new WeakSet<CliRenderer>();
35+
const pressListeners = new WeakMap<
36+
CliRenderer,
37+
((event: KeyEvent) => void)[]
38+
>();
39+
40+
function guardHost(renderer: CliRenderer): KeymapHost<Renderable, KeyEvent> {
41+
const host = createOpenTuiKeymapHost(renderer);
42+
const onKeyPress = host.onKeyPress.bind(host);
43+
const onKeyRelease = host.onKeyRelease.bind(host);
44+
const offs: (() => void)[] = [];
45+
const ignore = (): boolean => detached.has(renderer) || renderer.isDestroyed;
46+
47+
const wrap = (
48+
subscribe: (listener: (event: KeyEvent) => void) => () => void,
49+
listener: (event: KeyEvent) => void,
50+
record: boolean,
51+
): (() => void) => {
52+
const wrapped = (event: KeyEvent): void => {
53+
if (ignore()) return;
54+
listener(event);
55+
};
56+
if (record) {
57+
const list = pressListeners.get(renderer) ?? [];
58+
list.push(wrapped);
59+
pressListeners.set(renderer, list);
60+
}
61+
const off = subscribe(wrapped);
62+
offs.push(off);
63+
return off;
64+
};
65+
66+
host.onKeyPress = (listener) => wrap(onKeyPress, listener, true);
67+
host.onKeyRelease = (listener) => wrap(onKeyRelease, listener, false);
68+
69+
const originalDestroy = renderer.destroy.bind(renderer);
70+
renderer.destroy = () => {
71+
if (!detached.has(renderer)) {
72+
detached.add(renderer);
73+
for (const off of offs) off();
74+
offs.length = 0;
75+
}
76+
originalDestroy();
77+
};
78+
79+
return host;
80+
}
2481

2582
function keymapFor(renderer: CliRenderer): ShellKeymap {
2683
const existing = keymaps.get(renderer);
2784
if (existing !== undefined) return existing;
28-
const created = createDefaultOpenTuiKeymap(renderer);
29-
keymaps.set(renderer, created);
30-
return created;
85+
const keymap = new Keymap(guardHost(renderer));
86+
registerDefaultKeys(keymap);
87+
registerEnabledFields(keymap);
88+
registerMetadataFields(keymap);
89+
keymaps.set(renderer, keymap);
90+
return keymap;
91+
}
92+
93+
/** True while this renderer's keymap is still subscribed to keypress. */
94+
export function shellKeymapListening(renderer: CliRenderer): boolean {
95+
return keymaps.has(renderer) && !detached.has(renderer);
96+
}
97+
98+
/**
99+
* Re-enter the keymap press listeners. After destroy they must return
100+
* without calling into a destroyed host.
101+
*/
102+
export function pokeShellKeymapPress(renderer: CliRenderer): void {
103+
for (const listener of pressListeners.get(renderer) ?? []) {
104+
listener({ name: "c" } as KeyEvent);
105+
}
31106
}
32107

33108
function helpFromKeymap(keymap: ShellKeymap): readonly string[] {

0 commit comments

Comments
 (0)