Skip to content

Commit 6bfb07c

Browse files
committed
frontend/editor: catch async load errors, retry logic, and render error urging user to refresh the page -- #8623
1 parent 19c3748 commit 6bfb07c

File tree

7 files changed

+292
-57
lines changed

7 files changed

+292
-57
lines changed

src/.claude/settings.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
"Bash(git checkout:*)",
1818
"Bash(git commit:*)",
1919
"Bash(git diff:*)",
20+
"Bash(git log:*)",
2021
"Bash(git push:*)",
2122
"Bash(grep:*)",
2223
"Bash(ln:*)",

src/packages/frontend/editors/register-all.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,6 @@ import "@cocalc/frontend/codemirror/init";
3434

3535
// CSS for the lightweight (< 1MB) nextjs friendly CodeEditor
3636
// component (in components/code-editor).
37-
// This is only for making this editro work in this frontend app.
37+
// This is only for making this editor work in this frontend app.
3838
// This dist.css is only 7K.
3939
import "@uiw/react-textarea-code-editor/dist.css";
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/*
2+
* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.
3+
* License: MS-RSL – see LICENSE.md for details
4+
*/
5+
6+
import { Alert as AntdAlert, Button } from "antd";
7+
import { ReactElement } from "react";
8+
9+
import { Icon, Paragraph } from "@cocalc/frontend/components";
10+
11+
interface EditorLoadErrorComponentProps {
12+
path: string;
13+
error: Error;
14+
}
15+
16+
/**
17+
* Error component shown when editor fails to load.
18+
* Displays error message with a button to refresh the page.
19+
*/
20+
export function EditorLoadErrorComponent(
21+
props: EditorLoadErrorComponentProps,
22+
): ReactElement {
23+
const { path, error } = props;
24+
25+
const handleRefresh = () => {
26+
// Refresh the page while preserving the current URL
27+
window.location.reload();
28+
};
29+
30+
return (
31+
<AntdAlert
32+
type="error"
33+
message="Editor Load Failed"
34+
description={
35+
<div style={{ marginTop: "12px" }}>
36+
<Paragraph>File: {path}</Paragraph>
37+
<Paragraph code>{String(error)}</Paragraph>
38+
<Paragraph>
39+
This usually happens due to temporary network issues.
40+
</Paragraph>
41+
<Button type="primary" size="large" onClick={handleRefresh}>
42+
<Icon name="reload" /> Refresh Page
43+
</Button>
44+
</div>
45+
}
46+
showIcon
47+
style={{ margin: "20px" }}
48+
/>
49+
);
50+
}

src/packages/frontend/file-editors.ts

Lines changed: 54 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
* License: MS-RSL – see LICENSE.md for details
44
*/
55

6+
import { delay } from "awaiting";
7+
68
import type { IconName } from "@cocalc/frontend/components/icon";
79

810
import {
@@ -13,9 +15,10 @@ import {
1315
required,
1416
} from "@cocalc/util/misc";
1517

16-
import { React } from "./app-framework";
18+
import { React } from "@cocalc/frontend/app-framework";
1719

18-
import { delay } from "awaiting";
20+
import { alert_message } from "./alerts";
21+
import { EditorLoadErrorComponent } from "./file-editors-error";
1922

2023
declare let DEBUG: boolean;
2124

@@ -131,6 +134,20 @@ export function register_file_editor(opts: FileEditorInfo): void {
131134
}
132135
}
133136

137+
/**
138+
* Logs when a file extension falls back to the unknown editor.
139+
* This helps with debugging why an editor failed to load.
140+
*/
141+
function logFallback(
142+
ext: string | undefined,
143+
path: string,
144+
is_public: boolean,
145+
): void {
146+
console.warn(
147+
`Editor fallback triggered: No editor found for ext '${ext ?? "unknown"}' on path '${path}' (is_public: ${is_public}), using unknown editor catchall`,
148+
);
149+
}
150+
134151
// Get editor for given path and is_public state.
135152

136153
function get_ed(
@@ -152,10 +169,12 @@ function get_ed(
152169
filename_extension(path).toLowerCase();
153170

154171
// either use the one given by ext, or if there isn't one, use the '' fallback.
155-
const spec =
156-
file_editors[is_pub][ext] != null
157-
? file_editors[is_pub][ext]
158-
: file_editors[is_pub][""];
172+
let spec = file_editors[is_pub][ext];
173+
if (spec == null) {
174+
// Log when falling back to unknown editor
175+
logFallback(ext, path, !!is_public);
176+
spec = file_editors[is_pub][""];
177+
}
159178
if (spec == null) {
160179
// This happens if the editors haven't been loaded yet. A valid use
161180
// case is you open a project and session restore creates one *background*
@@ -183,7 +202,19 @@ export async function initializeAsync(
183202
return editor.init(path, redux, project_id, content);
184203
}
185204
if (editor.initAsync != null) {
186-
return await editor.initAsync(path, redux, project_id, content);
205+
try {
206+
return await editor.initAsync(path, redux, project_id, content);
207+
} catch (err) {
208+
console.error(`Failed to initialize async editor for ${path}: ${err}`);
209+
// Single point where all async editor load errors are reported to user
210+
alert_message({
211+
type: "error",
212+
title: "Editor Load Failed",
213+
message: `Failed to load editor for ${path}: ${err}. Please check your internet connection and refresh the page.`,
214+
timeout: 10,
215+
});
216+
throw err;
217+
}
187218
}
188219
}
189220

@@ -223,7 +254,22 @@ export async function generateAsync(
223254
const { component, componentAsync } = e;
224255
if (component == null) {
225256
if (componentAsync != null) {
226-
return await componentAsync();
257+
try {
258+
return await componentAsync();
259+
} catch (err) {
260+
const error = err as Error;
261+
console.error(`Failed to load editor component for ${path}: ${error}`);
262+
// Single point where all async editor load errors are reported to user
263+
alert_message({
264+
type: "error",
265+
title: "Editor Load Failed",
266+
message: `Failed to load editor for ${path}: ${error}. Please check your internet connection and refresh the page.`,
267+
timeout: 10,
268+
});
269+
// Return error component with refresh button
270+
return () =>
271+
React.createElement(EditorLoadErrorComponent, { path, error });
272+
}
227273
}
228274
return () =>
229275
React.createElement(

src/packages/frontend/frame-editors/code-editor/code-editor-manager.ts

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -25,19 +25,25 @@ export class CodeEditor {
2525
}
2626

2727
async init(): Promise<void> {
28-
const ext = filename_extension(this.path);
29-
let editor = get_file_editor(ext, false);
30-
if (editor == null) {
31-
// fallback to text
32-
editor = get_file_editor("txt", false);
33-
}
34-
let name: string;
35-
if (editor.init != null) {
36-
name = editor.init(this.path, redux, this.project_id);
37-
} else {
38-
name = await editor.initAsync(this.path, redux, this.project_id);
28+
try {
29+
const ext = filename_extension(this.path);
30+
let editor = get_file_editor(ext, false);
31+
if (editor == null) {
32+
// fallback to text
33+
editor = get_file_editor("txt", false);
34+
}
35+
let name: string;
36+
if (editor.init != null) {
37+
name = editor.init(this.path, redux, this.project_id);
38+
} else {
39+
name = await editor.initAsync(this.path, redux, this.project_id);
40+
}
41+
this.actions = redux.getActions(name) as unknown as Actions; // definitely right
42+
} catch (err) {
43+
console.error(`Failed to initialize editor for ${this.path}: ${err}`);
44+
// Alert is shown at higher level in file-editors.ts
45+
throw err;
3946
}
40-
this.actions = redux.getActions(name) as unknown as Actions; // definitely right
4147
}
4248

4349
close(): void {

src/packages/frontend/frame-editors/frame-tree/register.ts

Lines changed: 115 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,95 @@ if (DEBUG) {
8585
// (window as any).frame_editor_reference_count = reference_count;
8686
}
8787

88+
/**
89+
* Wraps an async data loader with timeout protection and retry logic.
90+
*
91+
* Strategy:
92+
* - If 10 second timeout occurs → retry immediately
93+
* - If asyncLoader() fails immediately due to network error → wait 5 seconds → retry
94+
* - Maximum 3 attempts total
95+
*
96+
* This ensures that temporary network hiccups don't silently cause fallback to wrong editor.
97+
* NOTE: The caller must wrap this with reuseInFlight to prevent duplicate simultaneous loads.
98+
*/
99+
function withTimeoutAndRetry<T>(
100+
asyncLoaderFn: () => Promise<T>,
101+
ext: string | string[],
102+
timeoutMs: number = 10000,
103+
maxRetries: number = 3,
104+
): () => Promise<T> {
105+
const extStr = Array.isArray(ext) ? ext.join(",") : ext;
106+
107+
return async () => {
108+
let lastError: Error | null = null;
109+
110+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
111+
try {
112+
// Only log if retrying (attempt >= 2), not on first attempt
113+
if (attempt >= 2) {
114+
console.warn(
115+
`frame-editor/register: loading ${extStr} (attempt ${attempt}/${maxRetries})`,
116+
);
117+
}
118+
119+
// TEST: Uncomment below to simulate network error for ipynb files
120+
// if (extStr === "ipynb") {
121+
// throw new Error("Simulated network error for testing");
122+
// }
123+
124+
const result = await Promise.race([
125+
asyncLoaderFn(),
126+
new Promise<T>((_, reject) =>
127+
setTimeout(
128+
() =>
129+
reject(
130+
new Error(
131+
`Editor load timeout after ${timeoutMs}ms for ${extStr}. Check your internet connection.`,
132+
),
133+
),
134+
timeoutMs,
135+
),
136+
),
137+
]);
138+
139+
// Only log success if we retried, not on first attempt
140+
if (attempt >= 2) {
141+
console.warn(`frame-editor/register: loaded ${extStr} successfully`);
142+
}
143+
return result;
144+
} catch (err) {
145+
lastError = err as Error;
146+
const errorMsg = lastError.message || String(lastError);
147+
148+
if (attempt < maxRetries) {
149+
// Check if it's a timeout error or immediate network error
150+
const isTimeout = errorMsg.includes("timeout");
151+
const retryDelayMs = isTimeout ? 0 : 5000;
152+
const retryDelayStr =
153+
retryDelayMs === 0 ? "immediately" : "after 5 seconds";
154+
155+
console.warn(
156+
`frame-editor/register: failed to load ${extStr} (attempt ${attempt}/${maxRetries}): ${errorMsg}. Retrying ${retryDelayStr}...`,
157+
);
158+
159+
// Wait before retry (0ms for timeout, 5s for network errors)
160+
if (retryDelayMs > 0) {
161+
await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
162+
}
163+
} else {
164+
// Final attempt failed
165+
console.error(
166+
`frame-editor/register: failed to load ${extStr} after ${maxRetries} attempts: ${errorMsg}`,
167+
);
168+
}
169+
}
170+
}
171+
172+
// All retries exhausted
173+
throw lastError || new Error(`Failed to load editor for ${extStr}`);
174+
};
175+
}
176+
88177
function register(
89178
icon: IconName | undefined,
90179
ext: string | string[],
@@ -182,20 +271,42 @@ function register(
182271
"either asyncData must be given or components and Actions must be given (or both)",
183272
);
184273
}
274+
185275
let async_data: any = undefined;
186-
// so calls to componentAsync and initAsync don't happen at once!
187-
const getAsyncData = reuseInFlight(asyncData);
276+
277+
// Wrap the entire withTimeoutAndRetry with reuseInFlight to ensure
278+
// that if multiple callers request the editor simultaneously,
279+
// only ONE attempt is made (with retry logic).
280+
const getAsyncData = reuseInFlight(withTimeoutAndRetry(asyncData, ext));
188281

189282
data.componentAsync = async () => {
190283
if (async_data == null) {
191-
async_data = await getAsyncData();
284+
try {
285+
async_data = await getAsyncData();
286+
} catch (err) {
287+
console.error(
288+
`Failed to load async editor component for ext '${
289+
Array.isArray(ext) ? ext.join(",") : ext
290+
}': ${err}`,
291+
);
292+
// Alert is shown at higher level in file-editors.ts
293+
throw err;
294+
}
192295
}
193296
return async_data.component;
194297
};
195298

196299
data.initAsync = async (path: string, redux, project_id: string) => {
197300
if (async_data == null) {
198-
async_data = await getAsyncData();
301+
try {
302+
async_data = await getAsyncData();
303+
} catch (err) {
304+
console.error(
305+
`Failed to load async editor for path '${path}': ${err}`,
306+
);
307+
// Alert is shown at higher level in file-editors.ts
308+
throw err;
309+
}
199310
}
200311
return init(async_data.Actions)(path, redux, project_id);
201312
};

0 commit comments

Comments
 (0)