From 9dca03f68218e5411c3bff9b0f85e1d65bb10963 Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Thu, 20 Aug 2026 09:59:38 +0100 Subject: [PATCH] chore: enforce exhaustive React hook dependencies (#4712) ## Summary Enables exhaustive React Hook dependency checking and resolves the existing violations across the dashboard and React hooks package. Effects and callbacks now track current values without introducing request, subscription, or render loops. ## Design Dependencies are included directly when the hook lifecycle should follow them. Timers, Remix fetchers, and realtime subscriptions use stable callbacks or latest-value refs where restarting work would change behavior. Unnecessary memoization was removed where ordinary derivation is clearer. Full lint and typechecks for the webapp and React hooks package pass. --- .oxlintrc.json | 2 +- .../assets/icons/AnimatedHourglassIcon.tsx | 11 +- apps/webapp/app/components/AskAI.tsx | 2 +- apps/webapp/app/components/DevPresence.tsx | 2 +- apps/webapp/app/components/Feedback.tsx | 4 +- .../components/admin/FeatureFlagsDialog.tsx | 11 +- apps/webapp/app/components/admin/debugRun.tsx | 5 +- .../app/components/code/AIQueryInput.tsx | 68 ++-- .../dashboard-agent/DashboardAgent.tsx | 2 +- .../dashboard-agent/DashboardAgentPanel.tsx | 1 + .../integrations/VercelOnboardingModal.tsx | 12 +- .../navigation/NotificationPanel.tsx | 86 +++-- .../navigation/useReorderableList.ts | 8 +- .../components/primitives/AgentDotMatrix.tsx | 8 + .../components/primitives/AnimatedNumber.tsx | 2 +- .../app/components/primitives/Checkbox.tsx | 10 +- .../app/components/primitives/DateField.tsx | 15 +- .../app/components/primitives/DateTime.tsx | 18 +- .../components/primitives/DurationPicker.tsx | 16 +- .../primitives/LoadingBarDivider.tsx | 2 +- .../app/components/primitives/Select.tsx | 2 +- .../app/components/primitives/Timeline.tsx | 33 +- .../primitives/TreeView/TreeView.tsx | 341 ++++++++---------- .../app/components/query/QueryEditor.tsx | 43 ++- .../app/components/runs/v3/AIFilterInput.tsx | 2 +- .../app/components/runs/v3/LiveTimer.tsx | 4 +- .../components/runs/v3/ReplayRunDialog.tsx | 31 +- .../app/components/runs/v3/RunFilters.tsx | 138 ++++--- .../app/components/runs/v3/SharedFilters.tsx | 8 +- .../app/components/runs/v3/TaskRunsList.tsx | 22 +- .../app/components/runs/v3/TaskRunsTable.tsx | 2 +- .../runs/v3/WaitpointTokenFilters.tsx | 27 +- .../components/runs/v3/ai/AIChatMessages.tsx | 4 +- .../webhookConsole/SampleSourcePicker.tsx | 10 +- apps/webapp/app/hooks/useAutoRevalidate.ts | 12 +- apps/webapp/app/hooks/useChanged.ts | 26 +- apps/webapp/app/hooks/useDashboardEditor.ts | 8 +- apps/webapp/app/hooks/useFuzzyFilter.ts | 2 +- apps/webapp/app/hooks/usePostHog.ts | 2 +- .../app/hooks/useReplaceSearchParams.ts | 2 +- .../route.tsx | 3 +- .../route.tsx | 25 +- .../route.tsx | 6 +- .../route.tsx | 8 +- .../route.tsx | 15 +- .../route.tsx | 25 +- .../route.tsx | 6 +- .../route.tsx | 7 +- .../route.tsx | 4 +- .../route.tsx | 19 +- .../route.tsx | 6 +- .../route.tsx | 48 +-- .../route.tsx | 32 +- .../AIPayloadTabContent.tsx | 42 +-- .../route.tsx | 18 +- .../route.tsx | 4 +- .../webapp/app/routes/admin.feature-flags.tsx | 2 +- .../webapp/app/routes/admin.queue-metrics.tsx | 22 +- .../webapp/app/routes/resources.incidents.tsx | 17 +- apps/webapp/app/routes/resources.metric.tsx | 42 +-- ...cts.$projectParam.env.$envParam.github.tsx | 5 +- .../route.tsx | 5 +- ...ectParam.env.$envParam.runs.bulkaction.tsx | 13 +- ...cts.$projectParam.env.$envParam.vercel.tsx | 52 ++- .../route.tsx | 33 +- ...ctParam.schedules.new.natural-language.tsx | 10 +- .../routes/resources.platform-changelogs.tsx | 26 +- .../resources.platform-notifications.tsx | 18 +- .../app/routes/storybook.ai-agent/route.tsx | 7 +- .../app/routes/storybook.filter/route.tsx | 22 +- .../app/routes/storybook.select/route.tsx | 5 +- packages/react-hooks/src/hooks/useRealtime.ts | 118 ++++-- 72 files changed, 888 insertions(+), 781 deletions(-) diff --git a/.oxlintrc.json b/.oxlintrc.json index 85a5dbdce..6483b5500 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -45,7 +45,7 @@ "typescript/consistent-type-imports": "error", "import/no-duplicates": "error", "import/namespace": "off", - "react/exhaustive-deps": "off", + "react/exhaustive-deps": "error", "react/rules-of-hooks": "off", "guard-for-in": "error", "symbol-description": "error", diff --git a/apps/webapp/app/assets/icons/AnimatedHourglassIcon.tsx b/apps/webapp/app/assets/icons/AnimatedHourglassIcon.tsx index 3c94426fa..95a16889e 100644 --- a/apps/webapp/app/assets/icons/AnimatedHourglassIcon.tsx +++ b/apps/webapp/app/assets/icons/AnimatedHourglassIcon.tsx @@ -1,6 +1,6 @@ import { useAnimate } from "framer-motion"; import { HourglassIcon } from "lucide-react"; -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; export function AnimatedHourglassIcon({ className, @@ -10,18 +10,21 @@ export function AnimatedHourglassIcon({ delay?: number; }) { const [scope, animate] = useAnimate(); + const initialDelay = useRef(delay); useEffect(() => { - animate( + const controls = animate( [ [scope.current, { rotate: 0 }, { duration: 0.7 }], [scope.current, { rotate: 180 }, { duration: 0.3 }], [scope.current, { rotate: 180 }, { duration: 0.7 }], [scope.current, { rotate: 360 }, { duration: 0.3 }], ], - { repeat: Infinity, delay } + { repeat: Infinity, delay: initialDelay.current } ); - }, []); + + return () => controls.stop(); + }, [animate, scope]); return ; } diff --git a/apps/webapp/app/components/AskAI.tsx b/apps/webapp/app/components/AskAI.tsx index 96be9dd70..37911b873 100644 --- a/apps/webapp/app/components/AskAI.tsx +++ b/apps/webapp/app/components/AskAI.tsx @@ -76,7 +76,7 @@ function useAskAIState() { next.delete(ASK_AI_DEEP_LINK_PARAM); setSearchParams(next); } - }, [searchParams, openAskAI]); + }, [searchParams, setSearchParams, openAskAI]); return { isOpen, setIsOpen, initialQuery, openAskAI, closeAskAI }; } diff --git a/apps/webapp/app/components/DevPresence.tsx b/apps/webapp/app/components/DevPresence.tsx index 4ce4a480c..907a52072 100644 --- a/apps/webapp/app/components/DevPresence.tsx +++ b/apps/webapp/app/components/DevPresence.tsx @@ -80,7 +80,7 @@ export function DevPresenceProvider({ children, enabled = true }: DevPresencePro // Calculate isConnected and memoize the context value const contextValue = useMemo(() => { return { isConnected }; - }, [isConnected, enabled]); + }, [isConnected]); return {children}; } diff --git a/apps/webapp/app/components/Feedback.tsx b/apps/webapp/app/components/Feedback.tsx index b679b042d..f6a9971cc 100644 --- a/apps/webapp/app/components/Feedback.tsx +++ b/apps/webapp/app/components/Feedback.tsx @@ -70,7 +70,7 @@ export function Feedback({ ) { setOpen(false); } - }, [navigation.formAction, navigation.state, form.allErrors]); + }, [navigation.formAction, navigation.state, form.allErrors, setOpen]); // Handle URL param functionality useEffect(() => { @@ -83,7 +83,7 @@ export function Feedback({ next.delete("feedbackPanel"); setSearchParams(next); } - }, [searchParams]); + }, [searchParams, setOpen, setSearchParams]); // Reset the topic to the default once the dialog closes, so reopening always starts fresh. The // dialog is now persistently mounted (hosted outside the popover), so without this it would keep diff --git a/apps/webapp/app/components/admin/FeatureFlagsDialog.tsx b/apps/webapp/app/components/admin/FeatureFlagsDialog.tsx index 887103700..8433c74f3 100644 --- a/apps/webapp/app/components/admin/FeatureFlagsDialog.tsx +++ b/apps/webapp/app/components/admin/FeatureFlagsDialog.tsx @@ -1,5 +1,5 @@ import { useFetcher } from "@remix-run/react"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import stableStringify from "json-stable-stringify"; import { Dialog, @@ -54,6 +54,9 @@ export function FeatureFlagsDialog({ }: FeatureFlagsDialogProps) { const loadFetcher = useFetcher(); const saveFetcher = useFetcher(); + const loadFeatureFlags = loadFetcher.load; + const onOpenChangeRef = useRef(onOpenChange); + onOpenChangeRef.current = onOpenChange; const [overrides, setOverrides] = useState>({}); const [initialOverrides, setInitialOverrides] = useState>({}); @@ -67,9 +70,9 @@ export function FeatureFlagsDialog({ setSaveError(null); setOverrides({}); setInitialOverrides({}); - loadFetcher.load(`/admin/api/v2/orgs/${orgId}/feature-flags`); + loadFeatureFlags(`/admin/api/v2/orgs/${orgId}/feature-flags`); } - }, [open, orgId]); + }, [loadFeatureFlags, open, orgId]); useEffect(() => { if (loadFetcher.data) { @@ -81,7 +84,7 @@ export function FeatureFlagsDialog({ useEffect(() => { if (saveFetcher.data?.success) { - onOpenChange(false); + onOpenChangeRef.current(false); } else if (saveFetcher.data?.error) { setSaveError(saveFetcher.data.error); } diff --git a/apps/webapp/app/components/admin/debugRun.tsx b/apps/webapp/app/components/admin/debugRun.tsx index 6274dda35..6e8d4b795 100644 --- a/apps/webapp/app/components/admin/debugRun.tsx +++ b/apps/webapp/app/components/admin/debugRun.tsx @@ -45,10 +45,11 @@ function DebugRunDialog({ friendlyId }: { friendlyId: string }) { function DebugRunContent({ friendlyId }: { friendlyId: string }) { const fetcher = useTypedFetcher(); const isLoading = fetcher.state === "loading"; + const load = fetcher.load; useEffect(() => { - fetcher.load(`/resources/taskruns/${friendlyId}/debug`); - }, [friendlyId]); + load(`/resources/taskruns/${friendlyId}/debug`); + }, [friendlyId, load]); return ( <> diff --git a/apps/webapp/app/components/code/AIQueryInput.tsx b/apps/webapp/app/components/code/AIQueryInput.tsx index f9ceb3384..e06d03268 100644 --- a/apps/webapp/app/components/code/AIQueryInput.tsx +++ b/apps/webapp/app/components/code/AIQueryInput.tsx @@ -65,6 +65,39 @@ export function AIQueryInput({ } }, [mode, canEdit]); + const processStreamEvent = useCallback( + (event: StreamEventType) => { + switch (event.type) { + case "thinking": + setThinking((prev) => prev + event.content); + break; + case "tool_call": + // Tool calls are handled silently — no UI text needed + break; + case "time_filter": + // Apply time filter immediately when the AI sets it + onTimeFilterChange?.(event.filter); + break; + case "result": + if (event.success) { + // Apply time filter if included in result (backup in case time_filter event was missed) + if (event.timeFilter) { + onTimeFilterChange?.(event.timeFilter); + } + onQueryGenerated(event.query); + setPrompt(""); + setLastResult("success"); + // Keep thinking visible to show what happened + } else { + setError(event.error); + setLastResult("error"); + } + break; + } + }, + [onQueryGenerated, onTimeFilterChange] + ); + const submitQuery = useCallback( async (queryPrompt: string, submitMode: AIQueryMode = mode) => { if (!queryPrompt.trim() || isLoading) return; @@ -158,40 +191,7 @@ export function AIQueryInput({ setIsLoading(false); } }, - [isLoading, resourcePath, mode, getCurrentQuery] - ); - - const processStreamEvent = useCallback( - (event: StreamEventType) => { - switch (event.type) { - case "thinking": - setThinking((prev) => prev + event.content); - break; - case "tool_call": - // Tool calls are handled silently — no UI text needed - break; - case "time_filter": - // Apply time filter immediately when the AI sets it - onTimeFilterChange?.(event.filter); - break; - case "result": - if (event.success) { - // Apply time filter if included in result (backup in case time_filter event was missed) - if (event.timeFilter) { - onTimeFilterChange?.(event.timeFilter); - } - onQueryGenerated(event.query); - setPrompt(""); - setLastResult("success"); - // Keep thinking visible to show what happened - } else { - setError(event.error); - setLastResult("error"); - } - break; - } - }, - [onQueryGenerated, onTimeFilterChange] + [getCurrentQuery, isLoading, mode, processStreamEvent, resourcePath] ); const handleSubmit = useCallback( diff --git a/apps/webapp/app/components/dashboard-agent/DashboardAgent.tsx b/apps/webapp/app/components/dashboard-agent/DashboardAgent.tsx index f6564a805..3d356d9b5 100644 --- a/apps/webapp/app/components/dashboard-agent/DashboardAgent.tsx +++ b/apps/webapp/app/components/dashboard-agent/DashboardAgent.tsx @@ -281,7 +281,7 @@ export function DashboardAgent({ cancelled = true; stop(); }; - }, [hasAccess, watching, actionPath, setPanelOpen, openChat]); + }, [hasAccess, watching, actionPath, setPanelOpen, openChat, rememberToasted]); // Zeroes the wake dot right away; the poll restores the truth if another chat has one. The // work count is not touched here: the panel derives it from the chat list. diff --git a/apps/webapp/app/components/dashboard-agent/DashboardAgentPanel.tsx b/apps/webapp/app/components/dashboard-agent/DashboardAgentPanel.tsx index 419fb5f22..a8e6c3264 100644 --- a/apps/webapp/app/components/dashboard-agent/DashboardAgentPanel.tsx +++ b/apps/webapp/app/components/dashboard-agent/DashboardAgentPanel.tsx @@ -476,6 +476,7 @@ export function DashboardAgentPanel({ watchCard.requestId, active?.chatId, actionPath, + organization.id, claimChatSlot, loadHistory, ]); diff --git a/apps/webapp/app/components/integrations/VercelOnboardingModal.tsx b/apps/webapp/app/components/integrations/VercelOnboardingModal.tsx index bda62a1b3..d606b5125 100644 --- a/apps/webapp/app/components/integrations/VercelOnboardingModal.tsx +++ b/apps/webapp/app/components/integrations/VercelOnboardingModal.tsx @@ -46,7 +46,7 @@ import { vercelResourcePath, } from "~/utils/pathBuilder"; import type { loader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel"; -import { useEffect, useState, useCallback, useRef } from "react"; +import { useEffect, useState, useCallback, useMemo, useRef } from "react"; import { usePostHogTracking } from "~/hooks/usePostHog"; import { TextLink } from "../primitives/TextLink"; @@ -126,9 +126,15 @@ export function VercelOnboardingModal({ const origin = searchParams.get("origin"); const fromMarketplaceContext = origin === "marketplace"; - const availableProjects = onboardingData?.availableProjects || []; + const availableProjects = useMemo( + () => onboardingData?.availableProjects ?? [], + [onboardingData?.availableProjects] + ); const _hasProjectSelected = onboardingData?.hasProjectSelected ?? false; - const customEnvironments = onboardingData?.customEnvironments || []; + const customEnvironments = useMemo( + () => onboardingData?.customEnvironments ?? [], + [onboardingData?.customEnvironments] + ); const envVars = onboardingData?.environmentVariables || []; const existingVars = onboardingData?.existingVariables || {}; const hasCustomEnvs = customEnvironments.length > 0 && hasStagingEnvironment; diff --git a/apps/webapp/app/components/navigation/NotificationPanel.tsx b/apps/webapp/app/components/navigation/NotificationPanel.tsx index 78cb52ca5..20c2b5897 100644 --- a/apps/webapp/app/components/navigation/NotificationPanel.tsx +++ b/apps/webapp/app/components/navigation/NotificationPanel.tsx @@ -42,60 +42,70 @@ export function NotificationPanel({ notifications: Notification[]; }; const [dismissedIds, setDismissedIds] = useState>(new Set()); - const dismissFetcher = useFetcher(); + const { submit: submitDismiss } = useFetcher(); const seenIdsRef = useRef>(new Set()); - const seenFetcher = useFetcher(); + const { submit: submitSeen } = useFetcher(); const clickedIdsRef = useRef>(new Set()); - const clickFetcher = useFetcher(); + const { submit: submitClick } = useFetcher(); const visibleNotifications = notifications.filter((n) => !dismissedIds.has(n.id)); const notification = visibleNotifications[0] ?? null; + const notificationId = notification?.id; - const handleDismiss = useCallback((id: string) => { - setDismissedIds((prev) => new Set(prev).add(id)); + const handleDismiss = useCallback( + (id: string) => { + setDismissedIds((prev) => new Set(prev).add(id)); - dismissFetcher.submit( - {}, - { - method: "POST", - action: `/resources/platform-notifications/${id}/dismiss`, - } - ); - }, []); + submitDismiss( + {}, + { + method: "POST", + action: `/resources/platform-notifications/${id}/dismiss`, + } + ); + }, + [submitDismiss] + ); - const fireClickBeacon = useCallback((id: string) => { - if (clickedIdsRef.current.has(id)) return; - clickedIdsRef.current.add(id); + const fireClickBeacon = useCallback( + (id: string) => { + if (clickedIdsRef.current.has(id)) return; + clickedIdsRef.current.add(id); - clickFetcher.submit( - {}, - { - method: "POST", - action: `/resources/platform-notifications/${id}/clicked`, - } - ); - }, []); + submitClick( + {}, + { + method: "POST", + action: `/resources/platform-notifications/${id}/clicked`, + } + ); + }, + [submitClick] + ); // Fire seen beacon - const fireSeenBeacon = useCallback((n: Notification) => { - if (seenIdsRef.current.has(n.id)) return; - seenIdsRef.current.add(n.id); + const fireSeenBeacon = useCallback( + (id: string) => { + if (seenIdsRef.current.has(id)) return; + seenIdsRef.current.add(id); - seenFetcher.submit( - {}, - { - method: "POST", - action: `/resources/platform-notifications/${n.id}/seen`, - } - ); - }, []); + submitSeen( + {}, + { + method: "POST", + action: `/resources/platform-notifications/${id}/seen`, + } + ); + }, + [submitSeen] + ); // Beacon current notification on mount useEffect(() => { - if (notification && !hasIncident) { - fireSeenBeacon(notification); + if (notificationId && !hasIncident) { + fireSeenBeacon(notificationId); } - }, [notification?.id, hasIncident]); + }, [notificationId, hasIncident, fireSeenBeacon]); if (!notification) { return null; diff --git a/apps/webapp/app/components/navigation/useReorderableList.ts b/apps/webapp/app/components/navigation/useReorderableList.ts index b98d9b551..bcde7edd1 100644 --- a/apps/webapp/app/components/navigation/useReorderableList.ts +++ b/apps/webapp/app/components/navigation/useReorderableList.ts @@ -1,5 +1,5 @@ import { useFetcher } from "@remix-run/react"; -import { type Ref, useCallback, useEffect, useMemo, useState } from "react"; +import { type Ref, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { type Layout, useContainerWidth } from "react-grid-layout"; /** @@ -33,9 +33,13 @@ export function useReorderableList({ const orderFetcher = useFetcher(); const [order, setOrder] = useState(() => initialOrder ?? items.map(itemKey)); + const resetOrderRef = useRef({ initialOrder, items, itemKey }); + resetOrderRef.current = { initialOrder, items, itemKey }; - // Sync order when organizationId changes (component may not remount) + // Only an organization switch resets user-managed order. Keep the latest inputs in a ref so + // ordinary item or callback identity changes don't discard a drag reorder. useEffect(() => { + const { initialOrder, items, itemKey } = resetOrderRef.current; setOrder(initialOrder ?? items.map(itemKey)); }, [organizationId]); diff --git a/apps/webapp/app/components/primitives/AgentDotMatrix.tsx b/apps/webapp/app/components/primitives/AgentDotMatrix.tsx index 63d70de3e..f82967828 100644 --- a/apps/webapp/app/components/primitives/AgentDotMatrix.tsx +++ b/apps/webapp/app/components/primitives/AgentDotMatrix.tsx @@ -288,6 +288,10 @@ export function AgentDotMatrix({ typeof palette === "string" ? DOT_MATRIX_PALETTES[palette] : palette; const paletteKey = paletteObj.stops.join(",") + (paletteObj.glow ?? ""); const playlistKey = playlist.join(","); + const paletteObjRef = useRef(paletteObj); + const playlistRef = useRef(playlist); + paletteObjRef.current = paletteObj; + playlistRef.current = playlist; useEffect(() => { activeRef.current = active; @@ -295,6 +299,10 @@ export function AgentDotMatrix({ }, [active]); useEffect(() => { + // The serialized keys restart this animation when contents change; refs avoid restarting for + // equivalent array/object identities while still exposing the matching current values. + const paletteObj = paletteObjRef.current; + const playlist = playlistRef.current; const canvas = canvasRef.current; const ctx = canvas?.getContext("2d"); if (!canvas || !ctx) return; diff --git a/apps/webapp/app/components/primitives/AnimatedNumber.tsx b/apps/webapp/app/components/primitives/AnimatedNumber.tsx index fea0f9d89..88c9bc3f9 100644 --- a/apps/webapp/app/components/primitives/AnimatedNumber.tsx +++ b/apps/webapp/app/components/primitives/AnimatedNumber.tsx @@ -65,7 +65,7 @@ export function AnimatedNumber({ duration, ease: "easeInOut", }); - }, [value, duration]); + }, [motionValue, value, duration]); return {display}; } diff --git a/apps/webapp/app/components/primitives/Checkbox.tsx b/apps/webapp/app/components/primitives/Checkbox.tsx index 8db6defb4..a00bfee99 100644 --- a/apps/webapp/app/components/primitives/Checkbox.tsx +++ b/apps/webapp/app/components/primitives/Checkbox.tsx @@ -80,12 +80,14 @@ export const CheckboxWithLabel = React.forwardRef { const [isChecked, setIsChecked] = useState(defaultChecked ?? false); const [isDisabled, setIsDisabled] = useState(disabled ?? false); + const onChangeRef = React.useRef(onChange); const generatedId = React.useId(); const inputId = id ?? generatedId; const labelId = `${inputId}-label`; @@ -105,9 +107,11 @@ export const CheckboxWithLabel = React.forwardRef { - if (props.onChange) { - props.onChange(isChecked); - } + onChangeRef.current = onChange; + }, [onChange]); + + useEffect(() => { + onChangeRef.current?.(isChecked); }, [isChecked]); useEffect(() => { diff --git a/apps/webapp/app/components/primitives/DateField.tsx b/apps/webapp/app/components/primitives/DateField.tsx index a68616fc1..883508316 100644 --- a/apps/webapp/app/components/primitives/DateField.tsx +++ b/apps/webapp/app/components/primitives/DateField.tsx @@ -80,20 +80,25 @@ export function DateField({ }, }); - //if the passed in value changes, we should update the date + const stateValueRef = useRef(state.value); + stateValueRef.current = state.value; + + // Sync only when the passed value or timezone mode changes. Depending on state.value directly + // would reset partially edited segments back to the default after every keystroke. useEffect(() => { - if (state.value === undefined && defaultValue === undefined) return; + const stateValue = stateValueRef.current; + if (stateValue === undefined && defaultValue === undefined) return; const calendarDate = utc ? utcDateToCalendarDate(defaultValue) : dateToCalendarDate(defaultValue); - //unchanged - if (state.value?.toDate("utc").getTime() === defaultValue?.getTime()) { + // unchanged + if (stateValue?.toDate(utc ? "utc" : deviceTimezone).getTime() === defaultValue?.getTime()) { return; } setValue(calendarDate); - }, [defaultValue]); + }, [defaultValue, utc]); const ref = useRef(null); const { labelProps: _labelProps, fieldProps } = useDateField( diff --git a/apps/webapp/app/components/primitives/DateTime.tsx b/apps/webapp/app/components/primitives/DateTime.tsx index 40015f01e..deac7a96e 100644 --- a/apps/webapp/app/components/primitives/DateTime.tsx +++ b/apps/webapp/app/components/primitives/DateTime.tsx @@ -243,12 +243,16 @@ const DateTimeAccurateInner = ({ const userTimeZone = useUserTimeZone(); // Use provided timeZone prop if available, otherwise fall back to user's preferred timezone const displayTimeZone = timeZone ?? userTimeZone; - const realDate = typeof date === "string" ? new Date(date) : date; - const realPrevDate = previousDate - ? typeof previousDate === "string" - ? new Date(previousDate) - : previousDate - : null; + const realDate = useMemo(() => (typeof date === "string" ? new Date(date) : date), [date]); + const realPrevDate = useMemo( + () => + previousDate + ? typeof previousDate === "string" + ? new Date(previousDate) + : previousDate + : null, + [previousDate] + ); // Smart formatting based on whether date changed const formattedDateTime = useMemo(() => { @@ -259,7 +263,7 @@ const DateTimeAccurateInner = ({ ? formatTimeOnly(realDate, displayTimeZone, locales, hour12) : formatDateTimeAccurate(realDate, displayTimeZone, locales, hour12) : formatDateTimeAccurate(realDate, displayTimeZone, locales, hour12); - }, [realDate, displayTimeZone, locales, hour12, hideDate, previousDate]); + }, [realDate, realPrevDate, displayTimeZone, locales, hour12, hideDate]); if (!showTooltip) return ( diff --git a/apps/webapp/app/components/primitives/DurationPicker.tsx b/apps/webapp/app/components/primitives/DurationPicker.tsx index e4f5af652..e1ce40f5d 100644 --- a/apps/webapp/app/components/primitives/DurationPicker.tsx +++ b/apps/webapp/app/components/primitives/DurationPicker.tsx @@ -42,15 +42,15 @@ export function DurationPicker({ // Sync internal state with external value changes useEffect(() => { - if (controlledValue !== undefined && controlledValue !== totalSeconds) { - const newHours = Math.floor(controlledValue / 3600); - const newMinutes = Math.floor((controlledValue % 3600) / 60); - const newSeconds = controlledValue % 60; + if (controlledValue === undefined) return; - setHours(newHours); - setMinutes(newMinutes); - setSeconds(newSeconds); - } + const newHours = Math.floor(controlledValue / 3600); + const newMinutes = Math.floor((controlledValue % 3600) / 60); + const newSeconds = controlledValue % 60; + + setHours(newHours); + setMinutes(newMinutes); + setSeconds(newSeconds); }, [controlledValue]); useEffect(() => { diff --git a/apps/webapp/app/components/primitives/LoadingBarDivider.tsx b/apps/webapp/app/components/primitives/LoadingBarDivider.tsx index 00259d38f..08e6a126d 100644 --- a/apps/webapp/app/components/primitives/LoadingBarDivider.tsx +++ b/apps/webapp/app/components/primitives/LoadingBarDivider.tsx @@ -39,7 +39,7 @@ function AnimationDivider({ isLoading }: LoadingBarDividerProps) { exitAnimation(); } - }, [isPresent, isLoading]); + }, [animate, isPresent, isLoading, safeToRemove, scope]); return ( diff --git a/apps/webapp/app/components/primitives/Select.tsx b/apps/webapp/app/components/primitives/Select.tsx index 85cd716e1..de453f3eb 100644 --- a/apps/webapp/app/components/primitives/Select.tsx +++ b/apps/webapp/app/components/primitives/Select.tsx @@ -190,7 +190,7 @@ export function Select({ } return matchSorter(items, searchValue, filter); - }, [searchValue, items]); + }, [searchValue, items, filter]); const enableItemShortcuts = allowItemShortcuts && matches.length === items?.length; diff --git a/apps/webapp/app/components/primitives/Timeline.tsx b/apps/webapp/app/components/primitives/Timeline.tsx index c2eecd075..b4562c3df 100644 --- a/apps/webapp/app/components/primitives/Timeline.tsx +++ b/apps/webapp/app/components/primitives/Timeline.tsx @@ -1,5 +1,5 @@ import type { ComponentPropsWithoutRef, ReactNode } from "react"; -import { Fragment, createContext, useCallback, useContext, useRef, useState } from "react"; +import { Fragment, createContext, useContext, useRef, useState } from "react"; import { inverseLerp, lerp } from "~/utils/lerp"; interface MousePosition { @@ -11,26 +11,23 @@ function MousePositionProvider({ children }: { children: ReactNode }) { const ref = useRef(null); const [position, setPosition] = useState(undefined); - const handleMouseMove = useCallback( - (e: React.MouseEvent) => { - if (!ref.current) { - setPosition(undefined); - return; - } + const handleMouseMove = (e: React.MouseEvent) => { + if (!ref.current) { + setPosition(undefined); + return; + } - const { top, left, width, height } = ref.current.getBoundingClientRect(); - const x = (e.clientX - left) / width; - const y = (e.clientY - top) / height; + const { top, left, width, height } = ref.current.getBoundingClientRect(); + const x = (e.clientX - left) / width; + const y = (e.clientY - top) / height; - if (x < 0 || x > 1 || y < 0 || y > 1) { - setPosition(undefined); - return; - } + if (x < 0 || x > 1 || y < 0 || y > 1) { + setPosition(undefined); + return; + } - setPosition({ x, y }); - }, - [ref.current] - ); + setPosition({ x, y }); + }; return (
({ if (autoFocus) { parentRef?.current?.focus(); } - }, [autoFocus, parentRef?.current]); + }, [autoFocus, parentRef]); const virtualItems = virtualizer.getVirtualItems(); @@ -57,21 +57,24 @@ export function TreeView({ return map; }, [tree]); - const scrollCallback = useCallback( - (event: Event) => { - if (!onScroll) return; - const target = event.target as HTMLElement; - onScroll?.(target.scrollTop); - }, - [onScroll] - ); - + const onScrollRef = useRef(onScroll); useEffect(() => { - //subscribe to scrollRef scroll event - if (!scrollRef?.current || onScroll === undefined) return; - scrollRef.current.addEventListener("scroll", scrollCallback); - return () => scrollRef.current?.removeEventListener("scroll", scrollCallback); - }, [scrollRef?.current]); + onScrollRef.current = onScroll; + }, [onScroll]); + + const hasOnScroll = onScroll !== undefined; + useEffect(() => { + const scrollElement = scrollRef?.current; + if (!scrollElement || !hasOnScroll) return; + + const handleScroll = (event: Event) => { + const target = event.target as HTMLElement; + onScrollRef.current?.(target.scrollTop); + }; + + scrollElement.addEventListener("scroll", handleScroll); + return () => scrollElement.removeEventListener("scroll", handleScroll); + }, [hasOnScroll, scrollRef]); return ( ({ }: TreeStateHookProps): UseTreeStateOutput { const previousNodeCount = useRef(tree.length); const previousSelectedId = useRef(selectedId); + const previousExternalSelectedId = useRef(selectedId); + const onSelectedIdChangedRef = useRef(onSelectedIdChanged); + const latestTreeRef = useRef(tree); + latestTreeRef.current = tree; const [state, dispatch] = useReducer( reducer, concreteStateFromInput({ tree, selectedId, collapsedIds, filter }) ); + const currentSelectedId = selectedIdFromState(state.nodes); // id -> index lookup so getNodeProps resolves in O(1) instead of scanning // the whole tree per rendered row. @@ -217,53 +225,56 @@ export function useTree({ return map; }, [tree]); - //sync external selectedId prop into internal state + // Sync external selectedId changes into internal state without turning the prop into + // a fully controlled value that immediately overrides internal keyboard selection. useEffect(() => { - const internalSelectedId = selectedIdFromState(state.nodes); - if (selectedId !== internalSelectedId) { - if (selectedId === undefined) { - dispatch({ type: "DESELECT_ALL_NODES" }); - } else { - dispatch({ - type: "SELECT_NODE", - payload: { id: selectedId, scrollToNode: false, scrollToNodeFn }, - }); - } + if (selectedId === previousExternalSelectedId.current) return; + previousExternalSelectedId.current = selectedId; + + if (selectedId === undefined) { + dispatch({ type: "DESELECT_ALL_NODES" }); + } else { + dispatch({ + type: "SELECT_NODE", + payload: { id: selectedId, scrollToNode: false, scrollToNodeFn: () => {} }, + }); } }, [selectedId]); - //fire onSelectedIdChanged() useEffect(() => { - const selectedId = selectedIdFromState(state.nodes); - if (selectedId !== previousSelectedId.current) { - previousSelectedId.current = selectedId; - onSelectedIdChanged?.(selectedId); - } - }, [state.changes.selectedId]); + onSelectedIdChangedRef.current = onSelectedIdChanged; + }, [onSelectedIdChanged]); - //update tree when the number of nodes changes + // Fire onSelectedIdChanged() only when selection changes, not when the callback is recreated. useEffect(() => { - if (tree.length !== previousNodeCount.current) { - previousNodeCount.current = tree.length; - dispatch({ type: "UPDATE_TREE", payload: { tree } }); + if (currentSelectedId !== previousSelectedId.current) { + previousSelectedId.current = currentSelectedId; + onSelectedIdChangedRef.current?.(currentSelectedId); } - }, [previousNodeCount.current, tree.length]); + }, [currentSelectedId]); - //update the filter, if it's changed - const previousFilter = useRef(filter); + const treeNodeCount = tree.length; + + // Callers may recreate the tree array; preserve reducer state unless its shape changes. useEffect(() => { - //check if the value (not reference) of the filter is the same - const previousValue = previousFilter.current - ? JSON.stringify(previousFilter.current.value) - : undefined; - const newValue = filter ? JSON.stringify(filter.value) : undefined; - - previousFilter.current = filter; - - if (previousValue !== newValue) { - dispatch({ type: "UPDATE_FILTER", payload: { filter } }); + if (treeNodeCount !== previousNodeCount.current) { + previousNodeCount.current = treeNodeCount; + dispatch({ type: "UPDATE_TREE", payload: { tree: latestTreeRef.current } }); } - }, [filter?.value]); + }, [treeNodeCount]); + + const latestFilterRef = useRef(filter); + latestFilterRef.current = filter; + const serializedFilterValue = filter ? JSON.stringify(filter.value) : undefined; + const previousSerializedFilterValue = useRef(serializedFilterValue); + + // Filter behavior is keyed by value; callers may recreate the filter function every render. + useEffect(() => { + if (serializedFilterValue === previousSerializedFilterValue.current) return; + + previousSerializedFilterValue.current = serializedFilterValue; + dispatch({ type: "UPDATE_FILTER", payload: { filter: latestFilterRef.current } }); + }, [serializedFilterValue]); const virtualizer = useVirtualizer({ count: state.visibleNodeIds.length, @@ -281,149 +292,98 @@ export function useTree({ overscan: 50, }); - const scrollToNodeFn = useCallback( - (id: string) => { - const itemIndex = state.visibleNodeIds.findIndex((n) => n === id); + const scrollToNodeFn = (id: string) => { + const itemIndex = state.visibleNodeIds.findIndex((nodeId) => nodeId === id); - if (itemIndex !== -1) { - virtualizer.scrollToIndex(itemIndex, { align: "auto" }); - } - }, - [state] - ); + if (itemIndex !== -1) { + virtualizer.scrollToIndex(itemIndex, { align: "auto" }); + } + }; - const selectNode = useCallback( - (id: string, scrollToNode = true) => { - dispatch({ type: "SELECT_NODE", payload: { id, scrollToNode, scrollToNodeFn } }); - }, - [state] - ); + const selectNode = (id: string, scrollToNode = true) => { + dispatch({ type: "SELECT_NODE", payload: { id, scrollToNode, scrollToNodeFn } }); + }; - const deselectNode = useCallback( - (id: string) => { - dispatch({ type: "DESELECT_NODE", payload: { id } }); - }, - [state] - ); + const deselectNode = (id: string) => { + dispatch({ type: "DESELECT_NODE", payload: { id } }); + }; - const deselectAllNodes = useCallback(() => { + const deselectAllNodes = () => { dispatch({ type: "DESELECT_ALL_NODES" }); - }, [state]); + }; - const toggleNodeSelection = useCallback( - (id: string, scrollToNode = true) => { - dispatch({ type: "TOGGLE_NODE_SELECTION", payload: { id, scrollToNode, scrollToNodeFn } }); - }, - [state] - ); + const toggleNodeSelection = (id: string, scrollToNode = true) => { + dispatch({ type: "TOGGLE_NODE_SELECTION", payload: { id, scrollToNode, scrollToNodeFn } }); + }; - const expandNode = useCallback( - (id: string, scrollToNode = true) => { - dispatch({ type: "EXPAND_NODE", payload: { id, scrollToNode, scrollToNodeFn } }); - }, - [state] - ); + const expandNode = (id: string, scrollToNode = true) => { + dispatch({ type: "EXPAND_NODE", payload: { id, scrollToNode, scrollToNodeFn } }); + }; - const collapseNode = useCallback( - (id: string) => { - dispatch({ type: "COLLAPSE_NODE", payload: { id } }); - }, - [state] - ); + const collapseNode = (id: string) => { + dispatch({ type: "COLLAPSE_NODE", payload: { id } }); + }; - const toggleExpandNode = useCallback( - (id: string, scrollToNode = true) => { - dispatch({ type: "TOGGLE_EXPAND_NODE", payload: { id, scrollToNode, scrollToNodeFn } }); - }, - [state] - ); + const toggleExpandNode = (id: string, scrollToNode = true) => { + dispatch({ type: "TOGGLE_EXPAND_NODE", payload: { id, scrollToNode, scrollToNodeFn } }); + }; - const selectFirstVisibleNode = useCallback( - (scrollToNode = true) => { - dispatch({ - type: "SELECT_FIRST_VISIBLE_NODE", - payload: { scrollToNode, scrollToNodeFn }, - }); - }, - [tree, state] - ); + const selectFirstVisibleNode = (scrollToNode = true) => { + dispatch({ + type: "SELECT_FIRST_VISIBLE_NODE", + payload: { scrollToNode, scrollToNodeFn }, + }); + }; - const selectLastVisibleNode = useCallback( - (scrollToNode = true) => { - dispatch({ - type: "SELECT_LAST_VISIBLE_NODE", - payload: { scrollToNode, scrollToNodeFn }, - }); - }, - [tree, state] - ); + const selectLastVisibleNode = (scrollToNode = true) => { + dispatch({ + type: "SELECT_LAST_VISIBLE_NODE", + payload: { scrollToNode, scrollToNodeFn }, + }); + }; - const selectNextVisibleNode = useCallback( - (scrollToNode = true) => { - dispatch({ - type: "SELECT_NEXT_VISIBLE_NODE", - payload: { scrollToNode, scrollToNodeFn }, - }); - }, - [state] - ); + const selectNextVisibleNode = (scrollToNode = true) => { + dispatch({ + type: "SELECT_NEXT_VISIBLE_NODE", + payload: { scrollToNode, scrollToNodeFn }, + }); + }; - const selectPreviousVisibleNode = useCallback( - (scrollToNode = true) => { - dispatch({ - type: "SELECT_PREVIOUS_VISIBLE_NODE", - payload: { scrollToNode, scrollToNodeFn }, - }); - }, - [state] - ); + const selectPreviousVisibleNode = (scrollToNode = true) => { + dispatch({ + type: "SELECT_PREVIOUS_VISIBLE_NODE", + payload: { scrollToNode, scrollToNodeFn }, + }); + }; - const selectParentNode = useCallback( - (scrollToNode = true) => { - dispatch({ - type: "SELECT_PARENT_NODE", - payload: { scrollToNode, scrollToNodeFn }, - }); - }, - [state] - ); + const selectParentNode = (scrollToNode = true) => { + dispatch({ + type: "SELECT_PARENT_NODE", + payload: { scrollToNode, scrollToNodeFn }, + }); + }; - const expandAllBelowDepth = useCallback( - (depth: number) => { - dispatch({ type: "EXPAND_ALL_BELOW_DEPTH", payload: { depth } }); - }, - [state] - ); + const expandAllBelowDepth = (depth: number) => { + dispatch({ type: "EXPAND_ALL_BELOW_DEPTH", payload: { depth } }); + }; - const collapseAllBelowDepth = useCallback( - (depth: number) => { - dispatch({ type: "COLLAPSE_ALL_BELOW_DEPTH", payload: { depth } }); - }, - [state] - ); + const collapseAllBelowDepth = (depth: number) => { + dispatch({ type: "COLLAPSE_ALL_BELOW_DEPTH", payload: { depth } }); + }; - const expandLevel = useCallback( - (level: number) => { - dispatch({ type: "EXPAND_LEVEL", payload: { level } }); - }, - [state] - ); + const expandLevel = (level: number) => { + dispatch({ type: "EXPAND_LEVEL", payload: { level } }); + }; - const collapseLevel = useCallback( - (level: number) => { - dispatch({ type: "COLLAPSE_LEVEL", payload: { level } }); - }, - [state] - ); + const collapseLevel = (level: number) => { + dispatch({ type: "COLLAPSE_LEVEL", payload: { level } }); + }; - const toggleExpandLevel = useCallback( - (level: number) => { - dispatch({ type: "TOGGLE_EXPAND_LEVEL", payload: { level } }); - }, - [state] - ); + const toggleExpandLevel = (level: number) => { + dispatch({ type: "TOGGLE_EXPAND_LEVEL", payload: { level } }); + }; - const getTreeProps = useCallback(() => { + const getTreeProps = () => { return { role: "tree", "aria-multiselectable": true, @@ -514,26 +474,23 @@ export function useTree({ } }, }; - }, [state]); + }; - const getNodeProps = useCallback( - (id: string) => { - const node = state.nodes[id]; - if (!node) return {}; - const treeItemIndex = treeIndexById.get(id) ?? -1; - const treeItem = tree[treeItemIndex]; - return { - "aria-expanded": node.expanded, - "aria-level": treeItem.level + 1, - role: "treeitem", - tabIndex: node.selected ? -1 : undefined, - }; - }, - [state, treeIndexById] - ); + const getNodeProps = (id: string) => { + const node = state.nodes[id]; + if (!node) return {}; + const treeItemIndex = treeIndexById.get(id) ?? -1; + const treeItem = tree[treeItemIndex]; + return { + "aria-expanded": node.expanded, + "aria-level": treeItem.level + 1, + role: "treeitem", + tabIndex: node.selected ? -1 : undefined, + }; + }; return { - selected: selectedIdFromState(state.nodes), + selected: currentSelectedId, nodes: state.nodes, getTreeProps, getNodeProps, diff --git a/apps/webapp/app/components/query/QueryEditor.tsx b/apps/webapp/app/components/query/QueryEditor.tsx index ea41be337..8c842c35f 100644 --- a/apps/webapp/app/components/query/QueryEditor.tsx +++ b/apps/webapp/app/components/query/QueryEditor.tsx @@ -377,22 +377,25 @@ export function QueryEditor({ // Use defaultData as initial results, then switch to fetcher data once a query is run const fetcherResults = fetcher.data; - const results = - fetcherResults ?? - (defaultData - ? { - error: null, - rows: defaultData.rows, - columns: defaultData.columns, - stats: null, - hiddenColumns: null, - reachedMaxRows: false, - explainOutput: null, - generatedSql: null, - queryId: null, - periodClipped: null, - } - : null); + const results = useMemo( + () => + fetcherResults ?? + (defaultData + ? { + error: null, + rows: defaultData.rows, + columns: defaultData.columns, + stats: null, + hiddenColumns: null, + reachedMaxRows: false, + explainOutput: null, + generatedSql: null, + queryId: null, + periodClipped: null, + } + : null), + [defaultData, fetcherResults] + ); const organization = useOrganization(); const project = useProject(); @@ -1255,13 +1258,13 @@ function ResultsBigNumber({ accessory?: ReactNode; }) { // Auto-select first numeric column if none selected - const numericColumns = columns.filter((c) => isNumericColumnType(c.type)); + const firstNumericColumn = columns.find((column) => isNumericColumnType(column.type)); useEffect(() => { - if (!bigNumberConfig.column && numericColumns.length > 0) { - onBigNumberConfigChange({ ...bigNumberConfig, column: numericColumns[0].name }); + if (!bigNumberConfig.column && firstNumericColumn) { + onBigNumberConfigChange({ ...bigNumberConfig, column: firstNumericColumn.name }); } - }, [columns]); + }, [bigNumberConfig, firstNumericColumn, onBigNumberConfigChange]); return ( diff --git a/apps/webapp/app/components/runs/v3/AIFilterInput.tsx b/apps/webapp/app/components/runs/v3/AIFilterInput.tsx index d6a6b3234..10b4986c2 100644 --- a/apps/webapp/app/components/runs/v3/AIFilterInput.tsx +++ b/apps/webapp/app/components/runs/v3/AIFilterInput.tsx @@ -53,7 +53,7 @@ export function AIFilterInput() { inputRef.current.focus(); } } - }, [fetcher.data, navigate]); + }, [fetcher.data, fetcher.state, navigate]); const isLoading = fetcher.state === "submitting"; diff --git a/apps/webapp/app/components/runs/v3/LiveTimer.tsx b/apps/webapp/app/components/runs/v3/LiveTimer.tsx index 3128c6003..496acbf86 100644 --- a/apps/webapp/app/components/runs/v3/LiveTimer.tsx +++ b/apps/webapp/app/components/runs/v3/LiveTimer.tsx @@ -23,7 +23,7 @@ export function LiveTimer({ }, updateInterval); return () => clearInterval(interval); - }, [startTime, endTime]); + }, [startTime, endTime, updateInterval]); return ( <> @@ -56,7 +56,7 @@ export function LiveCountdown({ }, updateInterval); return () => clearInterval(interval); - }, [endTime]); + }, [endTime, updateInterval]); return ( <> diff --git a/apps/webapp/app/components/runs/v3/ReplayRunDialog.tsx b/apps/webapp/app/components/runs/v3/ReplayRunDialog.tsx index 9e22052bb..65381312d 100644 --- a/apps/webapp/app/components/runs/v3/ReplayRunDialog.tsx +++ b/apps/webapp/app/components/runs/v3/ReplayRunDialog.tsx @@ -54,8 +54,10 @@ export function ReplayRunDialog({ runFriendlyId, failedRedirect }: ReplayRunDial function ReplayContent({ runFriendlyId, failedRedirect }: ReplayRunDialogProps) { const replayDataFetcher = useTypedFetcher(); + const { load: loadReplayData } = replayDataFetcher; const isLoading = replayDataFetcher.state === "loading"; const queueFetcher = useTypedFetcher(); + const { load: loadQueues } = queueFetcher; const [environmentIdOverride, setEnvironmentIdOverride] = useState(undefined); @@ -65,34 +67,35 @@ function ReplayContent({ runFriendlyId, failedRedirect }: ReplayRunDialogProps) searchParams.set("environmentIdOverride", environmentIdOverride); } - replayDataFetcher.load( - `/resources/taskruns/${runFriendlyId}/replay?${searchParams.toString()}` - ); - }, [runFriendlyId, environmentIdOverride]); + loadReplayData(`/resources/taskruns/${runFriendlyId}/replay?${searchParams.toString()}`); + }, [environmentIdOverride, loadReplayData, runFriendlyId]); const params = useParams(); + const environmentOverrideSlug = environmentIdOverride + ? replayDataFetcher.data?.environments.find((env) => env.id === environmentIdOverride)?.slug + : undefined; + useEffect(() => { if (params.organizationSlug && params.projectParam && params.envParam) { const searchParams = new URLSearchParams(); searchParams.set("type", "custom"); searchParams.set("per_page", "100"); - let envSlug = params.envParam; + const envSlug = environmentOverrideSlug ?? params.envParam; - if (environmentIdOverride) { - const environmentOverride = replayDataFetcher.data?.environments.find( - (env) => env.id === environmentIdOverride - ); - envSlug = environmentOverride?.slug ?? envSlug; - } - - queueFetcher.load( + loadQueues( `/resources/orgs/${params.organizationSlug}/projects/${ params.projectParam }/env/${envSlug}/queues?${searchParams.toString()}` ); } - }, [params.organizationSlug, params.projectParam, params.envParam, environmentIdOverride]); + }, [ + environmentOverrideSlug, + loadQueues, + params.envParam, + params.organizationSlug, + params.projectParam, + ]); const customQueues = useMemo(() => { return queueFetcher.data?.queues ?? []; diff --git a/apps/webapp/app/components/runs/v3/RunFilters.tsx b/apps/webapp/app/components/runs/v3/RunFilters.tsx index 27a7511e5..f08e09f44 100644 --- a/apps/webapp/app/components/runs/v3/RunFilters.tsx +++ b/apps/webapp/app/components/runs/v3/RunFilters.tsx @@ -1012,11 +1012,14 @@ function TagsDropdown({ from: value("from"), to: value("to"), }); + const fromTimestamp = from?.getTime(); + const toTimestamp = to?.getTime(); const tagValues = values("tags").filter((v) => v !== ""); const selected = tagValues.length > 0 ? tagValues : undefined; const fetcher = useFetcher(); + const { load } = fetcher; useEffect(() => { const searchParams = new URLSearchParams(); @@ -1026,14 +1029,14 @@ function TagsDropdown({ if (period) { searchParams.set("period", period); } - if (from) { - searchParams.set("from", from.getTime().toString()); + if (fromTimestamp !== undefined) { + searchParams.set("from", fromTimestamp.toString()); } - if (to) { - searchParams.set("to", to.getTime().toString()); + if (toTimestamp !== undefined) { + searchParams.set("to", toTimestamp.toString()); } - fetcher.load(`/resources/environments/${environment.id}/runs/tags?${searchParams}`); - }, [environment.id, searchValue, period, from?.getTime(), to?.getTime()]); + load(`/resources/environments/${environment.id}/runs/tags?${searchParams}`); + }, [environment.id, fromTimestamp, load, period, searchValue, toTimestamp]); const filtered = useMemo(() => { let items: string[] = []; @@ -1171,32 +1174,31 @@ function QueuesDropdown({ 250 ); - const filtered = useMemo(() => { - let items: { name: string; type: "custom" | "task"; value: string }[] = []; + const items: { name: string; type: "custom" | "task"; value: string }[] = []; - for (const queueName of selected ?? []) { - const queueItem = fetcher.data?.queues.find((q) => q.name === queueName); - if (!queueItem) { - if (queueName.startsWith("task/")) { - items.push({ - name: queueName.replace("task/", ""), - type: "task", - value: queueName, - }); - } else { - items.push({ - name: queueName, - type: "custom", - value: queueName, - }); - } + for (const queueName of selected ?? []) { + const queueItem = fetcher.data?.queues.find((q) => q.name === queueName); + if (!queueItem) { + if (queueName.startsWith("task/")) { + items.push({ + name: queueName.replace("task/", ""), + type: "task", + value: queueName, + }); + } else { + items.push({ + name: queueName, + type: "custom", + value: queueName, + }); } } + } - if (fetcher.data === undefined) { - return matchSorter(items, searchValue); - } - + let filtered: typeof items; + if (fetcher.data === undefined) { + filtered = matchSorter(items, searchValue); + } else { items.push( ...fetcher.data.queues.map((q) => ({ name: q.name, @@ -1205,10 +1207,10 @@ function QueuesDropdown({ })) ); - return matchSorter(Array.from(new Set(items)), searchValue, { + filtered = matchSorter(Array.from(new Set(items)), searchValue, { keys: ["name"], }); - }, [searchValue, fetcher.data]); + } return ( @@ -1318,29 +1320,27 @@ function RegionsDropdown({ const selected = values("regions").filter((v) => v !== ""); - const filtered = useMemo(() => { - type RegionItem = { masterQueue: string; name: string; location?: string }; - const items: RegionItem[] = []; + type RegionItem = { masterQueue: string; name: string; location?: string }; + const items: RegionItem[] = []; - for (const masterQueue of selected) { - const known = regions.find((r) => r.masterQueue === masterQueue); - if (!known) { - items.push({ masterQueue, name: masterQueue }); - } + for (const masterQueue of selected) { + const known = regions.find((r) => r.masterQueue === masterQueue); + if (!known) { + items.push({ masterQueue, name: masterQueue }); } + } - for (const region of regions) { - if (!items.some((i) => i.masterQueue === region.masterQueue)) { - items.push({ - masterQueue: region.masterQueue, - name: region.name, - location: region.location, - }); - } + for (const region of regions) { + if (!items.some((i) => i.masterQueue === region.masterQueue)) { + items.push({ + masterQueue: region.masterQueue, + name: region.name, + location: region.location, + }); } + } - return matchSorter(items, searchValue, { keys: ["name", "masterQueue"] }); - }, [searchValue, regions, selected.join(",")]); + const filtered = matchSorter(items, searchValue, { keys: ["name", "masterQueue"] }); return ( @@ -1566,33 +1566,31 @@ export function VersionsDropdown({ 250 ); - const filtered = useMemo(() => { - let items: { version: string; isCurrent: boolean }[] = []; + const items: { version: string; isCurrent: boolean }[] = []; - for (const version of selected ?? []) { - const versionItem = fetcher.data?.versions.find((v) => v.version === version); - if (!versionItem) { - items.push({ - version, - isCurrent: false, - }); - } - } - - if (fetcher.data === undefined) { - return matchSorter(items, searchValue); + for (const version of selected ?? []) { + const versionItem = fetcher.data?.versions.find((v) => v.version === version); + if (!versionItem) { + items.push({ + version, + isCurrent: false, + }); } + } + let filtered: typeof items; + if (fetcher.data === undefined) { + filtered = matchSorter(items, searchValue); + } else { items.push(...fetcher.data.versions); - if (searchValue === "") { - return items; - } - - return matchSorter(Array.from(new Set(items)), searchValue, { - keys: ["version"], - }); - }, [searchValue, fetcher.data]); + filtered = + searchValue === "" + ? items + : matchSorter(Array.from(new Set(items)), searchValue, { + keys: ["version"], + }); + } return ( diff --git a/apps/webapp/app/components/runs/v3/SharedFilters.tsx b/apps/webapp/app/components/runs/v3/SharedFilters.tsx index ee9225137..70832a1d0 100644 --- a/apps/webapp/app/components/runs/v3/SharedFilters.tsx +++ b/apps/webapp/app/components/runs/v3/SharedFilters.tsx @@ -495,7 +495,6 @@ function TimeDropdown({ const organization = useOptionalOrganization(); const [open, setOpen] = useState(); const { replace } = useSearchParams(); - const extraCleared = Object.fromEntries((clearParams ?? []).map((key) => [key, undefined])); const [fromValue, setFromValue] = useState(from); const [toValue, setToValue] = useState(to); @@ -569,7 +568,7 @@ function TimeDropdown({ onValueChange(values); } else { replace({ - ...extraCleared, + ...Object.fromEntries((clearParams ?? []).map((key) => [key, undefined])), period: periodToApply, cursor: undefined, direction: undefined, @@ -583,7 +582,7 @@ function TimeDropdown({ setOpen(false); onApply?.(values); }, - [maxPeriodDays, onValueChange, replace, onApply] + [clearParams, maxPeriodDays, onValueChange, replace, onApply] ); const applySelection = useCallback(() => { @@ -629,7 +628,7 @@ function TimeDropdown({ } else { // URL mode - navigate replace({ - ...extraCleared, + ...Object.fromEntries((clearParams ?? []).map((key) => [key, undefined])), period: undefined, cursor: undefined, direction: undefined, @@ -643,6 +642,7 @@ function TimeDropdown({ } }, [ activeSection, + clearParams, selectedPeriod, isCustomDurationValid, customValue, diff --git a/apps/webapp/app/components/runs/v3/TaskRunsList.tsx b/apps/webapp/app/components/runs/v3/TaskRunsList.tsx index 1b9830ec5..db92ad601 100644 --- a/apps/webapp/app/components/runs/v3/TaskRunsList.tsx +++ b/apps/webapp/app/components/runs/v3/TaskRunsList.tsx @@ -78,22 +78,20 @@ export function TaskRunsList({ } ); - const onClickShowNewRuns = () => { - const isPaginated = has("cursor") || has("direction"); - dismissNewRuns(); - if (isPaginated) { - replace({ cursor: undefined, direction: undefined }); - return; - } - revalidator.revalidate(); - }; - // Surface the banner to the top-bar button rendered by the page: keep the // ref's action current, mirror the count up, and clear it when this boundary // unmounts (e.g. the table re-suspends on a filter change). useEffect(() => { - showNewRunsRef.current = onClickShowNewRuns; - }, [onClickShowNewRuns, showNewRunsRef]); + showNewRunsRef.current = () => { + const isPaginated = has("cursor") || has("direction"); + dismissNewRuns(); + if (isPaginated) { + replace({ cursor: undefined, direction: undefined }); + return; + } + revalidator.revalidate(); + }; + }, [dismissNewRuns, has, replace, revalidator, showNewRunsRef]); useEffect(() => { onNewRunsCountChange(newRunsCount); }, [newRunsCount, onNewRunsCountChange]); diff --git a/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx b/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx index 4f134a14b..724b92d9e 100644 --- a/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx +++ b/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx @@ -155,7 +155,7 @@ export function TaskRunsTable({ } } }, - [checkboxes, runs] + [checkboxes, runs, select] ); return ( diff --git a/apps/webapp/app/components/runs/v3/WaitpointTokenFilters.tsx b/apps/webapp/app/components/runs/v3/WaitpointTokenFilters.tsx index 4eb31e617..3a7efa026 100644 --- a/apps/webapp/app/components/runs/v3/WaitpointTokenFilters.tsx +++ b/apps/webapp/app/components/runs/v3/WaitpointTokenFilters.tsx @@ -264,31 +264,30 @@ function TagsDropdown({ }; const fetcher = useFetcher(); + const { load } = fetcher; useEffect(() => { const searchParams = new URLSearchParams(); if (searchValue) { searchParams.set("name", searchValue); } - fetcher.load( + load( `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/waitpoints/tags?${searchParams}` ); - }, [searchValue]); + }, [environment.slug, load, organization.slug, project.slug, searchValue]); - const filtered = useMemo(() => { - let items: string[] = []; - if (searchValue === "") { - items = values("tags"); - } - - if (fetcher.data === undefined) { - return matchSorter(items, searchValue); - } + let items: string[] = []; + if (searchValue === "") { + items = values("tags"); + } + let filtered: string[]; + if (fetcher.data === undefined) { + filtered = matchSorter(items, searchValue); + } else { items.push(...fetcher.data.tags.map((t) => t.name)); - - return matchSorter(Array.from(new Set(items)), searchValue); - }, [searchValue, fetcher.data]); + filtered = matchSorter(Array.from(new Set(items)), searchValue); + } return ( diff --git a/apps/webapp/app/components/runs/v3/ai/AIChatMessages.tsx b/apps/webapp/app/components/runs/v3/ai/AIChatMessages.tsx index 8d144d596..96b1b094e 100644 --- a/apps/webapp/app/components/runs/v3/ai/AIChatMessages.tsx +++ b/apps/webapp/app/components/runs/v3/ai/AIChatMessages.tsx @@ -288,8 +288,8 @@ export function ToolUseRow({ tool }: { tool: ToolUse }) { // Auto-select input tab when input arrives after initial render (e.g. streaming tool calls) useEffect(() => { - if (!hasSubAgent && hasInput && activeTab === null) { - setActiveTab("input"); + if (!hasSubAgent && hasInput) { + setActiveTab((current) => current ?? "input"); } }, [hasInput, hasSubAgent]); diff --git a/apps/webapp/app/components/webhookConsole/SampleSourcePicker.tsx b/apps/webapp/app/components/webhookConsole/SampleSourcePicker.tsx index f705568b2..272cda08a 100644 --- a/apps/webapp/app/components/webhookConsole/SampleSourcePicker.tsx +++ b/apps/webapp/app/components/webhookConsole/SampleSourcePicker.tsx @@ -40,12 +40,12 @@ export function SampleSourcePicker({ }, [bodyFetcher.data, onLoad]); const manifest = listFetcher.data?.kind === "manifest" ? listFetcher.data : undefined; - const providers = manifest?.providers ?? []; - const samples = manifest?.samples ?? []; + const providers = manifest?.providers; + const samples = manifest?.samples; const listLoading = listFetcher.data === undefined; useEffect(() => { - if (providers.length === 0) return; + if (!providers || providers.length === 0) return; setSelectedProvider((current) => { if (current && providers.some((p) => p.id === current)) return current; if (endpointSource && providers.some((p) => p.id === endpointSource)) return endpointSource; @@ -54,6 +54,8 @@ export function SampleSourcePicker({ }, [providers, endpointSource]); const filteredProviders = useMemo(() => { + if (!providers) return []; + const query = producerQuery.trim().toLowerCase(); if (!query) return providers; return providers.filter( @@ -72,7 +74,7 @@ export function SampleSourcePicker({ return [...groups.entries()]; }, [filteredProviders]); - const events = samples + const events = (samples ?? []) .filter((item) => item.provider === selectedProvider) .filter((item) => { const query = topicQuery.trim().toLowerCase(); diff --git a/apps/webapp/app/hooks/useAutoRevalidate.ts b/apps/webapp/app/hooks/useAutoRevalidate.ts index 4205b03bc..4be50a34e 100644 --- a/apps/webapp/app/hooks/useAutoRevalidate.ts +++ b/apps/webapp/app/hooks/useAutoRevalidate.ts @@ -1,5 +1,5 @@ import { useRevalidator } from "@remix-run/react"; -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; type UseAutoRevalidateOptions = { interval?: number; // in milliseconds @@ -10,15 +10,17 @@ type UseAutoRevalidateOptions = { export function useAutoRevalidate(options: UseAutoRevalidateOptions = {}) { const { interval = 5000, onFocus = true, disabled = false } = options; const revalidator = useRevalidator(); + const revalidatorRef = useRef(revalidator); + revalidatorRef.current = revalidator; useEffect(() => { if (!interval || interval <= 0 || disabled) return; const intervalId = setInterval(() => { - if (revalidator.state === "loading") { + if (revalidatorRef.current.state === "loading") { return; } - revalidator.revalidate(); + revalidatorRef.current.revalidate(); }, interval); return () => clearInterval(intervalId); @@ -28,8 +30,8 @@ export function useAutoRevalidate(options: UseAutoRevalidateOptions = {}) { if (!onFocus || disabled) return; const handleFocus = () => { - if (document.visibilityState === "visible" && revalidator.state !== "loading") { - revalidator.revalidate(); + if (document.visibilityState === "visible" && revalidatorRef.current.state !== "loading") { + revalidatorRef.current.revalidate(); } }; diff --git a/apps/webapp/app/hooks/useChanged.ts b/apps/webapp/app/hooks/useChanged.ts index 650a6b378..e2f1b6215 100644 --- a/apps/webapp/app/hooks/useChanged.ts +++ b/apps/webapp/app/hooks/useChanged.ts @@ -7,20 +7,24 @@ export function useChanged( sendInitialUndefined = true ) { const previousItemId = useRef(); + const isInitialRender = useRef(true); + const actionRef = useRef(action); + const itemRef = useRef(); const item = getItem(); + const itemId = item?.id; + + actionRef.current = action; + itemRef.current = item; - //when the value changes, call the action useEffect(() => { - if (previousItemId.current !== item?.id) { - action(item); + const shouldSendInitialUndefined = + isInitialRender.current && itemId === undefined && sendInitialUndefined; + + if (previousItemId.current !== itemId || shouldSendInitialUndefined) { + actionRef.current(itemRef.current); } - previousItemId.current = item?.id; - }, [item]); - - //if sendInitialUndefined is true, call the action when the component first renders - useEffect(() => { - if (item !== undefined || sendInitialUndefined === false) return; - action(item); - }, []); + previousItemId.current = itemId; + isInitialRender.current = false; + }, [itemId, sendInitialUndefined]); } diff --git a/apps/webapp/app/hooks/useDashboardEditor.ts b/apps/webapp/app/hooks/useDashboardEditor.ts index c12a02005..30a0bae3e 100644 --- a/apps/webapp/app/hooks/useDashboardEditor.ts +++ b/apps/webapp/app/hooks/useDashboardEditor.ts @@ -206,6 +206,8 @@ export function useDashboardEditor({ const layoutDebounceRef = useRef | null>(null); const isInitializedRef = useRef(false); const currentLayoutJsonRef = useRef(JSON.stringify(initialData.layout)); + const initialDataRef = useRef(initialData); + initialDataRef.current = initialData; // Sync queue to prevent race conditions const syncQueueRef = useRef([]); @@ -217,6 +219,8 @@ export function useDashboardEditor({ widgets: initialData.widgets, }); useEffect(() => { + const { layout, widgets } = initialDataRef.current; + // Cancel any pending layout save if (layoutDebounceRef.current) { clearTimeout(layoutDebounceRef.current); @@ -229,11 +233,11 @@ export function useDashboardEditor({ // Reset state to new initial data dispatch({ type: "RESET_STATE", - payload: { layout: initialData.layout, widgets: initialData.widgets }, + payload: { layout, widgets }, }); // Update refs - currentLayoutJsonRef.current = JSON.stringify(initialData.layout); + currentLayoutJsonRef.current = JSON.stringify(layout); isInitializedRef.current = false; // Allow saves after a short delay to skip initial mount callbacks diff --git a/apps/webapp/app/hooks/useFuzzyFilter.ts b/apps/webapp/app/hooks/useFuzzyFilter.ts index 0efff8311..0ba80f5ce 100644 --- a/apps/webapp/app/hooks/useFuzzyFilter.ts +++ b/apps/webapp/app/hooks/useFuzzyFilter.ts @@ -55,7 +55,7 @@ export function useFuzzyFilter({ }), items ); - }, [items, filterText]); + }, [items, keys, filterText]); return { filterText, diff --git a/apps/webapp/app/hooks/usePostHog.ts b/apps/webapp/app/hooks/usePostHog.ts index 887151ca3..73b45f704 100644 --- a/apps/webapp/app/hooks/usePostHog.ts +++ b/apps/webapp/app/hooks/usePostHog.ts @@ -38,7 +38,7 @@ export const usePostHog = ( }, }); postHogInitialized.current = true; - }, [apiKey, uiHost, logging, user]); + }, [apiKey, uiHost, logging, debug, user]); useUserChanged((user) => { if (postHogInitialized.current === false) return; diff --git a/apps/webapp/app/hooks/useReplaceSearchParams.ts b/apps/webapp/app/hooks/useReplaceSearchParams.ts index 822217d96..6bd9d7e86 100644 --- a/apps/webapp/app/hooks/useReplaceSearchParams.ts +++ b/apps/webapp/app/hooks/useReplaceSearchParams.ts @@ -20,7 +20,7 @@ export function useReplaceSearchParams() { return s; }, navigateOpts); }, - [searchParams] + [setSearchParams] ); return { searchParams, setSearchParams, replaceSearchParam }; diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam._index/route.tsx index 00cdeb58a..1761f0e86 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam._index/route.tsx @@ -214,6 +214,7 @@ const TASK_TYPE_SEGMENTS: { ]; const PAGE_SIZE = 25; +const TASK_FILTER_KEYS = ["slug", "filePath", "triggerSource"]; export default function Page() { const organization = useOrganization(); @@ -266,7 +267,7 @@ export default function Page() { const { filteredItems } = useFuzzyFilter({ items, - keys: ["slug", "filePath", "triggerSource"], + keys: TASK_FILTER_KEYS, filterText: value("search") ?? "", }); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx index c3ff335e3..fe63e5726 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx @@ -505,17 +505,20 @@ export function BranchFilters() { const [searchParams, setSearchParams] = useSearchParams(); const { showArchived } = BranchesOptions.parse(Object.fromEntries(searchParams.entries())); - const handleArchivedChange = useCallback((checked: boolean) => { - setSearchParams((s) => { - if (checked) { - s.set("showArchived", "true"); - } else { - s.delete("showArchived"); - } - s.delete("page"); - return s; - }); - }, []); + const handleArchivedChange = useCallback( + (checked: boolean) => { + setSearchParams((s) => { + if (checked) { + s.set("showArchived", "true"); + } else { + s.delete("showArchived"); + } + s.delete("page"); + return s; + }); + }, + [setSearchParams] + ); return (
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.concurrency/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.concurrency/route.tsx index f9bddbcca..e866a4e8a 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.concurrency/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.concurrency/route.tsx @@ -644,17 +644,17 @@ function PurchaseConcurrencyModal({ // Close the panel, when we've succeeded // This is required because a redirect to the same path doesn't clear state const [searchParams, setSearchParams] = useSearchParams(); + const purchaseSucceeded = Boolean(searchParams.get("success")); const [open, setOpen] = useState(false); useEffect(() => { - const success = searchParams.get("success"); - if (success) { + if (purchaseSucceeded) { setOpen(false); setSearchParams((s) => { s.delete("success"); return s; }); } - }, [searchParams.get("success")]); + }, [purchaseSucceeded, setSearchParams]); const state = updateState({ value: amountValue, diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam/route.tsx index 15a9c3915..41977d983 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam/route.tsx @@ -300,7 +300,13 @@ export default function Page() { return () => { abortController.abort(); }; - }, [eventStream?.s2?.basin, eventStream?.s2?.stream, eventStream?.s2?.accessToken, isPending]); + }, [ + eventStream?.s2?.basin, + eventStream?.s2?.stream, + eventStream?.s2?.accessToken, + isPending, + logsDisabled, + ]); return (
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx index 1cc0c871f..54b850090 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx @@ -191,15 +191,24 @@ export default function Page() { useAutoRevalidate({ interval: autoReloadPollIntervalMs, onFocus: true }); + const selectedDeploymentShortCode = selectedDeployment?.shortCode; + // If we have a selected deployment from the version param, show it useEffect(() => { - if (selectedDeployment && !deploymentParam) { + if (selectedDeploymentShortCode && !deploymentParam) { const searchParams = new URLSearchParams(location.search); searchParams.delete("version"); searchParams.set("page", currentPage.toString()); - navigate(`${location.pathname}/${selectedDeployment.shortCode}?${searchParams.toString()}`); + navigate(`${location.pathname}/${selectedDeploymentShortCode}?${searchParams.toString()}`); } - }, [selectedDeployment, deploymentParam, location.search]); + }, [ + selectedDeploymentShortCode, + deploymentParam, + location.search, + location.pathname, + currentPage, + navigate, + ]); const currentDeployment = deployments.find((d) => d.isCurrent); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dev-branches/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dev-branches/route.tsx index 244b65e20..362e0dd91 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dev-branches/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dev-branches/route.tsx @@ -311,17 +311,20 @@ export function BranchFilters() { const [searchParams, setSearchParams] = useSearchParams(); const { showArchived } = BranchesOptions.parse(Object.fromEntries(searchParams.entries())); - const handleArchivedChange = useCallback((checked: boolean) => { - setSearchParams((s) => { - if (checked) { - s.set("showArchived", "true"); - } else { - s.delete("showArchived"); - } - s.delete("page"); - return s; - }); - }, []); + const handleArchivedChange = useCallback( + (checked: boolean) => { + setSearchParams((s) => { + if (checked) { + s.set("showArchived", "true"); + } else { + s.delete("showArchived"); + } + s.delete("page"); + return s; + }); + }, + [setSearchParams] + ); return (
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx index bce570c0a..6a87b4b14 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx @@ -10,7 +10,7 @@ import { import { Form, useActionData, useNavigate, useNavigation } from "@remix-run/react"; import { json } from "@remix-run/server-runtime"; import dotenv from "dotenv"; -import { useCallback, useState } from "react"; +import { useState } from "react"; import { redirect } from "remix-typedjson"; import invariant from "tiny-invariant"; import { z } from "zod"; @@ -570,7 +570,7 @@ function VariableFields({ insertAfter, } = useList([{ key: "", value: "" }]); - const handlePaste = useCallback((index: number, e: React.ClipboardEvent) => { + const handlePaste = (index: number, e: React.ClipboardEvent) => { const clipboardData = e.clipboardData; if (!clipboardData) return; @@ -593,7 +593,7 @@ function VariableFields({ form.insert({ name: variablesFields.name }); } insertAfter(index, rest); - }, []); + }; const fields = variablesFields.getFieldList(); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors.$fingerprint/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors.$fingerprint/route.tsx index 1946312aa..85b88a514 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors.$fingerprint/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors.$fingerprint/route.tsx @@ -100,6 +100,8 @@ export const meta = pageMeta(({ params }) => [ "Errors", ]); +const ERROR_CHART_COLORS = ["#6c5ce7", "#ec4899"]; + const emptyStringToUndefined = z.preprocess( (v) => (v === "" ? undefined : v), z.coerce.number().positive().optional() @@ -327,9 +329,9 @@ export default function Page() { } = useTypedLoaderData(); const location = useOptimisticLocation(); - const searchParams = new URLSearchParams(location.search); const errorsPath = useMemo(() => { + const searchParams = new URLSearchParams(location.search); const base = v3ErrorsPath( { slug: organizationSlug }, { slug: projectParam }, @@ -347,7 +349,7 @@ export default function Page() { } const qs = carry.toString(); return qs ? `${base}?${qs}` : base; - }, [organizationSlug, projectParam, envParam, searchParams.toString()]); + }, [organizationSlug, projectParam, envParam, location.search]); const alertsHref = useMemo(() => { const params = new URLSearchParams(location.search); @@ -856,7 +858,6 @@ function ActivityChart({ activity: ErrorGroupActivity; versions: ErrorGroupActivityVersions; }) { - const ERROR_CHART_COLORS = ["#6c5ce7", "#ec4899"]; const colors = useMemo( () => versions.map((_, i) => ERROR_CHART_COLORS[i % ERROR_CHART_COLORS.length]), [versions] diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors._index/route.tsx index 1825aeb7f..8d8adcf2c 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors._index/route.tsx @@ -567,9 +567,9 @@ function ErrorGroupRow({ envParam: string; }) { const location = useOptimisticLocation(); - const searchParams = new URLSearchParams(location.search); const errorPath = useMemo(() => { + const searchParams = new URLSearchParams(location.search); const base = v3ErrorPath( { slug: organizationSlug }, { slug: projectParam }, @@ -588,7 +588,7 @@ function ErrorGroupRow({ } const qs = carry.toString(); return qs ? `${base}?${qs}` : base; - }, [organizationSlug, projectParam, envParam, errorGroup.fingerprint, searchParams.toString()]); + }, [organizationSlug, projectParam, envParam, errorGroup.fingerprint, location.search]); const errorMessage = `${errorGroup.errorMessage}`; diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.models._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.models._index/route.tsx index 27ed07ec4..36ccdbff0 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.models._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.models._index/route.tsx @@ -756,6 +756,14 @@ function CompareDialog({ const project = useProject(); const environment = useEnvironment(); const fetcher = useFetcher(); + const loadComparison = fetcher.load; + const wasOpenRef = useRef(false); + const canCompare = models.length >= 2; + const comparisonPath = `${v3ModelComparePath( + organization, + project, + environment + )}?models=${models.join(",")}`; const comparison = (fetcher.data as { comparison?: ModelComparisonItem[] } | undefined) ?.comparison; @@ -764,13 +772,14 @@ function CompareDialog({ [comparison, models, catalogModels] ); - // eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally only fires on open; other deps are stable per dialog mount useEffect(() => { - if (open && models.length >= 2) { - const params = models.join(","); - fetcher.load(`${v3ModelComparePath(organization, project, environment)}?models=${params}`); + const wasOpen = wasOpenRef.current; + wasOpenRef.current = open; + + if (open && !wasOpen && canCompare) { + loadComparison(comparisonPath); } - }, [open]); + }, [open, canCompare, comparisonPath, loadComparison]); return ( diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx index be4b2b4a8..32ce8d715 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx @@ -22,7 +22,7 @@ import { } from "@trigger.dev/core/v3"; import type { RuntimeEnvironmentType } from "@trigger.dev/database"; import { motion } from "framer-motion"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useHotkeys } from "react-hotkeys-hook"; import { redirect } from "remix-typedjson"; import { ChevronExtraSmallDown } from "~/assets/icons/ChevronExtraSmallDown"; @@ -1966,10 +1966,10 @@ function SearchField({ onChange }: { onChange: (value: string) => void }) { onChange(text); }, 250); - const updateValue = useCallback((next: string) => { + const updateValue = (next: string) => { setValue(next); updateFilterText(next); - }, []); + }; return ; } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.integrations/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.integrations/route.tsx index f045467ad..3d848113c 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.integrations/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.integrations/route.tsx @@ -220,7 +220,14 @@ export default function IntegrationsSettingsPage() { const nextUrl = searchParams.get("next"); const [isModalOpen, setIsModalOpen] = useState(false); const vercelFetcher = useTypedFetcher(); + const loadVercelOnboarding = vercelFetcher.load; const onboardingData = vercelFetcher.data?.onboardingData ?? null; + const hasVercelFetcherData = vercelFetcher.data !== undefined; + const vercelOnboardingPath = `${vercelResourcePath( + organization.slug, + project.slug, + environment.slug + )}?vercelOnboarding=true`; // Helper to open modal and ensure query param is present const openVercelOnboarding = useCallback(() => { @@ -256,15 +263,9 @@ export default function IntegrationsSettingsPage() { if (!isModalOpen) { openVercelOnboarding(); } - } else if (vercelFetcher.state === "idle" && vercelFetcher.data === undefined) { + } else if (vercelFetcher.state === "idle" && !hasVercelFetcherData) { // Load onboarding data - vercelFetcher.load( - `${vercelResourcePath( - organization.slug, - project.slug, - environment.slug - )}?vercelOnboarding=true` - ); + loadVercelOnboarding(vercelOnboardingPath); } } else if (!hasQueryParam && isModalOpen) { // Query param removed but modal is open, close modal @@ -273,14 +274,13 @@ export default function IntegrationsSettingsPage() { }, [ hasQueryParam, vercelIntegrationEnabled, - organization.slug, - project.slug, - environment.slug, onboardingData, - vercelFetcher.data, + hasVercelFetcherData, vercelFetcher.state, isModalOpen, openVercelOnboarding, + loadVercelOnboarding, + vercelOnboardingPath, ]); // Ensure modal stays open when query param is present (even after data reloads) @@ -301,7 +301,7 @@ export default function IntegrationsSettingsPage() { openVercelOnboarding(); } } - }, [hasQueryParam, vercelFetcher.data, vercelFetcher.state, isModalOpen, openVercelOnboarding]); + }, [hasQueryParam, onboardingData, vercelFetcher.state, isModalOpen, openVercelOnboarding]); // Track if we're waiting for data from button click (not query param) const waitingForButtonClickRef = useRef(false); @@ -322,19 +322,11 @@ export default function IntegrationsSettingsPage() { } else { // Need to load data first, mark that we're waiting for button click waitingForButtonClickRef.current = true; - vercelFetcher.load( - `${vercelResourcePath( - organization.slug, - project.slug, - environment.slug - )}?vercelOnboarding=true` - ); + loadVercelOnboarding(vercelOnboardingPath); } }, [ - organization.slug, - project.slug, - environment.slug, - vercelFetcher, + loadVercelOnboarding, + vercelOnboardingPath, onboardingData, setSearchParams, hasQueryParam, @@ -415,12 +407,8 @@ export default function IntegrationsSettingsPage() { nextUrl={nextUrl ?? undefined} vercelManageAccessUrl={vercelFetcher.data?.vercelManageAccessUrl} onDataReload={(vercelEnvironmentId) => { - vercelFetcher.load( - `${vercelResourcePath( - organization.slug, - project.slug, - environment.slug - )}?vercelOnboarding=true${ + loadVercelOnboarding( + `${vercelOnboardingPath}${ vercelEnvironmentId ? `&vercelEnvironmentId=${encodeURIComponent(vercelEnvironmentId)}` : "" diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx index 1e377cd45..d56396dfb 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx @@ -553,6 +553,7 @@ function CreateScheduleSheet({ onClose: () => void; }) { const fetcher = useTypedFetcher(); + const loadScheduleForm = fetcher.load; // Embedded create — stays on this page via `_format=json`. const createFetcher = useFetcher<{ ok: boolean; message?: string }>(); const toast = useToast(); @@ -563,8 +564,8 @@ function CreateScheduleSheet({ const newPath = v3NewSchedulePath(organization, project, environment); useEffect(() => { - if (open) fetcher.load(newPath); - }, [open, newPath]); + if (open) loadScheduleForm(newPath); + }, [open, newPath, loadScheduleForm]); // Toast + close + revalidate so the new schedule appears. useEffect(() => { @@ -624,7 +625,9 @@ function ScheduleSheet({ onClose: () => void; }) { const detailFetcher = useTypedFetcher(); + const loadScheduleDetail = detailFetcher.load; const editFetcher = useTypedFetcher(); + const loadScheduleEditor = editFetcher.load; // Embedded enable/disable — stays in the sheet via `_format=json`. const activeToggleFetcher = useFetcher<{ ok: boolean; active?: boolean; message?: string }>(); // Embedded update submission — same idea. @@ -652,12 +655,12 @@ function ScheduleSheet({ }, [openScheduleId]); useEffect(() => { - if (detailPath) detailFetcher.load(detailPath); - }, [detailPath]); + if (detailPath) loadScheduleDetail(detailPath); + }, [detailPath, loadScheduleDetail]); useEffect(() => { - if (mode === "edit" && editPath) editFetcher.load(editPath); - }, [mode, editPath]); + if (mode === "edit" && editPath) loadScheduleEditor(editPath); + }, [mode, editPath, loadScheduleEditor]); // Reload inspector data so Enable/Disable label flips; revalidate the // route loader so the sidebar's list/Overview stay in sync; toast on error. @@ -667,12 +670,19 @@ function ScheduleSheet({ if (handledToggleRef.current === data) return; handledToggleRef.current = data; if (data.ok) { - if (detailPath) detailFetcher.load(detailPath); + if (detailPath) loadScheduleDetail(detailPath); revalidator.revalidate(); } else if (data.message) { toast.error(data.message); } - }, [activeToggleFetcher.state, activeToggleFetcher.data, detailPath, toast, revalidator]); + }, [ + activeToggleFetcher.state, + activeToggleFetcher.data, + detailPath, + toast, + revalidator, + loadScheduleDetail, + ]); // Toast + back to inspect + reload + revalidate so both the inspector // and the sidebar reflect the update. @@ -684,12 +694,12 @@ function ScheduleSheet({ if (data.ok) { toast.success(data.message ?? "Schedule updated"); setMode("inspect"); - if (detailPath) detailFetcher.load(detailPath); + if (detailPath) loadScheduleDetail(detailPath); revalidator.revalidate(); } else if (data.message) { toast.error(data.message); } - }, [updateFetcher.state, updateFetcher.data, detailPath, toast, revalidator]); + }, [updateFetcher.state, updateFetcher.data, detailPath, toast, revalidator, loadScheduleDetail]); // Toast + close + revalidate so the deleted row disappears. useEffect(() => { @@ -792,7 +802,7 @@ function ScheduledTaskDetailSidebar({ if (a.type === b.type) return 0; return a.type === "DECLARATIVE" ? -1 : 1; }); - }, [scheduleList?.schedules]); + }, [scheduleList]); const firstSchedule = sortedSchedules[0]; const [activeTab, setActiveTab] = useState<"overview" | "schedules">("overview"); return ( diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/AIPayloadTabContent.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/AIPayloadTabContent.tsx index 9c1499ec4..65f136de6 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/AIPayloadTabContent.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/AIPayloadTabContent.tsx @@ -63,6 +63,25 @@ export function AIPayloadTabContent({ const submitGeneration = useCallback( async (queryPrompt: string) => { + const processStreamEvent = (event: StreamEventType) => { + switch (event.type) { + case "thinking": + setThinking((prev) => prev + event.content); + break; + case "result": + if (event.success) { + onPayloadGenerated(event.payload); + setLastPayload(event.payload); + setPrompt(""); + setLastResult("success"); + } else { + setError(event.error); + setLastResult("error"); + } + break; + } + }; + if (!queryPrompt.trim() || isLoadingRef.current) return; isLoadingRef.current = true; @@ -168,31 +187,10 @@ export function AIPayloadTabContent({ isAgent, payloadKind, providerSource, + onPayloadGenerated, ] ); - const processStreamEvent = useCallback( - (event: StreamEventType) => { - switch (event.type) { - case "thinking": - setThinking((prev) => prev + event.content); - break; - case "result": - if (event.success) { - onPayloadGenerated(event.payload); - setLastPayload(event.payload); - setPrompt(""); - setLastResult("success"); - } else { - setError(event.error); - setLastResult("error"); - } - break; - } - }, - [onPayloadGenerated] - ); - const handleSubmit = useCallback( (e?: React.FormEvent) => { e?.preventDefault(); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx index 5697caccc..7ead145d0 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx @@ -297,6 +297,7 @@ export default function Page() { const params = useParams(); const queueFetcher = useFetcher(); + const { load: loadQueues } = queueFetcher; useEffect(() => { if (result.foundTask && params.organizationSlug && params.projectParam && params.envParam) { @@ -304,13 +305,13 @@ export default function Page() { searchParams.set("type", "custom"); searchParams.set("per_page", "100"); - queueFetcher.load( + loadQueues( `/resources/orgs/${params.organizationSlug}/projects/${params.projectParam}/env/${ params.envParam }/queues?${searchParams.toString()}` ); } - }, [result.foundTask, params.organizationSlug, params.projectParam, params.envParam]); + }, [result.foundTask, params.organizationSlug, params.projectParam, params.envParam, loadQueues]); const defaultTaskQueue = result.foundTask && "queue" in result ? result.queue : undefined; const queues = useMemo(() => { @@ -1758,6 +1759,7 @@ function CreateTemplateModal({ }) { const submit = useSubmit(); const [isModalOpen, setIsModalOpen] = useState(false); + const successMessageTimeoutRef = useRef>(); const actionData = useActionData(); const lastSubmission = @@ -1772,11 +1774,19 @@ function CreateTemplateModal({ if (lastSubmission && "success" in lastSubmission && lastSubmission.success === true) { setIsModalOpen(false); setShowCreatedSuccessMessage(true); - setTimeout(() => { + clearTimeout(successMessageTimeoutRef.current); + successMessageTimeoutRef.current = setTimeout(() => { setShowCreatedSuccessMessage(false); }, 2000); } - }, [lastSubmission]); + }, [lastSubmission, setShowCreatedSuccessMessage]); + + useEffect(() => { + return () => { + clearTimeout(successMessageTimeoutRef.current); + setShowCreatedSuccessMessage(false); + }; + }, [setShowCreatedSuccessMessage]); const [ form, diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test/route.tsx index 8aa406c59..c645b5f59 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test/route.tsx @@ -41,6 +41,8 @@ import { testAgentPageContext } from "~/components/dashboard-agent/suggested-pro import { WhenAgentUnavailable } from "~/components/dashboard-agent/WhenAgentUnavailable"; import type { Handle } from "~/utils/handle"; +const TASK_FILTER_KEYS = ["taskIdentifier", "friendlyId", "id", "filePath", "triggerSource"]; + export const handle: Handle = { agentPageContext: (data) => testAgentPageContext(data), }; @@ -138,7 +140,7 @@ function TaskSelector({ }) { const { filterText, setFilterText, filteredItems } = useFuzzyFilter({ items: tasks, - keys: ["taskIdentifier", "friendlyId", "id", "filePath", "triggerSource"], + keys: TASK_FILTER_KEYS, }); const hasTaskInEnvironment = activeTaskIdentifier ? tasks.some((t) => t.taskIdentifier === activeTaskIdentifier) diff --git a/apps/webapp/app/routes/admin.feature-flags.tsx b/apps/webapp/app/routes/admin.feature-flags.tsx index 4cb2f0cae..c4d2dca6a 100644 --- a/apps/webapp/app/routes/admin.feature-flags.tsx +++ b/apps/webapp/app/routes/admin.feature-flags.tsx @@ -178,7 +178,7 @@ export default function AdminFeatureFlagsRoute() { // Only track editable flags in state const editable: Record = {}; for (const [key, value] of Object.entries(loaded)) { - if (!isLocked(key)) { + if (unlocked || !GLOBAL_LOCKED_FLAGS.includes(key)) { editable[key] = value; } } diff --git a/apps/webapp/app/routes/admin.queue-metrics.tsx b/apps/webapp/app/routes/admin.queue-metrics.tsx index 6deaedce6..3624d05e0 100644 --- a/apps/webapp/app/routes/admin.queue-metrics.tsx +++ b/apps/webapp/app/routes/admin.queue-metrics.tsx @@ -1,6 +1,6 @@ import { useFetcher, useRevalidator } from "@remix-run/react"; import { json } from "@remix-run/server-runtime"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; import { Button } from "~/components/primitives/Buttons"; @@ -57,11 +57,12 @@ export const action = dashboardAction( export default function AdminQueueMetricsRoute() { const { controls, streams } = useTypedLoaderData(); const saveFetcher = useFetcher<{ success?: boolean; error?: string }>(); - const revalidator = useRevalidator(); + const { revalidate, state: revalidatorState } = useRevalidator(); const [enabled, setEnabled] = useState(controls.enabled); const [sampleRate, setSampleRate] = useState(String(controls.sampleRate)); const [error, setError] = useState(null); + const handledSaveDataRef = useRef(saveFetcher.data); useEffect(() => { setEnabled(controls.enabled); @@ -69,13 +70,18 @@ export default function AdminQueueMetricsRoute() { }, [controls.enabled, controls.sampleRate]); useEffect(() => { - if (saveFetcher.data?.success) { + if (!saveFetcher.data || handledSaveDataRef.current === saveFetcher.data) { + return; + } + handledSaveDataRef.current = saveFetcher.data; + + if (saveFetcher.data.success) { setError(null); - revalidator.revalidate(); - } else if (saveFetcher.data?.error) { + revalidate(); + } else if (saveFetcher.data.error) { setError(saveFetcher.data.error); } - }, [saveFetcher.data]); + }, [saveFetcher.data, revalidate]); const isSaving = saveFetcher.state === "submitting"; @@ -142,8 +148,8 @@ export default function AdminQueueMetricsRoute() { Stream health{totalLag > 0 ? ` (lag ${totalLag})` : ""} diff --git a/apps/webapp/app/routes/resources.incidents.tsx b/apps/webapp/app/routes/resources.incidents.tsx index 84f8dfc1d..782477de2 100644 --- a/apps/webapp/app/routes/resources.incidents.tsx +++ b/apps/webapp/app/routes/resources.incidents.tsx @@ -3,6 +3,7 @@ import { json } from "@remix-run/node"; import { useFetcher, type ShouldRevalidateFunction } from "@remix-run/react"; import { motion } from "framer-motion"; import { useEffect, useRef } from "react"; +import { useLatest } from "react-use"; import { LinkButton } from "~/components/primitives/Buttons"; import { Paragraph } from "~/components/primitives/Paragraph"; import { Popover, PopoverContent, PopoverTrigger } from "~/components/primitives/Popover"; @@ -41,26 +42,32 @@ const POLL_INTERVAL_MS = 60_000; export function useIncidentStatus() { const { isManagedCloud } = useFeatures(); const fetcher = useFetcher(); + const { load, state } = fetcher; + const stateRef = useLatest(state); const hasInitiallyFetched = useRef(false); useEffect(() => { if (!isManagedCloud) return; // Initial fetch on mount - if (!hasInitiallyFetched.current && fetcher.state === "idle") { + if (!hasInitiallyFetched.current && state === "idle") { hasInitiallyFetched.current = true; - fetcher.load("/resources/incidents"); + load("/resources/incidents"); } + }, [isManagedCloud, load, state]); + + useEffect(() => { + if (!isManagedCloud) return; // Poll every 60 seconds const interval = setInterval(() => { - if (fetcher.state === "idle") { - fetcher.load("/resources/incidents"); + if (stateRef.current === "idle") { + load("/resources/incidents"); } }, POLL_INTERVAL_MS); return () => clearInterval(interval); - }, [isManagedCloud]); + }, [isManagedCloud, load, stateRef]); return { status: fetcher.data?.status ?? "operational", diff --git a/apps/webapp/app/routes/resources.metric.tsx b/apps/webapp/app/routes/resources.metric.tsx index af98a54b8..7fcc1a872 100644 --- a/apps/webapp/app/routes/resources.metric.tsx +++ b/apps/webapp/app/routes/resources.metric.tsx @@ -202,12 +202,23 @@ export function MetricWidget({ const [isLoading, setIsLoading] = useState(false); const abortControllerRef = useRef(null); const isDirtyRef = useRef(false); + const submitRef = useRef<() => void>(() => {}); // Track the latest props so the submit callback always uses fresh values // without needing to be recreated (which would cause useInterval to re-register listeners). const propsRef = useRef(props); propsRef.current = props; + // Track visibility so we only fetch for on-screen widgets. + // When a widget scrolls into view and has no data yet, trigger a load. + const { ref: visibilityRef, isVisibleRef } = useElementVisibility({ + onVisibilityChange: (visible) => { + if (visible && (!response || isDirtyRef.current)) { + submitRef.current(); + } + }, + }); + const submit = useCallback(() => { if (!isVisibleRef.current) { isDirtyRef.current = true; @@ -251,17 +262,8 @@ export function MetricWidget({ setIsLoading(false); } }); - }, []); - - // Track visibility so we only fetch for on-screen widgets. - // When a widget scrolls into view and has no data yet, trigger a load. - const { ref: visibilityRef, isVisibleRef } = useElementVisibility({ - onVisibilityChange: (visible) => { - if (visible && (!response || isDirtyRef.current)) { - submit(); - } - }, - }); + }, [isVisibleRef]); + submitRef.current = submit; // Clean up on unmount useEffect(() => { @@ -273,24 +275,12 @@ export function MetricWidget({ // Reload periodically and on focus (onLoad: false — the useEffect below handles initial load) useInterval({ interval: refreshIntervalMs, callback: submit, onLoad: false }); + const reloadKey = JSON.stringify(props); + // Reload on mount and when query, time period, or filters change useEffect(() => { submit(); - }, [ - submit, - props.query, - props.from, - props.to, - props.period, - props.scope, - JSON.stringify(props.taskIdentifiers), - JSON.stringify(props.queues), - JSON.stringify(props.responseModels), - JSON.stringify(props.promptSlugs), - JSON.stringify(props.promptVersions), - JSON.stringify(props.operations), - JSON.stringify(props.providers), - ]); + }, [submit, reloadKey]); const data = response?.success ? { rows: response.data.rows, columns: response.data.columns } diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github.tsx index 4840a424d..fb12f772f 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github.tsx @@ -1077,6 +1077,7 @@ export function GitHubSettingsPanel({ layout?: "settings" | "compact"; }) { const fetcher = useTypedFetcher(); + const { load } = fetcher; const location = useLocation(); // Preserve current search params (e.g. origin=marketplace, next=...) but strip @@ -1088,8 +1089,8 @@ export function GitHubSettingsPanel({ return search ? `${location.pathname}?${search}` : location.pathname; })(); useEffect(() => { - fetcher.load(gitHubResourcePath(organizationSlug, projectSlug, environmentSlug)); - }, [organizationSlug, projectSlug, environmentSlug]); + load(gitHubResourcePath(organizationSlug, projectSlug, environmentSlug)); + }, [organizationSlug, projectSlug, environmentSlug, load]); const data = fetcher.data; diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx index bcfabb844..a8c59fe87 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx @@ -229,14 +229,15 @@ export function SpanView({ const project = useProject(); const environment = useEnvironment(); const fetcher = useTypedFetcher(); + const { load } = fetcher; useEffect(() => { if (spanId === undefined) return; const url = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${ environment.slug }/runs/${runParam}/spans/${spanId}${linkedRunId ? `?linkedRunId=${linkedRunId}` : ""}`; - fetcher.load(url); - }, [organization.slug, project.slug, environment.slug, runParam, spanId, linkedRunId]); + load(url); + }, [organization.slug, project.slug, environment.slug, runParam, spanId, linkedRunId, load]); if (spanId === undefined) { return null; diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx index 5bef068b9..d275e8e7b 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx @@ -255,22 +255,17 @@ export function CreateBulkActionInspector({ const project = useProject(); const environment = useEnvironment(); const fetcher = useTypedFetcher(); + const { load } = fetcher; const { value, replace, del } = useSearchParams(); - const [action, setAction] = useState( - bulkActionActionFromString(value("action")) - ); + const action = bulkActionActionFromString(value("action")); const location = useOptimisticLocation(); const user = useUser(); useEffect(() => { - fetcher.load( + load( `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/runs/bulkaction${location.search}` ); - }, [organization.id, project.id, environment.id, location.search]); - - useEffect(() => { - setAction(bulkActionActionFromString(value("action"))); - }, [value("action")]); + }, [organization.slug, project.slug, environment.slug, location.search, load]); const mode = bulkActionModeFromString(value("mode")); diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx index 70e727916..b8df543c8 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx @@ -695,7 +695,6 @@ function ConnectedVercelProjectForm({ const lastSubmission = useActionData() as any; const navigation = useNavigation(); - const [hasConfigChanges, setHasConfigChanges] = useState(false); const [configValues, setConfigValues] = useState({ atomicBuilds: connectedProject.integrationData.config.atomicBuilds ?? [], pullEnvVarsBeforeBuild: connectedProject.integrationData.config.pullEnvVarsBeforeBuild ?? [], @@ -712,35 +711,24 @@ function ConnectedVercelProjectForm({ connectedProject.integrationData.config.vercelStagingEnvironment ?? null; const originalAutoPromote = connectedProject.integrationData.config.autoPromote ?? true; - useEffect(() => { - const atomicBuildsChanged = - JSON.stringify([...configValues.atomicBuilds].sort()) !== - JSON.stringify([...originalAtomicBuilds].sort()); - const pullEnvVarsChanged = - JSON.stringify([...configValues.pullEnvVarsBeforeBuild].sort()) !== - JSON.stringify([...originalPullEnvVars].sort()); - const discoverEnvVarsChanged = - JSON.stringify([...configValues.discoverEnvVars].sort()) !== - JSON.stringify([...originalDiscoverEnvVars].sort()); - const stagingEnvChanged = - configValues.vercelStagingEnvironment?.environmentId !== originalStagingEnv?.environmentId; - const autoPromoteChanged = configValues.autoPromote !== originalAutoPromote; - - setHasConfigChanges( - atomicBuildsChanged || - pullEnvVarsChanged || - discoverEnvVarsChanged || - stagingEnvChanged || - autoPromoteChanged - ); - }, [ - configValues, - originalAtomicBuilds, - originalPullEnvVars, - originalDiscoverEnvVars, - originalStagingEnv, - originalAutoPromote, - ]); + const atomicBuildsChanged = + JSON.stringify([...configValues.atomicBuilds].sort()) !== + JSON.stringify([...originalAtomicBuilds].sort()); + const pullEnvVarsChanged = + JSON.stringify([...configValues.pullEnvVarsBeforeBuild].sort()) !== + JSON.stringify([...originalPullEnvVars].sort()); + const discoverEnvVarsChanged = + JSON.stringify([...configValues.discoverEnvVars].sort()) !== + JSON.stringify([...originalDiscoverEnvVars].sort()); + const stagingEnvChanged = + configValues.vercelStagingEnvironment?.environmentId !== originalStagingEnv?.environmentId; + const autoPromoteChanged = configValues.autoPromote !== originalAutoPromote; + const hasConfigChanges = + atomicBuildsChanged || + pullEnvVarsChanged || + discoverEnvVarsChanged || + stagingEnvChanged || + autoPromoteChanged; const [configForm, _fields] = useForm({ id: "update-vercel-config", @@ -1121,6 +1109,7 @@ function VercelSettingsPanel({ isLoadingVercelData?: boolean; }) { const fetcher = useTypedFetcher(); + const { load } = fetcher; const _location = useLocation(); const data = fetcher.data; const [hasError, _setHasError] = useState(false); @@ -1128,7 +1117,7 @@ function VercelSettingsPanel({ useEffect(() => { if (!data?.authInvalid && !hasError && !data && !hasFetched) { - fetcher.load(vercelResourcePath(organizationSlug, projectSlug, environmentSlug)); + load(vercelResourcePath(organizationSlug, projectSlug, environmentSlug)); setHasFetched(true); } }, [ @@ -1139,6 +1128,7 @@ function VercelSettingsPanel({ hasError, data, hasFetched, + load, ]); if (hasError) { diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.$waitpointFriendlyId.complete/route.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.$waitpointFriendlyId.complete/route.tsx index c202e2d89..c02a48246 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.$waitpointFriendlyId.complete/route.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.$waitpointFriendlyId.complete/route.tsx @@ -5,7 +5,7 @@ import type { WaitpointTokenStatus } from "@trigger.dev/core/v3"; import { stringifyIO, timeoutError } from "@trigger.dev/core/v3"; import { WaitpointId } from "@trigger.dev/core/v3/isomorphic"; import type { Waitpoint } from "@trigger.dev/database"; -import { useCallback, useRef } from "react"; +import { useRef } from "react"; import { z } from "zod"; import { AnimatedHourglassIcon } from "~/assets/icons/AnimatedHourglassIcon"; import { JSONEditor } from "~/components/code/JSONEditor"; @@ -321,25 +321,22 @@ function CompleteManualWaitpointForm({ waitpoint }: { waitpoint: { id: string } const currentJson = useRef("{\n\n}"); const formAction = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/waitpoints/${waitpoint.id}/complete`; - const submitForm = useCallback( - (e: React.FormEvent) => { - const formData = new FormData(e.currentTarget); - const data: Record = { - type: formData.get("type") as string, - failureRedirect: formData.get("failureRedirect") as string, - successRedirect: formData.get("successRedirect") as string, - }; + const submitForm = (e: React.FormEvent) => { + const formData = new FormData(e.currentTarget); + const data: Record = { + type: formData.get("type") as string, + failureRedirect: formData.get("failureRedirect") as string, + successRedirect: formData.get("successRedirect") as string, + }; - data.payload = currentJson.current; + data.payload = currentJson.current; - submit(data, { - action: formAction, - method: "post", - }); - e.preventDefault(); - }, - [currentJson] - ); + submit(data, { + action: formAction, + method: "post", + }); + e.preventDefault(); + }; return (
(); const [text, setText] = useState(""); + const onSuccessRef = useRef(onSuccess); + onSuccessRef.current = onSuccess; const organization = useOrganization(); const project = useProject(); const isLoading = fetcher.state !== "idle"; @@ -66,11 +68,11 @@ export function AIGeneratedCronField({ onSuccess }: AIGeneratedCronFieldProps) { useEffect(() => { if (resultData?.cron !== undefined) { - onSuccess(resultData.cron); + onSuccessRef.current(resultData.cron); } }, [resultData?.cron]); - const submit = useCallback(async (value: string) => { + const submit = (value: string) => { fetcher.submit( { message: value }, { @@ -79,7 +81,7 @@ export function AIGeneratedCronField({ onSuccess }: AIGeneratedCronFieldProps) { encType: "application/json", } ); - }, []); + }; return (
diff --git a/apps/webapp/app/routes/resources.platform-changelogs.tsx b/apps/webapp/app/routes/resources.platform-changelogs.tsx index ed62de3c1..17ddcf8d2 100644 --- a/apps/webapp/app/routes/resources.platform-changelogs.tsx +++ b/apps/webapp/app/routes/resources.platform-changelogs.tsx @@ -2,6 +2,7 @@ import { json } from "@remix-run/node"; import type { LoaderFunctionArgs } from "@remix-run/node"; import { useFetcher, type ShouldRevalidateFunction } from "@remix-run/react"; import { useEffect, useRef } from "react"; +import { useLatest } from "react-use"; import { logger } from "~/services/logger.server"; import { requireUserId } from "~/services/session.server"; import { getRecentChangelogs, verifyOrgMembership } from "~/services/platformNotifications.server"; @@ -42,28 +43,31 @@ const POLL_INTERVAL_MS = 60_000; export function useRecentChangelogs(organizationId?: string, projectId?: string) { const fetcher = useFetcher(); + const { load, state } = fetcher; + const stateRef = useLatest(state); const lastLoadedUrl = useRef(null); + const params = new URLSearchParams(); + if (organizationId) params.set("organizationId", organizationId); + if (projectId) params.set("projectId", projectId); + const qs = params.toString(); + const url = `/resources/platform-changelogs${qs ? `?${qs}` : ""}`; useEffect(() => { - const params = new URLSearchParams(); - if (organizationId) params.set("organizationId", organizationId); - if (projectId) params.set("projectId", projectId); - const qs = params.toString(); - const url = `/resources/platform-changelogs${qs ? `?${qs}` : ""}`; - - if (lastLoadedUrl.current !== url && fetcher.state === "idle") { + if (lastLoadedUrl.current !== url && state === "idle") { lastLoadedUrl.current = url; - fetcher.load(url); + load(url); } + }, [load, state, url]); + useEffect(() => { const interval = setInterval(() => { - if (fetcher.state === "idle") { - fetcher.load(url); + if (stateRef.current === "idle") { + load(url); } }, POLL_INTERVAL_MS); return () => clearInterval(interval); - }, [organizationId, projectId]); + }, [load, stateRef, url]); return { changelogs: fetcher.data?.changelogs ?? [], diff --git a/apps/webapp/app/routes/resources.platform-notifications.tsx b/apps/webapp/app/routes/resources.platform-notifications.tsx index bf3dfe41e..afa17181a 100644 --- a/apps/webapp/app/routes/resources.platform-notifications.tsx +++ b/apps/webapp/app/routes/resources.platform-notifications.tsx @@ -2,6 +2,7 @@ import { json } from "@remix-run/node"; import type { LoaderFunctionArgs } from "@remix-run/node"; import { useFetcher, type ShouldRevalidateFunction } from "@remix-run/react"; import { useEffect, useRef } from "react"; +import { useLatest } from "react-use"; import { requireUserId } from "~/services/session.server"; import { getActivePlatformNotifications, @@ -41,24 +42,27 @@ const POLL_INTERVAL_MS = 60000; // 1 minute export function usePlatformNotifications(organizationId: string, projectId: string) { const fetcher = useFetcher(); + const { load, state } = fetcher; + const stateRef = useLatest(state); const lastLoadedUrl = useRef(null); + const url = `/resources/platform-notifications?organizationId=${encodeURIComponent(organizationId)}&projectId=${encodeURIComponent(projectId)}`; useEffect(() => { - const url = `/resources/platform-notifications?organizationId=${encodeURIComponent(organizationId)}&projectId=${encodeURIComponent(projectId)}`; - - if (lastLoadedUrl.current !== url && fetcher.state === "idle") { + if (lastLoadedUrl.current !== url && state === "idle") { lastLoadedUrl.current = url; - fetcher.load(url); + load(url); } + }, [load, state, url]); + useEffect(() => { const interval = setInterval(() => { - if (fetcher.state === "idle") { - fetcher.load(url); + if (stateRef.current === "idle") { + load(url); } }, POLL_INTERVAL_MS); return () => clearInterval(interval); - }, [organizationId, projectId]); + }, [load, stateRef, url]); return { notifications: fetcher.data?.notifications ?? [], diff --git a/apps/webapp/app/routes/storybook.ai-agent/route.tsx b/apps/webapp/app/routes/storybook.ai-agent/route.tsx index f69a43c57..6220aec54 100644 --- a/apps/webapp/app/routes/storybook.ai-agent/route.tsx +++ b/apps/webapp/app/routes/storybook.ai-agent/route.tsx @@ -795,7 +795,6 @@ function AgentOrb({ colors = AGENT_ORB_PALETTE, restColor = "#ffffff", colored = true, - restShape = "triangle", dotCount = 21, orbitCount = 3, particlesPerOrbit = 3, @@ -824,7 +823,7 @@ function AgentOrb({ () => buildDotSpecs(effDotCount, orbitCount, effParticles), [effDotCount, orbitCount, effParticles] ); - const restPoints = useMemo(() => triangleOutline(effDotCount), [restShape, effDotCount]); + const restPoints = useMemo(() => triangleOutline(effDotCount), [effDotCount]); useEffect(() => { activeRef.current = active; @@ -846,7 +845,7 @@ function AgentOrb({ restPoints, dotSpecs, orbitGeoms, - paletteRgb: colors.map(hexToRgb), + paletteRgb: colorsKey.split(",").map(hexToRgb), restRgb: hexToRgb(restColor), colored, radiusScale, @@ -1129,7 +1128,7 @@ function AgentLogoMorph({ outline: logoOutlinePoints(dotCount), dotSpecs: buildDotSpecs(dotCount, orbitCount, particlesPerOrbit), orbitGeoms: buildOrbitGeoms(orbitCount), - paletteRgb: colors.map(hexToRgb), + paletteRgb: colorsKey.split(",").map(hexToRgb), logoRgb: hexToRgb(logoColor), }; diff --git a/apps/webapp/app/routes/storybook.filter/route.tsx b/apps/webapp/app/routes/storybook.filter/route.tsx index ed5b65ed0..6658ae695 100644 --- a/apps/webapp/app/routes/storybook.filter/route.tsx +++ b/apps/webapp/app/routes/storybook.filter/route.tsx @@ -149,10 +149,13 @@ const statuses = allTaskRunStatuses.map((status) => ({ function Statuses({ trigger, clearSearchValue, shortcut, searchValue, setFilterType }: MenuProps) { const { values, replace } = useSearchParams(); - const handleChange = useCallback((values: string[]) => { - clearSearchValue(); - replace({ status: values }); - }, []); + const handleChange = useCallback( + (values: string[]) => { + clearSearchValue(); + replace({ status: values }); + }, + [clearSearchValue, replace] + ); const filtered = useMemo(() => { return statuses.filter((item) => item.title.toLowerCase().includes(searchValue.toLowerCase())); @@ -205,10 +208,13 @@ function Environments({ }: MenuProps) { const { values, replace } = useSearchParams(); - const handleChange = useCallback((values: string[]) => { - clearSearchValue(); - replace({ environment: values }); - }, []); + const handleChange = useCallback( + (values: string[]) => { + clearSearchValue(); + replace({ environment: values }); + }, + [clearSearchValue, replace] + ); const filtered = useMemo(() => { return environments.filter((item) => diff --git a/apps/webapp/app/routes/storybook.select/route.tsx b/apps/webapp/app/routes/storybook.select/route.tsx index c9ce1495c..605abca5a 100644 --- a/apps/webapp/app/routes/storybook.select/route.tsx +++ b/apps/webapp/app/routes/storybook.select/route.tsx @@ -1,6 +1,5 @@ import { CircleStackIcon } from "@heroicons/react/20/solid"; import { Form, useNavigate } from "@remix-run/react"; -import { useCallback } from "react"; import { LogoIcon } from "~/components/LogoIcon"; import { Button } from "~/components/primitives/Buttons"; import { @@ -141,13 +140,13 @@ function Statuses() { const location = useOptimisticLocation(); const search = new URLSearchParams(location.search); - const handleChange = useCallback((values: string[]) => { + const handleChange = (values: string[]) => { search.delete("status"); for (const value of values) { search.append("status", value); } navigate(`${location.pathname}?${search.toString()}`, { replace: true }); - }, []); + }; return (