Fix: stuck app loading state on device wake up

This commit is contained in:
aleksey-hoffman committed 2026-06-19 21:37:17 +03:00
1 parent 40c9785778
commit 77464457c0
9 files changed
+406

No files matched your search

+1
View File
@@ -60,6 +60,7 @@
<div id="app-splash__spinner"></div>
</div>
<div id="app"></div>
<script src="/startup-recovery.js"></script>
<script type="module" src="/src/main.ts"></script>
</body>
+73
View File
@@ -0,0 +1,73 @@
(function () {
const recoveryStorageKey = 'sigma-file-manager:module-load-recovery-at';
const recoveryWindowMs = 30000;
const reloadDelayMs = 1500;
const recoveryMessage = 'Failed to load Sigma File Manager. Please restart the app.';
function getLastRecoveryAt() {
try {
const storedValue = sessionStorage.getItem(recoveryStorageKey);
const recoveryTime = Number(storedValue || '0');
return Number.isFinite(recoveryTime) ? recoveryTime : 0;
}
catch {
return 0;
}
}
function setLastRecoveryAt(recoveryTime) {
try {
sessionStorage.setItem(recoveryStorageKey, String(recoveryTime));
return true;
}
catch {
return false;
}
}
function showRecoveryMessage() {
const splashElement = document.getElementById('app-splash');
if (!splashElement) {
return;
}
splashElement.textContent = '';
const messageElement = document.createElement('div');
messageElement.textContent = recoveryMessage;
messageElement.style.maxWidth = '320px';
messageElement.style.padding = '0 24px';
messageElement.style.color = 'rgba(255, 255, 255, 0.78)';
messageElement.style.font = '14px system-ui, sans-serif';
messageElement.style.lineHeight = '1.45';
messageElement.style.textAlign = 'center';
splashElement.appendChild(messageElement);
}
function recoverFromModuleLoadFailure() {
const currentTime = Date.now();
if (currentTime - getLastRecoveryAt() < recoveryWindowMs) {
showRecoveryMessage();
return;
}
if (!setLastRecoveryAt(currentTime)) {
showRecoveryMessage();
return;
}
setTimeout(function () {
window.location.reload();
}, reloadDelayMs);
}
window.addEventListener('error', function (event) {
const target = event.target;
if (target instanceof HTMLScriptElement && target.type === 'module') {
recoverFromModuleLoadFailure();
}
}, true);
}());
@@ -592,6 +592,21 @@ describe('useInit startup restoration', () => {
expect(removeAppSplashMock).toHaveBeenCalledTimes(1);
});
it('removes the app splash when init fails', async () => {
platformInitMock.mockRejectedValue(new Error('Platform init failed'));
const { useInit } = await import('@/composables/use-init');
const { init } = useInit();
const initPromise = init();
await flushAsyncWork();
await initPromise;
await flushAsyncWork();
expect(removeAppSplashMock).toHaveBeenCalledTimes(1);
expect(showWindowMock).not.toHaveBeenCalled();
});
it('schedules background startup work after init resolves', async () => {
invokeMock.mockImplementation(async (command: string) => {
if (command === 'get_launch_context') {
+10
View File
@@ -263,6 +263,16 @@ export function useInit() {
}
async function init() {
try {
await runInit();
}
catch (error) {
console.error('Failed to initialize app:', error);
removeAppSplash();
}
}
async function runInit() {
const isMainWindow = isMainWebviewWindow();
logInitTrace(`init started (mainWindow=${isMainWindow})`);
+3
View File
@@ -8,9 +8,12 @@ import App from './app.vue';
import router from './router';
import { i18n } from '@/localization';
import VWave from 'v-wave';
import { installModuleLoadRecovery } from '@/utils/module-load-recovery';
import './styles/index.css';
installModuleLoadRecovery({ router });
const app = createApp(App);
app.use(createPinia());
@@ -0,0 +1,145 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// License: GNU GPLv3 or later. See the license file in the project root for more information.
// Copyright © 2021 - present Aleksey Hoffman. All rights reserved.
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi,
} from 'vitest';
import {
MODULE_LOAD_RECOVERY_MESSAGE,
MODULE_LOAD_RECOVERY_RELOAD_DELAY_MS,
MODULE_LOAD_RECOVERY_STORAGE_KEY,
MODULE_LOAD_RECOVERY_WINDOW_MS,
} from '@/utils/module-load-recovery.constants';
import {
installModuleLoadRecovery,
isModuleLoadFailure,
recoverFromModuleLoadFailure,
} from '@/utils/module-load-recovery';
import type { Router } from 'vue-router';
const startupRecoveryScript = readFileSync(
resolve(process.cwd(), 'public/startup-recovery.js'),
'utf8',
);
describe('module load recovery', () => {
beforeEach(() => {
vi.useFakeTimers();
sessionStorage.clear();
document.body.innerHTML = '<div id="app-splash"><div id="app-splash__spinner"></div></div>';
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
sessionStorage.clear();
});
it('keeps startup-recovery.js aligned with shared constants', () => {
expect(startupRecoveryScript).toContain(MODULE_LOAD_RECOVERY_STORAGE_KEY);
expect(startupRecoveryScript).toContain(String(MODULE_LOAD_RECOVERY_WINDOW_MS));
expect(startupRecoveryScript).toContain(String(MODULE_LOAD_RECOVERY_RELOAD_DELAY_MS));
expect(startupRecoveryScript).toContain(MODULE_LOAD_RECOVERY_MESSAGE);
});
it('detects failed dynamic import errors', () => {
expect(isModuleLoadFailure(new TypeError(
'Failed to fetch dynamically imported module: app://asset/assets/navigator.js',
))).toBe(true);
expect(isModuleLoadFailure('error loading dynamically imported module')).toBe(true);
expect(isModuleLoadFailure('Importing a module script failed.')).toBe(true);
});
it('detects failed Vite CSS preload errors', () => {
expect(isModuleLoadFailure(new Error(
'Unable to preload CSS for /assets/settings.css',
))).toBe(true);
});
it('ignores unrelated errors', () => {
expect(isModuleLoadFailure(new Error('Failed to read directory'))).toBe(false);
expect(isModuleLoadFailure(null)).toBe(false);
});
it('reloads after the delay on the first recovery attempt', () => {
const reloadMock = vi.fn();
Object.defineProperty(window, 'location', {
configurable: true,
value: { reload: reloadMock },
});
recoverFromModuleLoadFailure();
expect(reloadMock).not.toHaveBeenCalled();
vi.advanceTimersByTime(MODULE_LOAD_RECOVERY_RELOAD_DELAY_MS);
expect(reloadMock).toHaveBeenCalledTimes(1);
expect(document.getElementById('app-splash')?.textContent).not.toContain(
MODULE_LOAD_RECOVERY_MESSAGE,
);
});
it('shows a recovery message when throttled', () => {
const reloadMock = vi.fn();
Object.defineProperty(window, 'location', {
configurable: true,
value: { reload: reloadMock },
});
sessionStorage.setItem(
MODULE_LOAD_RECOVERY_STORAGE_KEY,
String(Date.now()),
);
recoverFromModuleLoadFailure();
expect(reloadMock).not.toHaveBeenCalled();
expect(document.getElementById('app-splash')?.textContent).toContain(
MODULE_LOAD_RECOVERY_MESSAGE,
);
});
it('shows a recovery message when session storage is unavailable', () => {
const reloadMock = vi.fn();
Object.defineProperty(window, 'location', {
configurable: true,
value: { reload: reloadMock },
});
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new Error('Storage unavailable');
});
recoverFromModuleLoadFailure();
expect(reloadMock).not.toHaveBeenCalled();
expect(document.getElementById('app-splash')?.textContent).toContain(
MODULE_LOAD_RECOVERY_MESSAGE,
);
});
it('installs recovery handlers once and routes module load failures', () => {
const reloadMock = vi.fn();
Object.defineProperty(window, 'location', {
configurable: true,
value: { reload: reloadMock },
});
const router = {
onError: vi.fn((handler: (error: unknown) => void) => {
handler(new TypeError('Failed to fetch dynamically imported module: /assets/navigator.js'));
}),
} as unknown as Router;
installModuleLoadRecovery({ router });
installModuleLoadRecovery({ router });
expect(router.onError).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(MODULE_LOAD_RECOVERY_RELOAD_DELAY_MS);
expect(reloadMock).toHaveBeenCalledTimes(1);
});
});
+25
View File
@@ -2,10 +2,35 @@
// License: GNU GPLv3 or later. See the license file in the project root for more information.
// Copyright © 2021 - present Aleksey Hoffman. All rights reserved.
import { MODULE_LOAD_RECOVERY_MESSAGE } from '@/utils/module-load-recovery.constants';
const SPLASH_ELEMENT_ID = 'app-splash';
const SPLASH_HIDDEN_CLASS = 'app-splash--hidden';
const SPLASH_REMOVAL_FALLBACK_MS = 500;
export function showModuleLoadRecoveryMessage(): void {
if (typeof document === 'undefined') {
return;
}
const splashElement = document.getElementById(SPLASH_ELEMENT_ID);
if (!splashElement) {
return;
}
splashElement.textContent = '';
const messageElement = document.createElement('div');
messageElement.textContent = MODULE_LOAD_RECOVERY_MESSAGE;
messageElement.style.maxWidth = '320px';
messageElement.style.padding = '0 24px';
messageElement.style.color = 'rgba(255, 255, 255, 0.78)';
messageElement.style.font = '14px system-ui, sans-serif';
messageElement.style.lineHeight = '1.45';
messageElement.style.textAlign = 'center';
splashElement.appendChild(messageElement);
}
export function removeAppSplash(): void {
if (typeof document === 'undefined') {
return;
@@ -0,0 +1,8 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// License: GNU GPLv3 or later. See the license file in the project root for more information.
// Copyright © 2021 - present Aleksey Hoffman. All rights reserved.
export const MODULE_LOAD_RECOVERY_STORAGE_KEY = 'sigma-file-manager:module-load-recovery-at';
export const MODULE_LOAD_RECOVERY_WINDOW_MS = 30000;
export const MODULE_LOAD_RECOVERY_RELOAD_DELAY_MS = 1500;
export const MODULE_LOAD_RECOVERY_MESSAGE = 'Failed to load Sigma File Manager. Please restart the app.';
+126
View File
@@ -0,0 +1,126 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// License: GNU GPLv3 or later. See the license file in the project root for more information.
// Copyright © 2021 - present Aleksey Hoffman. All rights reserved.
import type { Router } from 'vue-router';
import { showModuleLoadRecoveryMessage } from '@/utils/app-splash';
import {
MODULE_LOAD_RECOVERY_RELOAD_DELAY_MS,
MODULE_LOAD_RECOVERY_STORAGE_KEY,
MODULE_LOAD_RECOVERY_WINDOW_MS,
} from '@/utils/module-load-recovery.constants';
const MODULE_LOAD_FAILURE_SNIPPETS = [
'Failed to fetch dynamically imported module',
'error loading dynamically imported module',
'Importing a module script failed',
'Unable to preload CSS',
];
let isModuleLoadRecoveryInstalled = false;
export type ModuleLoadRecoveryOptions = {
router?: Router;
};
export function installModuleLoadRecovery(options: ModuleLoadRecoveryOptions = {}): void {
if (isModuleLoadRecoveryInstalled || typeof window === 'undefined') {
return;
}
isModuleLoadRecoveryInstalled = true;
window.addEventListener('vite:preloadError', handleVitePreloadError);
window.addEventListener('unhandledrejection', handleUnhandledRejection);
if (options.router) {
options.router.onError((error) => {
if (isModuleLoadFailure(error)) {
recoverFromModuleLoadFailure();
}
});
}
}
export function isModuleLoadFailure(error: unknown): boolean {
const message = getErrorMessage(error);
return MODULE_LOAD_FAILURE_SNIPPETS.some(snippet => message.includes(snippet));
}
export function recoverFromModuleLoadFailure(): void {
const lastRecoveryAt = getLastRecoveryAt();
const currentTime = Date.now();
if (currentTime - lastRecoveryAt < MODULE_LOAD_RECOVERY_WINDOW_MS) {
showModuleLoadRecoveryMessage();
return;
}
if (!setLastRecoveryAt(currentTime)) {
showModuleLoadRecoveryMessage();
return;
}
window.setTimeout(() => {
window.location.reload();
}, MODULE_LOAD_RECOVERY_RELOAD_DELAY_MS);
}
function handleVitePreloadError(event: Event): void {
event.preventDefault();
recoverFromModuleLoadFailure();
}
function handleUnhandledRejection(event: PromiseRejectionEvent): void {
if (!isModuleLoadFailure(event.reason)) {
return;
}
event.preventDefault();
recoverFromModuleLoadFailure();
}
function getErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
if (typeof error === 'string') {
return error;
}
return '';
}
function getLastRecoveryAt(): number {
let storedValue: string | null = null;
try {
storedValue = sessionStorage.getItem(MODULE_LOAD_RECOVERY_STORAGE_KEY);
}
catch {
return 0;
}
if (!storedValue) {
return 0;
}
const recoveryTime = Number(storedValue);
if (!Number.isFinite(recoveryTime)) {
return 0;
}
return recoveryTime;
}
function setLastRecoveryAt(recoveryTime: number): boolean {
try {
sessionStorage.setItem(MODULE_LOAD_RECOVERY_STORAGE_KEY, String(recoveryTime));
return true;
}
catch {
return false;
}
}