fix: stop the white flash when entering the app - #320
Conversation
Two independent sources of white, both before React can correct them.
`RouteSuspense` rendered `fallback={null}`, so nothing at all was on screen
while the lazy `main-layout` chunk was fetched — the window fell through to
the bare `<body>` canvas for the length of that fetch, which on a cold start
or a slow connection is the white screen a user sees right after signing in.
It now paints `bg-background` on the first frame, with the spinner and label
held back 300ms by CSS (no timer) so a fast chunk shows only the canvas and
never flashes an indicator. A `scope` prop keeps a nested boundary inside its
pane instead of covering the mounted sidebar. The login page also warms the
`main-layout` chunk while it waits on the user, so the post-sign-in route
swap does not begin with a fetch.
On Electron the theme class itself landed late. The renderer CSP rejects
next-themes' inline pre-paint script, so `.dark` could only be applied after
React mounted, and the window background followed `nativeTheme` — meaning a
user on an explicit dark theme under a light system opened a white window
and watched it turn black. The committed theme is now mirrored into the main
process, drives the window `backgroundColor` and win32 caption overlay before
the window exists, and reaches preload as a launch argument so the class is
on `<html>` before the first paint. A theme preview retints live chrome but
is never persisted.
Model: claude-opus-5[1m]
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ablated every mechanism the fix added. Three turned out not to carry weight.
An Electron 39 probe showed that at preload's first line the document is
`{ readyState: 'loading', documentElement: null, childCount: 0 }`, so the
synchronous "apply now if `<html>` exists" branch never runs and observing the
document is the only path rather than a fallback. That branch, its unreachable
"a class is already there" guard, and the test that covered it are gone, and
the module is now one function. The `typeof document !== 'undefined'` guard in
preload goes with them: preload only ever runs in a renderer.
`prewarm-main-layout.ts` existed for a module-level "already started" flag that
ablation showed changes nothing — the module registry already dedupes the
dynamic import — so the flag is gone and the remaining single idle-scheduled
import is inlined at its one call site.
Measuring the two `LoadingPlaceholder` variants in Chromium corrected the
reason `scope` exists: a `100dvh` placeholder does not cover the sidebar (a
flex sibling), it overflows its pane by exactly the top safe-area inset and
drops the spinner half an inset below centre, where `overflow-hidden` clips it.
Identical without an inset, so the comments, AGENTS entry and test now say
that instead.
Tests: removed a class-token snapshot that asserted `delay-300` and
`fill-mode-both` while missing `fade-in`, the one token whose removal silently
disables the whole deferral — it could not catch the regression it existed for,
so the fragile set is named in the code comment instead. Also folded four
overlapping cases into two and dropped two `className` assertions and one
duplicate onboarding assertion.
Kept despite no failing test: the `setStartupThemeSource` input guard (foreign
IPC input, and the predicate itself is covered) and `deferIndicator` (jsdom has
no CSS engine; the two Storybook stories cover it).
Model: claude-opus-5[1m]
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ablation resultsEvery mechanism, constant, branch, prop and early return the fix added was removed one at a time. Electron modules were checked with Two questions could not be settled by removing code, so they were measured directly with a throwaway Electron 39 harness (deleted afterwards, nothing committed):
Test deletionsFour cases removed, net −198/+84 lines.
No new tests were needed: the two ablations that surfaced holes (A3, A11) resolved to a code comment and a documented Electron-runtime limitation rather than to tests that would assert implementation details. Temporary artefactsSwept the branch diff. The only
|
One conflict, in `apps/electron/package.json`: both sides appended suites to the single `test` script line. Resolved as the union — main's three Sparkle suites stay where they were added, and this branch's two theme-bootstrap suites go back behind `system-language-argument.test.mjs`, the argument handling they sit next to. Both sides' suites now run (83 passing). Nothing else overlapped. Main did not touch window creation, the native theme, or preload, and the onboarding theme lifecycle this branch tests is unchanged, so the electron `AGENTS.md` merged cleanly with both sides' invariants intact.
… to launch
Review found the two try/catch blocks in `theme-settings.ts` guarding the wrong
step. They cover `get` and `set`, but `conf` reads and VALIDATES the config file
inside its constructor, and every store here is constructed at module scope — so
a malformed file threw during import, outside both guards, and the main process
did not start. The read guard's comment claimed to prevent exactly that.
Verified the premise before defending against it, on the installed conf 15.1.0:
`clearInvalidConfig` defaults to `false` and the constructor rethrows. Against a
real file, `{"startupThemeSource": "da` throws `SyntaxError` and
`{"startupThemeSource": "vesper"}` throws `Config schema violation:`. Both from
`new Conf(...)`, not from a later `get`.
The guard goes in `createMainSettingsStore`, introduced by this PR, so both
stores get it; `auto-launch-settings.ts` keeps its behavior on every path that
previously worked and only stops being able to abort startup. The fallback is an
in-memory store seeded with the schema defaults — this launch behaves as though
nothing had been persisted, which for the theme is the pre-persistence `system`
path. Deliberately not `clearInvalidConfig: true`: that would silently empty a
file the user might want back, and it does not cover an unreadable path anyway.
The `get`/`set` guards stay, now describing what they actually cover: `conf`
re-reads on every `get`, so a file damaged after construction still throws.
Split the electron-free half into `settings-store-core.ts`, matching
`image-export-core.ts`, so the test drives real `conf` against real corrupt
files on disk rather than a stubbed failure. Ablating the fallback fails both
corruption cases and leaves the healthy-file case passing.
Also recorded the login prewarm's cost where it is paid: on web and mobile it is
a real request /login did not use to make, including for a visitor who never
signs in. Not gated on a sign-in click on purpose — a social login navigates
away immediately, so a fetch started then is usually discarded.
Model: claude-opus-5[1m]
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The test diff was 318 of the PR's 793 added lines, and a good part of it was paperwork. Kept one thing per mechanism, dropped the rest. - `route-suspense`: dropped the scope-forwarding case (it asserted a prop reaches a prop; the pane-vs-viewport reasoning it carried lives in the component comment and the Storybook story) and, from the remaining case, the half that watched the resolved chunk replace the fallback, which was testing React's Suspense rather than this change. A never-resolving `lazy` removes the promise plumbing with it. 99 -> 46. - `theme-provider`: kept "a preview must not persist, a commit must", dropped the parallel `setNativeTheme` recording, which the onboarding test already covers. 60 -> 42. - `settings-store-core`: dropped the valid-file round trip, which tested `conf` and not the fallback; the two corruption cases share one test. 58 -> 36. - `initial-window-theme-argument`: the serialize/read round trip was a tautology through one shared constant. Folded its one useful assertion into the validation case. 29 -> 24. - `window-theme`: merged three cases over one pure function into two. Also tightened the two longest comment blocks. Net 793 -> 681 added lines, of which tests are 215. Model: claude-opus-5[1m] Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fixes the few-seconds white screen after signing in. Two independent causes, both landing before React can correct them.
A. The lazy layout rendered nothing
RouteSuspensewas<Suspense fallback={null}>, and it wraps the app's largest chunk (lazy(() => import('@/components/main-layout'))). While that chunk is fetched, React renders nothing at all and the window falls through to the bare<body>canvas — white, and on a cold start or a slow connection for a long time.bg-background(the same token the layout root uses) on frame one.animate-in fade-in delay-300 fill-mode-both ease-out— no timer, no React state. A chunk that arrives quickly shows only the canvas, so we do not trade a white flash for a spinner flash. Underprefers-reduced-motionthe global reset collapses the duration but keeps the delay, so the indicator simply appears at 300ms without the fade.RouteSuspensegained ascope.viewportis for a boundary that owns the window;content(the Archive route) fills its pane so it cannot paint over the already-mounted sidebar.login-page.tsxwarms themain-layoutchunk on an idle callback while the page waits on the user, so the post-sign-in route swap does not begin with a fetch.B.
.darklanded after the first paint (Electron)The renderer CSP (
script-src 'self') deliberately rejects inline scripts, so next-themes' blocking pre-paint script is neutered and the theme class could only be applied once React mounted. Meanwhile the windowbackgroundColorcame fromnativeTheme.shouldUseDarkColors. A user on an explicit dark theme under a light system therefore opened a#FFFFFFwindow with a light canvas and watched it turn black on mount.theme-settings.tsmirrors the committed theme into the main process (the renderer keeps it inlocalStorage, which main cannot read).getInitialMainWindowThemeSourcefeeds it tonativeTheme.themeSourcebefore theBrowserWindowis constructed, so the background color and the win32 caption overlay are right on frame one. Onboarding stays pinned to Light..dark/.lightclass from a--lody-initial-window-themelaunch argument, mirroring exactly whattheme-providerdoes on mount. If the parser has not produced<html>yet it observes the document and applies the class the instant it appears, which is still a microtask during parsing.DOMContentLoadedwould be too late: the app is a module script, so Chromium is free to paint the parsed body first.app.setNativeTheme, but never reaches the persisted startup theme — onlyapp.setStartupThemeSourcewrites it.Design review
Ran
better-uiover the fallback. Notes that shaped the result:bg-backgroundfixes at frame one.Tests
route-suspense.test.tsx: the fallback renders (notnull), carries the right scope, defers by CSS, and yields to the loaded route. The chunk is an explicitly resolved promise — no timers, no scheduler luck.theme-provider.test.ts: the committed theme is mirrored to main; a preview is not.onboarding-theme-lifecycle.test.tsx: onboarding's forced Light and restored System both reach the startup theme.window-theme.test.mjs,initial-window-theme-argument.test.mjs,initial-window-theme.test.mjs(preload, with an injected observer).LoadingPlaceholdergainedDeferredIndicatorandDeferredIndicatorInContentPane.pnpm checkandpnpm formatpass.Out of scope
The Capacitor mobile shell needs the same pre-paint contract for its WebView background and splash colour, but that project is outside this repository. The invariant is recorded in
packages/components/AGENTS.mdrather than faked here.🤖 Generated with Claude Code