Files
fusion/packages/dashboard/app/hooks/useToast.ts
gsxdsm 1be0702155 fix(dashboard-mobile): clear Android status bar, footer-nav overlap, fetch toast spam
- Header.css: mobile padding-top is additive (var(--space-md) + env(safe-area-inset-top)) so the brand row keeps its 12px breathing room below the Android status bar instead of having it replaced by the inset.
- ExecutorStatusBar.css: bottom offset now uses max(env(safe-area-inset-bottom), 12px) to match the floor MobileNavBar already applies, so the footer lands flush on top of the nav instead of inside its padding band.
- useToast.ts: silently drop bare "Failed to fetch" error toasts (from fetch() aborts on tab background/resume); toasts with additional context still pass through.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:03:22 -07:00

64 lines
2.1 KiB
TypeScript

import { useState, useCallback, useRef, createContext, useContext, useEffect } from "react";
import type { ReactNode } from "react";
import React from "react";
export type ToastType = "success" | "error" | "info" | "warning";
export interface Toast {
id: number;
message: string;
type: ToastType;
}
interface ToastContextValue {
toasts: Toast[];
addToast: (message: string, type?: ToastType) => void;
removeToast: (id: number) => void;
}
const ToastContext = createContext<ToastContextValue | null>(null);
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const nextId = useRef(0);
const timers = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map());
const removeToast = useCallback((id: number) => {
const timer = timers.current.get(id);
if (timer !== undefined) {
clearTimeout(timer);
timers.current.delete(id);
}
setToasts((prev) => prev.filter((t) => t.id !== id));
}, []);
const addToast = useCallback((message: string, type: ToastType = "info") => {
// Suppress bare network-failure toasts (TypeError "Failed to fetch" from
// aborted in-flight requests when the tab is backgrounded/resumed on
// mobile). Toasts with additional context still pass through.
if (type === "error" && /^\s*Failed to fetch\.?\s*$/i.test(message)) {
return;
}
const id = nextId.current++;
setToasts((prev) => [...prev, { id, message, type }]);
const timer = setTimeout(() => removeToast(id), 4000);
timers.current.set(id, timer);
}, [removeToast]);
// Clear all pending timers on unmount to prevent state updates on unmounted components
useEffect(() => {
return () => {
timers.current.forEach((timer) => clearTimeout(timer));
timers.current.clear();
};
}, []);
return React.createElement(ToastContext.Provider, { value: { toasts, addToast, removeToast } }, children);
}
export function useToast(): ToastContextValue {
const ctx = useContext(ToastContext);
if (!ctx) throw new Error("useToast must be used within ToastProvider");
return ctx;
}