Skip to content

Commit 453f8a6

Browse files
Merge pull request #8634 from sagemathinc/fix-jupyter-plain-code-8623
frontend/editor: catch async load errors, retry logic, and render error to refresh page
2 parents b0f7289 + 5f2a58e commit 453f8a6

File tree

7 files changed

+284
-52
lines changed

7 files changed

+284
-52
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: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
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 EditorLoadErrorProps {
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 EditorLoadError(props: EditorLoadErrorProps): ReactElement {
21+
const { path, error } = props;
22+
23+
const handleRefresh = () => {
24+
// Refresh the page while preserving the current URL
25+
window.location.reload();
26+
};
27+
28+
return (
29+
<AntdAlert
30+
type="error"
31+
message="Editor Load Failed"
32+
description={
33+
<div style={{ marginTop: "12px" }}>
34+
<Paragraph>File: {path}</Paragraph>
35+
<Paragraph code>{String(error)}</Paragraph>
36+
<Paragraph>
37+
This usually happens due to temporary network issues.
38+
</Paragraph>
39+
<Button type="primary" size="large" onClick={handleRefresh}>
40+
<Icon name="reload" /> Refresh Page
41+
</Button>
42+
</div>
43+
}
44+
showIcon
45+
style={{ margin: "20px" }}
46+
/>
47+
);
48+
}

src/packages/frontend/file-editors.ts

Lines changed: 55 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 { EditorLoadError } from "./file-editors-error";
1922

2023
declare let DEBUG: boolean;
2124

@@ -131,6 +134,22 @@ 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 '${
148+
ext ?? "unknown"
149+
}' on path '${path}' (is_public: ${is_public}), using unknown editor catchall`,
150+
);
151+
}
152+
134153
// Get editor for given path and is_public state.
135154

136155
function get_ed(
@@ -152,10 +171,12 @@ function get_ed(
152171
filename_extension(path).toLowerCase();
153172

154173
// 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][""];
174+
let spec = file_editors[is_pub][ext];
175+
if (spec == null) {
176+
// Log when falling back to unknown editor
177+
logFallback(ext, path, !!is_public);
178+
spec = file_editors[is_pub][""];
179+
}
159180
if (spec == null) {
160181
// This happens if the editors haven't been loaded yet. A valid use
161182
// case is you open a project and session restore creates one *background*
@@ -183,7 +204,19 @@ export async function initializeAsync(
183204
return editor.init(path, redux, project_id, content);
184205
}
185206
if (editor.initAsync != null) {
186-
return await editor.initAsync(path, redux, project_id, content);
207+
try {
208+
return await editor.initAsync(path, redux, project_id, content);
209+
} catch (err) {
210+
console.error(`Failed to initialize async editor for ${path}: ${err}`);
211+
// Single point where all async editor load errors are reported to user
212+
alert_message({
213+
type: "error",
214+
title: "Editor Load Failed",
215+
message: `Failed to load editor for ${path}: ${err}. Please check your internet connection and refresh the page.`,
216+
timeout: 10,
217+
});
218+
throw err;
219+
}
187220
}
188221
}
189222

@@ -223,7 +256,21 @@ export async function generateAsync(
223256
const { component, componentAsync } = e;
224257
if (component == null) {
225258
if (componentAsync != null) {
226-
return await componentAsync();
259+
try {
260+
return await componentAsync();
261+
} catch (err) {
262+
const error = err as Error;
263+
console.error(`Failed to load editor component for ${path}: ${error}`);
264+
// Single point where all async editor load errors are reported to user
265+
alert_message({
266+
type: "error",
267+
title: "Editor Load Failed",
268+
message: `Failed to load editor for ${path}: ${error}. Please check your internet connection and refresh the page.`,
269+
timeout: 10,
270+
});
271+
// Return error component with refresh button
272+
return () => React.createElement(EditorLoadError, { path, error });
273+
}
227274
}
228275
return () =>
229276
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: 114 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,94 @@ 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 15 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+
* TEST: refresh CoCalc page, but do not open a complex editor like Jupyter.
100+
* Open Chrome debug panel → Network → Network: "Offline"
101+
* Then click on the ipynb file and watch the console for the retries and the error popping up.
102+
*/
103+
function withTimeoutAndRetry<T>(
104+
asyncLoaderFn: () => Promise<T>,
105+
ext: string | string[],
106+
timeoutMs: number = 15_000, // wait up to 15 seconds to load all assets
107+
maxRetries: number = 3,
108+
): () => Promise<T> {
109+
const extStr = Array.isArray(ext) ? ext.join(",") : ext;
110+
111+
return async () => {
112+
let lastError: Error | null = null;
113+
114+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
115+
try {
116+
// Only log if retrying (attempt >= 2), not on first attempt
117+
if (attempt >= 2) {
118+
console.warn(
119+
`frame-editor/register: loading ${extStr} (attempt ${attempt}/${maxRetries})`,
120+
);
121+
}
122+
123+
const result = await Promise.race([
124+
asyncLoaderFn(),
125+
new Promise<T>((_, reject) =>
126+
setTimeout(
127+
() =>
128+
reject(
129+
new Error(
130+
`Editor load timeout after ${timeoutMs}ms for ${extStr}. Check your internet connection.`,
131+
),
132+
),
133+
timeoutMs,
134+
),
135+
),
136+
]);
137+
138+
// Only log success if we retried, not on first attempt
139+
if (attempt >= 2) {
140+
console.warn(`frame-editor/register: loaded ${extStr} successfully`);
141+
}
142+
return result;
143+
} catch (err) {
144+
lastError = err as Error;
145+
const errorMsg = lastError.message || String(lastError);
146+
147+
if (attempt < maxRetries) {
148+
// Check if it's a timeout error or immediate network error
149+
const isTimeout = errorMsg.includes("timeout");
150+
const retryDelayMs = isTimeout ? 0 : 5000;
151+
const retryDelayStr =
152+
retryDelayMs === 0 ? "immediately" : "after 5 seconds";
153+
154+
console.warn(
155+
`frame-editor/register: failed to load ${extStr} (attempt ${attempt}/${maxRetries}): ${errorMsg}. Retrying ${retryDelayStr}...`,
156+
);
157+
158+
// Wait before retry (0ms for timeout, 5s for network errors)
159+
if (retryDelayMs > 0) {
160+
await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
161+
}
162+
} else {
163+
// Final attempt failed
164+
console.error(
165+
`frame-editor/register: failed to load ${extStr} after ${maxRetries} attempts: ${errorMsg}`,
166+
);
167+
}
168+
}
169+
}
170+
171+
// All retries exhausted
172+
throw lastError || new Error(`Failed to load editor for ${extStr}`);
173+
};
174+
}
175+
88176
function register(
89177
icon: IconName | undefined,
90178
ext: string | string[],
@@ -182,20 +270,42 @@ function register(
182270
"either asyncData must be given or components and Actions must be given (or both)",
183271
);
184272
}
273+
185274
let async_data: any = undefined;
186-
// so calls to componentAsync and initAsync don't happen at once!
187-
const getAsyncData = reuseInFlight(asyncData);
275+
276+
// Wrap the entire withTimeoutAndRetry with reuseInFlight to ensure
277+
// that if multiple callers request the editor simultaneously,
278+
// only ONE attempt is made (with retry logic).
279+
const getAsyncData = reuseInFlight(withTimeoutAndRetry(asyncData, ext));
188280

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

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

0 commit comments

Comments
 (0)