Skip to content

fix(web): stop settings panels flashing on their first open - #1870

Merged
bobleer merged 1 commit into
GCWing:mainfrom
bobleer:fix/settings-first-open-flash
Jul 30, 2026
Merged

fix(web): stop settings panels flashing on their first open#1870
bobleer merged 1 commit into
GCWing:mainfrom
bobleer:fix/settings-first-open-flash

Conversation

@bobleer

@bobleer bobleer commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Summary

Every settings tab flashed once the first time it was opened after launch, then stayed smooth on later opens. Three per-tab resources only load once per process, and all three were being resolved while the panel was already on screen:

  1. Lazy i18n namespaces (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 when the JSON lands. Hits settings/basics, settings/appearance, settings/editor, worktrees, and most other panels.
  2. Suspense skeleton stealing a frame. lazy() runs its ctor on first render and throws a thenable even when the chunk is already cached, so SettingsScene committed its loading skeleton before the panel. NavPanel already works around this with startTransition; the settings nav did not.
  3. Per-section loading placeholders. Each config section reads state over IPC and painted a 200px "loading" block while doing so. On a cold tab that meant a page of placeholders appearing and collapsing within a frame or two; on a warm tab the ConfigManager cache made it invisible — hence "only the first time".

Changes:

  • New settingsTabI18n.ts maps each tab to the namespaces it renders from; preloadSettingsTabContent now warms chunk and namespaces, so activation waits for both. Existing hover/focus preloading means a click is usually already warm.
  • settingsContentRegistry tracks which tabs are fully warm (isSettingsTabContentReady).
  • Tab activation is wrapped in startTransition, so a cached-but-uninitialized lazy panel can no longer commit the skeleton.
  • SettingsScene holds only its cold first paint (first open after launch, deep links) until resources land. Tab switches never pass through that gate, so the existing "switch immediately, do not retain the outgoing panel" behavior is unchanged.
  • nav-registry loads the settings namespace together with the SettingsNav chunk, so sidebar labels no longer flash keys either.
  • ConfigPageLoading waits out a 200ms grace period and fades in over 140ms (prefers-reduced-motion respected), so warm reads land silently and only genuinely slow sections surface a placeholder.

Type and Areas

Type: bug fix (UI/UX)

Areas: web UI (settings scene, config panels, component-library ConfigPage, i18n preloading)

Motivation / Impact

The settings surface looked unfinished on first use: opening any settings item after launch showed a grey skeleton, then untranslated i18n keys, then a page of collapsing loading placeholders, before settling. Users see each tab paint once, fully rendered.

Trade-off worth calling out: a cold tab now waits for its chunk and namespaces before painting instead of painting a placeholder immediately. In exchange for a slightly later first paint (a preloaded tab is unaffected), there is no multi-stage flash. This is the same trade-off SettingsNav and NavPanel already made for navigation.

No API, config, or locale changes.

Verification

pnpm run type-check:web                # clean
pnpm --dir src/web-ui run lint          # clean
pnpm run i18n:audit                     # passed with 0 warnings
pnpm --dir src/web-ui run test:run      # 359 files / 2373 tests passed

New regression test settingsTabI18n.test.ts pins the root cause and the fix: before preloading, t('title') echoes the key back (the visible flash); after preloadSettingsTabContent('basics') the namespace is resolved and real copy comes out. It also asserts the namespace map covers every tab in SETTINGS_CATEGORIES, so a new tab cannot silently regress.

Two existing tests were adjusted to the new contract, with their original assertions intact:

  • SettingsScene.test.tsx — cold first render now waits for resources (chunk + JSON resolve off a macrotask, past what act() flushes). The "switches immediately without retaining the outgoing panel" case keeps its single-microtask await and all three assertions, which is what proves switches are still synchronous.
  • ExternalSourcesConfig.test.tsx — the test asserting the loading placeholder now advances fake timers past the grace period before looking for it.

Reviewer Notes

  • No manual visual confirmation in a running app: the browser tooling in my environment could not reach the dev server. Everything above is code-path analysis plus the executable assertions listed, not an observed recording — so no before/after capture is attached. Worth a quick eyeball on Basics (most sections, previously the worst offender) and Worktrees.
  • The namespace map in settingsTabI18n.ts is hand-maintained and must stay in sync with the useTranslation(...) / useI18n(...) calls in each panel. The coverage test catches a missing tab, not a missing namespace on an existing tab.
  • The 200ms ConfigPageLoading grace period does not eliminate every pathological case: a read finishing at ~250ms will still show a placeholder briefly. Bounding that would need a minimum-display duration, which felt like more machinery than the problem warranted.
  • AI-assisted: yes (Claude Code). Testing level: lightly tested — automated checks pass in full, no manual UI pass.

Checklist

  • This PR is focused and does not include secrets, temporary prompts, generated scratch files, or unrelated artifacts.
  • Relevant verification is recorded above, or skipped checks are explained.
  • User-facing strings, docs, and locales are updated where applicable. (No string changes; existing locales now resolve earlier.)

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.
@bobleer
bobleer merged commit af672ef into GCWing:main Jul 30, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant