import { createContext, useCallback, useContext, useEffect, useMemo, useReducer, useRef, } from 'react'; import { Platform, View } from 'react-native'; import { useSharedValue } from 'react-native-reanimated'; import { DefaultToast } from '../../components/Toast'; import { InsetsContainer } from './insets-container'; import { ToastLifecycleContext, type ToastLifecycleContextValue } from './lifecycle.context'; import { toastReducer } from './reducer'; import { ToastConfigContext } from './toast-config.context'; import { ToastItemRenderer } from './toast-item-renderer'; import type { ToastComponentProps, ToasterContextValue, ToastGlobalConfig, ToastProviderProps, ToastShowConfig, ToastShowOptions, ToastShowOptionsWithComponent, } from './types'; const DEFAULT_DURATION = 4000; /** * Context for toast manager */ const ToasterContext = createContext(null); /** * Merges global config with local config, ensuring local config takes precedence * Only includes defined values from localConfig to avoid overriding global config with undefined */ function mergeToastConfig( globalConfig: ToastGlobalConfig | undefined, localConfig: Partial, ): Partial { const result: Partial = { ...globalConfig }; // Only override with defined values from localConfig if (localConfig.color !== undefined) { result.color = localConfig.color; } if (localConfig.placement !== undefined) { result.placement = localConfig.placement; } if (localConfig.isSwipeable !== undefined) { result.isSwipeable = localConfig.isSwipeable; } if (localConfig.animation !== undefined) { result.animation = localConfig.animation; } return result; } /** * Creates a component function for simple string toast */ function createStringToastComponent( label: string, globalConfig: ToastGlobalConfig | undefined, ): (props: ToastComponentProps) => React.ReactElement { return (props: ToastComponentProps) => { const mergedConfig = mergeToastConfig(globalConfig, { color: 'neutral', }); return ( ); }; } /** * Creates a component function for config-based toast */ function createConfigToastComponent( config: ToastShowConfig, globalConfig: ToastGlobalConfig | undefined, ): (props: ToastComponentProps) => React.ReactElement { return (props: ToastComponentProps) => { const mergedConfig = mergeToastConfig(globalConfig, { color: config.color, placement: config.placement, isSwipeable: config.isSwipeable, animation: config.animation, }); return ( ); }; } /** * Toast provider component * Wraps your app to enable toast functionality */ export function ToastProvider({ defaultProps, insets, maxVisibleToasts = 3, contentWrapper, children, disableFullWindowOverlay = false, unstable_accessibilityContainerViewIsModal, announceLabel, }: Readonly) { const [toasts, dispatch] = useReducer(toastReducer, []); /** * Memoize global config to prevent unnecessary re-renders */ const globalConfig = useMemo(() => defaultProps, [defaultProps]); const isToastVisible = toasts.length > 0; const heights = useSharedValue>({}); const total = useSharedValue(0); /** * Derive total from toasts.length so the animated opacity/scale/translateY * interpolations always use the real count. Manual increment/decrement * was prone to drift when hide + show ran in the same tick or when * auto-dismiss raced with a manual hide (stale-closure mismatch). */ useEffect(() => { total.set(toasts.length); }, [toasts.length, total]); const idCounter = useRef(0); /** * Timer state per toast — tracks remaining time so pause/resume can * restart with only the leftover duration (WCAG 2.2.1). */ const timersRef = useRef< Map< string, { startedAt: number; remaining: number; timer: ReturnType | null; duration: number; } > >(new Map()); const pausedRef = useRef(false); const hideRef = useRef<((ids?: (string & {}) | string[] | 'all') => void) | null>(null); /** * Safety reset: when all toasts are gone the InsetsContainer unmounts and * no resume event can fire. Clear the paused flag and any orphaned timer * entries so the next toast's timer starts normally. */ useEffect(() => { if (toasts.length === 0) { pausedRef.current = false; timersRef.current.forEach((entry) => { if (entry.timer) clearTimeout(entry.timer); }); timersRef.current.clear(); } }, [toasts.length]); /** * Remove one or more toasts from the array. On web this is the second half * of the two-phase hide flow — the toast component calls it from * `onTransitionEnd` (or a safety timeout) after the exit transition plays. * Also cleans up per-id height state. */ const remove = useCallback( (ids: string | string[]) => { const idsArray = Array.isArray(ids) ? ids : [ids]; if (idsArray.length === 0) return; dispatch({ type: 'REMOVE', payload: { ids: idsArray } }); heights.modify(>(value: T): T => { 'worklet'; const result = { ...value }; for (const id of idsArray) { // eslint-disable-next-line @typescript-eslint/no-dynamic-delete delete result[id]; } return result; }); }, [heights], ); /** * Hide one or more toasts * - No argument: hides the last toast in the array * - "all": hides all toasts * - Single ID: hides that toast * - Array of IDs: hides those toasts * * On web this only marks toasts as `pendingRemoval` so their CSS exit * transition can play; the actual removal happens when the toast * component calls `remove(id)` on transition end. * On native this dispatches removal directly — Reanimated's `exiting` * layout animation plays before unmount. */ const hide = useCallback( (ids?: (string & {}) | string[] | 'all') => { const isWeb = Platform.OS === 'web'; if (ids === undefined) { if (toasts.length === 0) return; // Hide the last non-pending toast in the array. Using the last // pending toast would be a no-op on web. let lastToast = null; for (let i = toasts.length - 1; i >= 0; i--) { const candidate = toasts[i]; if (!candidate.pendingRemoval) { lastToast = candidate; break; } } if (!lastToast) return; const timerEntry = timersRef.current.get(lastToast.id); if (timerEntry?.timer) { clearTimeout(timerEntry.timer); } timersRef.current.delete(lastToast.id); if (lastToast.onHide) { lastToast.onHide(); } if (isWeb) { dispatch({ type: 'MARK_CLOSED', payload: { ids: [lastToast.id] } }); } else { remove([lastToast.id]); } } else if (ids === 'all') { timersRef.current.forEach((entry) => { if (entry.timer) clearTimeout(entry.timer); }); timersRef.current.clear(); const targetIds: string[] = []; toasts.forEach((toast) => { if (toast.pendingRemoval) return; targetIds.push(toast.id); if (toast.onHide) { toast.onHide(); } }); if (targetIds.length === 0) return; if (isWeb) { dispatch({ type: 'MARK_CLOSED', payload: { ids: targetIds } }); } else { remove(targetIds); } } else { const idsArray = Array.isArray(ids) ? ids : [ids]; const targetIds: string[] = []; idsArray.forEach((id) => { const toast = toasts.find((t) => t.id === id); if (!toast || toast.pendingRemoval) return; const timerEntry = timersRef.current.get(id); if (timerEntry?.timer) { clearTimeout(timerEntry.timer); } timersRef.current.delete(id); targetIds.push(id); if (toast.onHide) { toast.onHide(); } }); if (targetIds.length === 0) return; if (isWeb) { dispatch({ type: 'MARK_CLOSED', payload: { ids: targetIds } }); } else { remove(targetIds); } } }, [remove, toasts], ); // Keep hide ref up to date hideRef.current = hide; /** * Pause all running auto-dismiss timers (WCAG 2.2.1). * Stores elapsed time so resumeAll can restart with the remainder. */ const pauseAll = useCallback(() => { if (pausedRef.current) return; pausedRef.current = true; const now = Date.now(); timersRef.current.forEach((entry) => { if (entry.timer) { clearTimeout(entry.timer); entry.timer = null; } const elapsed = now - entry.startedAt; entry.remaining = Math.max(0, entry.remaining - elapsed); }); }, []); /** * Resume all paused auto-dismiss timers with their remaining duration. */ const resumeAll = useCallback(() => { pausedRef.current = false; const now = Date.now(); timersRef.current.forEach((entry, id) => { if (entry.timer) return; if (entry.remaining <= 0) { if (hideRef.current) hideRef.current(id); timersRef.current.delete(id); return; } entry.startedAt = now; entry.timer = setTimeout(() => { if (hideRef.current) hideRef.current(id); timersRef.current.delete(id); }, entry.remaining); }); }, []); const lifecycleContextValue = useMemo(() => { const pending = new Set(); let frontmostId: string | null = null; for (const toast of toasts) { if (toast.pendingRemoval) { pending.add(toast.id); } else { frontmostId = toast.id; } } return { remove: (id: string) => remove([id]), pendingRemovalIds: pending, frontmostId, }; }, [remove, toasts]); /** * Show a toast * Supports three usage patterns: * 1. Simple string: toast.show('This is toast') * 2. Config object: toast.show({ label, color, ... }) * 3. Custom component: toast.show({ component: (props) => ... }) */ const show = useCallback( (options: string | ToastShowOptions): string => { let normalizedOptions: ToastShowOptionsWithComponent; let duration: number | 'persistent' | undefined = DEFAULT_DURATION; // Default duration let explicitId: string | undefined; // Case 1: Simple string if (typeof options === 'string') { normalizedOptions = { id: undefined, component: createStringToastComponent(options, globalConfig), duration: DEFAULT_DURATION, }; duration = DEFAULT_DURATION; explicitId = undefined; } // Case 2: Config object without component else if (!('component' in options) || options.component === undefined) { const config = options as ToastShowConfig; duration = config.duration ?? DEFAULT_DURATION; explicitId = config.id; normalizedOptions = { id: config.id, component: createConfigToastComponent(config, globalConfig), duration, onShow: config.onShow, onHide: config.onHide, }; } // Case 3: Config object with component (existing behavior) else { normalizedOptions = options; duration = normalizedOptions.duration ?? DEFAULT_DURATION; explicitId = normalizedOptions.id; } const id = normalizedOptions.id ?? `toast-${String(Date.now())}-${String(idCounter.current++)}`; // If an explicit ID was provided, check if a toast with that ID already exists // If it exists, skip adding a new toast and return the existing ID if (explicitId !== undefined) { const existingToast = toasts.find((toast) => toast.id === explicitId); if (existingToast) { return existingToast.id; } } dispatch({ type: 'SHOW', payload: { id, component: normalizedOptions.component, duration, onShow: normalizedOptions.onShow, onHide: normalizedOptions.onHide, }, }); if (normalizedOptions.onShow) { normalizedOptions.onShow(); } // Set up auto-dismiss timeout synchronously if ( duration !== 'persistent' && typeof duration === 'number' && !Number.isNaN(duration) && duration >= 0 && duration !== Infinity ) { // Handle immediate dismissal if (duration === 0) { if (hideRef.current) { hideRef.current(id); } } else { const now = Date.now(); const timer = setTimeout(() => { if (hideRef.current) { hideRef.current(id); } timersRef.current.delete(id); }, duration); const entry = { startedAt: now, remaining: duration, timer: timer as ReturnType | null, duration, }; timersRef.current.set(id, entry); if (pausedRef.current) { clearTimeout(timer); entry.timer = null; } } } return id; }, [toasts, globalConfig], ); const contextValue = useMemo( () => ({ toast: { show, hide, pauseAll, resumeAll, }, isToastVisible, }), [show, hide, pauseAll, resumeAll, isToastVisible], ); return ( {children} {toasts.length > 0 && ( {toasts.map((toastItem, index) => ( ))} )} ); } /** * Hook to access toast functionality * * @returns Object containing toast manager and visibility state * * @example * ```tsx * const { toast, isToastVisible } = useToast(); * * // Show a toast * toast.show({ component: Hello }); * * // Hide a toast * toast.hide('my-toast'); * * // Check if any toast is visible * if (isToastVisible) { * console.log('A toast is currently displayed'); * } * ``` */ export function useToast() { const context = useContext(ToasterContext); if (!context) { throw new Error('useToast must be used within a ToastProvider provider'); } return { toast: context.toast, isToastVisible: context.isToastVisible, }; }