Created
November 11, 2025 20:20
-
-
Save garand/c6b006ca0650384bdf543bd4cc850d59 to your computer and use it in GitHub Desktop.
Event Queue
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import * as React from "react"; | |
| import { Button, Badge, Switch, Popover, Icon } from "@towbook/flatbed"; | |
| import { | |
| faListUl, | |
| faXmark, | |
| faCircle, | |
| faTrash, | |
| faChevronDown, | |
| } from "@fortawesome/pro-solid-svg-icons"; | |
| import { useEventQueueDevtools } from "~/hooks/useEventQueueDevtools"; | |
| import { setDevtoolsPause } from "~/utils/eventQueueManager"; | |
| const EventQueueDevTools = | |
| process.env.NODE_ENV === "development" | |
| ? () => { | |
| const [isOpen, setIsOpen] = React.useState(() => { | |
| // Read from localStorage on mount (client-side only) | |
| if (typeof window !== "undefined") { | |
| const saved = localStorage.getItem("eventQueueDevToolsOpen"); | |
| return saved === "true"; | |
| } | |
| return false; | |
| }); | |
| const { state, controls } = useEventQueueDevtools(); | |
| const [showClearConfirm, setShowClearConfirm] = React.useState(false); | |
| const [selectedItemKey, setSelectedItemKey] = React.useState<string | null>( | |
| null, | |
| ); | |
| const [isHoveringQueue, setIsHoveringQueue] = React.useState(false); | |
| // Update body padding when panel opens/closes - use CSS class | |
| React.useEffect(() => { | |
| document.body.classList.toggle("event-queue-devtools-open", isOpen); | |
| // Persist to localStorage (client-side only) | |
| if (typeof window !== "undefined") { | |
| localStorage.setItem("eventQueueDevToolsOpen", String(isOpen)); | |
| } | |
| return () => { | |
| document.body.classList.remove("event-queue-devtools-open"); | |
| }; | |
| }, [isOpen]); | |
| // Cleanup devtools pause on unmount | |
| React.useEffect(() => { | |
| return () => { | |
| setDevtoolsPause(false); | |
| }; | |
| }, []); | |
| // Create stable reference to queue item keys for dependency tracking | |
| const queueItemKeys = React.useMemo( | |
| () => state?.queueItems.map((i) => i.key).join(",") || "", | |
| [state?.queueItems], | |
| ); | |
| // Auto-clear selection if selected item no longer exists in queue | |
| React.useEffect(() => { | |
| if ( | |
| selectedItemKey && | |
| queueItemKeys && | |
| !queueItemKeys.includes(selectedItemKey) | |
| ) { | |
| setSelectedItemKey(null); | |
| setDevtoolsPause(false); | |
| } | |
| }, [selectedItemKey, queueItemKeys]); | |
| // Event handlers for hover - set devtools pause directly | |
| const handleMouseEnter = () => { | |
| setIsHoveringQueue(true); | |
| setDevtoolsPause(true); | |
| }; | |
| const handleMouseLeave = () => { | |
| setIsHoveringQueue(false); | |
| // Only unpause if nothing is selected | |
| if (!selectedItemKey) { | |
| setDevtoolsPause(false); | |
| } | |
| }; | |
| const handleLogQueue = () => { | |
| console.log("[EventQueueDevTools] Queue state:", state); | |
| }; | |
| const handleClearQueue = () => { | |
| if (showClearConfirm) { | |
| controls.clearQueue(); | |
| setShowClearConfirm(false); | |
| setSelectedItemKey(null); | |
| setDevtoolsPause(isHoveringQueue); | |
| } else { | |
| setShowClearConfirm(true); | |
| setTimeout(() => setShowClearConfirm(false), 3000); | |
| } | |
| }; | |
| // Event handler for item click - set devtools pause directly | |
| const handleItemClick = (key: string) => { | |
| const newSelection = selectedItemKey === key ? null : key; | |
| setSelectedItemKey(newSelection); | |
| setDevtoolsPause(!!newSelection || isHoveringQueue); | |
| }; | |
| if (!state) { | |
| // Queue not initialized yet | |
| return ( | |
| <div className="pointer-events-none fixed right-0 bottom-0 left-0 z-50"> | |
| {!isOpen && ( | |
| <div className="pointer-events-none pl-16"> | |
| <button | |
| className="bg-slate-3 border-slate-5 text-slate-8 pointer-events-auto mb-2.5 rounded-full border p-2.5 opacity-50" | |
| disabled | |
| > | |
| <Icon icon={faListUl} className="size-6" /> | |
| </button> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |
| const { | |
| queueSize, | |
| queueItems, | |
| isPaused, | |
| isProcessing, | |
| missedInterval, | |
| pauseConditions, | |
| processInterval, | |
| nextFlushCountdown, | |
| isManuallyPaused, | |
| manualPauseReason, | |
| } = state; | |
| const selectedItem = selectedItemKey | |
| ? queueItems.find((item) => item.key === selectedItemKey) | |
| : null; | |
| return ( | |
| <> | |
| <div className=""> | |
| {isOpen && ( | |
| <div className="border-slate-7 pointer-events-auto fixed right-0 bottom-0 left-0 z-999999 h-[512px] border-t bg-white shadow-lg"> | |
| {/* Header with All Controls */} | |
| <div className="border-slate-6 flex items-center justify-between gap-4 border-b px-4 py-2"> | |
| <h2 className="text-sm font-semibold"> | |
| EventQueue DevTools | |
| </h2> | |
| {/* Inline Controls */} | |
| <div className="flex flex-1 items-center gap-3"> | |
| {/* Manual Pause Switch */} | |
| <div className="flex items-center gap-2"> | |
| <span className="text-slate-11 text-xs">Enabled:</span> | |
| <Switch | |
| size="small" | |
| checked={!isManuallyPaused} | |
| onCheckedChange={(checked) => | |
| controls.setManualPause( | |
| !checked, | |
| checked ? undefined : "Manual pause via DevTools", | |
| ) | |
| } | |
| /> | |
| </div> | |
| {/* Config Display */} | |
| <div className="flex items-center gap-2 text-xs"> | |
| <span className="text-slate-11"> | |
| Debounce: <span className="font-semibold">0.5s</span> | |
| </span> | |
| <span className="text-slate-11">|</span> | |
| <span className="text-slate-11"> | |
| Max: <span className="font-semibold">5s</span> | |
| </span> | |
| </div> | |
| {/* Metrics */} | |
| <div className="flex items-center gap-3 text-xs"> | |
| <span className="text-slate-11"> | |
| Size:{" "} | |
| <span className="font-semibold">{queueSize}</span> | |
| </span> | |
| <span className="text-slate-11"> | |
| Next:{" "} | |
| <span className="font-semibold"> | |
| {nextFlushCountdown.toFixed(1)}s | |
| </span> | |
| </span> | |
| {isProcessing && ( | |
| <Badge color="blue"> | |
| <span className="animate-pulse">Processing</span> | |
| </Badge> | |
| )} | |
| {isManuallyPaused && ( | |
| <Badge color="purple">Manual Pause</Badge> | |
| )} | |
| {missedInterval && <Badge color="amber">Missed</Badge>} | |
| </div> | |
| </div> | |
| {/* Right Side Controls */} | |
| <div className="flex items-center gap-2"> | |
| {/* Quick Actions */} | |
| <Button | |
| size="small" | |
| variant="light" | |
| onClick={() => { | |
| controls.forceFlush(); | |
| setSelectedItemKey(null); | |
| }} | |
| > | |
| Force Flush | |
| </Button> | |
| <Button | |
| size="small" | |
| variant={showClearConfirm ? "default" : "light"} | |
| onClick={handleClearQueue} | |
| > | |
| <Icon icon={faTrash} className="mr-1" /> | |
| {showClearConfirm ? "Confirm?" : "Clear"} | |
| </Button> | |
| {/* Pause Status with Popover */} | |
| <Popover.Root> | |
| <Popover.Trigger asChild> | |
| <button className="hover:bg-slate-3 flex items-center gap-1.5 rounded px-2 py-1 text-xs"> | |
| <Icon | |
| icon={faCircle} | |
| className={`h-2 w-2 ${ | |
| isPaused ? "text-red-10" : "text-green-10" | |
| }`} | |
| /> | |
| <span className="font-medium"> | |
| {isPaused ? "Paused" : "Active"} | |
| </span> | |
| <Icon | |
| icon={faChevronDown} | |
| className="text-slate-10 h-3 w-3" | |
| /> | |
| </button> | |
| </Popover.Trigger> | |
| <Popover.Content | |
| side="top" | |
| align="end" | |
| className="w-64 p-3" | |
| > | |
| <div className="mb-2 text-xs font-semibold"> | |
| Pause Status | |
| </div> | |
| {/* Manual Pause */} | |
| {isManuallyPaused && ( | |
| <div className="border-purple-6 bg-purple-3 mb-2 rounded border p-2"> | |
| <div className="flex items-center gap-2"> | |
| <Icon | |
| icon={faCircle} | |
| className="text-purple-10 h-2 w-2" | |
| /> | |
| <span className="text-purple-12 text-xs font-medium"> | |
| Manual Pause | |
| </span> | |
| </div> | |
| {manualPauseReason && ( | |
| <div className="text-purple-11 mt-1 text-xs"> | |
| {manualPauseReason} | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| {/* Pause Conditions */} | |
| <div className="text-slate-11 mb-1.5 text-xs"> | |
| Conditions: {pauseConditions.length} | |
| </div> | |
| {pauseConditions.length === 0 ? ( | |
| <div className="text-slate-9 text-xs italic"> | |
| No conditions | |
| </div> | |
| ) : ( | |
| <div className="space-y-1.5"> | |
| {pauseConditions.map((condition) => ( | |
| <div | |
| key={condition.id} | |
| className="flex items-center gap-2" | |
| > | |
| <Icon | |
| icon={faCircle} | |
| className={`h-2 w-2 ${ | |
| condition.active | |
| ? "text-red-10" | |
| : "text-slate-7" | |
| }`} | |
| /> | |
| <span | |
| className={`text-xs ${ | |
| condition.active | |
| ? "text-slate-12 font-medium" | |
| : "text-slate-11" | |
| }`} | |
| > | |
| {condition.id} | |
| </span> | |
| </div> | |
| ))} | |
| </div> | |
| )} | |
| </Popover.Content> | |
| </Popover.Root> | |
| {/* Close Button */} | |
| <Button | |
| variant="text" | |
| size="small" | |
| square | |
| onClick={() => { | |
| setIsOpen(false); | |
| setSelectedItemKey(null); | |
| }} | |
| > | |
| <Icon icon={faXmark} /> | |
| </Button> | |
| </div> | |
| </div> | |
| {/* Content - Single Column for Queue Items */} | |
| <div className="h-[calc(512px-48px)] overflow-hidden px-4 py-3"> | |
| <div className="grid h-full grid-cols-2 gap-4"> | |
| {/* Left Column - Queue Items List */} | |
| <div | |
| className="flex h-full flex-col overflow-auto" | |
| onMouseEnter={handleMouseEnter} | |
| onMouseLeave={handleMouseLeave} | |
| > | |
| <div className="text-slate-11 mb-1.5 flex items-center justify-between text-xs font-medium"> | |
| <span>Queued Items ({queueItems.length})</span> | |
| {(isHoveringQueue || selectedItemKey) && ( | |
| <Badge size="small" color="amber"> | |
| Paused for review | |
| </Badge> | |
| )} | |
| </div> | |
| {queueItems.length === 0 ? ( | |
| <div className="text-slate-9 text-xs italic"> | |
| Queue is empty | |
| </div> | |
| ) : ( | |
| <div className="min-h-0 flex-1 overflow-y-auto"> | |
| <table className="w-full table-fixed text-xs"> | |
| <thead className="border-slate-6 sticky top-0 border-b bg-white"> | |
| <tr> | |
| <th className="text-slate-11 w-[45%] px-2 py-1.5 text-left font-medium"> | |
| Channel | |
| </th> | |
| <th className="text-slate-11 w-[45%] px-2 py-1.5 text-left font-medium"> | |
| Event | |
| </th> | |
| <th className="text-slate-11 w-[10%] px-2 py-1.5 text-right font-medium"> | |
| Age | |
| </th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {queueItems.map((item) => ( | |
| <tr | |
| key={item.key} | |
| onClick={() => handleItemClick(item.key)} | |
| className={`cursor-pointer transition-colors ${ | |
| selectedItemKey === item.key | |
| ? "border-blue-6 bg-blue-3" | |
| : "hover:bg-slate-3" | |
| }`} | |
| > | |
| <td className="text-slate-11 w-[45%] truncate px-2 py-1.5 font-mono"> | |
| {item.eventData?.channel || "—"} | |
| </td> | |
| <td className="text-slate-11 w-[45%] truncate px-2 py-1.5 font-mono"> | |
| {item.eventData?.event || item.key} | |
| </td> | |
| <td className="text-slate-9 w-[10%] px-2 py-1.5 text-right font-mono"> | |
| {item.age}s | |
| </td> | |
| </tr> | |
| ))} | |
| </tbody> | |
| </table> | |
| </div> | |
| )} | |
| </div> | |
| {/* Right Column - Item Detail View */} | |
| {selectedItem && ( | |
| <div className="border-slate-6 flex h-full flex-col border-l pl-4"> | |
| <div className="mb-2 flex items-center justify-between"> | |
| <h4 className="text-xs font-semibold"> | |
| Item Details | |
| </h4> | |
| <Button | |
| size="small" | |
| variant="text" | |
| square | |
| onClick={() => setSelectedItemKey(null)} | |
| > | |
| <Icon icon={faXmark} /> | |
| </Button> | |
| </div> | |
| <div className="min-h-0 flex-1 space-y-2 overflow-y-auto text-xs"> | |
| {/* Queue Key */} | |
| <div> | |
| <div className="text-slate-11 mb-1"> | |
| Queue Key | |
| </div> | |
| <div className="border-slate-6 bg-slate-2 rounded border p-2 font-mono"> | |
| {selectedItem.key} | |
| </div> | |
| </div> | |
| {/* Timing Info */} | |
| <div className="grid grid-cols-2 gap-2"> | |
| <div> | |
| <div className="text-slate-11 mb-1"> | |
| Added At | |
| </div> | |
| <div className="font-mono"> | |
| {new Date( | |
| selectedItem.timestamp, | |
| ).toLocaleTimeString()} | |
| </div> | |
| </div> | |
| <div> | |
| <div className="text-slate-11 mb-1">Age</div> | |
| <div className="font-mono"> | |
| {selectedItem.age}s | |
| </div> | |
| </div> | |
| </div> | |
| {/* Pusher Event Data */} | |
| {selectedItem.eventData ? ( | |
| <> | |
| <div> | |
| <div className="text-slate-11 mb-1"> | |
| Pusher Channel | |
| </div> | |
| <div className="font-mono"> | |
| {selectedItem.eventData.channel} | |
| </div> | |
| </div> | |
| <div> | |
| <div className="text-slate-11 mb-1"> | |
| Event Name | |
| </div> | |
| <div className="font-mono"> | |
| {selectedItem.eventData.event} | |
| </div> | |
| </div> | |
| <div> | |
| <div className="text-slate-11 mb-1"> | |
| Event Data | |
| </div> | |
| <pre className="border-slate-6 bg-slate-2 max-h-96 overflow-auto rounded border p-2 font-mono text-[10px] leading-relaxed"> | |
| {JSON.stringify( | |
| selectedItem.eventData.data, | |
| null, | |
| 2, | |
| )} | |
| </pre> | |
| </div> | |
| </> | |
| ) : ( | |
| <div className="text-slate-9 text-xs italic"> | |
| No Pusher event data available | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| {/* FAB Button - Hide when panel is open */} | |
| {!isOpen && ( | |
| <div className="pointer-events-none fixed right-33 bottom-3"> | |
| <button | |
| onClick={() => setIsOpen(true)} | |
| className={`bg-background shadow-soft-sm pointer-events-auto grid size-12 cursor-pointer place-items-center rounded-full transition-transform hover:scale-105 ${ | |
| isPaused ? "text-red-10" : "text-green-10" | |
| }`} | |
| > | |
| <div className="relative grid aspect-square place-items-center"> | |
| <Icon icon={faListUl} className="size-5" /> | |
| {queueSize > 0 && ( | |
| <span className="bg-red-9 text-background absolute -top-4 -right-4 flex items-center justify-center rounded-full px-1.5 py-0.5 text-[10px] font-medium tabular-nums"> | |
| {queueSize > 9 ? "9+" : queueSize} | |
| </span> | |
| )} | |
| </div> | |
| </button> | |
| </div> | |
| )} | |
| </div> | |
| </> | |
| ); | |
| } | |
| : () => null; | |
| export default EventQueueDevTools; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import * as React from "react"; | |
| import { destroyEventQueue, initEventQueue } from "./eventQueueManager"; | |
| /** | |
| * EventQueueInitializer Component | |
| * Initializes the global event queue on mount | |
| * This ensures the queue is available for batching updates | |
| */ | |
| export function EventQueueInitializer() { | |
| React.useEffect(() => { | |
| // Initialize event queue with 5-second processing interval | |
| initEventQueue(5000); | |
| // console.log("[EventQueueInitializer] Event queue initialized"); | |
| return () => { | |
| // Cleanup on unmount | |
| destroyEventQueue(); | |
| // console.log("[EventQueueInitializer] Event queue destroyed"); | |
| }; | |
| }, []); | |
| return null; // This component doesn't render anything | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import ms from "ms"; | |
| import { startTransition } from "react"; | |
| interface QueuedEvent { | |
| key: string; | |
| handler: () => void | Promise<void>; | |
| timestamp: number; | |
| eventData?: { | |
| channel: string; | |
| event: string; | |
| data: unknown; | |
| }; | |
| } | |
| /** | |
| * Pause condition interface | |
| * Allows pages to register custom pause logic | |
| */ | |
| export interface PauseCondition { | |
| id: string; | |
| shouldPause: () => boolean; | |
| } | |
| /** | |
| * Queue manager for batching events | |
| * Collects updates and processes them at regular intervals to reduce re-renders | |
| * Supports configurable pause conditions (e.g., during map interaction) | |
| * Catches up immediately when all pause conditions clear | |
| */ | |
| class EventQueueManager { | |
| private queue: Map<string, QueuedEvent> = new Map(); // Map for deduplication by ID | |
| private debounceTimer: NodeJS.Timeout | null = null; // Timer for debounced flush | |
| private maxWaitTimer: NodeJS.Timeout | null = null; // Timer for max wait flush | |
| private firstEventTime: number | null = null; // Timestamp of first event in current batch | |
| private readonly DEBOUNCE_DELAY = ms("250ms"); // 0.5s - flush if no events for this long | |
| private readonly MAX_WAIT_TIME = ms("2.5s"); // 5s - force flush after this long | |
| private isProcessing: boolean = false; | |
| private missedInterval: boolean = false; // Track if we skipped a flush due to pause | |
| private lastPausedState: boolean = false; | |
| private pauseConditions: Map<string, PauseCondition> = new Map(); | |
| private pauseCheckInterval: NodeJS.Timeout | null = null; | |
| private pauseEndDebounce: NodeJS.Timeout | null = null; // Debounce timer for pause end | |
| private readonly PAUSE_END_DELAY = 1000; // Wait 1 second after pause clears | |
| private lastFlushTime: number = Date.now(); // Track when last flush occurred | |
| private isManuallyPaused: boolean = false; // Manual pause state | |
| private manualPauseReason: string | null = null; // Optional reason for manual pause | |
| private devtoolsPaused: boolean = false; // DevTools-specific pause (for hover/selection) | |
| private processingKeys: Set<string> = new Set(); // Keys currently in processing batch | |
| private completedKeys: Set<string> = new Set(); // Keys that have started processing in current batch | |
| constructor() { | |
| this.startPauseMonitoring(); | |
| } | |
| /** | |
| * Register a pause condition | |
| * When any condition returns true, queue processing is paused | |
| */ | |
| registerPauseCondition(condition: PauseCondition) { | |
| this.pauseConditions.set(condition.id, condition); | |
| // console.log(`[EventQueue] Registered pause condition: ${condition.id}`); | |
| } | |
| /** | |
| * Unregister a pause condition | |
| */ | |
| unregisterPauseCondition(conditionId: string) { | |
| this.pauseConditions.delete(conditionId); | |
| // console.log(`[EventQueue] Unregistered pause condition: ${conditionId}`); | |
| } | |
| /** | |
| * Check if any pause condition is active | |
| */ | |
| private isPaused(): boolean { | |
| // DevTools pause takes highest priority (for hover/selection review) | |
| if (this.devtoolsPaused) { | |
| return true; | |
| } | |
| // Manual pause takes second priority | |
| if (this.isManuallyPaused) { | |
| return true; | |
| } | |
| // Check if any registered condition is active | |
| return Array.from(this.pauseConditions.values()).some((condition) => | |
| condition.shouldPause(), | |
| ); | |
| } | |
| /** | |
| * Monitor pause state changes | |
| * Polls conditions every 100ms to detect state transitions | |
| */ | |
| private startPauseMonitoring() { | |
| this.lastPausedState = this.isPaused(); | |
| // Poll every 100ms to detect pause state changes | |
| this.pauseCheckInterval = setInterval(() => { | |
| const isPaused = this.isPaused(); | |
| // Detect transition from paused -> not paused | |
| if (this.lastPausedState && !isPaused) { | |
| // console.log( | |
| // "[EventQueue] Pause cleared - waiting 1s to confirm", | |
| // ); | |
| // Clear any existing debounce timer | |
| if (this.pauseEndDebounce) { | |
| clearTimeout(this.pauseEndDebounce); | |
| } | |
| // Wait 1 second before flushing to ensure pause is truly cleared | |
| this.pauseEndDebounce = setTimeout(() => { | |
| // console.log("[EventQueue] Confirmed pause cleared"); | |
| if (this.missedInterval) { | |
| // console.log( | |
| // "[EventQueue] Processing missed interval immediately", | |
| // ); | |
| this.forceFlush(); | |
| } | |
| this.pauseEndDebounce = null; | |
| }, this.PAUSE_END_DELAY); | |
| } | |
| // Detect transition from not paused -> paused | |
| else if (!this.lastPausedState && isPaused) { | |
| // console.log("[EventQueue] Pause activated"); | |
| // Cancel pending flush if pause activates again | |
| if (this.pauseEndDebounce) { | |
| // console.log( | |
| // "[EventQueue] Cancelling pending flush - pause reactivated", | |
| // ); | |
| clearTimeout(this.pauseEndDebounce); | |
| this.pauseEndDebounce = null; | |
| } | |
| } | |
| this.lastPausedState = isPaused; | |
| }, 100); // Check every 100ms | |
| } | |
| /** | |
| * Add event to queue | |
| * If an event for the same key already exists, it will be replaced (deduplication) | |
| */ | |
| enqueue( | |
| key: string, | |
| handler: () => void | Promise<void>, | |
| eventData?: { channel: string; event: string; data: unknown }, | |
| ) { | |
| // Check if event is in processing batch but hasn't started yet | |
| if (this.processingKeys.has(key) && !this.completedKeys.has(key)) { | |
| console.log( | |
| `[EventQueue] Event ${key} is pending in current batch, skipping duplicate`, | |
| ); | |
| return; // Don't queue - will execute very soon | |
| } | |
| // Queue the event (either not processing, or already started and this is new data) | |
| const now = Date.now(); | |
| this.queue.set(key, { | |
| key, | |
| handler, | |
| timestamp: now, | |
| eventData, | |
| }); | |
| // Start max wait timer if this is the first event in batch | |
| if (this.firstEventTime === null) { | |
| this.firstEventTime = now; | |
| // Force flush after max wait time | |
| this.maxWaitTimer = setTimeout(() => { | |
| console.log("[EventQueue] Max wait time reached, forcing flush"); | |
| this.flush(); | |
| }, this.MAX_WAIT_TIME); | |
| } | |
| // Reset/start debounce timer | |
| if (this.debounceTimer) { | |
| clearTimeout(this.debounceTimer); | |
| } | |
| this.debounceTimer = setTimeout(() => { | |
| console.log("[EventQueue] Debounce timeout reached, flushing"); | |
| this.flush(); | |
| }, this.DEBOUNCE_DELAY); | |
| // console.log( | |
| // `[EventQueue] Queued event for key ${key}, Queue size: ${this.queue.size}`, | |
| // ); | |
| } | |
| /** | |
| * Process all queued updates in parallel | |
| * @param ignorePause - If true, flush even when paused (used by forceFlush) | |
| */ | |
| private async flush(ignorePause: boolean = false) { | |
| if (this.queue.size === 0) return; | |
| if (this.isProcessing) { | |
| console.warn("[EventQueue] Already processing, skipping flush"); | |
| return; | |
| } | |
| // Check if any pause condition is active (unless we're forcing) | |
| if (!ignorePause && this.isPaused()) { | |
| // console.log( | |
| // `[EventQueue] Queue is paused, skipping flush (${this.queue.size} updates queued)`, | |
| // ); | |
| this.missedInterval = true; // Mark that we skipped an interval | |
| return; // Skip this flush, updates stay queued for next interval | |
| } | |
| // Calculate how long queue was open (first event to flush) | |
| const queueOpenDuration = this.firstEventTime | |
| ? Date.now() - this.firstEventTime | |
| : 0; | |
| // Clear both timers since we're flushing now | |
| if (this.debounceTimer) { | |
| clearTimeout(this.debounceTimer); | |
| this.debounceTimer = null; | |
| } | |
| if (this.maxWaitTimer) { | |
| clearTimeout(this.maxWaitTimer); | |
| this.maxWaitTimer = null; | |
| } | |
| this.firstEventTime = null; | |
| this.isProcessing = true; | |
| const updatesToProcess = Array.from(this.queue.values()); | |
| // Mark all keys as processing | |
| updatesToProcess.forEach((item) => { | |
| this.processingKeys.add(item.key); | |
| }); | |
| this.queue.clear(); // Clear queue immediately | |
| console.log( | |
| `[EventQueue] Processing ${updatesToProcess.length} events | Queue open: ${queueOpenDuration}ms (${(queueOpenDuration / 1000).toFixed(2)}s)`, | |
| ); | |
| // Performance measurement using Performance API | |
| const markName = `event-queue-process-${Date.now()}`; | |
| const measureName = `event-queue-measure-${Date.now()}`; | |
| performance.mark(markName); | |
| // Process updates in parallel | |
| startTransition(async () => { | |
| await Promise.allSettled( | |
| updatesToProcess.map(async (item) => { | |
| // Mark as completed RIGHT BEFORE processing starts | |
| this.completedKeys.add(item.key); | |
| try { | |
| await item.handler(); | |
| } catch (error) { | |
| console.error( | |
| `[EventQueue] Error processing event for key ${item.key}:`, | |
| error, | |
| ); | |
| } | |
| }), | |
| ); | |
| }); | |
| // End performance measurement | |
| performance.measure(measureName, markName); | |
| const measures = performance.getEntriesByName(measureName); | |
| if (measures.length > 0) { | |
| const duration = measures[0].duration; | |
| console.log( | |
| `[EventQueue] Processed ${updatesToProcess.length} updates in ${duration.toFixed(2)}ms`, | |
| { | |
| queueSize: updatesToProcess.length, | |
| duration: `${duration.toFixed(2)}ms`, | |
| timestamp: new Date().toISOString(), | |
| }, | |
| ); | |
| // Clean up performance entries | |
| performance.clearMarks(markName); | |
| performance.clearMeasures(measureName); | |
| } | |
| // Clear both tracking sets after batch completes | |
| updatesToProcess.forEach((item) => { | |
| this.processingKeys.delete(item.key); | |
| this.completedKeys.delete(item.key); | |
| }); | |
| this.isProcessing = false; | |
| this.missedInterval = false; // Reset after successful flush | |
| this.lastFlushTime = Date.now(); // Track flush time for devtools | |
| } | |
| /** | |
| * Force immediate flush | |
| * Bypasses pause conditions and processes immediately | |
| * Useful for cleanup or manual testing | |
| */ | |
| forceFlush() { | |
| console.log("[EventQueue] Force flushing queue (bypassing pause)"); | |
| this.flush(true); // Pass true to bypass pause check | |
| } | |
| /** | |
| * Get current queue size | |
| */ | |
| getQueueSize(): number { | |
| return this.queue.size; | |
| } | |
| /** | |
| * Get queue items for devtools inspection | |
| * Returns array of items with keys, timestamps, and event data | |
| */ | |
| getQueue(): Array<{ | |
| key: string; | |
| timestamp: number; | |
| eventData?: { channel: string; event: string; data: unknown }; | |
| }> { | |
| return Array.from(this.queue.values()).map( | |
| ({ key, timestamp, eventData }) => ({ | |
| key, | |
| timestamp, | |
| eventData, | |
| }), | |
| ); | |
| } | |
| /** | |
| * Check if queue is currently paused | |
| */ | |
| getIsPaused(): boolean { | |
| return this.isPaused(); | |
| } | |
| /** | |
| * Check if queue is currently processing | |
| */ | |
| getIsProcessing(): boolean { | |
| return this.isProcessing; | |
| } | |
| /** | |
| * Check if intervals were missed due to pause | |
| */ | |
| getMissedInterval(): boolean { | |
| return this.missedInterval; | |
| } | |
| /** | |
| * Get all registered pause condition IDs | |
| */ | |
| getPauseConditions(): Array<{ id: string }> { | |
| return Array.from(this.pauseConditions.keys()).map((id) => ({ id })); | |
| } | |
| /** | |
| * Get currently active pause condition IDs | |
| */ | |
| getActivePauseConditions(): string[] { | |
| return Array.from(this.pauseConditions.entries()) | |
| .filter(([_, condition]) => condition.shouldPause()) | |
| .map(([id]) => id); | |
| } | |
| /** | |
| * Get the debounce delay in milliseconds | |
| */ | |
| getDebounceDelay(): number { | |
| return this.DEBOUNCE_DELAY; | |
| } | |
| /** | |
| * Get the max wait time in milliseconds | |
| */ | |
| getMaxWaitTime(): number { | |
| return this.MAX_WAIT_TIME; | |
| } | |
| /** | |
| * Get the timestamp of the last flush | |
| */ | |
| getLastFlushTime(): number { | |
| return this.lastFlushTime; | |
| } | |
| /** | |
| * Get timestamp of first event in current batch (for countdown calculation) | |
| */ | |
| getFirstEventTime(): number | null { | |
| return this.firstEventTime; | |
| } | |
| /** | |
| * Check if queue is manually paused | |
| */ | |
| getIsManuallyPaused(): boolean { | |
| return this.isManuallyPaused; | |
| } | |
| /** | |
| * Get manual pause reason | |
| */ | |
| getManualPauseReason(): string | null { | |
| return this.manualPauseReason; | |
| } | |
| /** | |
| * Manually pause or resume the queue | |
| * Manual pause takes priority over all pause conditions | |
| */ | |
| setManualPause(paused: boolean, reason?: string) { | |
| this.isManuallyPaused = paused; | |
| this.manualPauseReason = paused ? reason || null : null; | |
| console.log( | |
| `[EventQueue] Manual pause ${paused ? "enabled" : "disabled"}${ | |
| reason ? `: ${reason}` : "" | |
| }`, | |
| ); | |
| } | |
| /** | |
| * Set devtools pause state (for hover/selection review) | |
| * DevTools pause takes highest priority | |
| */ | |
| setDevtoolsPause(paused: boolean) { | |
| this.devtoolsPaused = paused; | |
| } | |
| /** | |
| * Check if queue is paused by devtools | |
| */ | |
| getIsDevtoolsPaused(): boolean { | |
| return this.devtoolsPaused; | |
| } | |
| /** | |
| * Clear all queued items | |
| * Useful for testing or manual cleanup | |
| */ | |
| clearQueue() { | |
| const size = this.queue.size; | |
| this.queue.clear(); | |
| console.log(`[EventQueue] Cleared ${size} items from queue`); | |
| } | |
| /** | |
| * Cleanup and stop processing | |
| */ | |
| destroy() { | |
| // console.log("[EventQueue] Destroying queue manager"); | |
| if (this.debounceTimer) { | |
| clearTimeout(this.debounceTimer); | |
| this.debounceTimer = null; | |
| } | |
| if (this.maxWaitTimer) { | |
| clearTimeout(this.maxWaitTimer); | |
| this.maxWaitTimer = null; | |
| } | |
| if (this.pauseCheckInterval) { | |
| clearInterval(this.pauseCheckInterval); | |
| this.pauseCheckInterval = null; | |
| } | |
| if (this.pauseEndDebounce) { | |
| clearTimeout(this.pauseEndDebounce); | |
| this.pauseEndDebounce = null; | |
| } | |
| // Clear all pause conditions | |
| this.pauseConditions.clear(); | |
| // Process any remaining updates before destroying (bypass pause) | |
| if (this.queue.size > 0) { | |
| // console.log( | |
| // `[EventQueue] Processing ${this.queue.size} remaining updates before destroy`, | |
| // ); | |
| this.flush(true); // Bypass pause on destroy | |
| } | |
| this.queue.clear(); | |
| } | |
| } | |
| // Global singleton instance | |
| let queueManager: EventQueueManager | null = null; | |
| /** | |
| * Initialize the event queue manager | |
| * @returns Queue manager instance | |
| */ | |
| export function initEventQueue() { | |
| if (queueManager) { | |
| // console.log( | |
| // "[EventQueue] Manager already initialized, destroying old instance", | |
| // ); | |
| queueManager.destroy(); | |
| } | |
| queueManager = new EventQueueManager(); | |
| return queueManager; | |
| } | |
| /** | |
| * Get the global queue manager instance | |
| * @throws Error if not initialized | |
| */ | |
| export function getEventQueue() { | |
| if (!queueManager) { | |
| throw new Error("EventQueue not initialized. Call initEventQueue() first."); | |
| } | |
| return queueManager; | |
| } | |
| /** | |
| * Register a pause condition | |
| * When the condition returns true, queue processing is paused | |
| */ | |
| export function registerPauseCondition(condition: PauseCondition) { | |
| const queue = getEventQueue(); | |
| queue.registerPauseCondition(condition); | |
| } | |
| /** | |
| * Unregister a pause condition | |
| */ | |
| export function unregisterPauseCondition(conditionId: string) { | |
| const queue = getEventQueue(); | |
| queue.unregisterPauseCondition(conditionId); | |
| } | |
| /** | |
| * Queue a event update for batched processing | |
| * @param key - Event key | |
| * @param handler - Function to execute when processing | |
| * @param eventData - Optional Pusher event data for debugging | |
| */ | |
| export function queueEvent( | |
| key: string, | |
| handler: () => void | Promise<void>, | |
| eventData?: { channel: string; event: string; data: unknown }, | |
| ) { | |
| try { | |
| const queue = getEventQueue(); | |
| queue.enqueue(key, handler, eventData); | |
| } catch (error) { | |
| console.error("[EventQueue] Failed to queue event:", error); | |
| // Fallback: Execute immediately if queue not available | |
| console.warn("[EventQueue] Executing update immediately as fallback"); | |
| handler(); | |
| } | |
| } | |
| /** | |
| * Set devtools pause state | |
| * Used by devtools to pause queue during hover/selection review | |
| */ | |
| export function setDevtoolsPause(paused: boolean) { | |
| try { | |
| const queue = getEventQueue(); | |
| queue.setDevtoolsPause(paused); | |
| } catch (error) { | |
| console.error("[EventQueue] Failed to set devtools pause:", error); | |
| } | |
| } | |
| /** | |
| * Destroy the global queue manager instance | |
| */ | |
| export function destroyEventQueue() { | |
| if (queueManager) { | |
| queueManager.destroy(); | |
| queueManager = null; | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import * as React from "react"; | |
| import { getEventQueue } from "~/utils/eventQueueManager"; | |
| export interface EventQueueDevtoolsState { | |
| queueSize: number; | |
| queueItems: Array<{ | |
| key: string; | |
| timestamp: number; | |
| age: number; | |
| eventData?: { channel: string; event: string; data: unknown }; | |
| }>; | |
| isPaused: boolean; | |
| isProcessing: boolean; | |
| missedInterval: boolean; | |
| pauseConditions: Array<{ id: string; active: boolean }>; | |
| processInterval: number; | |
| nextFlushCountdown: number; // seconds until next flush | |
| lastFlushTime: number; | |
| isManuallyPaused: boolean; | |
| manualPauseReason: string | null; | |
| } | |
| export interface EventQueueDevtoolsControls { | |
| setManualPause: (paused: boolean, reason?: string) => void; | |
| clearQueue: () => void; | |
| forceFlush: () => void; | |
| } | |
| /** | |
| * Hook to access EventQueue state for devtools | |
| * Polls every 100ms for real-time updates | |
| * Returns null if queue is not initialized | |
| */ | |
| export function useEventQueueDevtools(): { | |
| state: EventQueueDevtoolsState | null; | |
| controls: EventQueueDevtoolsControls; | |
| } { | |
| const [state, setState] = React.useState<EventQueueDevtoolsState | null>(null); | |
| React.useEffect(() => { | |
| const updateState = () => { | |
| try { | |
| const queue = getEventQueue(); | |
| const now = Date.now(); | |
| const debounceDelay = queue.getDebounceDelay(); | |
| const maxWaitTime = queue.getMaxWaitTime(); | |
| const firstEventTime = queue.getFirstEventTime(); | |
| // Calculate next flush time | |
| let nextFlushCountdown = 0; | |
| if (firstEventTime !== null) { | |
| const timeSinceFirst = now - firstEventTime; | |
| const maxWaitRemaining = (maxWaitTime - timeSinceFirst) / 1000; | |
| const debounceRemaining = debounceDelay / 1000; | |
| // Next flush is whichever comes first | |
| nextFlushCountdown = Math.max(0, Math.min(debounceRemaining, maxWaitRemaining)); | |
| } | |
| const queueItems = queue | |
| .getQueue() | |
| .map((item) => ({ | |
| ...item, | |
| age: Math.floor((now - item.timestamp) / 1000), // age in seconds | |
| })) | |
| .sort((a, b) => b.timestamp - a.timestamp); // Newest first | |
| const allPauseConditions = queue.getPauseConditions(); | |
| const activePauseConditionIds = queue.getActivePauseConditions(); | |
| const pauseConditions = allPauseConditions.map((condition) => ({ | |
| ...condition, | |
| active: activePauseConditionIds.includes(condition.id), | |
| })); | |
| setState({ | |
| queueSize: queue.getQueueSize(), | |
| queueItems, | |
| isPaused: queue.getIsPaused(), | |
| isProcessing: queue.getIsProcessing(), | |
| missedInterval: queue.getMissedInterval(), | |
| pauseConditions, | |
| processInterval: debounceDelay, // Use debounce delay for now (DevTools may need update) | |
| nextFlushCountdown, | |
| lastFlushTime: queue.getLastFlushTime(), | |
| isManuallyPaused: queue.getIsManuallyPaused(), | |
| manualPauseReason: queue.getManualPauseReason(), | |
| }); | |
| } catch (error) { | |
| // Queue not initialized yet | |
| setState(null); | |
| } | |
| }; | |
| // Initial update | |
| updateState(); | |
| // Poll every 100ms for real-time updates | |
| const interval = setInterval(updateState, 100); | |
| return () => clearInterval(interval); | |
| }, []); | |
| // Control functions | |
| const controls: EventQueueDevtoolsControls = { | |
| setManualPause: React.useCallback((paused: boolean, reason?: string) => { | |
| try { | |
| const queue = getEventQueue(); | |
| queue.setManualPause(paused, reason); | |
| } catch (error) { | |
| console.error("[EventQueueDevtools] Failed to set manual pause:", error); | |
| } | |
| }, []), | |
| clearQueue: React.useCallback(() => { | |
| try { | |
| const queue = getEventQueue(); | |
| queue.clearQueue(); | |
| } catch (error) { | |
| console.error("[EventQueueDevtools] Failed to clear queue:", error); | |
| } | |
| }, []), | |
| forceFlush: React.useCallback(() => { | |
| try { | |
| const queue = getEventQueue(); | |
| queue.forceFlush(); | |
| } catch (error) { | |
| console.error("[EventQueueDevtools] Failed to force flush:", error); | |
| } | |
| }, []), | |
| }; | |
| return { state, controls }; | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import * as React from "react"; | |
| import { | |
| registerPauseCondition, | |
| unregisterPauseCondition, | |
| type PauseCondition, | |
| } from "~/utils/eventQueueManager"; | |
| /** | |
| * Hook to register a pause condition for the event queue | |
| * When the condition returns true, queue processing is paused | |
| * Automatically unregisters on unmount | |
| * | |
| * @param conditionId - Unique identifier for this pause condition | |
| * @param shouldPause - Function that returns true when queue should be paused | |
| * | |
| * @example | |
| * // On map page - pause during map interactions | |
| * useEventQueuePause('map-interaction', () => useMapStore.getState().isMapInteracting) | |
| * | |
| * @example | |
| * // On dispatching page - no pause needed, don't call the hook | |
| */ | |
| export function useEventQueuePause( | |
| conditionId: string, | |
| shouldPause: () => boolean, | |
| ) { | |
| React.useEffect(() => { | |
| const condition: PauseCondition = { | |
| id: conditionId, | |
| shouldPause, | |
| }; | |
| // Register the pause condition | |
| registerPauseCondition(condition); | |
| // Cleanup: unregister on unmount | |
| return () => { | |
| unregisterPauseCondition(conditionId); | |
| }; | |
| }, [conditionId, shouldPause]); | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment