Framework-level UX fixes found while auditing staging (BUG-216..220).
- chat: route 44 previously discarded composer notices to sonner with
dedupe, so recovery, cancel and archive feedback is actually visible
(BUG-216)
- chat: anchor stream auto-scroll to bottom proximity and add a
jump-to-latest control, so reading history is no longer interrupted
on every token (BUG-218)
- reports: replace the silent 120s poll cutoff with an explicit
timed-out state, an 8m budget, stepped backoff and an elapsed
counter (BUG-217)
- reports: pause polling while the tab is hidden, via a shared hook
- app: add root error, global-error and not-found boundaries (BUG-219)
- admin: add antd SSR style extraction and the React 19 render adapter,
and move admin-only css out of the global stylesheet (BUG-220)
- membership: run bootstrap fetches concurrently and pause payment
polling while hidden
- build: configure optimizePackageImports
Verified on top of 2d370f2e: tsc, eslint, next build, and the related
frontend contract suites.
Co-authored-by: Cursor <cursoragent@cursor.com>
72 lines
1.9 KiB
TypeScript
72 lines
1.9 KiB
TypeScript
/**
|
|
* Interval polling that stops while the tab is hidden.
|
|
*
|
|
* While `document.hidden` is true the interval is torn down entirely, so a
|
|
* backgrounded tab or a locked phone issues no requests. Becoming visible
|
|
* again fires one immediate poll (unless `refreshOnVisible` is false) and then
|
|
* restarts the interval, so a returning user sees fresh state without waiting
|
|
* out a full tick. The callback is held in a ref, so a caller may pass an
|
|
* inline closure without restarting the interval on every render.
|
|
*/
|
|
|
|
"use client";
|
|
|
|
import { useEffect, useRef } from "react";
|
|
|
|
export type VisibilityAwarePollOptions = {
|
|
readonly enabled: boolean;
|
|
readonly intervalMs: number;
|
|
readonly onPoll: () => void;
|
|
readonly refreshOnVisible?: boolean;
|
|
};
|
|
|
|
export function useVisibilityAwarePoll(options: VisibilityAwarePollOptions): void {
|
|
const { enabled, intervalMs, onPoll, refreshOnVisible = true } = options;
|
|
const pollRef = useRef(onPoll);
|
|
|
|
useEffect(() => {
|
|
pollRef.current = onPoll;
|
|
}, [onPoll]);
|
|
|
|
useEffect(() => {
|
|
if (!enabled || typeof document === "undefined" || intervalMs <= 0) {
|
|
return;
|
|
}
|
|
|
|
let timer: number | null = null;
|
|
|
|
const stop = () => {
|
|
if (timer !== null) {
|
|
window.clearInterval(timer);
|
|
timer = null;
|
|
}
|
|
};
|
|
|
|
const start = () => {
|
|
stop();
|
|
timer = window.setInterval(() => pollRef.current(), intervalMs);
|
|
};
|
|
|
|
const handleVisibilityChange = () => {
|
|
if (document.hidden) {
|
|
stop();
|
|
return;
|
|
}
|
|
if (refreshOnVisible) {
|
|
pollRef.current();
|
|
}
|
|
start();
|
|
};
|
|
|
|
if (!document.hidden) {
|
|
start();
|
|
}
|
|
document.addEventListener("visibilitychange", handleVisibilityChange);
|
|
|
|
return () => {
|
|
stop();
|
|
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
|
};
|
|
}, [enabled, intervalMs, refreshOnVisible]);
|
|
}
|