Skip to content

Commit 3ea0463

Browse files
committed
fix(FR-2518): use React-driven splash dismissal to eliminate blank screen
Root cause: splash was dismissed before header/sider rendered. 1. Replace backend-ai-connected listener with globalThis.__dismissSplash() 2. MainLayout: DismissSplashOnMount inside header Suspense boundary fires only after WebUIHeader actually renders 3. LoginView: dismiss in open() callback when login form becomes visible (only fires in logged-out state) 4. MutationObserver fallback increased to 3s (was 800ms) to avoid racing with React-driven dismiss on normal routes 5. React-driven dismiss cancels MutationObserver fallback timer 6. Inline critical CSS in <head> for splash visibility before external CSS loads 7. Body background set inline to prevent white flash Verified via Playwright: splash fades only after UI is visible in both logged-in (header+sider) and logged-out (login form) flows.
1 parent b1ab3c8 commit 3ea0463

6 files changed

Lines changed: 120 additions & 206 deletions

File tree

index.html

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,18 @@
1919
<link rel="stylesheet" href="resources/custom.css">
2020
<script src="manifest/app.js"></script>
2121
<title>Backend.AI</title>
22+
<!-- Critical inline CSS: ensures splash is visible before external CSS loads -->
23+
<style>
24+
body { margin: 0; background-color: rgba(247, 247, 246, 1); }
25+
body.dark-theme { background-color: #191919; }
26+
#splash {
27+
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
28+
z-index: 10000; display: flex; align-items: center; justify-content: center;
29+
background-color: rgba(247, 247, 246, 1); transition: opacity 0.3s ease-out;
30+
}
31+
body.dark-theme #splash { background-color: #191919; }
32+
.splash-hidden { opacity: 0; pointer-events: none; }
33+
</style>
2234
<script nonce="{{nonce}}">
2335
globalThis.isElectron = isElectron();
2436
if (isElectron) {
@@ -56,12 +68,15 @@
5668
if (globalThis.isDarkMode) {
5769
document.body.classList.add('dark-theme');
5870
}
71+
// Set body background immediately to prevent white flash before CSS loads
72+
document.body.style.backgroundColor = globalThis.isDarkMode ? '#191919' : 'rgba(247, 247, 246, 1)';
5973
</script>
6074
<div>
61-
<div id="react-root">
62-
<div class="splash">
75+
<div id="react-root"></div>
76+
<div id="splash" class="splash">
77+
<div class="splash-drag-area"></div>
78+
<div class="splash-card">
6379
<div class="splash-header">
64-
<!-- <img src="manifest/backend.ai-text.svg" style="height:50px;padding:35px 20px;"> -->
6580
<div class="logo"></div>
6681
</div>
6782
<div class="splash-information">
@@ -112,6 +127,52 @@
112127
});
113128
}
114129
}
130+
131+
// Splash fade-out: React-driven dismissal.
132+
// React calls window.__dismissSplash() when meaningful UI is ready
133+
// (sider/header for logged-in, login form for logged-out).
134+
// MutationObserver serves as a fallback for independent routes
135+
// (/verify-email, /change-password, etc.) that render content quickly.
136+
(function () {
137+
var splash = document.getElementById('splash');
138+
if (!splash) return;
139+
var dismissed = false;
140+
var observer = null;
141+
var fallbackTimer = null;
142+
function dismiss() {
143+
if (dismissed) return;
144+
dismissed = true;
145+
if (observer) observer.disconnect();
146+
if (fallbackTimer) clearTimeout(fallbackTimer);
147+
splash.classList.add('splash-hidden');
148+
splash.addEventListener('transitionend', function handler(e) {
149+
if (e.propertyName === 'opacity') {
150+
splash.removeEventListener('transitionend', handler);
151+
splash.remove();
152+
}
153+
});
154+
// Safety net: remove even if transitionend never fires (e.g. prefers-reduced-motion)
155+
setTimeout(function () { if (splash.parentNode) splash.remove(); }, 1000);
156+
}
157+
// Primary trigger: called by React when the visible UI is ready.
158+
// Cancels the MutationObserver fallback timer to prevent premature dismissal.
159+
window.__dismissSplash = dismiss;
160+
// Fallback: MutationObserver for routes that don't explicitly call __dismissSplash
161+
// (e.g. /verify-email, /change-password). Uses a longer delay to avoid racing
162+
// with React-driven dismissal on normal routes.
163+
var reactRoot = document.getElementById('react-root');
164+
if (reactRoot) {
165+
observer = new MutationObserver(function () {
166+
if (reactRoot.childNodes.length > 0) {
167+
observer.disconnect();
168+
fallbackTimer = setTimeout(dismiss, 3000);
169+
}
170+
});
171+
observer.observe(reactRoot, { childList: true });
172+
}
173+
// Ultimate safety fallback
174+
setTimeout(dismiss, 10000);
175+
})();
115176
</script>
116177
</body>
117178

react/src/components/LoadingCurtain.test.tsx

Lines changed: 0 additions & 102 deletions
This file was deleted.

react/src/components/LoadingCurtain.tsx

Lines changed: 0 additions & 87 deletions
This file was deleted.

react/src/components/LoginView.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,8 @@ const LoginView: React.FC = () => {
261261
}
262262
setIsLoginPanelOpen(true);
263263
setIsBlockPanelOpen(false);
264+
// Dismiss splash when login form becomes visible (logged-out state)
265+
(globalThis as any).__dismissSplash?.();
264266

265267
const urlParams = new URLSearchParams(window.location.search);
266268
const tokenParam = urlParams.get('token');
@@ -996,7 +998,7 @@ const LoginView: React.FC = () => {
996998
loginWithOpenID(client);
997999
}, [apiEndpoint]);
9981000

999-
// Wrapper creates a stacking context above LoadingCurtain (z-index 9999).
1001+
// Wrapper creates a stacking context above the splash overlay (z-index 10001 > splash 10000).
10001002
// Zero-sized so it doesn't intercept pointer events. Child modals use
10011003
// position:fixed internally so they are visible and interactive.
10021004
return (
@@ -1017,7 +1019,7 @@ const LoginView: React.FC = () => {
10171019
left: 0,
10181020
width: 0,
10191021
height: 0,
1020-
zIndex: 10000,
1022+
zIndex: 10001,
10211023
overflow: 'visible',
10221024
}}
10231025
>

react/src/components/MainLayout/MainLayout.tsx

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ import BAIErrorBoundary from '../BAIErrorBoundary';
1414
import BAISider from '../BAISider';
1515
import ErrorBoundaryWithNullFallback from '../ErrorBoundaryWithNullFallback';
1616
import ForceTOTPChecker from '../ForceTOTPChecker';
17-
import LoadingCurtain from '../LoadingCurtain';
1817
import NetworkStatusBanner from '../NetworkStatusBanner';
1918
import NoResourceGroupAlert from '../NoResourceGroupAlert';
2019
import PasswordChangeRequestAlert from '../PasswordChangeRequestAlert';
@@ -127,7 +126,6 @@ function MainLayout() {
127126

128127
return (
129128
<LayoutWithPageTestId>
130-
<LoadingCurtain />
131129
<CSSTokenVariables />
132130
<style>
133131
{`
@@ -215,6 +213,7 @@ function MainLayout() {
215213
<WebUIHeader
216214
onClickMenuIcon={() => setSideCollapsed((v) => !v)}
217215
/>
216+
<DismissSplashOnMount />
218217
{/* sticky Alert components with banner props */}
219218
<ErrorBoundaryWithNullFallback>
220219
<Suspense fallback={null}>
@@ -415,4 +414,16 @@ ${Object.entries(token)
415414
);
416415
};
417416

417+
/**
418+
* Dismisses the HTML splash overlay when mounted.
419+
* Placed inside a Suspense boundary so it only fires after the visible UI
420+
* (sider, header) has actually rendered — not when React merely mounts wrappers.
421+
*/
422+
const DismissSplashOnMount = () => {
423+
useEffect(() => {
424+
(globalThis as any).__dismissSplash?.();
425+
}, []);
426+
return null;
427+
};
428+
418429
export default MainLayout;

0 commit comments

Comments
 (0)