/** * useIconPreload Hook * * Preloads icon images for smooth rendering without flash. * Used in constructor.tsx to preload navigation/tooltip/description icons. */ import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { resolveAssetPlaybackUrl } from '../lib/assetUrl'; import { logger } from '../lib/logger'; interface UseIconPreloadOptions { /** Array of icon URLs to preload */ iconUrls: string[]; /** Whether preloading is enabled */ enabled?: boolean; } interface UseIconPreloadResult { /** Map of URL to preload status (true if ready) */ preloadedUrlMap: Record; /** Check if a specific URL is preloaded */ isPreloaded: (url: string) => boolean; /** Number of icons currently being preloaded */ pendingCount: number; /** Whether all icons are preloaded */ allPreloaded: boolean; } /** * Hook for preloading icon images. * Prevents flash when icons are first rendered by pre-decoding them. * * @example * const iconUrls = elements * .filter(el => el.iconUrl) * .map(el => resolveAssetPlaybackUrl(el.iconUrl)); * * const { isPreloaded } = useIconPreload({ iconUrls }); * * // Only render element if icon is ready * if (element.iconUrl && !isPreloaded(element.iconUrl)) return null; */ export function useIconPreload({ iconUrls, enabled = true, }: UseIconPreloadOptions): UseIconPreloadResult { const [preloadedUrlMap, setPreloadedUrlMap] = useState< Record >({}); const preloadedUrlsRef = useRef>(new Set()); // Clean up preloaded set when target URLs change useEffect(() => { if (typeof window === 'undefined') return; const targetSet = new Set(iconUrls); const nextPreloaded = new Set(); // Keep only URLs that are still in the target list preloadedUrlsRef.current.forEach((url) => { if (targetSet.has(url)) { nextPreloaded.add(url); } }); preloadedUrlsRef.current = nextPreloaded; // Update state map setPreloadedUrlMap(() => { const nextMap: Record = {}; nextPreloaded.forEach((url) => { nextMap[url] = true; }); return nextMap; }); }, [iconUrls]); // Preload icons useEffect(() => { if (typeof window === 'undefined') return; if (!enabled || !iconUrls.length) return; let isCancelled = false; const preloadImages: HTMLImageElement[] = []; iconUrls.forEach((url) => { // Skip if already preloaded if (preloadedUrlsRef.current.has(url)) return; const image = new Image(); const markReady = () => { if (isCancelled) return; preloadedUrlsRef.current.add(url); setPreloadedUrlMap((prev) => { if (prev[url]) return prev; return { ...prev, [url]: true }; }); }; image.onload = markReady; image.onerror = () => { logger.error('Failed to preload icon asset:', { url }); markReady(); // Mark as ready anyway to avoid blocking }; image.src = url; preloadImages.push(image); }); return () => { isCancelled = true; preloadImages.forEach((image) => { image.onload = null; image.onerror = null; }); }; }, [iconUrls, enabled]); const isPreloaded = useCallback( (url: string): boolean => { return Boolean(preloadedUrlMap[url]); }, [preloadedUrlMap], ); const pendingCount = useMemo(() => { return iconUrls.filter((url) => !preloadedUrlMap[url]).length; }, [iconUrls, preloadedUrlMap]); const allPreloaded = useMemo(() => { return iconUrls.every((url) => preloadedUrlMap[url]); }, [iconUrls, preloadedUrlMap]); return { preloadedUrlMap, isPreloaded, pendingCount, allPreloaded, }; } /** * Build icon preload targets from elements. * Utility to extract icon URLs that need preloading. */ export function buildIconPreloadTargets( elements: Array<{ type: string; iconUrl?: string }>, typeCheckers: { isNavigationElementType: (type: string) => boolean; isTooltipElementType: (type: string) => boolean; isDescriptionElementType: (type: string) => boolean; }, ): string[] { const { isNavigationElementType, isTooltipElementType, isDescriptionElementType, } = typeCheckers; const urls = elements .filter( (element) => (isNavigationElementType(element.type) || isTooltipElementType(element.type) || isDescriptionElementType(element.type)) && Boolean(element.iconUrl), ) .map((element) => resolveAssetPlaybackUrl(element.iconUrl)) .filter(Boolean) as string[]; return Array.from(new Set(urls)); } export default useIconPreload;