fix(FN-1135): retry xterm imports when MIME errors occur

- Add retryDynamicImport helper in TerminalModal with targeted retry detection for MIME type and dynamic import fetch failures
- Retry xterm module imports with backoff delays (500ms, 1500ms, 3000ms) and preserve original error reporting when retries are exhausted
- Add TerminalModal tests covering successful retry recovery, exhausted retry fallback to init error UI, and no-retry behavior for non-retryable failures
- Add a changeset patch for @gsxdsm/fusion describing the terminal initialization fix
This commit is contained in:
gsxdsm
2026-04-07 22:50:17 -07:00
parent 69bb0290aa
commit 9a332c4b61
3 changed files with 177 additions and 5 deletions

View File

@@ -10,6 +10,62 @@ import type { FitAddon } from "@xterm/addon-fit";
/** Timeout for xterm.js dynamic imports + terminal.open() setup. */
const XTERM_INIT_TIMEOUT_MS = 10000;
const XTERM_IMPORT_RETRY_DELAYS_MS = [500, 1500, 3000] as const;
function isRetryableDynamicImportError(error: unknown): boolean {
const message =
typeof error === "string"
? error
: error instanceof Error
? error.message
: String(error);
return (
message.includes("MIME type") ||
message.includes("Failed to fetch dynamically imported module")
);
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export async function retryDynamicImport<T>(
importFactory: () => Promise<T>,
retryDelaysMs: readonly number[] = XTERM_IMPORT_RETRY_DELAYS_MS,
): Promise<T> {
let originalError: unknown;
for (let attempt = 0; attempt <= retryDelaysMs.length; attempt += 1) {
try {
return await importFactory();
} catch (error) {
if (!isRetryableDynamicImportError(error)) {
throw error;
}
if (originalError === undefined) {
originalError = error;
}
const delayMs = retryDelaysMs[attempt];
if (delayMs === undefined) {
throw originalError ?? error;
}
const message = error instanceof Error ? error.message : String(error);
console.warn(
`[TerminalModal] Dynamic xterm import failed (attempt ${attempt + 1}/${retryDelaysMs.length + 1}). Retrying in ${delayMs}ms...`,
message,
);
await sleep(delayMs);
}
}
throw originalError ?? new Error("Dynamic import failed");
}
/** Whether the current device is likely mobile (touch-primary, small viewport). */
function isMobileDevice(): boolean {
if (typeof window === "undefined") return false;
@@ -241,11 +297,13 @@ export function TerminalModal({ isOpen, onClose, initialCommand }: TerminalModal
const initTerminal = async () => {
// Dynamically import xterm modules with watchdog timeout
const importsPromise = Promise.all([
import("@xterm/xterm"),
import("@xterm/addon-fit"),
import("@xterm/addon-web-links"),
]);
const importsPromise = retryDynamicImport(() =>
Promise.all([
import("@xterm/xterm"),
import("@xterm/addon-fit"),
import("@xterm/addon-web-links"),
]),
);
// Watchdog: reject if imports + setup take too long
const timeoutPromise = new Promise<never>((_resolve, reject) => {

View File

@@ -843,6 +843,115 @@ describe("TerminalModal", () => {
});
});
describe("xterm import MIME type retry", () => {
function isXtermImportBatch(values: Iterable<unknown>): values is Promise<unknown>[] {
return (
Array.isArray(values) &&
values.length === 3 &&
values.every((entry) => entry && typeof (entry as Promise<unknown>).then === "function")
);
}
afterEach(() => {
vi.useRealTimers();
});
it("retries MIME type import failures and initializes successfully on a later attempt", async () => {
vi.useFakeTimers();
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const originalPromiseAll = Promise.all.bind(Promise);
let importAttempts = 0;
vi.spyOn(Promise, "all").mockImplementation(((values: Iterable<unknown>) => {
if (isXtermImportBatch(values)) {
importAttempts += 1;
if (importAttempts === 1) {
return Promise.reject(
new Error("'text/html' is not a valid JavaScript MIME type"),
);
}
}
return originalPromiseAll(values);
}) as typeof Promise.all);
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
await act(async () => {
await vi.advanceTimersByTimeAsync(500);
});
vi.useRealTimers();
await waitFor(() => {
expect(mockTerminalInstance.open).toHaveBeenCalled();
});
expect(importAttempts).toBe(2);
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(screen.queryByTestId("terminal-xterm-init-error")).toBeNull();
});
it("shows xterm init error UI when MIME type import retries are exhausted", async () => {
vi.useFakeTimers();
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const originalPromiseAll = Promise.all.bind(Promise);
let importAttempts = 0;
vi.spyOn(Promise, "all").mockImplementation(((values: Iterable<unknown>) => {
if (isXtermImportBatch(values)) {
importAttempts += 1;
return Promise.reject(
new Error("'text/html' is not a valid JavaScript MIME type"),
);
}
return originalPromiseAll(values);
}) as typeof Promise.all);
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
await act(async () => {
await vi.advanceTimersByTimeAsync(5000);
});
vi.useRealTimers();
await waitFor(() => {
expect(screen.getByTestId("terminal-xterm-init-error")).toBeTruthy();
});
expect(screen.getByText(/MIME type/)).toBeTruthy();
expect(importAttempts).toBe(4);
expect(warnSpy).toHaveBeenCalledTimes(3);
});
it("does not retry non-MIME import failures", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const originalPromiseAll = Promise.all.bind(Promise);
let importAttempts = 0;
vi.spyOn(Promise, "all").mockImplementation(((values: Iterable<unknown>) => {
if (isXtermImportBatch(values)) {
importAttempts += 1;
return Promise.reject(new Error("xterm constructor failed"));
}
return originalPromiseAll(values);
}) as typeof Promise.all);
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
await waitFor(() => {
expect(screen.getByTestId("terminal-xterm-init-error")).toBeTruthy();
});
expect(screen.getByText(/xterm constructor failed/)).toBeTruthy();
expect(importAttempts).toBe(1);
expect(warnSpy).not.toHaveBeenCalled();
});
});
// --- Invalid session auto-recovery ---
describe("invalid session auto-recovery (FN-1021)", () => {
it("calls replaceActiveTabSession when WebSocket reports session invalid (code 4004)", async () => {