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); +});