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:
5
.changeset/fix-terminal-mime-retry.md
Normal file
5
.changeset/fix-terminal-mime-retry.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@gsxdsm/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix terminal failing to initialize on first page load with MIME type error. The terminal now automatically retries dynamic xterm.js imports when the server returns an HTML response instead of JavaScript.
|
||||||
@@ -10,6 +10,62 @@ import type { FitAddon } from "@xterm/addon-fit";
|
|||||||
/** Timeout for xterm.js dynamic imports + terminal.open() setup. */
|
/** Timeout for xterm.js dynamic imports + terminal.open() setup. */
|
||||||
const XTERM_INIT_TIMEOUT_MS = 10000;
|
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). */
|
/** Whether the current device is likely mobile (touch-primary, small viewport). */
|
||||||
function isMobileDevice(): boolean {
|
function isMobileDevice(): boolean {
|
||||||
if (typeof window === "undefined") return false;
|
if (typeof window === "undefined") return false;
|
||||||
@@ -241,11 +297,13 @@ export function TerminalModal({ isOpen, onClose, initialCommand }: TerminalModal
|
|||||||
|
|
||||||
const initTerminal = async () => {
|
const initTerminal = async () => {
|
||||||
// Dynamically import xterm modules with watchdog timeout
|
// Dynamically import xterm modules with watchdog timeout
|
||||||
const importsPromise = Promise.all([
|
const importsPromise = retryDynamicImport(() =>
|
||||||
import("@xterm/xterm"),
|
Promise.all([
|
||||||
import("@xterm/addon-fit"),
|
import("@xterm/xterm"),
|
||||||
import("@xterm/addon-web-links"),
|
import("@xterm/addon-fit"),
|
||||||
]);
|
import("@xterm/addon-web-links"),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
// Watchdog: reject if imports + setup take too long
|
// Watchdog: reject if imports + setup take too long
|
||||||
const timeoutPromise = new Promise<never>((_resolve, reject) => {
|
const timeoutPromise = new Promise<never>((_resolve, reject) => {
|
||||||
|
|||||||
@@ -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 ---
|
// --- Invalid session auto-recovery ---
|
||||||
describe("invalid session auto-recovery (FN-1021)", () => {
|
describe("invalid session auto-recovery (FN-1021)", () => {
|
||||||
it("calls replaceActiveTabSession when WebSocket reports session invalid (code 4004)", async () => {
|
it("calls replaceActiveTabSession when WebSocket reports session invalid (code 4004)", async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user