fix: gate native window reveal on renderer load

Keep BrowserWindow hidden until both ready-to-show and did-finish-load are complete, preventing the incomplete header from reaching the screen.

Render a static upload-speed baseline beneath the transparent canvas so the zero line exists independently of renderer timers.

Add regression coverage for both reveal event orders, load failures, and the script-independent header baseline.
This commit is contained in:
Sucukdeluxe
2026-08-20 13:45:02 +02:00
parent f3001828a0
commit ff1593be81
4 changed files with 61 additions and 5 deletions
+19 -2
View File
@@ -15,14 +15,31 @@ function createStartupQuery(config, version) {
function createStartupWindow(BrowserWindow, options) { function createStartupWindow(BrowserWindow, options) {
const window = new BrowserWindow({ ...options, show: false }); const window = new BrowserWindow({ ...options, show: false });
window.once('ready-to-show', () => { let nativePaintReady = false;
let rendererLoadFinished = false;
let revealed = false;
const reveal = () => {
if (revealed || !nativePaintReady || !rendererLoadFinished) return;
revealed = true;
window.show(); window.show();
};
window.once('ready-to-show', () => {
nativePaintReady = true;
reveal();
});
window.webContents.once('did-finish-load', () => {
rendererLoadFinished = true;
reveal();
}); });
return { return {
window, window,
load(target, onLoadError, options) { load(target, onLoadError, options) {
return window.loadFile(target, options).catch(onLoadError); return window.loadFile(target, options).catch((error) => {
rendererLoadFinished = true;
reveal();
return onLoadError(error);
});
} }
}; };
} }
+4 -1
View File
@@ -55,7 +55,10 @@
<div class="header-spacer" aria-hidden="true"></div> <div class="header-spacer" aria-hidden="true"></div>
<div class="header-cluster header-utilities"> <div class="header-cluster header-utilities">
<div class="upload-speed-sparkline" id="uploadSpeedSparkline" title="Aktuelle Upload-Geschwindigkeit" aria-label="Aktuelle Upload-Geschwindigkeit"> <div class="upload-speed-sparkline" id="uploadSpeedSparkline" title="Aktuelle Upload-Geschwindigkeit" aria-label="Aktuelle Upload-Geschwindigkeit">
<canvas id="uploadSpeedCanvas" width="232" height="44" aria-hidden="true"></canvas> <span class="upload-speed-chart" aria-hidden="true">
<span class="upload-speed-baseline"></span>
<canvas id="uploadSpeedCanvas" width="232" height="44"></canvas>
</span>
<strong id="uploadSpeedValue">0 B/s</strong> <strong id="uploadSpeedValue">0 B/s</strong>
</div> </div>
<button class="header-update-button" id="headerUpdateBtn" title="Nach Aktualisierungen suchen" aria-label="Nach Aktualisierungen suchen" data-tooltip="Nach Aktualisierungen suchen"> <button class="header-update-button" id="headerUpdateBtn" title="Nach Aktualisierungen suchen" aria-label="Nach Aktualisierungen suchen" data-tooltip="Nach Aktualisierungen suchen">
+21 -1
View File
@@ -1960,7 +1960,27 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; }
transition: opacity .16s ease; transition: opacity .16s ease;
} }
.upload-speed-chart {
position: relative;
min-width: 0;
height: 22px;
display: block;
}
.upload-speed-baseline {
position: absolute;
left: 0;
right: 0;
bottom: 2px;
height: 2px;
border-radius: 999px;
background: var(--success);
pointer-events: none;
}
.upload-speed-sparkline canvas { .upload-speed-sparkline canvas {
position: relative;
z-index: 1;
width: 100%; width: 100%;
height: 22px; height: 22px;
display: block; display: block;
@@ -4053,7 +4073,7 @@ input[type="checkbox"] {
justify-content: flex-end; justify-content: flex-end;
} }
.upload-speed-sparkline canvas { .upload-speed-chart {
display: none; display: none;
} }
+17 -1
View File
@@ -8,6 +8,7 @@ const { configureStartupRenderer, createStartupWindow, resolveStartupLanguage, c
class TestBrowserWindow extends EventEmitter { class TestBrowserWindow extends EventEmitter {
constructor(options) { constructor(options) {
super(); super();
this.webContents = new EventEmitter();
this.options = options; this.options = options;
this.showCalls = 0; this.showCalls = 0;
this.startupEvents = []; this.startupEvents = [];
@@ -27,7 +28,7 @@ class TestBrowserWindow extends EventEmitter {
loadFile(target, options) { loadFile(target, options) {
this.startupEvents.push(`load:${target}`); this.startupEvents.push(`load:${target}`);
this.loadOptions = options; this.loadOptions = options;
return Promise.reject(this.loadError); return this.loadError ? Promise.reject(this.loadError) : Promise.resolve();
} }
} }
@@ -78,6 +79,7 @@ test('main window uses the branded application icon', () => {
test('startup load registers visibility before navigation and shows only once', async () => { test('startup load registers visibility before navigation and shows only once', async () => {
const startup = createStartupWindow(TestBrowserWindow, {}); const startup = createStartupWindow(TestBrowserWindow, {});
startup.window.loadError = null;
const loading = startup.load('renderer/index.html', () => {}); const loading = startup.load('renderer/index.html', () => {});
assert.deepEqual(startup.window.startupEvents, [ assert.deepEqual(startup.window.startupEvents, [
@@ -86,12 +88,24 @@ test('startup load registers visibility before navigation and shows only once',
]); ]);
startup.window.emit('ready-to-show'); startup.window.emit('ready-to-show');
assert.equal(startup.window.showCalls, 0);
startup.window.webContents.emit('did-finish-load');
startup.window.emit('ready-to-show'); startup.window.emit('ready-to-show');
startup.window.webContents.emit('did-finish-load');
await loading; await loading;
assert.equal(startup.window.showCalls, 1); assert.equal(startup.window.showCalls, 1);
}); });
test('startup waits for native paint readiness when renderer loading finishes first', () => {
const startup = createStartupWindow(TestBrowserWindow, {});
startup.window.webContents.emit('did-finish-load');
assert.equal(startup.window.showCalls, 0);
startup.window.emit('ready-to-show');
assert.equal(startup.window.showCalls, 1);
});
test('startup load forwards a rejected navigation to the error handler', async () => { test('startup load forwards a rejected navigation to the error handler', async () => {
const startup = createStartupWindow(TestBrowserWindow, {}); const startup = createStartupWindow(TestBrowserWindow, {});
let handledError; let handledError;
@@ -150,4 +164,6 @@ test('header occupies its final geometry before asynchronous initialization', ()
assert.ok(firstFrameInitialization < asynchronousInitialization); assert.ok(firstFrameInitialization < asynchronousInitialization);
assert.match(mainSource, /createStartupQuery\([^,]+,\s*app\.getVersion\(\)\)/u); assert.match(mainSource, /createStartupQuery\([^,]+,\s*app\.getVersion\(\)\)/u);
assert.doesNotMatch(mainSource, /runAutomaticUpdateCheck\(true\);\s*\},\s*3000\)/u); assert.doesNotMatch(mainSource, /runAutomaticUpdateCheck\(true\);\s*\},\s*3000\)/u);
assert.match(html, /class="upload-speed-baseline"/u);
assert.match(css, /\.upload-speed-baseline\s*\{[^}]*background:\s*var\(--success\);/su);
}); });