Skip to content

Commit 674e477

Browse files
fix(tui): lift the key emitter listener cap and align the solid-js peer (#493)
#380 item 1: @opentui/solid's useKeyboard/usePaste mount one listener per component on the shared renderer.keyInput emitter (core registers its own keypress listener there too), and nothing in @opentui/[email protected] raises the cap, so Node's default of 10 per event is reached by a couple of in-tree hooks plus ~8 plugin components. The resulting MaxListenersExceededWarning is routed into opentui's console cache, so it is invisible and only makes the cap harder to notice. Raise the cap on keyInput next to the existing renderer.setMaxListeners(0), and cover it with an app-lifecycle test that fails without the fix with "Expected: 0 / Received: 10". #380 item 3: the catalog pinned solid-js 1.9.10 while @opentui/[email protected] requires the exact peer 1.9.12. Bumping to 1.9.12 means 4 of the 10 hunks in patches/[email protected] (the anomalyco#2046 transition fix) are already upstream and no longer apply, so the patch is re-derived for 1.9.12 keeping only the 6 still-needed cleanNode capture hunks. #380 item 2 (dead win32InstallCtrlCGuard) is already satisfied on this base: tui.ts:17 imports it, :203 installs it and :346 uninstalls it, since 54a2ee5. 0.6.6 never touches ENABLE_PROCESSED_INPUT/SetConsoleMode itself, so the guard is not obsolete and stays as is. Fixes #380 Co-authored-by: agenteinstinct1-max <[email protected]> Co-authored-by: arena-agent <[email protected]>
1 parent 73ddbc6 commit 674e477

5 files changed

Lines changed: 79 additions & 51 deletions

File tree

‎bun.lock‎

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@
8080
"@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020",
8181
"@sentry/solid": "10.36.0",
8282
"@sentry/vite-plugin": "4.6.0",
83-
"solid-js": "1.9.10",
83+
"solid-js": "1.9.12",
8484
"solid-sonner": "0.3.1",
8585
"vite-plugin-solid": "2.11.10",
8686
"@lydell/node-pty": "1.2.0-beta.12"
@@ -137,7 +137,7 @@
137137
"@npmcli/[email protected]": "patches/@npmcli%[email protected]",
138138
"@silvia-odwyer/[email protected]": "patches/@silvia-odwyer%[email protected]",
139139
"@standard-community/[email protected]": "patches/@standard-community%[email protected]",
140-
"[email protected].10": "patches/[email protected].10.patch",
140+
"[email protected].12": "patches/[email protected].12.patch",
141141
"@ai-sdk/[email protected]": "patches/@ai-sdk%[email protected]",
142142
"@ai-sdk/[email protected]": "patches/@ai-sdk%[email protected]",
143143

‎packages/tui/src/app.tsx‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,11 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
415415
// Every component that reads the terminal size adds its own resize listener, and a busy
416416
// session has well over Node's default of 10. The warning would print into the screen.
417417
renderer.setMaxListeners(0)
418+
// Keyboard and paste hooks share a single emitter (renderer.keyInput) for the whole tree, so
419+
// every component and plugin calling useKeyboard/usePaste adds a listener there. Past Node's
420+
// default of 10 per event that trips MaxListenersExceededWarning, which opentui routes into its
421+
// console cache instead of the terminal, so the cap has to be lifted on this emitter too.
422+
renderer.keyInput.setMaxListeners(0)
418423
const dialog = useDialog()
419424
const local = useLocal()
420425
const kv = useKV()

‎packages/tui/test/app-lifecycle.test.tsx‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,3 +191,62 @@ test("app.exit prints the session epilogue after scoped cleanup", async () => {
191191
mock.restore()
192192
}
193193
})
194+
195+
test("app lifts the key emitter listener cap for keyboard and paste hooks", async () => {
196+
const setup = await createTestRenderer({ width: 80, height: 24 })
197+
const core = await import("@opentui/core")
198+
mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
199+
const events = createEventSource()
200+
const calls = createFetch()
201+
let started!: () => void
202+
const ready = new Promise<void>((resolve) => {
203+
started = resolve
204+
})
205+
206+
try {
207+
const { run } = await import("../src/app")
208+
const task = Effect.runPromise(
209+
run({
210+
url: "http://test",
211+
directory,
212+
config: createTuiResolvedConfig({ plugin_enabled: {} }),
213+
fetch: calls.fetch,
214+
events: events.source,
215+
args: {},
216+
pluginHost: {
217+
async start() {
218+
started()
219+
},
220+
async dispose() {},
221+
},
222+
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node))),
223+
)
224+
225+
await ready
226+
// Node's default cap is 10 listeners per event, and useKeyboard/usePaste each mount one on
227+
// this shared emitter, so a plain `expect(...getMaxListeners()).toBe(0)` already fails
228+
// without the fix. Mounting past the cap must stay silent as well.
229+
expect(setup.renderer.keyInput.getMaxListeners()).toBe(0)
230+
const added = Array.from({ length: 11 }, () => () => {})
231+
for (const listener of added) setup.renderer.keyInput.on("keypress", listener)
232+
expect(setup.renderer.keyInput.listenerCount("keypress")).toBeGreaterThan(10)
233+
234+
let warned = false
235+
const onWarning = (warning: Error) => {
236+
if (warning.name === "MaxListenersExceededWarning") warned = true
237+
}
238+
process.on("warning", onWarning)
239+
setup.renderer.keyInput.on("keypress", () => {})
240+
await new Promise((resolve) => setImmediate(resolve))
241+
process.off("warning", onWarning)
242+
expect(warned).toBe(false)
243+
244+
for (const listener of added) setup.renderer.keyInput.off("keypress", listener)
245+
process.emit("SIGHUP")
246+
await task
247+
expect(setup.renderer.isDestroyed).toBe(true)
248+
} finally {
249+
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
250+
mock.restore()
251+
}
252+
})
Lines changed: 8 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,8 @@
11
diff --git a/dist/dev.cjs b/dist/dev.cjs
2-
index 7104749..dc3eac9 100644
2+
index 3b7a5f4..18dc630 100644
33
--- a/dist/dev.cjs
44
+++ b/dist/dev.cjs
5-
@@ -764,6 +764,8 @@ function runComputation(node, value, time) {
6-
if (node.updatedAt != null && "observers" in node) {
7-
writeSignal(node, nextValue, true);
8-
} else if (Transition && Transition.running && node.pure) {
9-
+ // On first computation during transition, also set committed value #2046
10-
+ if (!Transition.sources.has(node)) node.value = nextValue;
11-
Transition.sources.add(node);
12-
node.tValue = nextValue;
13-
} else node.value = nextValue;
14-
@@ -987,18 +989,21 @@ function cleanNode(node) {
5+
@@ -997,18 +997,21 @@ function cleanNode(node) {
156
}
167
}
178
if (node.tOwned) {
@@ -37,19 +28,10 @@ index 7104749..dc3eac9 100644
3728
if (Transition && Transition.running) node.tState = 0;else node.state = 0;
3829
delete node.sourceMap;
3930
diff --git a/dist/dev.js b/dist/dev.js
40-
index ea5e4bc..a2e2d59 100644
31+
index 1001c82..8036a9f 100644
4132
--- a/dist/dev.js
4233
+++ b/dist/dev.js
43-
@@ -762,6 +762,8 @@ function runComputation(node, value, time) {
44-
if (node.updatedAt != null && "observers" in node) {
45-
writeSignal(node, nextValue, true);
46-
} else if (Transition && Transition.running && node.pure) {
47-
+ // On first computation during transition, also set committed value #2046
48-
+ if (!Transition.sources.has(node)) node.value = nextValue;
49-
Transition.sources.add(node);
50-
node.tValue = nextValue;
51-
} else node.value = nextValue;
52-
@@ -985,18 +987,21 @@ function cleanNode(node) {
34+
@@ -995,18 +995,21 @@ function cleanNode(node) {
5335
}
5436
}
5537
if (node.tOwned) {
@@ -117,19 +99,10 @@ index d5f8803..320d9af 100644
11799
}
118100
function catchError(fn, handler) {
119101
diff --git a/dist/solid.cjs b/dist/solid.cjs
120-
index 7c133a2..5ef1501 100644
102+
index a2abf2a..be5f484 100644
121103
--- a/dist/solid.cjs
122104
+++ b/dist/solid.cjs
123-
@@ -717,6 +717,8 @@ function runComputation(node, value, time) {
124-
if (node.updatedAt != null && "observers" in node) {
125-
writeSignal(node, nextValue, true);
126-
} else if (Transition && Transition.running && node.pure) {
127-
+ // On first computation during transition, also set committed value #2046
128-
+ if (!Transition.sources.has(node)) node.value = nextValue;
129-
Transition.sources.add(node);
130-
node.tValue = nextValue;
131-
} else node.value = nextValue;
132-
@@ -938,18 +940,21 @@ function cleanNode(node) {
105+
@@ -952,18 +952,21 @@ function cleanNode(node) {
133106
}
134107
}
135108
if (node.tOwned) {
@@ -155,19 +128,10 @@ index 7c133a2..5ef1501 100644
155128
if (Transition && Transition.running) node.tState = 0;else node.state = 0;
156129
}
157130
diff --git a/dist/solid.js b/dist/solid.js
158-
index 656fd26..6e0038c 100644
131+
index 10d3b70..5b6ecde 100644
159132
--- a/dist/solid.js
160133
+++ b/dist/solid.js
161-
@@ -715,6 +715,8 @@ function runComputation(node, value, time) {
162-
if (node.updatedAt != null && "observers" in node) {
163-
writeSignal(node, nextValue, true);
164-
} else if (Transition && Transition.running && node.pure) {
165-
+ // On first computation during transition, also set committed value #2046
166-
+ if (!Transition.sources.has(node)) node.value = nextValue;
167-
Transition.sources.add(node);
168-
node.tValue = nextValue;
169-
} else node.value = nextValue;
170-
@@ -936,18 +938,21 @@ function cleanNode(node) {
134+
@@ -950,18 +950,21 @@ function cleanNode(node) {
171135
}
172136
}
173137
if (node.tOwned) {

0 commit comments

Comments
 (0)