Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 145 additions & 0 deletions src/hooks/ui/useErrorHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,130 @@ interface ErrorHandlerOptions {
onError?: (error: unknown) => void;
}

// ─────────────────────────────────────────────────────────────────────────────
// FIX 2026-06-02: detecção de stale chunk error após deploy
//
// Quando o Vercel publica novos hashes de chunks JS, tabs abertas com versão
// antiga tentam fetch dos chunks antigos (que não existem mais).
// O servidor responde com index.html (200, text/html), e o browser recusa:
// "Failed to fetch dynamically imported module"
// "Expected a JavaScript-or-Wasm module script but the server responded
// with a MIME type of text/html"
//
// Solução: detectar esses erros e fazer 1 (e apenas 1) reload automático,
// usando sessionStorage para prevenir loop infinito caso o deploy esteja
// realmente quebrado.
// ─────────────────────────────────────────────────────────────────────────────

const CHUNK_RELOAD_KEY = '__pg_chunk_reload_attempt__';
const CHUNK_RELOAD_MAX = 1;
const CHUNK_RELOAD_DELAY_MS = 1500;
const CHUNK_RELOAD_CLEAR_MS = 5000;

const CHUNK_ERROR_PATTERNS: ReadonlyArray<RegExp> = [
/Failed to fetch dynamically imported module/i,
/Failed to load module script/i,
/error loading dynamically imported module/i,
/Importing a module script failed/i,
/Loading chunk \w+ failed/i,
/ChunkLoadError/i,
/Expected a JavaScript-or-Wasm module script/i,
];

/**
* Detecta se um erro é causado por chunk JS faltando (stale deploy).
* Cobre os padrões observados em Chromium, Firefox e Safari.
*/
export function isChunkLoadError(error: unknown): boolean {
if (!error) return false;
const msg =
error instanceof Error ? error.message : typeof error === 'string' ? error : String(error);
if (msg && CHUNK_ERROR_PATTERNS.some((p) => p.test(msg))) return true;
// Fallback: TypeError cujo stack referencia /assets/*.js é quase sempre chunk error
if (
error instanceof TypeError &&
typeof error.stack === 'string' &&
/\/assets\/[^/]+\.js/.test(error.stack)
Comment on lines +55 to +57
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Narrow the TypeError stack fallback

In production, ordinary application TypeErrors also have stack frames pointing at bundled files under /assets/*.js, so this fallback will classify many real runtime bugs (for example a null dereference inside a lazy page) as stale chunk failures. Those errors then skip the normal global error logging/toast path and trigger an automatic reload, masking the original bug and disrupting users even when no deploy-stale chunk is involved.

Useful? React with 👍 / 👎.

) {
return true;
}
return false;
}

function getReloadAttempts(): number {
try {
return parseInt(sessionStorage.getItem(CHUNK_RELOAD_KEY) || '0', 10) || 0;
} catch {
return 0;
}
}

function setReloadAttempts(n: number): void {
try {
sessionStorage.setItem(CHUNK_RELOAD_KEY, String(n));
} catch {
// sessionStorage indisponível (Safari private etc) — silencia
}
Comment on lines +75 to +77
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the reload guard when storage is blocked

When this catch path is hit (for example in a sandboxed frame or a browser/privacy mode that denies sessionStorage writes), the reload attempt is never persisted; getReloadAttempts() will keep returning 0, so any persistent chunk failure is treated as a first attempt and schedules another automatic reload. That defeats the loop protection this handler is meant to provide and can trap affected users in a reload loop instead of showing the manual hard-refresh message.

Useful? React with 👍 / 👎.

}

function clearReloadAttempts(): void {
try {
sessionStorage.removeItem(CHUNK_RELOAD_KEY);
} catch {
// ignora
}
}

/**
* Trata um chunk error detectado: tenta 1 reload automático, ou orienta o
* usuário a fazer hard refresh se já tentamos antes nesta sessão.
*/
function handleChunkLoadError(
error: unknown,
log: ReturnType<typeof createClientLogger>,
): boolean {
const attempts = getReloadAttempts();

if (attempts >= CHUNK_RELOAD_MAX) {
log.error('chunk_load_error_max_reload_reached', { err: error, attempts });
toast.error(
'Não foi possível carregar a nova versão. Pressione Ctrl+Shift+R (Cmd+Shift+R no Mac) para atualizar.',
{ duration: 12000 },
);
return false;
}

setReloadAttempts(attempts + 1);
log.warn('chunk_load_error_reloading', { err: error, attempts });
toast.info('Nova versão disponível. Atualizando…', { duration: CHUNK_RELOAD_DELAY_MS + 500 });

window.setTimeout(() => {
window.location.reload();
}, CHUNK_RELOAD_DELAY_MS);
Comment on lines +111 to +113

return true;
}

/**
* Wrapper opcional para React.lazy que tenta novamente uma vez ao falhar.
* Uso (futuro, em rotas que sofrem com isso):
* const Page = lazy(lazyWithRetry(() => import('./Page')));
*/
export function lazyWithRetry<T>(
importFn: () => Promise<T>,
retries = 1,
): () => Promise<T> {
return async () => {
try {
return await importFn();
} catch (error) {
if (!isChunkLoadError(error) || retries <= 0) throw error;
await new Promise((resolve) => setTimeout(resolve, 800));
return await importFn();
}
};
}
Comment on lines +118 to +136

/**
* useErrorHandler — Centralised async error handling with toast notifications.
*
Expand Down Expand Up @@ -71,12 +195,26 @@ export function useErrorHandler() {
/**
* useGlobalErrorCatcher — Captures unhandled errors & promise rejections globally.
* Mount once at the app root (e.g. inside App or a top-level provider).
*
* FIX 2026-06-02: agora detecta chunk load errors (stale deploy) e dispara
* reload automático em vez de mostrar toast genérico de "erro inesperado".
*/
export function useGlobalErrorCatcher() {
useEffect(() => {
const log = createClientLogger('GlobalCatcher');

// Carregou com sucesso — limpa o contador de reload após grace period
const clearTimer = window.setTimeout(() => {
clearReloadAttempts();
}, CHUNK_RELOAD_CLEAR_MS);
Comment on lines +207 to +209
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Do not clear attempts before lazy chunks are proven healthy

Because this timer clears the guard merely 5 seconds after the shell boots, a deploy with a broken lazy route that is not requested during those first 5 seconds will lose the previous auto-reload count. If the user later navigates to that route, the same persistent chunk error is treated as a fresh first attempt and auto-reloads again instead of stopping after one session attempt, so the advertised broken-deploy fallback can still loop on delayed route access.

Useful? React with 👍 / 👎.


Comment on lines +206 to +210
const onUnhandled = (event: ErrorEvent) => {
// Chunk error detectado (deploy stale) — auto-reload
if (isChunkLoadError(event.error) || isChunkLoadError(event.message)) {
event.preventDefault();
handleChunkLoadError(event.error ?? event.message, log);
return;
}
log.error('unhandled_error', { err: event.error });
void import('@/services/telemetryService')
.then(({ telemetryService }) => {
Expand All @@ -87,6 +225,12 @@ export function useGlobalErrorCatcher() {
};

const onUnhandledRejection = (event: PromiseRejectionEvent) => {
// Chunk error em Promise rejection (caso mais comum: import() falhou)
if (isChunkLoadError(event.reason)) {
event.preventDefault();
handleChunkLoadError(event.reason, log);
return;
}
log.error('unhandled_rejection', { err: event.reason });
void import('@/services/telemetryService')
.then(({ telemetryService }) => {
Expand All @@ -100,6 +244,7 @@ export function useGlobalErrorCatcher() {
window.addEventListener('unhandledrejection', onUnhandledRejection);

return () => {
window.clearTimeout(clearTimer);
window.removeEventListener('error', onUnhandled);
window.removeEventListener('unhandledrejection', onUnhandledRejection);
};
Expand Down
Loading