Skip to content
Merged
Show file tree
Hide file tree
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
17 changes: 16 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,22 @@ const App = () => {
<AccessibilityProvider>
<AriaLiveProvider>
<TooltipProvider>
<BrowserRouter future={{ v7_relativeSplatPath: true, v7_startTransition: true }}>
{/*
* BUG FIX: v7_startTransition REMOVIDO.
*
* v7_startTransition: true envolvia toda chamada navigate() em
* React.startTransition(), tornando navegacoes low-priority.
* Com rendering concorrente ativo (Supabase Realtime, intervals
* do RootInteractivityGuard, etc.), o React abandonava transicoes
* de navegacao — a URL atualizava no window.history mas o
* componente nao re-renderizava, dando a impressao de que o clique
* nao fez nada. Hard refresh carregava a URL ja atualizada e
* parecia "executar" a acao.
*
* v7_relativeSplatPath mantido — normaliza apenas matching de
* splat routes e nao afeta rendering concorrente.
*/}
Comment on lines +44 to +58
<BrowserRouter future={{ v7_relativeSplatPath: true }}>
<AuthProvider>
<AppBootstrapContainer>
<AppBootstrap>
Expand Down
18 changes: 14 additions & 4 deletions src/components/common/RouteScrollReset.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,34 @@
import { useEffect, useRef } from "react";
import { useLocation, useNavigationType } from "react-router-dom";
import { releaseScrollLockIfIdle } from "@/lib/dom/scroll-lock";

/**
* RouteScrollReset
* -----------------
* Em navegações SPA (PUSH/REPLACE), rola a window suavemente até o topo,
* para que o conteúdo da nova rota seja exibido a partir do início.
* Em navegacoes SPA (PUSH/REPLACE), rola a window suavemente ate o topo,
* para que o conteudo da nova rota seja exibido a partir do inicio.
*
* Regras:
* - POP (back/forward) preserva o scroll do navegador.
* - Se a URL contém hash âncora (#id), respeita o destino e não força topo.
* - Se a URL contem hash ancora (#id), respeita o destino e nao forca topo.
* - Honra `prefers-reduced-motion` (fallback para `behavior: "auto"`).
* - Skip no primeiro mount (evita interferir em deep-links com âncora).
* - Skip no primeiro mount (evita interferir em deep-links com ancora).
*
* BUG FIX: A cada mudanca de rota, libera proativamente qualquer scroll-lock
* residual do Radix UI (pointer-events: none preso em <html>/<body>). Isso
* previne o cenario em que um Dialog/Dropdown fecha com race condition e
* deixa a UI completamente nao-clicavel ate o watchdog de 300ms agir.
* releaseScrollLockIfIdle() e no-op se houver overlay legitimo aberto.
Comment on lines +8 to +21
*/
export function RouteScrollReset() {
const { pathname, hash } = useLocation();
const navType = useNavigationType();
const isFirstMount = useRef(true);

useEffect(() => {
// Libera scroll-lock residual do Radix em toda troca de rota.
releaseScrollLockIfIdle();

if (isFirstMount.current) {
isFirstMount.current = false;
return;
Expand Down
37 changes: 15 additions & 22 deletions src/components/system/RootInteractivityGuard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,30 @@ import { useEffect, useState } from 'react';
import { hasOpenOverlay, isRootInert, forceRootInteractive } from '@/lib/dom/scroll-lock';

/**
* RootInteractivityGuard last-resort watchdog that guarantees the app never
* RootInteractivityGuard - last-resort watchdog that guarantees the app never
* gets stuck completely unclickable.
*
* Mounted at the very top of the tree (App.tsx, OUTSIDE MainLayout) so it runs
* on EVERY route including ones that don't render MainLayout. It recovers
* on EVERY route - including ones that don't render MainLayout. It recovers
* from two whole classes of "the whole UI is frozen to clicks" bugs and logs a
* precise diagnostic naming the culprit each time it acts:
*
* A) A stuck `pointer-events: none` on <html>/<body>/#root (Radix's
* react-remove-scroll race, or any other code that injects it) while no
* modal is genuinely open restored to interactive.
* modal is genuinely open -> restored to interactive.
* B) An invisible full-viewport "ghost" element sitting on top and swallowing
* every click (orphan backdrop, stray fixed layer, a third-party toolbar
* overlay, etc.) its `pointer-events` is disabled so clicks fall through.
* overlay, etc.) -> its `pointer-events` is disabled so clicks fall through.
*
* Conservative by design: never touches the document root elements as "ghosts",
* never neutralizes an element that belongs to a legitimately-open overlay, and
* only treats an element as a ghost when it covers the viewport AND is visually
* empty/transparent.
*
* BUG FIX: intervalo reduzido de 1500ms -> 300ms para encurtar a janela de
* tempo em que a UI pode ficar travada entre dois ciclos do watchdog.
* O overhead e negligivel (apenas getComputedStyle em 3 elementos por ciclo).
* Boot-time timeouts tambem adensados: [0, 100, 300, 600, 1000].
Comment on lines +25 to +28
*/

const COVERAGE = 0.9; // element must span >=90% of the viewport in both axes
Expand All @@ -30,8 +35,6 @@ function isElementVisiblyEmpty(el: HTMLElement): boolean {
const style = getComputedStyle(el);
if (parseFloat(style.opacity || '1') < 0.05) return true;
if (style.visibility === 'hidden') return true;
// Transparent background AND no rendered text → nothing for the user to see,
// yet it still eats clicks.
const bg = style.backgroundColor || '';
const transparentBg = bg === 'transparent' || bg === 'rgba(0, 0, 0, 0)' || bg === '';
const hasText = (el.textContent || '').trim().length > 0;
Expand All @@ -45,7 +48,6 @@ function findGhostOverlay(): HTMLElement | null {
const root = document.getElementById('root');
const w = window.innerWidth;
const h = window.innerHeight;
// Probe several points; a true full-screen blocker is topmost at all of them.
const points: [number, number][] = [
[w / 2, h / 2],
[w * 0.25, h * 0.35],
Expand All @@ -57,16 +59,13 @@ function findGhostOverlay(): HTMLElement | null {
const el = document.elementFromPoint(x, y) as HTMLElement | null;
if (!el) return null;
if (candidate === null) candidate = el;
else if (candidate !== el) return null; // not a single covering element
else if (candidate !== el) return null;
}
if (!candidate) return null;

// Never treat the document root chain as a ghost — those are handled by the
// pointer-events recovery, and disabling them would freeze the app.
if (candidate === document.body || candidate === document.documentElement || candidate === root) {
return null;
}
// Skip anything that is part of a genuinely-open overlay.
if (
candidate.closest(
'[data-state="open"],[data-radix-popper-content-wrapper],[role="dialog"],[role="alertdialog"]',
Expand Down Expand Up @@ -103,7 +102,7 @@ export function RootInteractivityGuard() {
let lastLog = '';
const log = (reason: string, extra: Record<string, unknown>) => {
const key = reason + JSON.stringify(extra);
if (key === lastLog) return; // dedupe identical consecutive events
if (key === lastLog) return;
lastLog = key;
console.warn(`[InteractivityGuard] recovered: ${reason}`, extra);
setRecoveries((n) => n + 1);
Expand All @@ -121,21 +120,16 @@ export function RootInteractivityGuard() {
};
};

// The ghost must survive two consecutive sweeps before we neutralize it, so
// transient transparent click-catchers (tied to a brief interaction) are
// never killed mid-use — only a persistent blocker is.
let pendingGhost: HTMLElement | null = null;

const check = (allowGhost: boolean) => {
// A) stuck pointer-events on the root chain (safe, instant)
if (isRootInert()) {
const before = snapshot();
forceRootInteractive();
log('root pointer-events:none', before);
return;
}
if (!allowGhost) return;
// B) invisible full-viewport ghost overlay swallowing clicks
const ghost = findGhostOverlay();
if (ghost && ghost === pendingGhost) {
ghost.style.pointerEvents = 'none';
Expand All @@ -150,7 +144,6 @@ export function RootInteractivityGuard() {
}
};

// Recover instantly when the user tries to click while frozen (root PE only).
const onPointerDown = () => check(false);
window.addEventListener('pointerdown', onPointerDown, { capture: true });

Expand All @@ -159,9 +152,9 @@ export function RootInteractivityGuard() {
};
document.addEventListener('visibilitychange', onVisibility);

// Catch boot-time freezes (run a few times early) + a slow steady sweep.
const timeouts = [0, 300, 1000, 2500].map((d) => window.setTimeout(() => check(true), d));
const interval = window.setInterval(() => check(true), 1500);
// BUG FIX: intervalo reduzido 1500ms -> 300ms. Boot-times adensados.
const timeouts = [0, 100, 300, 600, 1000].map((d) => window.setTimeout(() => check(true), d));
const interval = window.setInterval(() => check(true), 300);
Comment on lines +155 to +157

return () => {
window.removeEventListener('pointerdown', onPointerDown, { capture: true });
Expand All @@ -187,7 +180,7 @@ export function RootInteractivityGuard() {
borderRadius: 6,
}}
>
InteractivityGuard agiu {recoveries}× — veja o console
InteractivityGuard agiu {recoveries}x - veja o console
</div>
);
}
Expand Down
Loading