From 1d9bc8c1bdc54e5b534adf056b1c8ac50460f25c Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 13 Oct 2026 18:35:14 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=A4=96=20perf:=20remove=20the=20static=20?= =?UTF-8?q?first-run=20view=20when=20the=20wizard=20is=20not=20first,=20an?= =?UTF-8?q?d=20cache=20a=20viewed=20wizard=20(T3=20C=20step=202=20PR=201b)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SplashScreenProvider removes #xum-pre once its queue is known and the wizard is not first (viewed, read failed, splashes off or no API, another splash first). It sets the new onboardingWizardViewed paint cache (registered as cache) when the viewed list holds the wizard and after dismiss() marks it viewed, so the early script can skip its API calls. Inert without #xum-pre. --- .../SplashScreens/SplashScreenProvider.tsx | 24 ++++ src/common/constants/storage.ts | 8 ++ tests/ui/onboarding/providerHandoff.test.ts | 132 ++++++++++++++++++ 3 files changed, 164 insertions(+) create mode 100644 tests/ui/onboarding/providerHandoff.test.ts diff --git a/src/browser/features/SplashScreens/SplashScreenProvider.tsx b/src/browser/features/SplashScreens/SplashScreenProvider.tsx index 5211d4062d9..29aa42e16ad 100644 --- a/src/browser/features/SplashScreens/SplashScreenProvider.tsx +++ b/src/browser/features/SplashScreens/SplashScreenProvider.tsx @@ -4,6 +4,7 @@ import { useState, useCallback, useEffect, + useLayoutEffect, useMemo, type ReactNode, } from "react"; @@ -16,13 +17,32 @@ import { import { useAPI, type APIClient } from "@/browser/contexts/API"; import { isConnectionLostError } from "@/browser/contexts/connectionLink"; import { useSettings } from "@/browser/contexts/SettingsContext"; +import { updatePersistedState } from "@/browser/hooks/usePersistedState"; import { FIRST_RUN_VIEW_ID } from "@/common/constants/firstRunView"; +import { ONBOARDING_VIEWED_KEY } from "@/common/constants/storage"; /** 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(); } +/** Sets the early script's paint cache once these splash ids include the onboarding wizard. */ +function cacheViewedWizard(viewedIds: readonly string[]): void { + if (viewedIds.includes(ONBOARDING_WIZARD_SPLASH_ID)) { + updatePersistedState(ONBOARDING_VIEWED_KEY, true); + } +} + +/** + * The static first-run view shows the wizard's gateway step. Once the queue is known and the + * wizard is not first (already viewed, read failed, splashes off, another splash), it must go. + */ +function useFirstRunViewQueueGuard(loaded: boolean, wizardIsFirst: boolean): void { + useLayoutEffect(() => { + if (loaded && !wizardIsFirst) removeFirstRunView(); + }, [loaded, wizardIsFirst]); +} + const SplashScreenActiveContext = createContext(false); interface PausedOnboardingState { @@ -63,6 +83,8 @@ function getActiveSplashQueue(viewedIds: readonly string[]): SplashConfig[] { async function markViewed(api: APIClient, splashId: string): Promise { try { await api.splashScreens.markSplashScreenViewed({ splashId }); + // Only a saved view may set the paint cache: markViewed also returns true after other errors. + cacheViewedWizard([splashId]); } catch (error) { if (isConnectionLostError(error)) return false; console.error("Failed to mark splash screen as viewed:", error); @@ -89,6 +111,7 @@ export function SplashScreenProvider({ children }: { children: ReactNode }) { void (async () => { try { const viewedIds = await api.splashScreens.getViewedSplashScreens(); + cacheViewedWizard(viewedIds); setQueue(getActiveSplashQueue(viewedIds)); } catch (error) { // A lost connection is no load failure: keep the shown splash for the reconnect. @@ -116,6 +139,7 @@ export function SplashScreenProvider({ children }: { children: ReactNode }) { const currentSplash = queue[0] ?? null; const isOnboardingSplash = currentSplash?.id === ONBOARDING_WIZARD_SPLASH_ID; const isPaused = isOnboardingSplash && onboardingPaused; + useFirstRunViewQueueGuard(loaded, isOnboardingSplash); useEffect(() => { if (!isOnboardingSplash || onboardingPaused || pausedOnboarding == null) { diff --git a/src/common/constants/storage.ts b/src/common/constants/storage.ts index 2a88d5ce44d..f4b7d023edf 100644 --- a/src/common/constants/storage.ts +++ b/src/common/constants/storage.ts @@ -65,6 +65,12 @@ export function isNonWorkspaceScopeId(scopeId: string): boolean { */ export const UI_THEME_KEY = "uiTheme"; +/** + * Paint cache for the static first-run view (T3 #5971, C step 2): true once this browser saw the + * onboarding wizard marked viewed, so the server's early script loads the app without asking. + */ +export const ONBOARDING_VIEWED_KEY = "onboardingWizardViewed"; + /** * Get the localStorage key for the last selected provider when adding custom models (global) * Format: "lastCustomModelProvider" @@ -870,6 +876,8 @@ export const PERSISTED_KEY_REGISTRY: readonly PersistedKeyRegistration[] = [ // Global UI state. // Paint cache of appearance.theme for index.html, written by ThemeProvider. globalKey(UI_THEME_KEY, "cache", 64), + // Paint cache for the first-run view's early script, written by SplashScreenProvider. + globalKey(ONBOARDING_VIEWED_KEY, "cache", 8), globalKey(ARTIFACTS_SELECTION_KEY, "ui", ARTIFACTS_SELECTION_MAX_CHARS), globalKey(LAST_CUSTOM_MODEL_PROVIDER_KEY, "ui", 128), // { workspaceId } (older builds also stored paths; readers use only the id). diff --git a/tests/ui/onboarding/providerHandoff.test.ts b/tests/ui/onboarding/providerHandoff.test.ts new file mode 100644 index 00000000000..4a2acaaedf9 --- /dev/null +++ b/tests/ui/onboarding/providerHandoff.test.ts @@ -0,0 +1,132 @@ +/** + * SplashScreenProvider and the static first-run view (T3 #5971, C step 2): every queue in which the + * wizard is not first removes #xum-pre, and a viewed wizard sets the early script's paint cache. + */ +import "../dom"; +import React from "react"; +import { act, cleanup, fireEvent, render } from "@testing-library/react"; + +const mockApi = { + splashScreens: { + getViewedSplashScreens: jest.fn, []>(), + markSplashScreenViewed: jest.fn(() => Promise.resolve()), + }, +}; +let mockApiReady = true; +let mockOtherSplashFirst = false; + +jest.mock("@/browser/contexts/API", () => ({ + useAPI: () => ({ api: mockApiReady ? mockApi : null }), +})); +jest.mock("@/browser/contexts/SettingsContext", () => ({ + useSettings: () => ({ registerOnClose: () => () => undefined }), +})); +jest.mock("@/browser/features/SplashScreens/index", () => { + const actual = jest.requireActual( + "@/browser/features/SplashScreens/index" + ); + const { createElement } = jest.requireActual("react"); + const splash = (id: string, priority: number) => ({ + id, + priority, + component: (props: { onDismiss: () => void }) => + createElement("button", { onClick: props.onDismiss }, `splash ${id}`), + }); + return { + ...actual, + // Test mode turns splash screens off (index.ts), and this suite needs them on. + DISABLE_SPLASH_SCREENS: false, + get SPLASH_REGISTRY() { + const wizard = splash(actual.ONBOARDING_WIZARD_SPLASH_ID, 5); + return mockOtherSplashFirst ? [wizard, splash("other", 6)] : [wizard]; + }, + }; +}); + +import { SplashScreenProvider } from "@/browser/features/SplashScreens/SplashScreenProvider"; +import { ONBOARDING_WIZARD_SPLASH_ID } from "@/browser/features/SplashScreens/index"; +import { readPersistedState } from "@/browser/hooks/usePersistedState"; +import { ONBOARDING_VIEWED_KEY } from "@/common/constants/storage"; + +const firstRunView = () => document.getElementById("xum-pre"); +const viewedCached = () => readPersistedState(ONBOARDING_VIEWED_KEY, false); + +async function renderProvider() { + const view = render(React.createElement(SplashScreenProvider, null, "app")); + await act(async () => await Promise.resolve()); + return view; +} + +beforeEach(() => { + const view = document.createElement("div"); + view.id = "xum-pre"; + document.body.append(view); + localStorage.clear(); + mockApiReady = true; + mockOtherSplashFirst = false; + mockApi.splashScreens.getViewedSplashScreens.mockResolvedValue([]); + jest.spyOn(console, "error").mockImplementation(() => undefined); // the failed-read row logs +}); + +afterEach(() => { + jest.restoreAllMocks(); + cleanup(); + document.body.innerHTML = ""; +}); + +test("keeps the static view while the wizard is first in the queue", async () => { + await renderProvider(); + expect(firstRunView()).not.toBeNull(); + expect(viewedCached()).toBe(false); +}); + +test.each([ + [ + "the wizard was viewed", + () => + mockApi.splashScreens.getViewedSplashScreens.mockResolvedValue([ONBOARDING_WIZARD_SPLASH_ID]), + ], + [ + "the viewed list cannot be read", + () => mockApi.splashScreens.getViewedSplashScreens.mockRejectedValue(new Error("read failed")), + ], + ["another splash comes first", () => (mockOtherSplashFirst = true)], + ["there is no API", () => (mockApiReady = false)], +])("removes the static view when %s", async (_name, arrange) => { + arrange(); + await renderProvider(); + expect(firstRunView()).toBeNull(); +}); + +test("caches a viewed wizard for the early script, on load and on dismiss", async () => { + mockApi.splashScreens.getViewedSplashScreens.mockResolvedValue([ONBOARDING_WIZARD_SPLASH_ID]); + await renderProvider(); + expect(viewedCached()).toBe(true); + cleanup(); + + localStorage.clear(); + mockApi.splashScreens.getViewedSplashScreens.mockResolvedValue([]); + const view = await renderProvider(); + expect(viewedCached()).toBe(false); + fireEvent.click(view.getByText(`splash ${ONBOARDING_WIZARD_SPLASH_ID}`)); + await act(async () => await Promise.resolve()); + expect(viewedCached()).toBe(true); +}); + +// Catches the cache write moving out of markViewed's try (for example to after +// `if (!(await markViewed(...))) return;` in dismiss): markViewed also returns true when the save +// failed for a reason other than a lost connection, and only a saved view may set the cache. +test.each([ + ["fails with an error other than a lost connection", false], + ["succeeds", true], +])("on dismiss, a save that %s sets the paint cache: %s", async (_name, saved) => { + mockApi.splashScreens.markSplashScreenViewed.mockClear(); + if (!saved) { + mockApi.splashScreens.markSplashScreenViewed.mockRejectedValueOnce(new Error("save failed")); + } + const view = await renderProvider(); + fireEvent.click(view.getByText(`splash ${ONBOARDING_WIZARD_SPLASH_ID}`)); + await act(async () => await new Promise((resolve) => setTimeout(resolve, 0))); + expect(mockApi.splashScreens.markSplashScreenViewed).toHaveBeenCalled(); + expect(viewedCached()).toBe(saved); +});