From 4dec0485a6c3e839a82b50c9f28cbec75d09cc1f Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Wed, 29 Jul 2026 19:04:33 -0700 Subject: [PATCH] fix(web): stop settings panels flashing on their first open Every settings tab flashed once on the first open after launch and then stayed smooth, because three per-tab resources only load once per process and all three were resolved while the panel was already on screen. Lazy i18n namespaces were the main cause. Only WEB_UI_BOOTSTRAP_NAMESPACES ship with the initial bundle, and `react.useSuspense` is off, so a panel mounted ahead of its namespace renders raw i18n keys ("title", "subtitle", "logging.sections.logging") and reflows into real copy once the JSON lands. Map every tab to the namespaces it renders from and load them alongside the tab's lazy chunk in preloadSettingsTabContent, so activation waits for both. The settings nav does the same for its own `settings` namespace. The Suspense skeleton also stole a frame: lazy() runs its ctor on first render and throws a thenable even when the chunk is already cached, so SettingsScene committed the loading skeleton before the panel. Wrap tab activation in startTransition, matching what NavPanel already does for scene navs. Cold entries into the scene (first open, deep links) preload before their first paint instead of stepping through skeleton, then keys, then content. Finally, each config section reads its state over IPC and painted a 200px "loading" block while doing so, which on a cold tab meant a page of placeholders appearing and collapsing within a frame or two. Give ConfigPageLoading a 200ms grace period and a short fade-in, so warm reads land silently and only genuinely slow ones surface a placeholder. --- src/web-ui/src/app/scenes/nav-registry.ts | 13 +++- .../src/app/scenes/settings/SettingsNav.tsx | 15 +++-- .../scenes/settings/SettingsScene.test.tsx | 17 ++++++ .../src/app/scenes/settings/SettingsScene.tsx | 31 +++++++++- .../settings/settingsContentRegistry.ts | 26 +++++++- .../scenes/settings/settingsTabI18n.test.ts | 42 +++++++++++++ .../app/scenes/settings/settingsTabI18n.ts | 60 +++++++++++++++++++ .../components/ConfigPage/ConfigPage.scss | 14 +++++ .../ConfigPage/ConfigPageLoading.tsx | 32 +++++++++- .../components/ExternalSourcesConfig.test.tsx | 6 ++ 10 files changed, 247 insertions(+), 9 deletions(-) create mode 100644 src/web-ui/src/app/scenes/settings/settingsTabI18n.test.ts create mode 100644 src/web-ui/src/app/scenes/settings/settingsTabI18n.ts diff --git a/src/web-ui/src/app/scenes/nav-registry.ts b/src/web-ui/src/app/scenes/nav-registry.ts index 0ac6975b03..0a4f9645e6 100644 --- a/src/web-ui/src/app/scenes/nav-registry.ts +++ b/src/web-ui/src/app/scenes/nav-registry.ts @@ -14,7 +14,18 @@ import type { SceneTabId } from '../components/SceneBar/types'; type LazyNavComponent = ReturnType>; -const loadSettingsNav = () => import('./settings/SettingsNav'); +/** + * The settings nav renders every label from the lazy `settings` namespace, so the + * chunk and that namespace are loaded together — mounting before it resolves + * paints raw i18n keys for a frame. + */ +const loadSettingsNav = async () => { + const [navModule] = await Promise.all([ + import('./settings/SettingsNav'), + import('./settings/settingsTabI18n').then((m) => m.preloadSettingsShellI18n()), + ]); + return navModule; +}; const loadFileViewerNav = () => import('./file-viewer/FileViewerNav'); const loadShellNav = () => import('./shell/ShellNav'); diff --git a/src/web-ui/src/app/scenes/settings/SettingsNav.tsx b/src/web-ui/src/app/scenes/settings/SettingsNav.tsx index 2ed2835a2c..bfad63176f 100644 --- a/src/web-ui/src/app/scenes/settings/SettingsNav.tsx +++ b/src/web-ui/src/app/scenes/settings/SettingsNav.tsx @@ -12,6 +12,7 @@ */ import React, { + startTransition, useCallback, useEffect, useMemo, @@ -185,8 +186,13 @@ function useSettingsNav() { const requestId = ++activationRequestRef.current; const commit = () => { if (activationRequestRef.current !== requestId) return; - setActiveTab(tab); - clearSearch(); + // A cached lazy panel still suspends for one promise microtask on its + // first mount; inside a transition React keeps the painted panel until + // the new one is ready instead of committing the skeleton fallback. + startTransition(() => { + setActiveTab(tab); + clearSearch(); + }); }; void preloadSettingsTabContent(tab).then(commit, commit); }, @@ -197,9 +203,10 @@ function useSettingsNav() { (tab: ConfigTab) => { const requestId = ++activationRequestRef.current; const commit = () => { - if (activationRequestRef.current === requestId) { + if (activationRequestRef.current !== requestId) return; + startTransition(() => { setActiveTab(tab); - } + }); }; void preloadSettingsTabContent(tab).then(commit, commit); }, diff --git a/src/web-ui/src/app/scenes/settings/SettingsScene.test.tsx b/src/web-ui/src/app/scenes/settings/SettingsScene.test.tsx index 1ea8581fe5..89df4790e6 100644 --- a/src/web-ui/src/app/scenes/settings/SettingsScene.test.tsx +++ b/src/web-ui/src/app/scenes/settings/SettingsScene.test.tsx @@ -78,6 +78,20 @@ describe('SettingsScene lazy tab routing', () => { vi.useRealTimers(); }); + /** + * The scene holds its very first paint until the active tab's lazy chunk and + * i18n namespaces are in memory, so a cold entry cannot flash a skeleton and a + * frame of raw i18n keys. Both land off a macrotask, past what act() flushes. + */ + async function waitForPanelContent(testId: string) { + for (let attempt = 0; attempt < 50; attempt += 1) { + if (container.querySelector(`[data-testid="${testId}"]`)) return; + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + } + } + async function renderActiveTab( tab: 'mcp-tools' | 'acp-agents' | 'external-sources' | 'voice-input' ) { @@ -85,6 +99,7 @@ describe('SettingsScene lazy tab routing', () => { await act(async () => { root.render(); }); + await waitForPanelContent(`${tab}-config`); } it('renders the lazy MCP tools config tab', async () => { @@ -115,7 +130,9 @@ describe('SettingsScene lazy tab routing', () => { await act(async () => { root.render(); }); + await waitForPanelContent('basics-config'); + /** Only the cold first paint waits for resources; later switches are synchronous. */ await act(async () => { useSettingsStore.setState({ activeTab: 'appearance' }); await Promise.resolve(); diff --git a/src/web-ui/src/app/scenes/settings/SettingsScene.tsx b/src/web-ui/src/app/scenes/settings/SettingsScene.tsx index 2593d1953b..04c37abf6f 100644 --- a/src/web-ui/src/app/scenes/settings/SettingsScene.tsx +++ b/src/web-ui/src/app/scenes/settings/SettingsScene.tsx @@ -9,6 +9,7 @@ import React, { Suspense, useEffect, + useState, } from 'react'; import { useSettingsStore } from './settingsStore'; import type { ConfigTab } from './settingsConfig'; @@ -30,6 +31,8 @@ import { SessionPersonalizationConfig, VoiceInputConfig, WorktreesConfig, + isSettingsTabContentReady, + preloadSettingsTabContent, } from './settingsContentRegistry'; import './SettingsScene.scss'; @@ -81,7 +84,33 @@ const SettingsScene: React.FC = () => { } }, [activeTab, setActiveTab]); - const Content = resolveSettingsContent(resolvedTab); + /** + * Cold entries into the scene (first open after launch, deep links) mount a + * panel whose chunk and i18n namespaces are still in flight, which paints the + * skeleton and then a frame of raw i18n keys. Hold the first paint until those + * resources land — an empty content area for a few ms reads as instant, a + * three-stage flash does not. SettingsNav preloads before it flips the active + * tab, so tab switches are never gated here. + */ + const [firstPaintReady, setFirstPaintReady] = useState(() => + isSettingsTabContentReady(resolvedTab) + ); + + useEffect(() => { + if (firstPaintReady) return; + + let cancelled = false; + const commit = () => { + if (!cancelled) setFirstPaintReady(true); + }; + void preloadSettingsTabContent(resolvedTab).then(commit, commit); + + return () => { + cancelled = true; + }; + }, [firstPaintReady, resolvedTab]); + + const Content = firstPaintReady ? resolveSettingsContent(resolvedTab) : null; return (
diff --git a/src/web-ui/src/app/scenes/settings/settingsContentRegistry.ts b/src/web-ui/src/app/scenes/settings/settingsContentRegistry.ts index dc000f35cb..3882c074e0 100644 --- a/src/web-ui/src/app/scenes/settings/settingsContentRegistry.ts +++ b/src/web-ui/src/app/scenes/settings/settingsContentRegistry.ts @@ -1,5 +1,6 @@ import { lazy } from 'react'; import type { ConfigTab } from './settingsConfig'; +import { preloadSettingsTabI18n } from './settingsTabI18n'; const loadAIModelConfig = () => import('../../../infrastructure/config/components/AIModelConfig'); const loadMcpToolsConfig = () => import('../../../infrastructure/config/components/McpToolsConfig'); @@ -64,6 +65,29 @@ const SETTINGS_CONTENT_LOADERS: Partial Promise keyboard: loadKeyboardShortcutsTab, }; +/** + * Tabs whose chunk *and* i18n namespaces are already in memory. Rendering such a + * tab is a plain synchronous mount: no Suspense fallback, no frame of raw i18n keys. + */ +const readyTabs = new Set(); + +export function isSettingsTabContentReady(tab: ConfigTab): boolean { + return readyTabs.has(tab); +} + +/** + * Warm everything a tab needs to paint in a single frame: its lazy panel chunk + * (with the CSS Vite ships alongside it) and its i18n namespaces. Callers await + * this before switching tabs so the panel appears fully rendered instead of + * stepping through skeleton → untranslated keys → content. + */ export async function preloadSettingsTabContent(tab: ConfigTab): Promise { - await SETTINGS_CONTENT_LOADERS[tab]?.(); + if (readyTabs.has(tab)) return; + + await Promise.all([ + SETTINGS_CONTENT_LOADERS[tab]?.(), + preloadSettingsTabI18n(tab), + ]); + + readyTabs.add(tab); } diff --git a/src/web-ui/src/app/scenes/settings/settingsTabI18n.test.ts b/src/web-ui/src/app/scenes/settings/settingsTabI18n.test.ts new file mode 100644 index 0000000000..04b0e4ff88 --- /dev/null +++ b/src/web-ui/src/app/scenes/settings/settingsTabI18n.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; + +import { i18nService } from '@/infrastructure/i18n/core/I18nService'; +import { SETTINGS_TAB_I18N_NAMESPACES, preloadSettingsShellI18n } from './settingsTabI18n'; +import { isSettingsTabContentReady, preloadSettingsTabContent } from './settingsContentRegistry'; +import { SETTINGS_CATEGORIES } from './settingsConfig'; + +/** + * A namespace that has not resolved yet makes `t(key)` echo the key back, which is + * the raw-key frame a panel paints when it mounts ahead of its translations. + */ +function resolveKey(namespace: string, key: string): string { + return String(i18nService.getI18nInstance().getFixedT(null, namespace)(key)); +} + +describe('settings tab i18n preloading', () => { + it('covers every nav tab', () => { + for (const category of SETTINGS_CATEGORIES) { + for (const tab of category.tabs) { + expect(SETTINGS_TAB_I18N_NAMESPACES[tab.id]?.length ?? 0).toBeGreaterThan(0); + } + } + }); + + it('resolves a tab namespace before the panel can render', async () => { + expect(resolveKey('settings/basics', 'title')).toBe('title'); + + await preloadSettingsTabContent('basics'); + + expect(isSettingsTabContentReady('basics')).toBe(true); + expect(i18nService.getI18nInstance().hasLoadedNamespace('settings/basics')).toBe(true); + expect(resolveKey('settings/basics', 'title')).not.toBe('title'); + }); + + it('resolves the shell namespace behind the nav labels', async () => { + await preloadSettingsShellI18n(); + + expect(resolveKey('settings', 'configCenter.tabs.basics')).not.toBe( + 'configCenter.tabs.basics' + ); + }); +}); diff --git a/src/web-ui/src/app/scenes/settings/settingsTabI18n.ts b/src/web-ui/src/app/scenes/settings/settingsTabI18n.ts new file mode 100644 index 0000000000..59cf070804 --- /dev/null +++ b/src/web-ui/src/app/scenes/settings/settingsTabI18n.ts @@ -0,0 +1,60 @@ +/** + * settingsTabI18n — i18n namespaces every settings tab renders from. + * + * Only WEB_UI_BOOTSTRAP_NAMESPACES ship with the initial bundle; everything else + * resolves through the lazy namespace backend. A panel mounted before its + * namespace lands paints raw i18n keys for a frame (react.useSuspense is off, so + * `t()` returns the key) and then swaps to real copy — the visible "flash" on the + * first open of a tab. Loading these alongside the tab's lazy chunk makes the + * first open look identical to every later one. + * + * Keep in sync with the `useTranslation(...)` / `useI18n(...)` namespaces used by + * each panel and the components it renders. + */ + +import { i18nService } from '@/infrastructure/i18n/core/I18nService'; +import type { I18nNamespace } from '@/infrastructure/i18n/types'; +import type { ConfigTab } from './settingsConfig'; + +/** Namespace behind the settings shell itself: category labels, tab labels, keyboard tab copy. */ +export const SETTINGS_SHELL_NAMESPACES: readonly I18nNamespace[] = ['settings']; + +export const SETTINGS_TAB_I18N_NAMESPACES: Record = { + basics: ['settings/basics'], + appearance: ['settings/appearance', 'settings/basics'], + models: ['settings/ai-model', 'settings/default-model', 'components'], + 'archived-sessions': ['common'], + worktrees: ['worktrees'], + // Both session panels live in the same module and share its sub-sections. + 'session-personalization': ['settings/session-config', 'settings/agentic-tools', 'settings/debug'], + 'session-permissions': ['settings/session-config', 'settings/agentic-tools', 'settings/debug'], + 'quick-actions': ['settings/quick-actions'], + 'voice-input': ['settings/voice-input'], + review: ['settings/review'], + memories: ['settings/memories'], + 'mcp-tools': ['settings/mcp-tools', 'settings/mcp', 'shared'], + 'external-sources': ['settings/external-sources', 'shared'], + hooks: ['settings/hooks'], + 'acp-agents': ['settings/acp-agents'], + editor: ['settings/editor'], + keyboard: ['settings'], +}; + +async function preloadNamespaces(namespaces: readonly I18nNamespace[]): Promise { + await Promise.all( + namespaces.map((namespace) => + i18nService.loadNamespace(namespace).catch(() => { + // A namespace that fails to resolve must not block tab activation; + // i18next keeps falling back the same way it does today. + }) + ) + ); +} + +export function preloadSettingsShellI18n(): Promise { + return preloadNamespaces(SETTINGS_SHELL_NAMESPACES); +} + +export function preloadSettingsTabI18n(tab: ConfigTab): Promise { + return preloadNamespaces(SETTINGS_TAB_I18N_NAMESPACES[tab] ?? []); +} diff --git a/src/web-ui/src/component-library/components/ConfigPage/ConfigPage.scss b/src/web-ui/src/component-library/components/ConfigPage/ConfigPage.scss index 3ebdb166ff..44757c87ab 100644 --- a/src/web-ui/src/component-library/components/ConfigPage/ConfigPage.scss +++ b/src/web-ui/src/component-library/components/ConfigPage/ConfigPage.scss @@ -1,3 +1,8 @@ +@keyframes bitfun-config-page-loading-in { + from { opacity: 0; } + to { opacity: 1; } +} + .bitfun-config-page-loading { min-height: 200px; display: flex; @@ -5,6 +10,15 @@ justify-content: center; color: var(--color-text-muted); font-size: var(--font-size-sm); + /* Mounts only after a grace period (see ConfigPageLoading), so ease it in + rather than popping a block of text into an otherwise settled page. */ + animation: bitfun-config-page-loading-in 140ms cubic-bezier(0.23, 1, 0.32, 1) both; +} + +@media (prefers-reduced-motion: reduce) { + .bitfun-config-page-loading { + animation: none; + } } .bitfun-config-page-message { diff --git a/src/web-ui/src/component-library/components/ConfigPage/ConfigPageLoading.tsx b/src/web-ui/src/component-library/components/ConfigPage/ConfigPageLoading.tsx index 063cb1b5b5..ef13f498c3 100644 --- a/src/web-ui/src/component-library/components/ConfigPage/ConfigPageLoading.tsx +++ b/src/web-ui/src/component-library/components/ConfigPage/ConfigPageLoading.tsx @@ -1,19 +1,47 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import './ConfigPage.scss'; +/** + * Grace period before a section admits it is loading. + * + * Config sections read their state through ConfigManager, which serves warm paths + * from cache and cold ones over IPC. Painting the placeholder unconditionally means + * a page full of 200px "loading" blocks appears and collapses within one or two + * frames on the first open of a tab. Staying invisible for a beat lets fast reads + * land silently; only genuinely slow ones surface a placeholder. + */ +const LOADING_VISIBLE_DELAY_MS = 200; + export interface ConfigPageLoadingProps { text: string; className?: string; + /** Override the grace period; 0 paints immediately. */ + delayMs?: number; } export const ConfigPageLoading: React.FC = ({ text, className = '', + delayMs = LOADING_VISIBLE_DELAY_MS, }) => { + const [visible, setVisible] = useState(delayMs <= 0); + + useEffect(() => { + if (delayMs <= 0) { + setVisible(true); + return; + } + + setVisible(false); + const timer = window.setTimeout(() => setVisible(true), delayMs); + return () => window.clearTimeout(timer); + }, [delayMs]); + + if (!visible) return null; + return (
{text}
); }; - diff --git a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.test.tsx b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.test.tsx index 3b3d7fb889..3246872038 100644 --- a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.test.tsx +++ b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.test.tsx @@ -301,6 +301,12 @@ describe('ExternalSourcesConfig', () => { '.bitfun-config-page-layout.bitfun-external-sources-config', ); expect(page?.querySelector('.bitfun-config-page-header__title')?.textContent).toBe('title'); + + /** + * ConfigPageLoading waits out a grace period before admitting it is loading, so + * reads served from the ConfigManager cache never flash a placeholder. + */ + await act(async () => vi.advanceTimersByTimeAsync(260)); expect(page?.querySelector( '.bitfun-config-page-content .bitfun-config-page-loading', )?.textContent).toBe('loading');