From ba5667bf0f7e05e096f08addcde7e6be3204b8e5 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 13 Oct 2026 18:33:50 +0000 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=A4=96=20tests:=20pin=20the=20rendere?= =?UTF-8?q?r=20entry's=20service-worker=20registration=20(T3=20C=20step=20?= =?UTF-8?q?2=20PR=201a)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Test only, green on main: the entry registers the service worker on the window load event. --- tests/ui/onboarding/mainEntry.test.ts | 53 +++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 tests/ui/onboarding/mainEntry.test.ts diff --git a/tests/ui/onboarding/mainEntry.test.ts b/tests/ui/onboarding/mainEntry.test.ts new file mode 100644 index 00000000000..a4be89accab --- /dev/null +++ b/tests/ui/onboarding/mainEntry.test.ts @@ -0,0 +1,53 @@ +/** + * 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() { + document.body.innerHTML = '
'; + 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(); +}); From 8786197d9d683cdea79614f7bfdfda821f097806 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 13 Oct 2026 18:34:35 +0000 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=A4=96=20perf:=20never=20leave=20the?= =?UTF-8?q?=20static=20first-run=20view=20over=20the=20token=20prompt,=20a?= =?UTF-8?q?n=20error=20or=20a=20crash=20(T3=20C=20step=202=20PR=201a)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the shared #xum-pre id and removeFirstRunView. AppLoader removes the view on auth_required, error and reconnecting. A boundary around AppLoader removes it on an uncaught render error, then renders the empty root such an error leaves today and reports the error. The entry registers the service worker at once when it runs after load. Inert without #xum-pre. --- .../components/AppLoader/AppLoader.tsx | 17 ++++++++- .../SplashScreens/SplashScreenProvider.tsx | 6 +++ src/browser/main.tsx | 38 +++++++++++++++++-- src/common/constants/firstRunView.ts | 6 +++ tests/ui/onboarding/connectionGuard.test.ts | 34 +++++++++++++++++ tests/ui/onboarding/mainEntry.test.ts | 24 +++++++++++- 6 files changed, 119 insertions(+), 6 deletions(-) create mode 100644 src/common/constants/firstRunView.ts create mode 100644 tests/ui/onboarding/connectionGuard.test.ts 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 index a4be89accab..a9b02eacafb 100644 --- a/tests/ui/onboarding/mainEntry.test.ts +++ b/tests/ui/onboarding/mainEntry.test.ts @@ -16,8 +16,9 @@ jest.mock("@/browser/components/AppLoader/AppLoader", () => ({ const register = jest.fn(() => Promise.resolve({})); /** Evaluates a fresh copy of the entry, as the browser would on one page load. */ -async function loadEntry() { - document.body.innerHTML = '
'; +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 }, @@ -51,3 +52,22 @@ test("registers the service worker on the window load event", async () => { 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" })); +});