Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .changeset/avoid-duplicate-tui-cancel-footer.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
"react-doctor": patch
---

Prevent Ctrl-C from printing the completed-scan footer after the interactive cancellation footer.
Keep transient project selection out of terminal history, and preserve final interactive results when Ctrl-C prints the cancellation footer without printing a second completed-scan footer.
47 changes: 21 additions & 26 deletions packages/react-doctor/src/cli/ink/run-scan-app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -183,13 +183,30 @@ const resolveSelectedDirectories = async (
return promptProjectSelection(packages, rootDirectory);
};

const registerMountedTuiRenderer = (instance: ReturnType<typeof render>): (() => void) => {
let didDisposeRenderer = false;
const disposeRenderer = (shouldClearOutput: boolean): void => {
if (didDisposeRenderer) return;
didDisposeRenderer = true;
if (shouldClearOutput) instance.clear();
instance.unmount();
};
const unregisterActiveTuiRenderer = registerActiveTuiRenderer({
preserveOutput: () => disposeRenderer(false),
});
return () => {
unregisterActiveTuiRenderer();
disposeRenderer(true);
};
};

const promptProjectSelection = (
packages: ReadonlyArray<WorkspacePackage>,
rootDirectory: string,
): Promise<string[]> =>
new Promise((resolve) => {
let disposeRenderer = (): void => {};
recordCount(METRIC.tuiProjectSelectInlineShown);
recordCount(METRIC.tuiProjectSelectShown);
const instance = render(
<ProjectSelect
packages={packages}
Expand All @@ -199,20 +216,9 @@ const promptProjectSelection = (
resolve(directories);
}}
/>,
{ alternateScreen: false, exitOnCtrlC: false },
{ alternateScreen: true, exitOnCtrlC: false },
);
let didClearRenderer = false;
const clearRenderer = (): void => {
if (didClearRenderer) return;
didClearRenderer = true;
instance.clear();
instance.unmount();
};
const unregisterActiveTuiRenderer = registerActiveTuiRenderer({ clear: clearRenderer });
disposeRenderer = () => {
unregisterActiveTuiRenderer();
clearRenderer();
};
disposeRenderer = registerMountedTuiRenderer(instance);
});

interface ScanReportInput {
Expand Down Expand Up @@ -394,20 +400,9 @@ const mountScanApp = async (
/>,
{ alternateScreen: false, exitOnCtrlC: false },
);
let didClearRenderer = false;
const clearRenderer = (): void => {
if (didClearRenderer) return;
didClearRenderer = true;
instance.clear();
instance.unmount();
};
const unregisterActiveTuiRenderer = registerActiveTuiRenderer({ clear: clearRenderer });
return {
instance,
dispose: () => {
unregisterActiveTuiRenderer();
clearRenderer();
},
dispose: registerMountedTuiRenderer(instance),
};
};
const executePendingActions = async (): Promise<void> => {
Expand Down
6 changes: 3 additions & 3 deletions packages/react-doctor/src/cli/utils/active-tui-renderer.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
export interface ActiveTuiRenderer {
readonly clear: () => void;
readonly preserveOutput: () => void;
}

let activeTuiRenderer: ActiveTuiRenderer | null = null;
Expand All @@ -11,8 +11,8 @@ export const registerActiveTuiRenderer = (renderer: ActiveTuiRenderer): (() => v
};
};

export const clearActiveTuiRenderer = (): void => {
export const preserveActiveTuiRendererOutput = (): void => {
const renderer = activeTuiRenderer;
activeTuiRenderer = null;
renderer?.clear();
renderer?.preserveOutput();
};
2 changes: 1 addition & 1 deletion packages/react-doctor/src/cli/utils/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,7 @@ export const METRIC = {
tuiFindingNavigated: "tui.finding_navigated",
tuiIssueStreamShown: "tui.issue_stream_shown",
tuiProjectPathContextShown: "tui.project_path_context_shown",
tuiProjectSelectInlineShown: "tui.project_select_inline_shown",
tuiProjectSelectShown: "tui.project_select_shown",
tuiReportActionSelected: "tui.report_action_selected",
tuiCancelled: "tui.cancelled",
tuiScanInlineShown: "tui.scan_inline_shown",
Expand Down
4 changes: 2 additions & 2 deletions packages/react-doctor/src/cli/utils/exit-gracefully.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { flushSentry } from "../../instrument.js";
import { activeScanAbortRegistry } from "./active-scan-abort-registry.js";
import { clearActiveTuiRenderer } from "./active-tui-renderer.js";
import { preserveActiveTuiRendererOutput } from "./active-tui-renderer.js";
import { buildFooterLinkLines } from "./build-footer-link-lines.js";
import { buildSectionDivider } from "./build-section-divider.js";
import { SIGINT_EXIT_CODE } from "./constants.js";
Expand All @@ -14,7 +14,7 @@ export const exitGracefully = (): void => {
if (didStartExiting) return process.exit(SIGINT_EXIT_CODE);
didStartExiting = true;
activeScanAbortRegistry.abortAll();
clearActiveTuiRenderer();
preserveActiveTuiRendererOutput();
try {
if (isJsonModeActive()) {
writeJsonErrorReport(new Error("Scan cancelled by user (SIGINT/SIGTERM)"));
Expand Down
22 changes: 12 additions & 10 deletions packages/react-doctor/tests/active-tui-renderer.test.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,23 @@
import { describe, expect, it, vi } from "vite-plus/test";
import {
clearActiveTuiRenderer,
preserveActiveTuiRendererOutput,
registerActiveTuiRenderer,
} from "../src/cli/utils/active-tui-renderer.js";

describe("activeTuiRenderer", () => {
it("clears only the currently registered renderer", () => {
const firstClear = vi.fn();
const secondClear = vi.fn();
const unregisterFirst = registerActiveTuiRenderer({ clear: firstClear });
registerActiveTuiRenderer({ clear: secondClear });
it("preserves only the currently registered renderer", () => {
const firstPreserveOutput = vi.fn();
const secondPreserveOutput = vi.fn();
const unregisterFirst = registerActiveTuiRenderer({
preserveOutput: firstPreserveOutput,
});
registerActiveTuiRenderer({ preserveOutput: secondPreserveOutput });

unregisterFirst();
clearActiveTuiRenderer();
clearActiveTuiRenderer();
preserveActiveTuiRendererOutput();
preserveActiveTuiRendererOutput();

expect(firstClear).not.toHaveBeenCalled();
expect(secondClear).toHaveBeenCalledTimes(1);
expect(firstPreserveOutput).not.toHaveBeenCalled();
expect(secondPreserveOutput).toHaveBeenCalledTimes(1);
});
});
4 changes: 2 additions & 2 deletions packages/react-doctor/tests/ink/exit-on-ctrl-c.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ describe("useExitOnCtrlC", () => {
lifecycleEvents.push("log");
});
registerActiveTuiRenderer({
clear: () => lifecycleEvents.push("clear"),
preserveOutput: () => lifecycleEvents.push("preserve"),
});

const { stdin, unmount } = render(<Harness />);
Expand All @@ -36,7 +36,7 @@ describe("useExitOnCtrlC", () => {
expect(exitSpy).toHaveBeenCalledWith(130);
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining(DOCS_URL));
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining(CANONICAL_GITHUB_URL));
expect(lifecycleEvents.slice(0, 2)).toEqual(["clear", "log"]);
expect(lifecycleEvents.slice(0, 2)).toEqual(["preserve", "log"]);
unmount();
});

Expand Down
28 changes: 20 additions & 8 deletions packages/react-doctor/tests/ink/run-scan-app.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as path from "node:path";
import * as Effect from "effect/Effect";
import { render } from "ink";
import { isValidElement } from "react";
import { afterEach, describe, expect, it, vi } from "vite-plus/test";
import type {
DiffInfo,
Expand All @@ -11,7 +12,7 @@ import type {
import { getBaselineDiffPlan, getDiffInfo, Reporter, resolveScanTarget } from "@react-doctor/core";
import { runScanApp } from "../../src/cli/ink/run-scan-app.js";
import type { ScanStore, TuiHandoffRequest } from "../../src/cli/ink/scan-store.js";
import { clearActiveTuiRenderer } from "../../src/cli/utils/active-tui-renderer.js";
import { preserveActiveTuiRendererOutput } from "../../src/cli/utils/active-tui-renderer.js";
import { computeProjectedScore } from "../../src/cli/utils/compute-score-projection.js";
import { inspect } from "../../src/inspect.js";
import { buildDiagnostic, buildTestProject } from "../regressions/_helpers.js";
Expand All @@ -38,6 +39,7 @@ const mockState = vi.hoisted(() => ({
shouldRequestHandoff: false,
shouldSetUpCi: false,
shouldQuit: false,
shouldAutoSubmitProjectSelection: true,
scanRendererClearCount: 0,
lifecycleEvents: new Array<string>(),
scanStores: new Array<ScanStore>(),
Expand All @@ -51,7 +53,11 @@ vi.mock("ink", async (importOriginal) => {
return {
...actual,
render: vi.fn((node) => {
if (React.isValidElement<MockProjectSelectProps>(node) && node.props.packages) {
if (
React.isValidElement<MockProjectSelectProps>(node) &&
node.props.packages &&
mockState.shouldAutoSubmitProjectSelection
) {
queueMicrotask(() => node.props.onSubmit?.(mockState.projectDirectories));
}
if (React.isValidElement<MockScanAppProps>(node)) {
Expand Down Expand Up @@ -196,6 +202,7 @@ describe("runScanApp", () => {
mockState.shouldRequestHandoff = false;
mockState.shouldSetUpCi = false;
mockState.shouldQuit = false;
mockState.shouldAutoSubmitProjectSelection = true;
mockState.scanRendererClearCount = 0;
mockState.lifecycleEvents.length = 0;
mockState.scanStores.length = 0;
Expand All @@ -205,7 +212,7 @@ describe("runScanApp", () => {
vi.clearAllMocks();
});

it("keeps terminal scrollback visible during project selection", async () => {
it("uses a disposable screen for project selection", async () => {
vi.spyOn(process.stdout, "write").mockImplementation(() => true);
const rootDirectory = "/repo";
const originalIsTtyDescriptor = Object.getOwnPropertyDescriptor(process.stdin, "isTTY");
Expand All @@ -230,12 +237,12 @@ describe("runScanApp", () => {
}

expect(vi.mocked(render).mock.calls[0]?.[1]).toEqual({
alternateScreen: false,
alternateScreen: true,
exitOnCtrlC: false,
});
});

it("clears the project selection screen through the active renderer lifecycle", async () => {
it("preserves the active screen in scrollback when exiting", async () => {
vi.spyOn(process.stdout, "write").mockImplementation(() => true);
const rootDirectory = "/repo";
const originalIsTtyDescriptor = Object.getOwnPropertyDescriptor(process.stdin, "isTTY");
Expand All @@ -244,6 +251,7 @@ describe("runScanApp", () => {
{ name: "web", directory: "/repo/apps/web" },
{ name: "admin", directory: "/repo/apps/admin" },
);
mockState.shouldAutoSubmitProjectSelection = false;
mockState.scanTargets.set(
rootDirectory,
buildScanTarget(rootDirectory, rootDirectory, null, rootDirectory),
Expand All @@ -254,12 +262,16 @@ describe("runScanApp", () => {
await vi.waitFor(() => expect(render).toHaveBeenCalled());
const selectionRenderer = vi.mocked(render).mock.results[0]?.value;

clearActiveTuiRenderer();
preserveActiveTuiRendererOutput();

expect(selectionRenderer?.clear).toHaveBeenCalledOnce();
expect(selectionRenderer?.clear).not.toHaveBeenCalled();
expect(selectionRenderer?.unmount).toHaveBeenCalledOnce();
const selectionNode = vi.mocked(render).mock.calls[0]?.[0];
if (isValidElement<MockProjectSelectProps>(selectionNode)) {
selectionNode.props.onSubmit?.([]);
}
await scanPromise;
expect(selectionRenderer?.clear).toHaveBeenCalledOnce();
expect(selectionRenderer?.clear).not.toHaveBeenCalled();
expect(selectionRenderer?.unmount).toHaveBeenCalledOnce();
} finally {
if (originalIsTtyDescriptor) {
Expand Down
Loading