diff --git a/src/browser/components/AppLoader/AppLoader.tsx b/src/browser/components/AppLoader/AppLoader.tsx index df293ff19c0..619a1a4c157 100644 --- a/src/browser/components/AppLoader/AppLoader.tsx +++ b/src/browser/components/AppLoader/AppLoader.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef, type ReactNode } from "react"; +import { useState, useEffect, useLayoutEffect, useRef, type ReactNode } from "react"; import App from "../../App"; import { AuthTokenModal } from "../AuthTokenModal/AuthTokenModal"; import { ThemeProvider } from "../../contexts/ThemeContext"; @@ -20,9 +20,23 @@ import { WorkspaceProvider, useWorkspaceContext } from "../../contexts/Workspace import { RouterProvider } from "../../contexts/RouterContext"; import { TerminalRouterProvider } from "../../terminal/TerminalRouterContext"; import { UpdateRestartOverlay } from "@/browser/components/UpdateRestartOverlay/UpdateRestartOverlay"; +import { removeFirstRunView } from "@/browser/features/SplashScreens/SplashScreenProvider"; const USER_PREFERENCES_BOOTSTRAP_TIMEOUT_MS = 2000; +/** + * T3 #5971, C step 2: the static first-run view covers the page until the live wizard replaces it. + * It must never cover the token prompt, a startup error or the reconnect screen. + */ +export function FirstRunViewConnectionGuard() { + const { status } = useAPI(); + const blocked = status === "auth_required" || status === "error" || status === "reconnecting"; + useLayoutEffect(() => { + if (blocked) removeFirstRunView(); + }, [blocked]); + return null; +} + function UserPreferencesStartupGate(props: { children: ReactNode }) { const apiState = useAPI(); const [ready, setReady] = useState(false); @@ -143,6 +157,7 @@ export function AppLoader(props: AppLoaderProps) { return ( + diff --git a/src/browser/features/SplashScreens/SplashScreenProvider.tsx b/src/browser/features/SplashScreens/SplashScreenProvider.tsx index 58911daa77a..c56658c3860 100644 --- a/src/browser/features/SplashScreens/SplashScreenProvider.tsx +++ b/src/browser/features/SplashScreens/SplashScreenProvider.tsx @@ -15,6 +15,12 @@ import { } from "./index"; import { useAPI } from "@/browser/contexts/API"; import { useSettings } from "@/browser/contexts/SettingsContext"; +import { FIRST_RUN_VIEW_ID } from "@/common/constants/firstRunView"; + +/** Removes the static first-run view. Callers do it whenever the live wizard will not replace it. */ +export function removeFirstRunView(): void { + document.getElementById(FIRST_RUN_VIEW_ID)?.remove(); +} const SplashScreenActiveContext = createContext(false); diff --git a/src/browser/main.tsx b/src/browser/main.tsx index 450644b6d55..c3507392498 100644 --- a/src/browser/main.tsx +++ b/src/browser/main.tsx @@ -11,6 +11,7 @@ import { trackAppStarted } from "@/browser/utils/telemetryLifecycle"; import { getAppConfigStore } from "@/browser/stores/AppConfigStore"; import { initTitlebarInsets } from "@/browser/hooks/useDesktopTitlebar"; import { resolveBrowserAssetUrl } from "@/browser/utils/frontendBasePath"; +import { removeFirstRunView } from "@/browser/features/SplashScreens/SplashScreenProvider"; // Initialize telemetry on app startup try { @@ -66,9 +67,36 @@ window.addEventListener("unhandledrejection", (event) => { } }); +/** + * T3 #5971, C step 2: an uncaught render error unmounts the whole root, and the static first-run + * view (outside #root) would stay on screen. This boundary removes it, then renders what such an + * error leaves today (an empty root) and reports the error as an uncaught one. + */ +export class FirstRunViewErrorBoundary extends React.Component< + { children: React.ReactNode }, + { failed: boolean } +> { + state = { failed: false }; + + static getDerivedStateFromError() { + return { failed: true }; + } + + componentDidCatch(error: Error) { + removeFirstRunView(); + window.reportError(error); + } + + render() { + return this.state.failed ? null : this.props.children; + } +} + ReactDOM.createRoot(document.getElementById("root")!).render( - + + + ); @@ -77,7 +105,7 @@ if ("serviceWorker" in navigator) { const isHttpProtocol = window.location.protocol === "http:" || window.location.protocol === "https:"; if (isHttpProtocol) { - window.addEventListener("load", () => { + const registerServiceWorker = () => { navigator.serviceWorker .register(resolveBrowserAssetUrl("service-worker.js")) .then((registration) => { @@ -86,6 +114,10 @@ if ("serviceWorker" in navigator) { .catch((error) => { console.log("Service Worker registration failed:", error); }); - }); + }; + // C step 2 loads this module after the static first-run view paints, which can be after + // `load`: then the event never fires again. + if (document.readyState === "complete") registerServiceWorker(); + else window.addEventListener("load", registerServiceWorker); } } diff --git a/src/common/constants/firstRunView.ts b/src/common/constants/firstRunView.ts new file mode 100644 index 00000000000..d6be2fec078 --- /dev/null +++ b/src/common/constants/firstRunView.ts @@ -0,0 +1,6 @@ +/** + * The static first-run view (T3 #5971, Option C step 2): `xum server` paints a static copy of the + * onboarding wizard's gateway step in this element before the app JS runs, and the app removes it + * once the live wizard shows the same step. scripts/perf/pageTimingProbe.ts uses the same id. + */ +export const FIRST_RUN_VIEW_ID = "xum-pre"; diff --git a/tests/ui/onboarding/connectionGuard.test.ts b/tests/ui/onboarding/connectionGuard.test.ts new file mode 100644 index 00000000000..d9e6d918b9f --- /dev/null +++ b/tests/ui/onboarding/connectionGuard.test.ts @@ -0,0 +1,34 @@ +/** + * AppLoader's first-run view guard (T3 #5971, C step 2): the static first-run view must never cover + * the token prompt, a startup error or the reconnect screen, and stays while the app connects. + */ +import "../dom"; +import React from "react"; +import { cleanup, render } from "@testing-library/react"; + +let mockStatus = "connecting"; +jest.mock("@/browser/contexts/API", () => ({ + ...jest.requireActual("@/browser/contexts/API"), + useAPI: () => ({ status: mockStatus }), +})); + +import { FirstRunViewConnectionGuard } from "@/browser/components/AppLoader/AppLoader"; + +afterEach(() => { + cleanup(); + document.body.innerHTML = ""; +}); + +test.each([ + ["connecting", true], + ["connected", true], + ["degraded", true], + ["auth_required", false], + ["error", false], + ["reconnecting", false], +])("with status %s, the static view stays: %s", (status, stays) => { + document.body.innerHTML = '
'; + mockStatus = status; + render(React.createElement(FirstRunViewConnectionGuard)); + expect(document.getElementById("xum-pre") != null).toBe(stays); +}); diff --git a/tests/ui/onboarding/mainEntry.test.ts b/tests/ui/onboarding/mainEntry.test.ts new file mode 100644 index 00000000000..a9b02eacafb --- /dev/null +++ b/tests/ui/onboarding/mainEntry.test.ts @@ -0,0 +1,73 @@ +/** + * The renderer entry (src/browser/main.tsx) and the static first-run view (T3 #5971, C step 2). + * C step 2 starts the app JS only after the static view paints, so the entry can run after `load`. + */ +import "../dom"; +import { act } from "@testing-library/react"; + +let mockAppLoaderThrows = false; +jest.mock("@/browser/components/AppLoader/AppLoader", () => ({ + AppLoader: () => { + if (mockAppLoaderThrows) throw new Error("render failed"); + return null; + }, +})); + +const register = jest.fn(() => Promise.resolve({})); + +/** Evaluates a fresh copy of the entry, as the browser would on one page load. */ +async function loadEntry(options: { keepBody?: boolean } = {}) { + if (!options.keepBody) document.body.innerHTML = ""; + document.body.insertAdjacentHTML("beforeend", '
'); + Object.defineProperty(window.navigator, "serviceWorker", { + configurable: true, + value: { register }, + }); + await act(async () => { + jest.isolateModules(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports -- a fresh entry per test + require("@/browser/main"); + }); + await Promise.resolve(); + }); +} + +/** Lets the registration promise settle, so its log lands inside the test. */ +const settle = () => act(async () => await Promise.resolve()); + +beforeEach(() => { + register.mockClear(); + mockAppLoaderThrows = false; +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +test("registers the service worker on the window load event", async () => { + jest.spyOn(document, "readyState", "get").mockReturnValue("loading"); + await loadEntry(); + expect(register).not.toHaveBeenCalled(); + window.dispatchEvent(new Event("load")); + await settle(); + expect(register).toHaveBeenCalled(); +}); + +test("registers the service worker at once when the entry runs after load", async () => { + jest.spyOn(document, "readyState", "get").mockReturnValue("complete"); + await loadEntry(); + await settle(); + expect(register).toHaveBeenCalled(); +}); + +test("an uncaught render error removes the static first-run view and leaves the root empty", async () => { + jest.spyOn(console, "error").mockImplementation(() => undefined); // React logs the caught error + const reportError = jest.fn(); + window.reportError = reportError; + mockAppLoaderThrows = true; + document.body.innerHTML = '
'; + await loadEntry({ keepBody: true }); + expect(document.getElementById("xum-pre")).toBeNull(); + expect(document.getElementById("root")?.childNodes.length).toBe(0); + expect(reportError).toHaveBeenCalledWith(expect.objectContaining({ message: "render failed" })); +});