Skip to content
This repository was archived by the owner on May 15, 2026. It is now read-only.

Commit b6e9436

Browse files
committed
fix: show loading view with retry instead of grey screen when state not hydrated
Replace `return null` with a LoadingView component when the webview has not yet received its initial state from the extension host. This prevents users from seeing a blank grey panel (#11931). The LoadingView shows a spinner while waiting, automatically retries the webviewDidLaunch message up to 3 times (every 5 seconds), and displays a manual "Retry Connection" button after all retries are exhausted.
1 parent ad25634 commit b6e9436

4 files changed

Lines changed: 179 additions & 2 deletions

File tree

webview-ui/src/App.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { MarketplaceView } from "./components/marketplace/MarketplaceView"
1919
import { CheckpointRestoreDialog } from "./components/chat/CheckpointRestoreDialog"
2020
import { DeleteMessageDialog, EditMessageDialog } from "./components/chat/MessageModificationConfirmationDialog"
2121
import ErrorBoundary from "./components/ErrorBoundary"
22+
import LoadingView from "./components/LoadingView"
2223
import { CloudView } from "./components/cloud/CloudView"
2324
import { useAddNonInteractiveClickListener } from "./components/ui/hooks/useNonInteractiveClick"
2425
import { TooltipProvider } from "./components/ui/tooltip"
@@ -217,7 +218,7 @@ const App = () => {
217218
}, [tab])
218219

219220
if (!didHydrateState) {
220-
return null
221+
return <LoadingView />
221222
}
222223

223224
// Do not conditionally load ChatView, it's expensive and there's state we
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import React, { useEffect, useState, useCallback } from "react"
2+
import { useAppTranslation } from "../i18n/TranslationContext"
3+
import { vscode } from "../utils/vscode"
4+
5+
const RETRY_INTERVAL_MS = 5_000
6+
const MAX_RETRIES = 3
7+
8+
/**
9+
* LoadingView is displayed while the webview waits for the extension host to
10+
* send the initial state hydration message. It replaces the previous
11+
* `return null` which left users staring at a blank grey panel (see #11931).
12+
*
13+
* If the state message does not arrive within {@link RETRY_INTERVAL_MS} the
14+
* component automatically re-sends the `webviewDidLaunch` message up to
15+
* {@link MAX_RETRIES} times, after which a manual "Retry" button is shown.
16+
*/
17+
export default function LoadingView() {
18+
const { t } = useAppTranslation()
19+
const [retryCount, setRetryCount] = useState(0)
20+
const [showRetryButton, setShowRetryButton] = useState(false)
21+
22+
const retry = useCallback(() => {
23+
vscode.postMessage({ type: "webviewDidLaunch" })
24+
setRetryCount((prev) => prev + 1)
25+
}, [])
26+
27+
// Automatic retries on a timer
28+
useEffect(() => {
29+
if (showRetryButton) {
30+
return // Stop auto-retrying once we're showing the manual button.
31+
}
32+
33+
const timer = setTimeout(() => {
34+
if (retryCount < MAX_RETRIES) {
35+
retry()
36+
} else {
37+
setShowRetryButton(true)
38+
}
39+
}, RETRY_INTERVAL_MS)
40+
41+
return () => clearTimeout(timer)
42+
}, [retryCount, showRetryButton, retry])
43+
44+
return (
45+
<div className="absolute inset-0 flex flex-col bg-vscode-editor-background text-vscode-foreground">
46+
<div className="flex-1 flex items-center justify-center px-6">
47+
<div className="flex flex-col items-center gap-5 text-center">
48+
{!showRetryButton ? (
49+
<div className="flex items-center gap-2 text-sm text-vscode-descriptionForeground">
50+
<span className="codicon codicon-loading codicon-modifier-spin text-base" />
51+
<span>{t("common:ui.initializing")}</span>
52+
</div>
53+
) : (
54+
<div className="flex flex-col items-center gap-3">
55+
<p className="text-sm text-vscode-descriptionForeground">
56+
{t("common:ui.connection_failed")}
57+
</p>
58+
<button
59+
className="px-4 py-1.5 rounded text-sm font-medium bg-vscode-button-background text-vscode-button-foreground hover:bg-vscode-button-hoverBackground"
60+
onClick={() => {
61+
setRetryCount(0)
62+
setShowRetryButton(false)
63+
retry()
64+
}}>
65+
{t("common:ui.retry_connection")}
66+
</button>
67+
</div>
68+
)}
69+
</div>
70+
</div>
71+
</div>
72+
)
73+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
// npx vitest run src/components/__tests__/LoadingView.spec.tsx
2+
3+
import React from "react"
4+
import { render, screen, act } from "@testing-library/react"
5+
import LoadingView from "../LoadingView"
6+
7+
vi.mock("@src/utils/vscode", () => ({
8+
vscode: {
9+
postMessage: vi.fn(),
10+
},
11+
}))
12+
13+
vi.mock("@src/i18n/TranslationContext", () => ({
14+
useAppTranslation: () => ({
15+
t: (key: string) => {
16+
const translations: Record<string, string> = {
17+
"common:ui.initializing": "Initializing...",
18+
"common:ui.retry_connection": "Retry Connection",
19+
"common:ui.connection_failed":
20+
"Unable to connect to the extension host. Click the button below to retry.",
21+
}
22+
return translations[key] ?? key
23+
},
24+
}),
25+
}))
26+
27+
describe("LoadingView", () => {
28+
beforeEach(() => {
29+
vi.clearAllMocks()
30+
vi.useFakeTimers()
31+
})
32+
33+
afterEach(() => {
34+
vi.useRealTimers()
35+
})
36+
37+
it("renders a spinner and initializing text", () => {
38+
render(<LoadingView />)
39+
expect(screen.getByText("Initializing...")).toBeInTheDocument()
40+
})
41+
42+
it("does not show retry button initially", () => {
43+
render(<LoadingView />)
44+
expect(screen.queryByText("Retry Connection")).not.toBeInTheDocument()
45+
})
46+
47+
it("retries webviewDidLaunch after timeout", async () => {
48+
const { vscode } = await import("@src/utils/vscode")
49+
render(<LoadingView />)
50+
51+
// Advance past the first retry interval (5s)
52+
act(() => {
53+
vi.advanceTimersByTime(5_000)
54+
})
55+
56+
expect(vscode.postMessage).toHaveBeenCalledWith({ type: "webviewDidLaunch" })
57+
})
58+
59+
it("shows retry button after max retries", async () => {
60+
render(<LoadingView />)
61+
62+
// Advance through all 3 retries (5s each) + one more to trigger the button
63+
for (let i = 0; i < 4; i++) {
64+
act(() => {
65+
vi.advanceTimersByTime(5_000)
66+
})
67+
}
68+
69+
expect(screen.getByText("Retry Connection")).toBeInTheDocument()
70+
expect(
71+
screen.getByText("Unable to connect to the extension host. Click the button below to retry."),
72+
).toBeInTheDocument()
73+
})
74+
75+
it("allows manual retry when button is clicked", async () => {
76+
const { vscode } = await import("@src/utils/vscode")
77+
render(<LoadingView />)
78+
79+
// Advance through all retries to show the button
80+
for (let i = 0; i < 4; i++) {
81+
act(() => {
82+
vi.advanceTimersByTime(5_000)
83+
})
84+
}
85+
86+
const calls = (vscode.postMessage as ReturnType<typeof vi.fn>).mock.calls.length
87+
88+
const retryButton = screen.getByText("Retry Connection")
89+
act(() => {
90+
retryButton.click()
91+
})
92+
93+
// Should have sent another webviewDidLaunch
94+
expect((vscode.postMessage as ReturnType<typeof vi.fn>).mock.calls.length).toBeGreaterThan(calls)
95+
expect(vscode.postMessage).toHaveBeenCalledWith({ type: "webviewDidLaunch" })
96+
97+
// Should go back to showing the spinner
98+
expect(screen.getByText("Initializing...")).toBeInTheDocument()
99+
})
100+
})

webview-ui/src/i18n/locales/en/common.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,10 @@
2121
},
2222
"ui": {
2323
"search_placeholder": "Search...",
24-
"no_results": "No results found"
24+
"no_results": "No results found",
25+
"initializing": "Initializing...",
26+
"retry_connection": "Retry Connection",
27+
"connection_failed": "Unable to connect to the extension host. Click the button below to retry."
2528
},
2629
"mermaid": {
2730
"loading": "Generating mermaid diagram...",

0 commit comments

Comments
 (0)