import { useEffect, useRef, useState } from 'preact/compat'; import { cn } from '~web/utils/helpers'; import { InteractionEvent, NotificationEvent, getComponentName, getEventSeverity, getTotalTime, useNotificationsContext, } from './data'; import { ClearIcon, KeyboardIcon, PointerIcon, TrendingDownIcon, } from './icons'; import { Popover } from './popover'; import { iife } from '~core/notifications/performance-utils'; import { toolbarEventStore } from '~core/notifications/event-tracking'; import { CollapsedDroppedFrame, CollapsedItem } from './collapsed-event'; const useFlashManager = (events: NotificationEvent[]) => { const prevEventsRef = useRef([]); const [newEventIds, setNewEventIds] = useState>(new Set()); const isInitialMount = useRef(true); useEffect(() => { if (isInitialMount.current) { isInitialMount.current = false; prevEventsRef.current = events; return; } const currentIds = new Set(events.map((e) => e.id)); const prevIds = new Set(prevEventsRef.current.map((e) => e.id)); const newIds = new Set(); currentIds.forEach((id) => { if (!prevIds.has(id)) { newIds.add(id); } }); if (newIds.size > 0) { setNewEventIds(newIds); setTimeout(() => { setNewEventIds(new Set()); }, 2000); } prevEventsRef.current = events; }, [events]); return (id: string) => newEventIds.has(id); }; const useFlash = ({ shouldFlash }: { shouldFlash: boolean }) => { const [isFlashing, setIsFlashing] = useState(shouldFlash); useEffect(() => { if (shouldFlash) { setIsFlashing(true); const timer = setTimeout(() => { setIsFlashing(false); }, 1000); return () => clearTimeout(timer); } }, [shouldFlash]); return isFlashing; }; export const SlowdownHistoryItem = ({ event, shouldFlash, }: { event: NotificationEvent; shouldFlash: boolean; }) => { const { notificationState, setNotificationState } = useNotificationsContext(); const severity = getEventSeverity(event); const isFlashing = useFlash({ shouldFlash }); switch (event.kind) { case 'interaction': { return ( ); } case 'dropped-frames': { return ( ); } } }; type CollapsedKeyboardInput = { kind: 'collapsed-keyboard'; events: Array; timestamp: number; }; type HistoryEvent = | { kind: 'single'; event: NotificationEvent; timestamp: number; } | CollapsedKeyboardInput | CollapsedDroppedFrame; const collapseEvents = (events: Array) => { const newEvents = events.reduce>((prev, curr) => { const lastEvent = prev.at(-1); if (!lastEvent) { return [ { kind: 'single', event: curr, timestamp: curr.timestamp, }, ]; } switch (lastEvent.kind) { case 'collapsed-keyboard': { if ( curr.kind === 'interaction' && curr.type === 'keyboard' && // must be on the same semantic component, it would be ideal to compare on fiberId, but i digress curr.componentPath.join('-') === lastEvent.events[0].componentPath.join('-') ) { const eventsWithoutLast = prev.filter((e) => e !== lastEvent); return [ ...eventsWithoutLast, { kind: 'collapsed-keyboard', events: [...lastEvent.events, curr], timestamp: Math.max( ...[...lastEvent.events, curr].map((e) => e.timestamp), ), }, ]; } return [ ...prev, { kind: 'single', event: curr, timestamp: curr.timestamp, }, ]; } case 'single': { // if its a keyboard input on the same element if ( lastEvent.event.kind === 'interaction' && lastEvent.event.type === 'keyboard' && curr.kind === 'interaction' && curr.type === 'keyboard' && lastEvent.event.componentPath.join('-') === curr.componentPath.join('-') ) { const eventsWithoutLast = prev.filter((e) => e !== lastEvent); return [ ...eventsWithoutLast, { kind: 'collapsed-keyboard', events: [lastEvent.event, curr], timestamp: Math.max(lastEvent.event.timestamp, curr.timestamp), }, ]; } if ( lastEvent.event.kind === 'dropped-frames' && curr.kind === 'dropped-frames' ) { const eventsWithoutLast = prev.filter((e) => e !== lastEvent); return [ ...eventsWithoutLast, { kind: 'collapsed-frame-drops', events: [lastEvent.event, curr], timestamp: Math.max(lastEvent.event.timestamp, curr.timestamp), }, ]; } return [ ...prev, { kind: 'single', event: curr, timestamp: curr.timestamp, }, ]; } case 'collapsed-frame-drops': { if (curr.kind === 'dropped-frames') { const eventsWithoutLast = prev.filter((e) => e !== lastEvent); return [ ...eventsWithoutLast, { kind: 'collapsed-frame-drops', events: [...lastEvent.events, curr], timestamp: Math.max( ...[...lastEvent.events, curr].map((e) => e.timestamp), ), }, ]; } return [ ...prev, { kind: 'single', event: curr, timestamp: curr.timestamp, }, ]; } } }, []); return newEvents; }; export const useLaggedEvents = (lagMs = 150) => { const { notificationState } = useNotificationsContext(); const [laggedEvents, setLaggedEvents] = useState(notificationState.events); // oxlint-disable-next-line react-hooks/exhaustive-deps useEffect(() => { setTimeout(() => { setLaggedEvents(notificationState.events); }, lagMs); }, [notificationState.events]); return [laggedEvents, setLaggedEvents] as const; }; export const SlowdownHistory = () => { const { notificationState, setNotificationState } = useNotificationsContext(); const shouldFlash = useFlashManager(notificationState.events); const [laggedEvents, setLaggedEvents] = useLaggedEvents(); // this is to avoid a flicker from our overlapping events deduping logic. This should be handled downstream, but this simplifies logic for now const collapsedEvents = collapseEvents(laggedEvents).toSorted( (a, b) => b.timestamp - a.timestamp, ); return (
History { toolbarEventStore.getState().actions.clear(); setNotificationState((prev) => ({ ...prev, selectedEvent: null, selectedFiber: null, route: prev.route === 'other-visualization' ? 'other-visualization' : 'render-visualization', })); setLaggedEvents([]); }} > } >
Clear all events
{collapsedEvents.length === 0 && (
No Events
)} {collapsedEvents.map((historyItem) => iife(() => { switch (historyItem.kind) { case 'collapsed-keyboard': { return ( ); } case 'single': { return ( ); } case 'collapsed-frame-drops': { return ( ); } } }), )}
); };