Rules Hub
Coding Rules Library
Rule priority, scope & exceptions
Use this to align rules with the senior-level structure (P0/P1/P2, scope, exceptions/tradeoffs).
Gate 'seen-by-user' timers on both viewport and tab visibility
A countdown that should only run while the user is actually looking must check IntersectionObserver AND document.visibilityState, and restart on tab refocus since IO won't re-fire.
Bad example
| 1 | // Marks an item 'seen' after 5s of being in the viewport — but a backgrounded |
| 2 | // tab keeps counting, so it fires even though the user never looked at it. |
| 3 | useEffect(() => { |
| 4 | const io = new IntersectionObserver(([entry]) => { |
| 5 | if (entry.isIntersecting) { |
| 6 | setTimeout(onSeen, 5000); // no tab check, no clear -> stacks duplicate timers too |
| 7 | } |
| 8 | }); |
| 9 | io.observe(ref.current!); |
| 10 | return () => io.disconnect(); |
| 11 | }, []); |
Explanation (EN)
IntersectionObserver only reports geometry; an element fully in a backgrounded tab is still 'intersecting', so a pure-IO timer counts time the user never spent looking. IO also fires multiple times (resize, layout shift, threshold jitter); without clearing first you stack duplicate timers and fire several times.
Objašnjenje (HR)
IntersectionObserver javlja samo geometriju; element koji je u potpunosti u pozadinskom tabu i dalje 'intersecta', pa čisti IO tajmer broji vrijeme koje korisnik nije proveo gledajući. IO se okida i više puta (resize, pomak layouta, jitter praga); bez prethodnog čišćenja gomilate duple tajmere i okidate ih nekoliko puta.
Good example
| 1 | const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null); |
| 2 | const isIntersectingRef = useRef(false); |
| 3 | const clearTimer = () => { if (timerRef.current) { clearTimeout(timerRef.current); timerRef.current = null; } }; |
| 4 |
|
| 5 | const io = new IntersectionObserver(([entry]) => { |
| 6 | clearTimer(); // guarantee at most one timer |
| 7 | isIntersectingRef.current = entry.isIntersecting; |
| 8 | if (!entry.isIntersecting) return; // out of view -> don't start |
| 9 | if (document.visibilityState !== 'visible') return; // background tab -> don't start |
| 10 | timerRef.current = setTimeout(onSeen, 5000); |
| 11 | }, { threshold: 0.5 }); |
| 12 |
|
| 13 | const onVisibilityChange = () => { |
| 14 | if (document.visibilityState === 'hidden') { clearTimer(); return; } |
| 15 | // refocused & element never left viewport: IO won't fire, so restart fresh |
| 16 | if (isIntersectingRef.current && !timerRef.current) timerRef.current = setTimeout(onSeen, 5000); |
| 17 | }; |
| 18 | document.addEventListener('visibilitychange', onVisibilityChange); |
| 19 | // cleanup: io.disconnect(); removeEventListener; clearTimer(); |
Explanation (EN)
The timer only runs when the element is in the viewport AND the tab is foregrounded, so 'seen for N seconds' means actually seen. Clearing before any (re)start keeps exactly one timer despite IO's repeated firing. The visibilitychange handler restarts on refocus because IntersectionObserver does not re-fire when no threshold boundary was crossed while hidden.
Objašnjenje (HR)
Tajmer radi samo kad je element u viewportu I tab je u prvom planu, pa 'viđeno N sekundi' stvarno znači viđeno. Čišćenje prije svakog (ponovnog) pokretanja drži točno jedan tajmer unatoč ponovljenom okidanju IO-a. Handler za visibilitychange ponovno pokreće tajmer pri vraćanju fokusa jer IntersectionObserver ne okida ponovno kad nijedna granica praga nije prijeđena dok je bilo skriveno.
Notes (EN)
Same shape applies to impression/analytics 'viewability' tracking and auto-dismiss toasts. Keep an isIntersecting ref so the visibilitychange handler can decide whether to restart.
Bilješke (HR)
Isti oblik vrijedi za 'viewability' praćenje impresija/analitike i toastove s automatskim zatvaranjem. Drži isIntersecting ref kako bi handler za visibilitychange mogao odlučiti treba li ponovno pokrenuti.