use central unmount for ink render tree; stop messing with node's event loop#7153
use central unmount for ink render tree; stop messing with node's event loop#7153isaacroldan merged 3 commits intomainfrom
Conversation
This stack of pull requests is managed by Graphite. Learn more about stacking. |
|
We detected some changes at Caution DO NOT create changesets for features which you do not wish to be included in the public changelog of the next CLI release. |
dmerand
left a comment
There was a problem hiding this comment.
My AI pal who's been looking at Ink/React has some thoughts on the design here. I'm still not an expert in Ink/React lifecycle, but I left the stuff worth sharing/considering.
| * fires post-render, guaranteeing all batched state updates have been flushed | ||
| * before the tree is torn down. | ||
| */ | ||
| export function InkLifecycleRoot({children}: {children: React.ReactNode}) { | ||
| const {exit} = useApp() | ||
| const [exitResult, setExitResult] = useState<{error?: Error} | null>(null) | ||
|
|
||
| const complete = useCallback((error?: Error) => { | ||
| setExitResult({error}) | ||
| }, []) | ||
|
|
||
| useEffect(() => { | ||
| if (exitResult !== null) { | ||
| exit(exitResult.error) | ||
| } | ||
| }, [exitResult, exit]) | ||
|
|
||
| return <CompletionContext.Provider value={complete}>{children}</CompletionContext.Provider> | ||
| } | ||
|
|
||
| interface RenderOnceOptions { |
There was a problem hiding this comment.
🎉 Praise: This is the first change in this area that feels like it's addressing the root architectural problem instead of compensating for it.
Moving exit() ownership into a single root effect is a much better model than continuing to let leaf components imperatively tear down Ink from async callbacks / event handlers. Even if we still need to harden the contract a bit, this is the right direction.
| const {exit} = useApp() | ||
| const [exitResult, setExitResult] = useState<{error?: Error} | null>(null) | ||
|
|
||
| const complete = useCallback((error?: Error) => { | ||
| setExitResult({error}) | ||
| }, []) | ||
|
|
||
| useEffect(() => { | ||
| if (exitResult !== null) { | ||
| exit(exitResult.error) | ||
| } | ||
| }, [exitResult, exit]) | ||
|
|
||
| return <CompletionContext.Provider value={complete}>{children}</CompletionContext.Provider> | ||
| } |
There was a problem hiding this comment.
🏗️ Design: The root still needs an explicit completion policy.
Right now complete(error?) just updates exitResult, which means multiple terminal signals can race and overwrite one another. In practice I could imagine:
- normal success
- abort
- error
all trying to complete the same tree from different effects. If that happens, the final exit outcome becomes timing-dependent again — just at the root instead of in leaf components.
Suggestion: Encode an explicit policy — either first terminal signal wins, or error/abort beats success, etc. Even a simple "first write wins" guard would make this much easier to reason about:
const complete = useCallback((error?: Error) => {
setExitResult((prev) => prev ?? {error})
}, [])Whichever policy we want, it should be encoded here rather than left to update ordering.
There was a problem hiding this comment.
I can't make heads or tails of this comment. wrapping the state change in a useCallback seems like arbitrary complexity. I think the pattern here is correct.
| setCompletionResult({}) | ||
| } | ||
| // eslint-disable-next-line no-catch-all/no-catch-all | ||
| } catch (error: unknown) { | ||
| if (!keepRunningAfterProcessesResolve) { | ||
| setImmediate(() => setTimeout(() => unmountInk(error as Error | undefined), 0)) | ||
| setCompletionResult({error: error as Error}) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // eslint-disable-next-line @typescript-eslint/no-floating-promises | ||
| runProcesses() | ||
| }, [abortSignal, processes, writableStream, unmountInk, keepRunningAfterProcessesResolve]) | ||
| }, [abortSignal, processes, writableStream, keepRunningAfterProcessesResolve]) | ||
|
|
||
| useEffect(() => { | ||
| if (completionResult !== null) { | ||
| complete(completionResult.error) | ||
| } | ||
| }, [completionResult, complete]) | ||
|
|
||
| const {lineVertical} = figures | ||
|
|
There was a problem hiding this comment.
💡 Improvement: This is a much healthier direction than the previous setImmediate(() => setTimeout(...)) pattern.
The main thing missing now is direct regression coverage for terminal-state ordering. Since completion is now mediated through state/effects, we need tests that exercise the exact cases we're trying to stabilize:
- success renders final state before exit
- error renders final state before exit
- abort renders final shutdown state before exit
- abort racing with natural completion resolves deterministically
Suggestion: Prioritize one ConcurrentOutput test and one abort-vs-completion test, since those seem like the highest-risk paths historically. Without that, we may still have a timing bug here — just a different one than before.
db1ae11 to
0893a42
Compare
- ConcurrentOutput tests: use gate pattern to keep processes alive during assertions instead of relying on setImmediate timing - Downstream tests (init, theme-downloader, theme-app-extension): mock renderTasks so business logic tests don't spin up real Ink instances Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
|
||
| return {...realModule, ...mockModule} | ||
| return { | ||
| ...realModule, |
There was a problem hiding this comment.
this will stabilize theme app timeouts. previously only part of the render flow was mocked, but ink/react isn't fully set up in the test env, so we were getting timeouts. making renderTasks async mimics the existing concurrentoutput/etc behavior.
| setExitResult({error}) | ||
| }, []) | ||
|
|
||
| useEffect(() => { |
There was a problem hiding this comment.
this still feels incredibly cursed. the ink docs mention it's a common case, but i want to keep thinking about this (or remove ink). imperatively managing unmount is exactly the thing i don't want to do if i have react. but at least for now it removes the mnual event loop ticks and race conditions
| * fires post-render, guaranteeing all batched state updates have been flushed | ||
| * before the tree is torn down. | ||
| */ | ||
| export function InkLifecycleRoot({children}: {children: React.ReactNode}) { |
There was a problem hiding this comment.
i don't know ink that well, but i am hoping there's a better way to do this. having to manually manage teardown
- useComplete() throws if called outside InkLifecycleRoot instead of silently returning a no-op that hangs - Test render helper wraps rerenders with InkLifecycleRoot so useComplete() works after rerender - Add sequential render regression test verifying no teardown interleaving after setImmediate removal Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Differences in type declarationsWe detected differences in the type declarations generated by Typescript for this branch compared to the baseline ('main' branch). Please, review them to ensure they are backward-compatible. Here are some important things to keep in mind:
New type declarationsWe found no new type declarations in this PR Existing type declarationspackages/cli-kit/dist/private/node/ui.d.ts import { Logger, LogLevel } from '../../public/node/output.js';
+import React from 'react';
import { Key, RenderOptions } from 'ink';
import { EventEmitter } from 'events';
+/**
+ * Signal that the current Ink tree is done. Must be called within an
+ * InkLifecycleRoot — throws if the provider is missing so lifecycle
+ * bugs surface immediately instead of silently hanging.
+ */
+export declare function useComplete(): (error?: Error) => void;
+/**
+ * Root wrapper for Ink trees. Owns the single `exit()` call site — children
+ * signal completion via `useComplete()`, which sets state here. The `useEffect`
+ * fires post-render, guaranteeing all batched state updates have been flushed
+ * before the tree is torn down.
+ */
+export declare function InkLifecycleRoot({ children }: {
+ children: React.ReactNode;
+}): React.JSX.Element;
interface RenderOnceOptions {
logLevel?: LogLevel;
logger?: Logger;
renderOptions?: RenderOptions;
}
export declare function renderOnce(element: JSX.Element, { logLevel, renderOptions }: RenderOnceOptions): string | undefined;
-export declare function render(element: JSX.Element, options?: RenderOptions): Promise<unknown>;
+export declare function render(element: JSX.Element, options?: RenderOptions): Promise<void>;
export declare class Stdout extends EventEmitter {
columns: number;
rows: number;
readonly frames: string[];
private _lastFrame?;
constructor(options: {
columns?: number;
rows?: number;
});
write: (frame: string) => void;
lastFrame: () => string | undefined;
}
export declare function handleCtrlC(input: string, key: Key, exit?: () => void): void;
export {};
packages/cli-kit/dist/public/node/ui.d.ts@@ -34,7 +34,7 @@ export interface RenderConcurrentOptions extends PartialBy<ConcurrentOutputProps
* 00:00:00 │ frontend │ third frontend message
*
*/
-export declare function renderConcurrent({ renderOptions, ...props }: RenderConcurrentOptions): Promise<unknown>;
+export declare function renderConcurrent({ renderOptions, ...props }: RenderConcurrentOptions): Promise<void>;
export type AlertCustomSection = CustomSection;
export type RenderAlertOptions = Omit<AlertOptions, 'type'>;
/**
|
| // Given | ||
| const backendSync = new Synchronizer() | ||
| const frontendSync = new Synchronizer() | ||
| const gate = new Synchronizer() |
There was a problem hiding this comment.
this needs a better solution. these tests are already relying on promise flushing in a roundabout way. we should rethink the entire approach.
| }) | ||
| }, []) | ||
|
|
||
| useEffect(() => { |
There was a problem hiding this comment.
all these components are way too effectful at the moment. we need to follow up on this.
isaacroldan
left a comment
There was a problem hiding this comment.
🎩'ed and working as expected, great work! 👏

WHY are these changes introduced?
The Ink UI layer accumulated
setImmediate(() => setTimeout(() => unmountInk()))workarounds to deal with React 19's concurrent scheduler. Components were callinguseApp().exit()from.then()callbacks and event listeners — outside React's render cycle — which raced with the batch flush, tearing down the tree before the final state rendered (e.g. loading bars persisting in the terminal).This resulted in components calling exit at the wrong point in React's lifecycle. The workarounds tried to manually yield to the event loop to let React catch up. This was fragile and forced us to mess with Node.js scheduler internals.
Demo fix:
kitchen sinkno longer double-renders:Screen Recording 2026-04-01 at 5.52.39 PM.mov (uploaded via Graphite)
WHAT is this pull request doing?
Introduces
InkLifecycleRoot, a single root wrapper component that is the only placeuseApp().exit()is ever called. Components signal completion through React context (useComplete()) — a normal state change, not an imperative unmount.The exit chain follows React's lifecycle:
useEffectfires post-render → callscomplete()useEffectfires → callsexit()This guarantees all state is flushed before teardown.
Measuring impact
Checklist