fix(desktop): устранить белый экран на первом холодном заходе (PWA boot race)

Гонка на первом заходе в проде: ленивый чанк маршрута падал на import()
из-за конкуренции за сеть с фоновым precache только что установленного SW,
а перехватить ошибку было некому → пустой router-view до ручной перезагрузки.

- FIX-1: router.onError ловит провал загрузки чанка и делает авто-reload на
  целевой путь (с защитой от reload-цикла через sessionStorage).
- FIX-2: регистрацию Service Worker откладываем до window 'load', чтобы
  precache-шторм не конкурировал с первой отрисовкой и догрузкой чанков.
- Диагностика: таймстемп-логи [BOOTRACE] по всей boot-цепочке (initApp,
  router beforeEach/afterEach/onError, App.onMounted/isLoaded, SW register,
  safety-timeout) для грепа порядка инициализации на проде.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
coopops
2026-06-09 16:48:51 +00:00
parent 7ca3c21079
commit cba0d41020
4 changed files with 142 additions and 3 deletions
@@ -105,8 +105,24 @@ if (!shouldRegisterSW) {
}
};
// Регистрируем Service Worker (указываем явный путь для SSR режима)
register(process.env.SERVICE_WORKER_FILE || '/service-worker.js', {
// Диагностический таймстемп первого старта (грепается по слову BOOTRACE).
const bootraceTs = (): string => {
try {
return `${Math.round(performance.now())}ms`;
} catch {
return '?';
}
};
// [FIX-2] Регистрацию SW откладываем до window 'load'. Раньше register()
// вызывался на этапе eval модуля — и фоновой precache всего build-manifest
// (до 5 МБ/файл) стартовал ОДНОВРЕМЕННО с первой отрисовкой и догрузкой ленивых
// чанков маршрутов, конкурируя за сеть. На холодном заходе в проде это и
// приводило к провалу import() чанка → пустой router-view. После 'load'
// критический путь уже отрисован, precache идёт «вторым эшелоном».
const doRegister = () => {
console.log(`[BOOTRACE] ${bootraceTs()} SW: старт регистрации (после window load)`);
register(process.env.SERVICE_WORKER_FILE || '/service-worker.js', {
// The registrationOptions object will be passed as the second argument
// to ServiceWorkerContainer.register()
// https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerContainer/register#Parameter
@@ -238,6 +254,15 @@ if (!shouldRegisterSW) {
console.log('Приложение будет работать без Service Worker');
},
});
};
// window.load уже мог пройти к моменту eval (SSR-гидратация быстрая) —
// тогда регистрируем сразу, иначе ждём событие load.
if (document.readyState === 'complete') {
doRegister();
} else {
window.addEventListener('load', () => doRegister(), { once: true });
}
// Присваиваем функции глобальному объекту window
window.applyUpdate = applyUpdate;
+12 -1
View File
@@ -28,17 +28,27 @@ const { info } = system;
const isLoaded = ref(false);
// [BOOTRACE] таймстемп первого холодного старта (грепается по слову BOOTRACE).
const bootraceTs = (): string => {
try {
return `${Math.round(performance.now())}ms`;
} catch {
return '?';
}
};
// Диалог разрешения уведомлений
const { showDialog } = useNotificationPermissionDialog();
onMounted(async () => {
console.log(`[BOOTRACE] ${bootraceTs()} App.onMounted старт`);
const SAFETY_REMOVE_LOADER_MS = 60_000;
let safetyTimerId: ReturnType<typeof setTimeout> | undefined;
if (typeof window !== 'undefined') {
safetyTimerId = setTimeout(() => {
if (!isLoaded.value) {
console.warn(
'Экран загрузки снят по таймауту: boot или инициализация зависли дольше ожидаемого.',
`[BOOTRACE] ${bootraceTs()} SAFETY-TIMEOUT 60s: boot/инициализация зависли, снимаем лоадер принудительно`,
);
removeLoader();
isLoaded.value = true;
@@ -113,6 +123,7 @@ onMounted(async () => {
removeLoader();
isLoaded.value = true;
console.log(`[BOOTRACE] ${bootraceTs()} App.isLoaded=true (лоадер снят, router-view рендерится)`);
clearSafetyTimer();
// Показываем диалог разрешения уведомлений после загрузки
@@ -15,6 +15,17 @@ function getHistoryMode() {
return env.VUE_ROUTER_MODE === 'history' ? createWebHistory : createWebHashHistory;
}
// Диагностический логгер первого холодного старта. Грепается по слову BOOTRACE.
// performance.now() — мс от старта документа; позволяет восстановить порядок гонки
// «загрузка столов ↔ регистрация маршрутов ↔ навигация гарда ↔ догрузка чанка».
function bootraceTs(): string {
try {
return `${Math.round(performance.now())}ms`;
} catch {
return '?';
}
}
// Main route export
export default route(function () {
const Router = createRouter({
@@ -25,5 +36,75 @@ export default route(function () {
},
});
// [FIX-1] Авто-восстановление при провале загрузки ленивого чанка маршрута.
// На первом холодном заходе в проде import() компонента маршрута может упасть
// (сетевая латентность + фоновой precache-шторм только что установленного SW).
// Без этого обработчика Vue Router молча гасит навигацию → router-view пустой →
// белый экран до ручной перезагрузки. Здесь превращаем провал в авто-reload на
// целевой путь (тёплый кэш SW отдаёт чанки мгновенно — повтор проходит).
if (typeof window !== 'undefined') {
Router.onError((err: unknown, to) => {
const message = (err as { message?: string })?.message ?? String(err);
const isChunkError =
/dynamically imported module|Importing a module|ChunkLoadError|Failed to fetch|error loading dynamically imported module/i.test(
message,
);
console.error(`[BOOTRACE] ${bootraceTs()} router.onError`, {
isChunkError,
target: to?.fullPath,
message,
});
if (isChunkError) {
// Защита от бесконечного reload-цикла: помечаем попытку в sessionStorage,
// чтобы при повторном провале на той же сессии не зациклиться.
const guardKey = '__bootrace_chunk_reload__';
let alreadyTried = false;
try {
alreadyTried = sessionStorage.getItem(guardKey) === to?.fullPath;
if (!alreadyTried && to?.fullPath) {
sessionStorage.setItem(guardKey, to.fullPath);
}
} catch {
// sessionStorage недоступен (инкогнито с жёсткими настройками) — просто reload
}
if (!alreadyTried) {
console.warn(
`[BOOTRACE] ${bootraceTs()} chunk-load failed → авто-reload на ${to?.fullPath ?? 'текущий'}`,
);
if (to?.fullPath) window.location.assign(to.fullPath);
else window.location.reload();
} else {
console.error(
`[BOOTRACE] ${bootraceTs()} chunk-load снова упал после reload — НЕ зацикливаемся`,
);
}
}
});
// [BOOTRACE] Тайминг навигации — видно, когда гард стартует относительно
// загрузки столов и успевают ли зарегистрироваться динамические маршруты.
Router.beforeEach((to, from) => {
const matched = to.matched.length;
console.log(
`[BOOTRACE] ${bootraceTs()} beforeEach ${from.fullPath ?? '∅'}${to.fullPath} (matched=${matched})`,
);
// Успешная навигация на сматченный путь — снимаем reload-guard.
try {
if (matched > 0) sessionStorage.removeItem('__bootrace_chunk_reload__');
} catch {
/* noop */
}
});
Router.afterEach((to) => {
console.log(
`[BOOTRACE] ${bootraceTs()} afterEach установлен маршрут ${to.fullPath} (name=${String(to.name ?? '∅')})`,
);
});
}
return Router;
});
@@ -13,12 +13,27 @@ import { LocalStorage } from 'quasar';
// Проверка, работаем ли мы на сервере (SSR)
const isServer = typeof window === 'undefined';
// [BOOTRACE] Диагностика порядка инициализации первого холодного старта.
// Грепается по слову BOOTRACE. Логируем только на клиенте — гонка именно там.
function bootrace(stage: string): void {
if (isServer) return;
let ts = '?';
try {
ts = `${Math.round(performance.now())}ms`;
} catch {
/* noop */
}
console.log(`[BOOTRACE] ${ts} initApp: ${stage}`);
}
export async function useInitAppProcess(router: Router) {
bootrace('start');
applyThemeFromStorage();
const system = useSystemStore();
try {
await system.loadSystemInfo();
bootrace('loadSystemInfo OK');
// Сохраняем реферала из URL, если он есть
if (!isServer) {
@@ -31,6 +46,7 @@ export async function useInitAppProcess(router: Router) {
}
}
} catch (error) {
bootrace('loadSystemInfo FAIL');
console.warn('Failed to load initial system info, backend might be unavailable:', error);
// Продолжаем инициализацию даже при недоступности бэкенда
}
@@ -49,15 +65,19 @@ export async function useInitAppProcess(router: Router) {
try {
await desktops.loadDesktop();
bootrace('loadDesktop OK');
} catch (error) {
bootrace('loadDesktop FAIL');
console.warn('Failed to load desktop configuration:', error);
// Продолжаем инициализацию даже при ошибках загрузки десктопа
}
// Регистрируем маршруты рабочего стола до выбора активного рабочего стола
desktops.registerWorkspaceMenus(router);
bootrace(`registerWorkspaceMenus done (routes=${router.getRoutes().length})`);
await useInitWalletProcess().run();
bootrace('initWallet done');
// Выбираем authorized-рабочий стол только если пайщик принят советом
// (status='active'). На промежуточных статусах оставляем дефолтный
@@ -70,7 +90,9 @@ export async function useInitAppProcess(router: Router) {
useBranchOverlayProcess();
setupNavigationGuard(router);
bootrace('navigationGuard installed');
await useInitExtensionsProcess(router);
bootrace(`initExtensions done (routes=${router.getRoutes().length})`);
}