fix: stabilize the header on first paint
Pass the installed version with the startup language so the renderer can display it before asynchronous initialization. Seed and start the upload sparkline immediately, keep a fixed update-action slot across all update states, and remove the delayed automatic update check. Add first-frame regression coverage for version, speed baseline, update geometry, and startup query behavior.
This commit is contained in:
@@ -1,6 +1,10 @@
|
||||
(function (root) {
|
||||
'use strict';
|
||||
|
||||
function createInitialSpeedHistoryState() {
|
||||
return { display: 0, history: [0, 0] };
|
||||
}
|
||||
|
||||
function updateSpeedHistory(state, target, maxSamples = 160) {
|
||||
const nextTarget = Number.isFinite(Number(target)) ? Math.max(0, Number(target)) : 0;
|
||||
const previous = Number.isFinite(Number(state.display)) ? Math.max(0, Number(state.display)) : 0;
|
||||
@@ -14,7 +18,7 @@
|
||||
return state;
|
||||
}
|
||||
|
||||
const api = { updateSpeedHistory };
|
||||
const api = { updateSpeedHistory, createInitialSpeedHistoryState };
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||
else if (root) root.SpeedHistory = api;
|
||||
})(typeof window !== 'undefined' ? window : this);
|
||||
|
||||
@@ -8,6 +8,11 @@ function resolveStartupLanguage(config) {
|
||||
return config && config.globalSettings && config.globalSettings.language === 'de' ? 'de' : 'en';
|
||||
}
|
||||
|
||||
function createStartupQuery(config, version) {
|
||||
const normalizedVersion = /^\d+\.\d+\.\d+$/.test(String(version || '').trim()) ? String(version).trim() : '';
|
||||
return { language: resolveStartupLanguage(config), version: normalizedVersion };
|
||||
}
|
||||
|
||||
function createStartupWindow(BrowserWindow, options) {
|
||||
const window = new BrowserWindow({ ...options, show: false });
|
||||
window.once('ready-to-show', () => {
|
||||
@@ -22,4 +27,4 @@ function createStartupWindow(BrowserWindow, options) {
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { configureStartupRenderer, createStartupWindow, resolveStartupLanguage };
|
||||
module.exports = { configureStartupRenderer, createStartupWindow, resolveStartupLanguage, createStartupQuery };
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
process.env.UV_THREADPOOL_SIZE = process.env.UV_THREADPOOL_SIZE || '8';
|
||||
const { monitorEventLoopDelay, PerformanceObserver } = require('perf_hooks');
|
||||
const { app, BrowserWindow, ipcMain, dialog, clipboard, nativeTheme, Tray, Menu, nativeImage } = require('electron');
|
||||
const { configureStartupRenderer, createStartupWindow, resolveStartupLanguage } = require('./lib/startup-renderer');
|
||||
const { configureStartupRenderer, createStartupWindow, createStartupQuery } = require('./lib/startup-renderer');
|
||||
configureStartupRenderer(app);
|
||||
nativeTheme.themeSource = 'dark';
|
||||
const path = require('path');
|
||||
@@ -1508,12 +1508,12 @@ function createWindow() {
|
||||
debugLog(`CHILD PROCESS GONE: type=${details.type} reason=${details.reason} exitCode=${details.exitCode}`);
|
||||
});
|
||||
|
||||
let startupLanguage = 'en';
|
||||
try { startupLanguage = resolveStartupLanguage(configStore.load()); } catch {}
|
||||
let startupQuery = createStartupQuery(null, app.getVersion());
|
||||
try { startupQuery = createStartupQuery(configStore.load(), app.getVersion()); } catch {}
|
||||
startupWindow.load(path.join(__dirname, 'renderer', 'index.html'), (err) => {
|
||||
_writeCrashLog('LOAD FILE FAILED', err);
|
||||
debugLog(`LOAD FILE FAILED: ${err && err.stack ? err.stack : err}`);
|
||||
}, { query: { language: startupLanguage } });
|
||||
}, { query: startupQuery });
|
||||
}
|
||||
|
||||
function createTray() {
|
||||
@@ -1652,7 +1652,7 @@ app.whenReady().then(async () => {
|
||||
}
|
||||
} catch {}
|
||||
|
||||
setTimeout(() => { void runAutomaticUpdateCheck(true); }, 3000);
|
||||
void runAutomaticUpdateCheck(true);
|
||||
updateCheckInterval = setInterval(() => { void runAutomaticUpdateCheck(true); }, 5 * 60 * 1000);
|
||||
updateCheckInterval.unref?.();
|
||||
});
|
||||
|
||||
+10
-3
@@ -309,7 +309,7 @@ let settingsBaseline = '';
|
||||
let settingsDirty = false;
|
||||
let settingsSaving = false;
|
||||
let lastUploadStats = { state: 'idle', globalSpeedKbs: 0, totalBytes: 0, elapsed: 0, activeJobs: 0 };
|
||||
const uploadSpeedState = { display: 0, history: [] };
|
||||
const uploadSpeedState = window.SpeedHistory.createInitialSpeedHistoryState();
|
||||
let uploadSpeedTimer = null;
|
||||
const AUTO_CHECK_PREF_KEY = 'autoHealthCheckBeforeUpload';
|
||||
const QUEUE_COL_WIDTHS_KEY = 'queueColumnWidthsPx';
|
||||
@@ -417,7 +417,6 @@ async function init() {
|
||||
renderAccounts();
|
||||
setupListeners();
|
||||
importEntryCoordinator.ready();
|
||||
initUploadSpeedSparkline();
|
||||
restoreQueueColumnWidths();
|
||||
loadHistory();
|
||||
_refreshSessionFailedSnapshot();
|
||||
@@ -808,7 +807,6 @@ function _syncHeaderUpdateState() {
|
||||
? `Update v${version || 'unbekannt'} verfügbar. Klicken zum Installieren.`
|
||||
: 'Nach Aktualisierungen suchen';
|
||||
if (button) {
|
||||
button.hidden = !available;
|
||||
button.classList.toggle('update-available', available);
|
||||
button.classList.toggle('is-checking', _updateCheckBusy);
|
||||
button.disabled = _updateCheckBusy;
|
||||
@@ -4195,6 +4193,14 @@ function initUploadSpeedSparkline() {
|
||||
window.addEventListener('beforeunload', () => window.clearInterval(uploadSpeedTimer), { once: true });
|
||||
}
|
||||
|
||||
function initializeStaticHeader() {
|
||||
const version = new URLSearchParams(window.location.search).get('version') || '';
|
||||
const versionLabel = document.getElementById('versionLabel');
|
||||
if (versionLabel && /^\d+\.\d+\.\d+$/.test(version)) versionLabel.textContent = `v${version}`;
|
||||
initUploadSpeedSparkline();
|
||||
_syncHeaderUpdateState();
|
||||
}
|
||||
|
||||
function updateStatusBar() {
|
||||
const stats = _computeQueueStats();
|
||||
|
||||
@@ -8116,6 +8122,7 @@ function updateStatsPanel() {
|
||||
window.api.onUpdateAvailable(showUpdateBanner);
|
||||
window.api.onUpdateProgress(handleUpdateProgress);
|
||||
window.api.onPrepareClose(prepareForWindowClose);
|
||||
initializeStaticHeader();
|
||||
setupDragDrop();
|
||||
init().then(() => {
|
||||
window.api.signalCloseHandshakeReady();
|
||||
|
||||
+1
-1
@@ -58,7 +58,7 @@
|
||||
<canvas id="uploadSpeedCanvas" width="232" height="44" aria-hidden="true"></canvas>
|
||||
<strong id="uploadSpeedValue">0 B/s</strong>
|
||||
</div>
|
||||
<button class="header-update-button" id="headerUpdateBtn" title="Nach Aktualisierungen suchen" aria-label="Nach Aktualisierungen suchen" data-tooltip="Nach Aktualisierungen suchen" hidden>
|
||||
<button class="header-update-button" id="headerUpdateBtn" title="Nach Aktualisierungen suchen" aria-label="Nach Aktualisierungen suchen" data-tooltip="Nach Aktualisierungen suchen">
|
||||
<svg class="header-action-icon" aria-hidden="true"><use href="#icon-download"></use></svg>
|
||||
<span class="header-update-label">Update</span>
|
||||
</button>
|
||||
|
||||
+17
-6
@@ -2091,17 +2091,18 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; }
|
||||
|
||||
.header-update-button {
|
||||
position: relative;
|
||||
min-width: 106px;
|
||||
width: 146px;
|
||||
flex: 0 0 146px;
|
||||
height: 32px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid transparent;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--accent);
|
||||
color: var(--accent-ink);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
@@ -2109,8 +2110,10 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; }
|
||||
transition: background-color .14s, transform .14s;
|
||||
}
|
||||
|
||||
.header-update-button[hidden] {
|
||||
display: none;
|
||||
.header-update-button.update-available {
|
||||
border-color: transparent;
|
||||
background: var(--accent);
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
|
||||
.header-update-button::after {
|
||||
@@ -2145,7 +2148,13 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; }
|
||||
}
|
||||
|
||||
.header-update-button:hover {
|
||||
background: var(--bg-card-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.header-update-button.update-available:hover {
|
||||
background: var(--accent-end);
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
|
||||
.header-update-button:active {
|
||||
@@ -2276,6 +2285,7 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; }
|
||||
}
|
||||
|
||||
.version-badge {
|
||||
min-width: 48px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -3987,6 +3997,7 @@ input[type="checkbox"] {
|
||||
.header-update-button {
|
||||
min-width: 34px;
|
||||
width: 34px;
|
||||
flex: 0 0 34px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { updateSpeedHistory } = require('../lib/speed-history');
|
||||
const { updateSpeedHistory, createInitialSpeedHistoryState } = require('../lib/speed-history');
|
||||
|
||||
test('initial speed history contains a drawable zero baseline', () => {
|
||||
assert.deepEqual(createInitialSpeedHistoryState(), { display: 0, history: [0, 0] });
|
||||
});
|
||||
|
||||
test('speed history smooths rising and falling samples independently', () => {
|
||||
const state = { display: 0, history: [] };
|
||||
|
||||
@@ -3,7 +3,7 @@ const assert = require('node:assert/strict');
|
||||
const { EventEmitter } = require('node:events');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { configureStartupRenderer, createStartupWindow, resolveStartupLanguage } = require('../lib/startup-renderer');
|
||||
const { configureStartupRenderer, createStartupWindow, resolveStartupLanguage, createStartupQuery } = require('../lib/startup-renderer');
|
||||
|
||||
class TestBrowserWindow extends EventEmitter {
|
||||
constructor(options) {
|
||||
@@ -50,6 +50,14 @@ test('resolveStartupLanguage accepts only the supported persisted language', ()
|
||||
assert.equal(resolveStartupLanguage(null), 'en');
|
||||
});
|
||||
|
||||
test('startup query carries language and installed version into the first renderer frame', () => {
|
||||
assert.deepEqual(createStartupQuery({ globalSettings: { language: 'de' } }, '2.1.25'), {
|
||||
language: 'de',
|
||||
version: '2.1.25'
|
||||
});
|
||||
assert.deepEqual(createStartupQuery(null, 'invalid'), { language: 'en', version: '' });
|
||||
});
|
||||
|
||||
test('createStartupWindow forces the main window to start hidden', () => {
|
||||
const startup = createStartupWindow(TestBrowserWindow, { width: 1100, show: true });
|
||||
|
||||
@@ -124,3 +132,22 @@ test('upload sidebar renders and updates the remaining upload size', () => {
|
||||
assert.match(html, /Verbleibende Größe[\s\S]*id="uploadTelemetryRemainingSize"[^>]*>0 B</u);
|
||||
assert.match(appSource, /_setUploadTelemetryText\(['"]uploadTelemetryRemainingSize['"],\s*formatBytes\(stats\.bytesRemaining\)\)/u);
|
||||
});
|
||||
|
||||
test('header occupies its final geometry before asynchronous initialization', () => {
|
||||
const projectRoot = path.join(__dirname, '..');
|
||||
const html = fs.readFileSync(path.join(projectRoot, 'renderer', 'index.html'), 'utf8');
|
||||
const appSource = fs.readFileSync(path.join(projectRoot, 'renderer', 'app.js'), 'utf8');
|
||||
const mainSource = fs.readFileSync(path.join(projectRoot, 'main.js'), 'utf8');
|
||||
const css = fs.readFileSync(path.join(projectRoot, 'renderer', 'styles.css'), 'utf8');
|
||||
const updateButton = html.match(/<button class="header-update-button"[^>]*id="headerUpdateBtn"[^>]*>/u)?.[0] || '';
|
||||
const firstFrameInitialization = appSource.lastIndexOf('\ninitializeStaticHeader();');
|
||||
const asynchronousInitialization = appSource.lastIndexOf('\ninit().then(');
|
||||
|
||||
assert.doesNotMatch(updateButton, /\shidden(?:\s|>)/u);
|
||||
assert.match(css, /\.header-update-button\s*\{[^}]*width:\s*146px;[^}]*flex:\s*0 0 146px;/su);
|
||||
assert.match(css, /\.version-badge\s*\{[^}]*min-width:\s*48px;/su);
|
||||
assert.notEqual(firstFrameInitialization, -1);
|
||||
assert.ok(firstFrameInitialization < asynchronousInitialization);
|
||||
assert.match(mainSource, /createStartupQuery\([^,]+,\s*app\.getVersion\(\)\)/u);
|
||||
assert.doesNotMatch(mainSource, /runAutomaticUpdateCheck\(true\);\s*\},\s*3000\)/u);
|
||||
});
|
||||
|
||||
+6
-6
@@ -190,7 +190,7 @@ setTimeout(async () => {
|
||||
await wc.executeJavaScript('_knownUpdateInfo = null; closeUpdateDialog(); _syncHeaderUpdateState();');
|
||||
|
||||
const germanStartupReady = await waitUntil(() => wc.executeJavaScript('document.documentElement.lang + "|" + document.getElementById("languageInput")?.value + "|" + [...document.querySelectorAll(".tab")].map(tab => tab.textContent.trim()).join(",")'));
|
||||
check('Returning German profiles never expose an English frame while startup config is pending', startupLanguagePendingSnapshot !== null && (!startupLanguagePendingSnapshot.visible || startupLanguagePendingSnapshot.language === 'de') && startupLanguagePendingSnapshot.query === '?language=de' && germanStartupReady === 'de|de|Upload,Accounts,Einstellungen,Verlauf');
|
||||
check('Returning German profiles never expose an English frame while startup config is pending', startupLanguagePendingSnapshot !== null && (!startupLanguagePendingSnapshot.visible || startupLanguagePendingSnapshot.language === 'de') && startupLanguagePendingSnapshot.query === ${JSON.stringify(`?language=de&version=${productVersion}`)} && germanStartupReady === 'de|de|Upload,Accounts,Einstellungen,Verlauf');
|
||||
await wc.executeJavaScript('(async () => { config.globalSettings = { ...(config.globalSettings || {}), language: "en" }; await window.api.saveGlobalSettings(config.globalSettings); setUiLanguage("en"); renderSettings(); })()');
|
||||
const languageReady = await waitUntil(() => wc.executeJavaScript('Boolean(document.getElementById("languageInput"))'));
|
||||
check('Fresh profiles render in English by default', languageReady === true && await wc.executeJavaScript('document.documentElement.lang + "|" + document.getElementById("languageInput")?.value + "|" + [...document.querySelectorAll(".tab")].map(tab => tab.textContent.trim()).join(",")') === 'en|en|Upload,Accounts,Settings,History');
|
||||
@@ -270,7 +270,7 @@ setTimeout(async () => {
|
||||
await saveSettings({ feedbackText: 'Gespeichert' });
|
||||
return { query: new URL(location.href).searchParams.get('language'), active: document.documentElement.lang };
|
||||
})()\`);
|
||||
check('Saved language remains the startup language after a renderer reload', englishLanguageQuery === 'en' && reloadedLanguageState === 'en|?language=en|Upload,Accounts,Settings,History' && germanLanguageQuery.query === 'de' && germanLanguageQuery.active === 'de');
|
||||
check('Saved language remains the startup language after a renderer reload', englishLanguageQuery === 'en' && reloadedLanguageState === ${JSON.stringify(`en|?language=en&version=${productVersion}|Upload,Accounts,Settings,History`)} && germanLanguageQuery.query === 'de' && germanLanguageQuery.active === 'de');
|
||||
|
||||
await wc.executeJavaScript('queueJobs = []; selectedFiles = []; selectedJobIds.clear(); rebuildJobIndex(); setUploadSidebarFilter("all"); updateUploadView(); renderQueueTable(); updateStatusBar();');
|
||||
console.log('\\n=== Upload View ===');
|
||||
@@ -291,7 +291,7 @@ setTimeout(async () => {
|
||||
check('App header exposes the update action', headerUpdateButtonExists);
|
||||
|
||||
const initialHeaderUpdateVisibility = await wc.executeJavaScript('(() => { const button = document.getElementById("headerUpdateBtn"); return [button?.hidden, getComputedStyle(button).display].join("|"); })()');
|
||||
check('Header update action stays hidden until an update is available', initialHeaderUpdateVisibility === 'true|none');
|
||||
check('Header update action keeps its layout slot before an update is available', initialHeaderUpdateVisibility === 'false|flex');
|
||||
|
||||
const initialUpdateLabel = await wc.executeJavaScript('document.querySelector("#headerUpdateBtn .header-update-label")?.textContent?.trim()');
|
||||
check('App header uses the compact update label', initialUpdateLabel === 'Update');
|
||||
@@ -1199,11 +1199,11 @@ setTimeout(async () => {
|
||||
})()\`);
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
const coordinatedUpdateBusy = await wc.executeJavaScript('(() => { const manual = document.getElementById("manualUpdateCheckBtn"); const header = document.getElementById("headerUpdateBtn"); return [manual?.disabled, manual?.getAttribute("aria-busy"), manual?.textContent?.trim(), header?.disabled, header?.getAttribute("aria-busy"), header?.hidden].join("|"); })()');
|
||||
check('All update entry points share one in-flight check', updateCheckCallCount === 1 && coordinatedUpdateBusy === 'true|true|Prüfe…|true|true|true');
|
||||
check('All update entry points share one in-flight check', updateCheckCallCount === 1 && coordinatedUpdateBusy === 'true|true|Prüfe…|true|true|false');
|
||||
updateCheckResolvers.splice(0).forEach(resolve => resolve({ available: false, error: 'Simulierter Netzwerkfehler' }));
|
||||
await new Promise(resolve => setTimeout(resolve, 150));
|
||||
const coordinatedUpdateError = await wc.executeJavaScript('(() => { const manual = document.getElementById("manualUpdateCheckBtn"); const header = document.getElementById("headerUpdateBtn"); return [manual?.disabled, header?.disabled, header?.hidden, manual?.textContent?.trim(), document.getElementById("copyToast")?.textContent?.trim()].join("|"); })()');
|
||||
check('Settings update check uses the shared error contract', coordinatedUpdateError === 'false|false|true|Nach Updates suchen|Updateprüfung fehlgeschlagen');
|
||||
check('Settings update check uses the shared error contract', coordinatedUpdateError === 'false|false|false|Nach Updates suchen|Updateprüfung fehlgeschlagen');
|
||||
await wc.executeJavaScript('document.getElementById("copyToast")?.classList.remove("show")');
|
||||
|
||||
await wc.executeJavaScript('requestUpdateCheck(); true');
|
||||
@@ -1211,7 +1211,7 @@ setTimeout(async () => {
|
||||
updateCheckResolvers.splice(0).forEach(resolve => resolve({ available: false }));
|
||||
await new Promise(resolve => setTimeout(resolve, 150));
|
||||
const noUpdateHeaderVisibility = await wc.executeJavaScript('(() => { const button = document.getElementById("headerUpdateBtn"); return [button?.hidden, getComputedStyle(button).display].join("|"); })()');
|
||||
check('Successful no-update result keeps the header action hidden', noUpdateHeaderVisibility === 'true|none');
|
||||
check('Successful no-update result keeps the header action in its stable slot', noUpdateHeaderVisibility === 'false|flex');
|
||||
|
||||
const settingsNavigation = await wc.executeJavaScript('(() => { const buttons = [...document.querySelectorAll(".settings-nav-button")]; return [buttons.length, buttons.map(button => button.textContent.trim()).join("|"), document.querySelector(".settings-nav-button.active")?.dataset.settingsPage, document.getElementById("settingsSearchInput")?.placeholder].join("::"); })()');
|
||||
check('Settings use the task-based sidebar navigation', settingsNavigation === '8::Allgemein|Uploads|Automatik|Benachrichtigungen|Logs & Support|Fernsteuerung|Diagnose-Zugriff|Backup & Übertragen::allgemein::Einstellungen durchsuchen');
|
||||
|
||||
Reference in New Issue
Block a user