Skip to content

Commit 69634b2

Browse files
Add startup robustness API (waitUntilReady, timeout, offline, load error)
Move MakeCode iframe startup detection into the library so consumers don't need to reimplement timeout, offline, and load error handling. - Add waitUntilReady() on MakeCodeFrameDriver that races editor readiness against configurable timeout, offline detection, and iframe load errors - Add startUpTimeout option (default 90s) - Add notifyLoadError() and isReady getter - Export StartUpResult and StartUpFailure types
1 parent 46f47e4 commit 69634b2

4 files changed

Lines changed: 154 additions & 0 deletions

File tree

src/react/MakeCodeFrame.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,10 @@ export interface MakeCodeFrameProps
4646

4747
onDownload?: (download: { name: string; hex: string }) => void;
4848
onSave?: (save: { name: string; hex: string }) => void;
49+
onLoadError?: () => void;
4950
onBack?: () => void;
5051
onBackLongPress?: () => void;
52+
startUpTimeout?: number;
5153

5254
onEditorContentLoaded?(event: EditorContentLoadedRequest): void;
5355
onWorkspaceLoaded?(event: EditorWorkspaceSyncRequest): void;
@@ -81,8 +83,10 @@ const MakeCodeFrame = forwardRef<MakeCodeFrameDriver, MakeCodeFrameProps>(
8183

8284
onDownload,
8385
onSave,
86+
onLoadError,
8487
onBack,
8588
onBackLongPress,
89+
startUpTimeout,
8690
onEditorContentLoaded,
8791
onWorkspaceLoaded,
8892
onWorkspaceSync,
@@ -102,8 +106,10 @@ const MakeCodeFrame = forwardRef<MakeCodeFrameDriver, MakeCodeFrameProps>(
102106

103107
onDownload,
104108
onSave,
109+
onLoadError,
105110
onBack,
106111
onBackLongPress,
112+
startUpTimeout,
107113
onEditorContentLoaded,
108114
onWorkspaceLoaded,
109115
onWorkspaceSync,
@@ -120,6 +126,7 @@ const MakeCodeFrame = forwardRef<MakeCodeFrameDriver, MakeCodeFrameProps>(
120126
onBackLongPress,
121127
onDownload,
122128
onEditorContentLoaded,
129+
onLoadError,
123130
onSave,
124131
onTutorialEvent,
125132
onWorkspaceEvent,
@@ -128,6 +135,7 @@ const MakeCodeFrame = forwardRef<MakeCodeFrameDriver, MakeCodeFrameProps>(
128135
onWorkspaceSave,
129136
onWorkspaceSync,
130137
searchBar,
138+
startUpTimeout,
131139
]);
132140

133141
// Reload MakeCode if the URL changes
@@ -184,6 +192,7 @@ const MakeCodeFrameInner = forwardRef<
184192
title="MakeCode"
185193
style={{ ...styles.iframe, ...style }}
186194
allow="usb; autoplay; camera; microphone;"
195+
onError={() => driverRef.current.notifyLoadError()}
187196
{...rest}
188197
/>
189198
);

src/react/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ export {
2222
export {
2323
MakeCodeFrameDriver,
2424
createMakeCodeURL,
25+
type StartUpFailure,
26+
type StartUpResult,
2527
} from '../vanilla/makecode-frame-driver.js';
2628

2729
export { BlockLayout } from '../vanilla/pxt.js';

src/vanilla/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ export {
88
createMakeCodeURL,
99
type EditorShareOptions,
1010
type Options,
11+
type StartUpFailure,
12+
type StartUpResult,
1113
} from '../vanilla/makecode-frame-driver.js';
1214

1315
export { BlockLayout } from '../vanilla/pxt.js';

src/vanilla/makecode-frame-driver.ts

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,18 @@ export interface EditorShareOptions {
5555
projectName: string;
5656
}
5757

58+
/**
59+
* Reasons the MakeCode editor startup can fail.
60+
*/
61+
export type StartUpFailure = 'timeout' | 'offline' | 'load-error';
62+
63+
/**
64+
* Result of waitUntilReady().
65+
*/
66+
export type StartUpResult =
67+
| { ready: true }
68+
| { ready: false; reason: StartUpFailure };
69+
5870
export interface Options {
5971
/**
6072
* A function that provides the initial set of projects to be used when initialising MakeCode.
@@ -132,6 +144,14 @@ export interface Options {
132144
*/
133145
onSave?: (save: { name: string; hex: string }) => void;
134146

147+
/**
148+
* Called when the iframe fails to load.
149+
*
150+
* This does not cover all failure modes but when it fires it is an
151+
* unambiguous signal of failure.
152+
*/
153+
onLoadError?: () => void;
154+
135155
/**
136156
* Requests the embedding app handles a press/tap on the back arrow.
137157
*
@@ -146,6 +166,29 @@ export interface Options {
146166
* Applies only with `controller` set to `2`.
147167
*/
148168
onBackLongPress?: () => void;
169+
170+
/**
171+
* Timeout in milliseconds for the MakeCode editor to reach the
172+
* workspace loaded state. Defaults to 90000 (90 seconds).
173+
* Set to 0 to disable the timeout.
174+
*/
175+
startUpTimeout?: number;
176+
}
177+
178+
/**
179+
* A one-shot signal that can be resolved once and awaited many times.
180+
*/
181+
class Signal {
182+
private resolve!: () => void;
183+
readonly promise = new Promise<void>((r) => {
184+
this.resolve = r;
185+
});
186+
resolved = false;
187+
188+
fire() {
189+
this.resolved = true;
190+
this.resolve();
191+
}
149192
}
150193

151194
/**
@@ -166,6 +209,11 @@ export class MakeCodeFrameDriver {
166209
}
167210
>();
168211

212+
private workspaceLoaded = new Signal();
213+
private editorContentLoaded = new Signal();
214+
private loadError = new Signal();
215+
private startUpTimestamp = 0;
216+
169217
private _expectedOrigin: string | undefined;
170218
private expectedOrigin = () => {
171219
if (this._expectedOrigin) {
@@ -260,6 +308,7 @@ export class MakeCodeFrameDriver {
260308
);
261309
}
262310
case 'workspaceloaded': {
311+
this.workspaceLoaded.fire();
263312
return this.options.onWorkspaceLoaded?.(
264313
data as EditorWorkspaceSyncRequest
265314
);
@@ -271,6 +320,7 @@ export class MakeCodeFrameDriver {
271320
return;
272321
}
273322
case 'editorcontentloaded': {
323+
this.editorContentLoaded.fire();
274324
return this.options.onEditorContentLoaded?.(
275325
data as EditorContentLoadedRequest
276326
);
@@ -311,6 +361,11 @@ export class MakeCodeFrameDriver {
311361
) {}
312362

313363
initialize() {
364+
this.startUpTimestamp = Date.now();
365+
this.workspaceLoaded = new Signal();
366+
this.editorContentLoaded = new Signal();
367+
this.loadError = new Signal();
368+
314369
window.addEventListener('message', this.listener);
315370
// If the iframe is already loaded this will ensure we still initialize correctly
316371
this.iframe()?.contentWindow?.postMessage(
@@ -329,6 +384,92 @@ export class MakeCodeFrameDriver {
329384
window.removeEventListener('message', this.listener);
330385
}
331386

387+
/**
388+
* Notify the driver that the iframe failed to load.
389+
*
390+
* Called by the React component's onError handler or manually by vanilla
391+
* consumers.
392+
*/
393+
notifyLoadError(): void {
394+
this.loadError.fire();
395+
this.options.onLoadError?.();
396+
}
397+
398+
/**
399+
* Whether the editor workspace has fully loaded since initialize().
400+
*/
401+
get isReady(): boolean {
402+
return this.workspaceLoaded.resolved && this.editorContentLoaded.resolved;
403+
}
404+
405+
/**
406+
* Resolves when the MakeCode editor is fully loaded (both editor content
407+
* loaded and workspace loaded), or resolves with a failure reason if
408+
* startup times out, goes offline, or encounters a load error.
409+
*/
410+
async waitUntilReady(): Promise<StartUpResult> {
411+
if (this.isReady) {
412+
return { ready: true };
413+
}
414+
if (this.loadError.resolved) {
415+
return { ready: false, reason: 'load-error' };
416+
}
417+
418+
const timeout = this.options.startUpTimeout ?? 90_000;
419+
const elapsed = Date.now() - this.startUpTimestamp;
420+
const remaining = timeout - elapsed;
421+
422+
if (timeout > 0 && remaining <= 0) {
423+
return { ready: false, reason: 'timeout' };
424+
}
425+
426+
const raceEntries: Promise<StartUpResult>[] = [
427+
Promise.all([
428+
this.editorContentLoaded.promise,
429+
this.workspaceLoaded.promise,
430+
]).then((): StartUpResult => ({ ready: true })),
431+
];
432+
433+
if (timeout > 0 && remaining > 0) {
434+
raceEntries.push(
435+
new Promise((resolve) =>
436+
setTimeout(
437+
() => resolve({ ready: false, reason: 'timeout' }),
438+
remaining
439+
)
440+
)
441+
);
442+
}
443+
444+
raceEntries.push(
445+
this.loadError.promise.then(
446+
(): StartUpResult => ({ ready: false, reason: 'load-error' })
447+
)
448+
);
449+
450+
// MakeCode requires a network connection to load.
451+
const offlineAbort = new AbortController();
452+
raceEntries.push(
453+
new Promise((resolve) => {
454+
if (!navigator.onLine) {
455+
resolve({ ready: false, reason: 'offline' });
456+
} else {
457+
window.addEventListener(
458+
'offline',
459+
() => resolve({ ready: false, reason: 'offline' }),
460+
{ once: true, signal: offlineAbort.signal }
461+
);
462+
}
463+
})
464+
);
465+
466+
try {
467+
return await Promise.race(raceEntries);
468+
} finally {
469+
offlineAbort.abort();
470+
}
471+
}
472+
332473
private sendRequest = (
333474
message: EditorMessageRequestUnion
334475
): Promise<unknown> => {

0 commit comments

Comments
 (0)