import { useEffect, useRef, useState } from 'react'; import { useQueryClient } from '@tanstack/react-query'; const SCAN_TIMEOUT_MS = 30000; /** * Triggers a headless axe-core scan of a same-origin URL via a hidden iframe * with ?aai_scan= (see overlay.js's auto-scan mode + class-admin-bar.php's * inject_overlay(), which loads overlay.js for logged-in admins on every * frontend page). The iframe's page posts back {type:'aai-scan-complete'|'aai-scan-error', scanId} * once axe-core finishes and the result is stored via /axe-scan. */ export function useHeadlessScan() { const qc = useQueryClient(); const [scanning, setScanning] = useState>({}); const [scanErrors, setScanErrors] = useState>({}); const scanIdToUrlRef = useRef>({}); const iframesRef = useRef>({}); const scanTimersRef = useRef>({}); // Populated only for scans started via scanUrlAsync() (sequential bulk scan) — // startScan()'s regular fire-and-forget callers never touch this. const resolversRef = useRef void; reject: (e: Error) => void }>>({}); function finishScan(scanId: string, error: string | null) { const url = scanIdToUrlRef.current[scanId]; if (!url) return; delete scanIdToUrlRef.current[scanId]; iframesRef.current[scanId]?.remove(); delete iframesRef.current[scanId]; window.clearTimeout(scanTimersRef.current[scanId]); delete scanTimersRef.current[scanId]; setScanning((s) => { const n = { ...s }; delete n[url]; return n; }); const resolver = resolversRef.current[scanId]; delete resolversRef.current[scanId]; if (error) { setScanErrors((s) => ({ ...s, [url]: error })); window.setTimeout(() => { setScanErrors((s) => { const n = { ...s }; delete n[url]; return n; }); }, 4000); resolver?.reject(new Error(error)); } else { qc.invalidateQueries({ queryKey: ['page-scans'] }); resolver?.resolve(); } } useEffect(() => { function onMessage(e: MessageEvent) { if (e.origin !== window.location.origin) return; const data = e.data as { type?: string; scanId?: string; error?: string } | undefined; if (!data?.scanId) return; if (data.type === 'aai-scan-complete') finishScan(data.scanId, null); else if (data.type === 'aai-scan-error') finishScan(data.scanId, data.error ?? 'Scan failed'); } window.addEventListener('message', onMessage); return () => { window.removeEventListener('message', onMessage); Object.values(iframesRef.current).forEach((f) => f.remove()); Object.values(scanTimersRef.current).forEach((t) => window.clearTimeout(t)); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); function beginScan(url: string): string { const scanId = `${Date.now()}-${Math.random().toString(36).slice(2)}`; scanIdToUrlRef.current[scanId] = url; setScanning((s) => ({ ...s, [url]: true })); setScanErrors((s) => { const n = { ...s }; delete n[url]; return n; }); const iframe = document.createElement('iframe'); iframe.src = url + (url.includes('?') ? '&' : '?') + 'aai_scan=' + encodeURIComponent(scanId); iframe.setAttribute('aria-hidden', 'true'); iframe.style.cssText = 'position:fixed; top:-9999px; left:-9999px; width:1280px; height:900px; border:0;'; document.body.appendChild(iframe); iframesRef.current[scanId] = iframe; scanTimersRef.current[scanId] = window.setTimeout(() => finishScan(scanId, 'Scan timed out'), SCAN_TIMEOUT_MS); return scanId; } function startScan(url: string) { if (scanning[url]) return; beginScan(url); } /** Sequential/bulk use — awaits a single scan's completion instead of firing and forgetting. */ function scanUrlAsync(url: string): Promise { if (scanning[url]) return Promise.resolve(); return new Promise((resolve, reject) => { const scanId = beginScan(url); resolversRef.current[scanId] = { resolve, reject }; }); } return { scanning, scanErrors, startScan, scanUrlAsync }; }