Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion src/browser/components/AppLoader/AppLoader.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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);
Expand Down Expand Up @@ -143,6 +157,7 @@ export function AppLoader(props: AppLoaderProps) {
return (
<ThemeProvider>
<APIProvider client={props.client}>
<FirstRunViewConnectionGuard />
<UserPreferencesStartupGate>
<RouterProvider>
<ProjectProvider>
Expand Down
6 changes: 6 additions & 0 deletions src/browser/features/SplashScreens/SplashScreenProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
38 changes: 35 additions & 3 deletions src/browser/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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(
<React.StrictMode>
<AppLoader />
<FirstRunViewErrorBoundary>
<AppLoader />
</FirstRunViewErrorBoundary>
</React.StrictMode>
);

Expand All @@ -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) => {
Expand All @@ -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);
}
}
6 changes: 6 additions & 0 deletions src/common/constants/firstRunView.ts
Original file line number Diff line number Diff line change
@@ -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";
34 changes: 34 additions & 0 deletions tests/ui/onboarding/connectionGuard.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import("@/browser/contexts/API")>("@/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 = '<div id="xum-pre"></div>';
mockStatus = status;
render(React.createElement(FirstRunViewConnectionGuard));
expect(document.getElementById("xum-pre") != null).toBe(stays);
});
73 changes: 73 additions & 0 deletions tests/ui/onboarding/mainEntry.test.ts
Original file line number Diff line number Diff line change
@@ -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", '<div id="root"></div>');
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 = '<div id="xum-pre"></div>';
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" }));
});
Loading