From 77464457c06d8cfd0a3107f3dc5286948af006a7 Mon Sep 17 00:00:00 2001 From: aleksey-hoffman Date: Fri, 19 Jun 2026 21:37:17 +0300 Subject: [PATCH] Fix: stuck app loading state on device wake up --- index.html | 1 + public/startup-recovery.js | 73 +++++++++ src/composables/__tests__/use-init.test.ts | 15 ++ src/composables/use-init.ts | 10 ++ src/main.ts | 3 + .../__tests__/module-load-recovery.test.ts | 145 ++++++++++++++++++ src/utils/app-splash.ts | 25 +++ src/utils/module-load-recovery.constants.ts | 8 + src/utils/module-load-recovery.ts | 126 +++++++++++++++ 9 files changed, 406 insertions(+) create mode 100644 public/startup-recovery.js create mode 100644 src/utils/__tests__/module-load-recovery.test.ts create mode 100644 src/utils/module-load-recovery.constants.ts create mode 100644 src/utils/module-load-recovery.ts diff --git a/index.html b/index.html index e9db5cae..425f2953 100644 --- a/index.html +++ b/index.html @@ -60,6 +60,7 @@
+ diff --git a/public/startup-recovery.js b/public/startup-recovery.js new file mode 100644 index 00000000..fba6720c --- /dev/null +++ b/public/startup-recovery.js @@ -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); +}()); diff --git a/src/composables/__tests__/use-init.test.ts b/src/composables/__tests__/use-init.test.ts index c4ff8e34..4d5ede2a 100644 --- a/src/composables/__tests__/use-init.test.ts +++ b/src/composables/__tests__/use-init.test.ts @@ -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') { diff --git a/src/composables/use-init.ts b/src/composables/use-init.ts index ef142d8a..e3836b2e 100644 --- a/src/composables/use-init.ts +++ b/src/composables/use-init.ts @@ -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})`); diff --git a/src/main.ts b/src/main.ts index a89c1f89..cabeee23 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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()); diff --git a/src/utils/__tests__/module-load-recovery.test.ts b/src/utils/__tests__/module-load-recovery.test.ts new file mode 100644 index 00000000..141e549c --- /dev/null +++ b/src/utils/__tests__/module-load-recovery.test.ts @@ -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 = '
'; + }); + + 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); + }); +}); diff --git a/src/utils/app-splash.ts b/src/utils/app-splash.ts index 336e393f..5201ff23 100644 --- a/src/utils/app-splash.ts +++ b/src/utils/app-splash.ts @@ -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; diff --git a/src/utils/module-load-recovery.constants.ts b/src/utils/module-load-recovery.constants.ts new file mode 100644 index 00000000..129dd742 --- /dev/null +++ b/src/utils/module-load-recovery.constants.ts @@ -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.'; diff --git a/src/utils/module-load-recovery.ts b/src/utils/module-load-recovery.ts new file mode 100644 index 00000000..efaae369 --- /dev/null +++ b/src/utils/module-load-recovery.ts @@ -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; + } +}