Skip to content

Reapply: central unmount for ink render tree + fix render lifecycle race#7217

Merged
ryancbahan merged 3 commits intomainfrom
rcb/rendering-changes-react-fix-v2
Apr 9, 2026
Merged

Reapply: central unmount for ink render tree + fix render lifecycle race#7217
ryancbahan merged 3 commits intomainfrom
rcb/rendering-changes-react-fix-v2

Conversation

@ryancbahan
Copy link
Copy Markdown
Contributor

@ryancbahan ryancbahan commented Apr 7, 2026

Summary

Reapplies #7153 (reverted in #7157) with a fix for the two bugs that caused the revert:

  1. Loading bar stayed visible after deploy / store execute completed
  2. Last line of output was cut off in some commands

Root cause

renderTasks and renderSingleTask resolved their promises via onComplete={resolve} — a callback that fires inside the React tree before ink unmounts. The caller resumed and wrote to stdout while ink was still tearing down, causing ink's cleanup to overwrite the caller's output.

Fix

Changed renderTasks and renderSingleTask to use the same pattern the prompt functions (renderSelectPrompt, renderTextPrompt, etc.) already use:

// Before (broken): promise resolves before ink unmounts
return new Promise((resolve, reject) => {
  render(<Component onComplete={resolve} />, options).catch(reject)
})

// After (correct): awaits full ink lifecycle before returning
let result: T
await render(<Component onComplete={(v) => { result = v }} />, options)
return result!

This guarantees the caller only resumes after ink has fully unmounted and flushed its final frame to stdout. This aligns with Ink's rendering approach when concurrent mode is disabled.

Tophatting

  • All existing UI tests pass (17/17)
  • shopify app deploy — loading bar disappears after completion
  • pnpm shopify store execute — loading bars disappear after completion
  • Last line of output is not cut off in any command
  • shopify app init — no render issues
  • shopify app generate extension — no render issues
  • shopify kitchen-sink async — loading bar clears properly
  • Select/text prompts still work (e.g. shopify app dev org selection)

Copy link
Copy Markdown
Contributor Author

This stack of pull requests is managed by Graphite. Learn more about stacking.

@ryancbahan ryancbahan changed the title Reapply "use central unmount for ink render tree; stop messing with node's event loop" Reapply: central unmount for ink render tree + fix render lifecycle race Apr 7, 2026
renderTasks and renderSingleTask resolved their promises via
onComplete={resolve}, which fires inside the React tree before
ink unmounts. This caused the caller to write to stdout while
ink was still tearing down, resulting in loading bars staying
visible and last lines of output being cut off.

Changed both functions to await render() (which awaits
waitUntilExit()) before returning, matching the pattern the
prompt functions already use.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@ryancbahan ryancbahan marked this pull request as ready for review April 7, 2026 22:19
@ryancbahan ryancbahan requested review from a team as code owners April 7, 2026 22:19
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 7, 2026

Differences in type declarations

We 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:

  • Some seemingly private modules might be re-exported through public modules.
  • If the branch is behind main you might see odd diffs, rebase main into this branch.

New type declarations

We found no new type declarations in this PR

Existing type declarations

packages/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'>;
 /**

Copy link
Copy Markdown
Contributor

@gonzaloriestra gonzaloriestra left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


Review assisted by pair-review

Copy link
Copy Markdown
Contributor

@isaacroldan isaacroldan left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👏

@ryancbahan ryancbahan added this pull request to the merge queue Apr 9, 2026
Merged via the queue into main with commit fdfccd5 Apr 9, 2026
26 checks passed
@ryancbahan ryancbahan deleted the rcb/rendering-changes-react-fix-v2 branch April 9, 2026 14:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants