Skip to content

Commit 73bb072

Browse files
authored
fix: iframe retry connection (#2968)
1 parent 4c7c2eb commit 73bb072

File tree

4 files changed

+190
-87
lines changed

4 files changed

+190
-87
lines changed

apps/web/client/src/app/project/[id]/_components/canvas/frame/index.tsx

Lines changed: 43 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,60 +1,37 @@
11
import { useEditorEngine } from '@/components/store/editor';
22
import { type Frame } from '@onlook/models';
33
import { Icons } from '@onlook/ui/icons';
4-
import { toast } from '@onlook/ui/sonner';
54
import { colors } from '@onlook/ui/tokens';
6-
import { debounce } from 'lodash';
75
import { observer } from 'mobx-react-lite';
8-
import { useEffect, useRef, useState } from 'react';
6+
import { useRef, useState } from 'react';
97
import { RightClickMenu } from '../../right-click-menu';
108
import { GestureScreen } from './gesture';
119
import { ResizeHandles } from './resize-handles';
1210
import { TopBar } from './top-bar';
11+
import { useFrameReload } from './use-frame-reload';
12+
import { useSandboxTimeout } from './use-sandbox-timeout';
1313
import { FrameComponent, type IFrameView } from './view';
1414

1515
export const FrameView = observer(({ frame, isInDragSelection = false }: { frame: Frame; isInDragSelection?: boolean }) => {
1616
const editorEngine = useEditorEngine();
1717
const iFrameRef = useRef<IFrameView>(null);
1818
const [isResizing, setIsResizing] = useState(false);
19-
const [reloadKey, setReloadKey] = useState(0);
20-
const [hasTimedOut, setHasTimedOut] = useState(false);
21-
const isSelected = editorEngine.frames.isSelected(frame.id);
2219

23-
const branchData = editorEngine.branches.getBranchDataById(frame.branchId);
24-
const isConnecting = branchData?.sandbox?.session?.isConnecting ?? false;
20+
const {
21+
reloadKey,
22+
immediateReload,
23+
handleConnectionFailed,
24+
handleConnectionSuccess,
25+
getPenpalTimeout,
26+
} = useFrameReload();
27+
28+
const { hasTimedOut, isConnecting } = useSandboxTimeout(frame, handleConnectionFailed);
2529

30+
const isSelected = editorEngine.frames.isSelected(frame.id);
31+
const branchData = editorEngine.branches.getBranchDataById(frame.branchId);
2632
const preloadScriptReady = branchData?.sandbox?.preloadScriptInjected ?? false;
2733
const isFrameReady = preloadScriptReady && !(isConnecting && !hasTimedOut);
2834

29-
useEffect(() => {
30-
if (!isConnecting) {
31-
setHasTimedOut(false);
32-
return;
33-
}
34-
35-
const timeoutId = setTimeout(() => {
36-
const currentBranchData = editorEngine.branches.getBranchDataById(frame.branchId);
37-
const stillConnecting = currentBranchData?.sandbox?.session?.isConnecting ?? false;
38-
39-
if (stillConnecting) {
40-
setHasTimedOut(true);
41-
toast.error('Connection timeout', {
42-
description: `Failed to connect to the branch ${currentBranchData?.branch?.name}. Please try reloading.`,
43-
});
44-
}
45-
}, 30000);
46-
47-
return () => clearTimeout(timeoutId);
48-
}, [isConnecting, frame.branchId]);
49-
50-
const undebouncedReloadIframe = () => {
51-
setReloadKey(prev => prev + 1);
52-
};
53-
54-
const reloadIframe = debounce(undebouncedReloadIframe, 1000, {
55-
leading: true,
56-
});
57-
5835
return (
5936
<div
6037
className="flex flex-col fixed"
@@ -63,20 +40,42 @@ export const FrameView = observer(({ frame, isInDragSelection = false }: { frame
6340
<RightClickMenu>
6441
<TopBar frame={frame} isInDragSelection={isInDragSelection} />
6542
</RightClickMenu>
66-
<div className="relative" style={{
67-
outline: isSelected ? `2px solid ${colors.teal[400]}` : isInDragSelection ? `2px solid ${colors.teal[500]}` : 'none',
68-
borderRadius: '4px',
69-
}}>
43+
<div
44+
className="relative"
45+
style={{
46+
outline: isSelected
47+
? `2px solid ${colors.teal[400]}`
48+
: isInDragSelection
49+
? `2px solid ${colors.teal[500]}`
50+
: 'none',
51+
borderRadius: '4px',
52+
}}
53+
>
7054
<ResizeHandles frame={frame} setIsResizing={setIsResizing} />
71-
<FrameComponent key={reloadKey} frame={frame} reloadIframe={reloadIframe} isInDragSelection={isInDragSelection} ref={iFrameRef} />
55+
<FrameComponent
56+
key={reloadKey}
57+
frame={frame}
58+
reloadIframe={immediateReload}
59+
onConnectionFailed={handleConnectionFailed}
60+
onConnectionSuccess={handleConnectionSuccess}
61+
penpalTimeoutMs={getPenpalTimeout()}
62+
isInDragSelection={isInDragSelection}
63+
ref={iFrameRef}
64+
/>
7265
<GestureScreen frame={frame} isResizing={isResizing} />
7366

7467
{!isFrameReady && (
7568
<div
7669
className="absolute inset-0 bg-background/80 backdrop-blur-sm flex items-center justify-center z-50 rounded-md"
77-
style={{ width: frame.dimension.width, height: frame.dimension.height }}
70+
style={{
71+
width: frame.dimension.width,
72+
height: frame.dimension.height,
73+
}}
7874
>
79-
<div className="flex items-center gap-3 text-foreground" style={{ transform: `scale(${1 / editorEngine.canvas.scale})` }}>
75+
<div
76+
className="flex items-center gap-3 text-foreground"
77+
style={{ transform: `scale(${1 / editorEngine.canvas.scale})` }}
78+
>
8079
<Icons.LoadingSpinner className="animate-spin h-8 w-8" />
8180
</div>
8281
</div>
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { debounce } from 'lodash';
2+
import { useEffect, useRef, useState } from 'react';
3+
4+
// Reload timing constants
5+
const RELOAD_BASE_DELAY_MS = 2000;
6+
const RELOAD_INCREMENT_MS = 1000;
7+
const PENPAL_BASE_TIMEOUT_MS = 5000;
8+
const PENPAL_TIMEOUT_INCREMENT_MS = 2000;
9+
const PENPAL_MAX_TIMEOUT_MS = 30000;
10+
11+
export function useFrameReload() {
12+
const reloadCountRef = useRef(0);
13+
const reloadTimeoutRef = useRef<NodeJS.Timeout | null>(null);
14+
const [reloadKey, setReloadKey] = useState(0);
15+
const [isPenpalConnected, setIsPenpalConnected] = useState(false);
16+
17+
const immediateReload = () => {
18+
setReloadKey(prev => prev + 1);
19+
};
20+
21+
const scheduleReload = () => {
22+
if (reloadTimeoutRef.current) {
23+
clearTimeout(reloadTimeoutRef.current);
24+
}
25+
26+
reloadCountRef.current += 1;
27+
const reloadDelay = RELOAD_BASE_DELAY_MS + (RELOAD_INCREMENT_MS * (reloadCountRef.current - 1));
28+
29+
reloadTimeoutRef.current = setTimeout(() => {
30+
setReloadKey(prev => prev + 1);
31+
reloadTimeoutRef.current = null;
32+
}, reloadDelay);
33+
};
34+
35+
const handleConnectionFailed = debounce(() => {
36+
setIsPenpalConnected(false);
37+
scheduleReload();
38+
}, 1000, { leading: true });
39+
40+
const handleConnectionSuccess = () => {
41+
setIsPenpalConnected(true);
42+
};
43+
44+
const getPenpalTimeout = () => {
45+
return Math.min(
46+
PENPAL_BASE_TIMEOUT_MS + (reloadCountRef.current * PENPAL_TIMEOUT_INCREMENT_MS),
47+
PENPAL_MAX_TIMEOUT_MS
48+
);
49+
};
50+
51+
// Reset reload counter on successful connection
52+
useEffect(() => {
53+
if (isPenpalConnected && reloadCountRef.current > 0) {
54+
reloadCountRef.current = 0;
55+
}
56+
}, [isPenpalConnected]);
57+
58+
// Reset connection state on reload
59+
useEffect(() => {
60+
setIsPenpalConnected(false);
61+
}, [reloadKey]);
62+
63+
// Cleanup on unmount
64+
useEffect(() => {
65+
return () => {
66+
if (reloadTimeoutRef.current) {
67+
clearTimeout(reloadTimeoutRef.current);
68+
}
69+
};
70+
}, []);
71+
72+
return {
73+
reloadKey,
74+
isPenpalConnected,
75+
immediateReload,
76+
handleConnectionFailed,
77+
handleConnectionSuccess,
78+
getPenpalTimeout,
79+
};
80+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { useEditorEngine } from '@/components/store/editor';
2+
import type { Frame } from '@onlook/models';
3+
import { toast } from '@onlook/ui/sonner';
4+
import { useEffect, useState } from 'react';
5+
6+
const SANDBOX_TIMEOUT_MS = 30000;
7+
8+
export function useSandboxTimeout(frame: Frame, onTimeout: () => void) {
9+
const editorEngine = useEditorEngine();
10+
const [hasTimedOut, setHasTimedOut] = useState(false);
11+
12+
const branchData = editorEngine.branches.getBranchDataById(frame.branchId);
13+
const isConnecting = branchData?.sandbox?.session?.isConnecting ?? false;
14+
15+
useEffect(() => {
16+
if (!isConnecting) {
17+
setHasTimedOut(false);
18+
return;
19+
}
20+
21+
const timeoutId = setTimeout(() => {
22+
const currentBranchData = editorEngine.branches.getBranchDataById(frame.branchId);
23+
const stillConnecting = currentBranchData?.sandbox?.session?.isConnecting ?? false;
24+
25+
if (stillConnecting) {
26+
console.log(`[Frame ${frame.id}] Sandbox connection timeout after ${SANDBOX_TIMEOUT_MS}ms`);
27+
toast.info('Connection slow, retrying...', {
28+
description: `Reconnecting to ${currentBranchData?.branch?.name}...`,
29+
});
30+
setHasTimedOut(true);
31+
onTimeout();
32+
}
33+
}, SANDBOX_TIMEOUT_MS);
34+
35+
return () => clearTimeout(timeoutId);
36+
}, [isConnecting, frame.branchId, frame.id, onTimeout, editorEngine]);
37+
38+
return { hasTimedOut, isConnecting };
39+
}

apps/web/client/src/app/project/[id]/_components/canvas/frame/view.tsx

Lines changed: 28 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -54,52 +54,30 @@ const createSafeFallbackMethods = (): PromisifiedPendpalChildMethods => {
5454

5555
interface FrameViewProps extends IframeHTMLAttributes<HTMLIFrameElement> {
5656
frame: Frame;
57-
reloadIframe?: () => void;
57+
reloadIframe: () => void;
58+
onConnectionFailed: () => void;
59+
onConnectionSuccess: () => void;
60+
penpalTimeoutMs?: number;
5861
isInDragSelection?: boolean;
5962
}
6063

6164
export const FrameComponent = observer(
62-
forwardRef<IFrameView, FrameViewProps>(({ frame, reloadIframe, isInDragSelection = false, ...props }, ref) => {
65+
forwardRef<IFrameView, FrameViewProps>(({ frame, reloadIframe, onConnectionFailed, onConnectionSuccess, penpalTimeoutMs = 5000, isInDragSelection = false, ...props }, ref) => {
6366
const editorEngine = useEditorEngine();
6467
const iframeRef = useRef<HTMLIFrameElement>(null);
6568
const zoomLevel = useRef(1);
6669
const isConnecting = useRef(false);
6770
const connectionRef = useRef<ReturnType<typeof connect> | null>(null);
68-
const retryCount = useRef(0);
69-
const maxRetries = 3;
70-
const baseDelay = 1000;
7171
const [penpalChild, setPenpalChild] = useState<PenpalChildMethods | null>(null);
7272
const isSelected = editorEngine.frames.isSelected(frame.id);
7373
const isActiveBranch = editorEngine.branches.activeBranch.id === frame.branchId;
7474

75-
const retrySetupPenpalConnection = (error?: Error) => {
76-
if (retryCount.current >= maxRetries) {
77-
console.error(
78-
`${PENPAL_PARENT_CHANNEL} (${frame.id}) - Max retries (${maxRetries}) reached, reloading iframe`,
79-
error,
80-
);
81-
retryCount.current = 0;
82-
reloadIframe?.();
83-
return;
84-
}
85-
86-
retryCount.current += 1;
87-
const delay = baseDelay * Math.pow(2, retryCount.current - 1);
88-
89-
console.log(
90-
`${PENPAL_PARENT_CHANNEL} (${frame.id}) - Retrying connection attempt ${retryCount.current}/${maxRetries} in ${delay}ms`,
91-
);
92-
93-
setTimeout(() => {
94-
setupPenpalConnection();
95-
}, delay);
96-
};
97-
9875
const setupPenpalConnection = () => {
9976
try {
10077
if (!iframeRef.current?.contentWindow) {
101-
console.error('No iframe found');
102-
throw new Error('No iframe found');
78+
console.error(`${PENPAL_PARENT_CHANNEL} (${frame.id}) - No iframe found`);
79+
onConnectionFailed;
80+
return;
10381
}
10482

10583
if (isConnecting.current) {
@@ -110,7 +88,7 @@ export const FrameComponent = observer(
11088
}
11189
isConnecting.current = true;
11290

113-
// Destroy any existing connection before creating a new one
91+
// Destroy any existing connection
11492
if (connectionRef.current) {
11593
connectionRef.current.destroy();
11694
connectionRef.current = null;
@@ -138,44 +116,51 @@ export const FrameComponent = observer(
138116
} satisfies PenpalParentMethods,
139117
});
140118

141-
// Store the connection reference
142119
connectionRef.current = connection;
143120

144-
connection.promise
121+
// Create a timeout promise that rejects after specified timeout
122+
const timeoutPromise = new Promise<never>((_, reject) => {
123+
setTimeout(() => {
124+
reject(new Error(`Penpal connection timeout after ${penpalTimeoutMs}ms`));
125+
}, penpalTimeoutMs);
126+
});
127+
128+
// Race the connection promise against the timeout
129+
Promise.race([connection.promise, timeoutPromise])
145130
.then((child) => {
146131
isConnecting.current = false;
147132
if (!child) {
148-
const error = new Error('Failed to setup penpal connection: child is null');
149133
console.error(
150-
`${PENPAL_PARENT_CHANNEL} (${frame.id}) - ${error.message}`,
134+
`${PENPAL_PARENT_CHANNEL} (${frame.id}) - Connection failed: child is null`,
151135
);
152-
retrySetupPenpalConnection(error);
136+
onConnectionFailed;
153137
return;
154138
}
155139

156-
// Reset retry count on successful connection
157-
retryCount.current = 0;
140+
console.log(`${PENPAL_PARENT_CHANNEL} (${frame.id}) - Penpal connection set`);
158141

159142
const remote = child as unknown as PenpalChildMethods;
160143
setPenpalChild(remote);
161144
remote.setFrameId(frame.id);
162145
remote.setBranchId(frame.branchId);
163146
remote.handleBodyReady();
164147
remote.processDom();
165-
console.log(`${PENPAL_PARENT_CHANNEL} (${frame.id}) - Penpal connection set `);
148+
149+
// Notify parent of successful connection
150+
onConnectionSuccess;
166151
})
167152
.catch((error) => {
168153
isConnecting.current = false;
169154
console.error(
170155
`${PENPAL_PARENT_CHANNEL} (${frame.id}) - Failed to setup penpal connection:`,
171156
error,
172157
);
173-
retrySetupPenpalConnection(error);
158+
onConnectionFailed;
174159
});
175160
} catch (error) {
176161
isConnecting.current = false;
177-
console.error('Failed to setup penpal connection', error);
178-
retrySetupPenpalConnection(error as Error);
162+
console.error(`${PENPAL_PARENT_CHANNEL} (${frame.id}) - Setup failed:`, error);
163+
onConnectionFailed;
179164
}
180165
};
181166

@@ -271,7 +256,7 @@ export const FrameComponent = observer(
271256
iframe.style.transform = `scale(${level})`;
272257
iframe.style.transformOrigin = 'top left';
273258
},
274-
reload: () => reloadIframe?.(),
259+
reload: () => reloadIframe(),
275260
isLoading: () => iframe.contentDocument?.readyState !== 'complete',
276261
};
277262

0 commit comments

Comments
 (0)