Summary
Errors thrown inside async useEffect callbacks in screen() commands are silently swallowed by Ink/React. The process exits with no error output — no stack trace, no message, nothing. This makes debugging screen-based commands extremely difficult.
Reproduction
// In any screen() component's useEffect:
useEffect(() => {
async function init() {
throw new Error('This error is never seen')
}
init() // fire-and-forget async — rejection swallowed by React
}, [])
The screen renders briefly (e.g., a loading spinner), then exits silently back to the shell.
Root Cause
init() is an async function called without .catch() inside useEffect
- The rejected promise becomes an unhandled rejection inside React's effect lifecycle
- Ink/React catches it internally and never re-throws to the process level
process.on('unhandledRejection') never fires
- The fullscreen TUI exits, clearing the terminal — no trace of the error remains
This is a known Ink behavior: vadimdemedes/ink#288, vadimdemedes/ink#542
Expected Behavior
When a screen() command's component tree throws an unhandled error, kidd-cli should:
- Catch the error at the framework level (React error boundary or
waitUntilExit rejection)
- Exit fullscreen mode cleanly (let Ink's cleanup run)
- Then print the error to stderr so the user can see it
- Exit with code 1
Proposed Fix
kidd-cli's screen() implementation should wrap the rendered component tree in a React error boundary that:
Alternatively, screen() could install its own process.on('unhandledRejection') handler scoped to the render lifecycle.
Workaround
CLI authors can work around this by:
- Adding
.catch() to all async calls in useEffect
- Manually calling
reportCrash() + process.exit(1) in the catch handler
- Using the hook's error state to display the error in the TUI
This is what we're doing in zpress — see thebytefarm/ciderpress#105.
Related
Summary
Errors thrown inside async
useEffectcallbacks inscreen()commands are silently swallowed by Ink/React. The process exits with no error output — no stack trace, no message, nothing. This makes debugging screen-based commands extremely difficult.Reproduction
The screen renders briefly (e.g., a loading spinner), then exits silently back to the shell.
Root Cause
init()is an async function called without.catch()insideuseEffectprocess.on('unhandledRejection')never firesThis is a known Ink behavior: vadimdemedes/ink#288, vadimdemedes/ink#542
Expected Behavior
When a
screen()command's component tree throws an unhandled error, kidd-cli should:waitUntilExitrejection)Proposed Fix
kidd-cli's
screen()implementation should wrap the rendered component tree in a React error boundary that:exit(error)to trigger clean unmountprocess.stderr.writeAlternatively,
screen()could install its ownprocess.on('unhandledRejection')handler scoped to the render lifecycle.Workaround
CLI authors can work around this by:
.catch()to all async calls inuseEffectreportCrash()+process.exit(1)in the catch handlerThis is what we're doing in zpress — see thebytefarm/ciderpress#105.
Related