From 43cca6740d14eebd4b8db9c4ce8f4ada78a5892a Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Wed, 19 Aug 2026 17:09:18 +0100 Subject: [PATCH 1/2] chore: enforce exhaustive React hook dependencies --- .oxlintrc.json | 2 +- .../assets/icons/AnimatedHourglassIcon.tsx | 6 +- 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 | 18 +- .../primitives/LoadingBarDivider.tsx | 2 +- .../app/components/primitives/Select.tsx | 2 +- .../app/components/primitives/Timeline.tsx | 39 +- .../primitives/TreeView/TreeView.tsx | 339 ++++++++---------- .../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 | 140 ++++---- .../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 | 11 +- apps/webapp/app/routes/resources.metric.tsx | 55 +-- ...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 | 11 +- .../resources.platform-notifications.tsx | 11 +- .../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, 882 insertions(+), 775 deletions(-) diff --git a/.oxlintrc.json b/.oxlintrc.json index 85a5dbdce0b..6483b550050 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 3c94426fa03..0585cbafe0a 100644 --- a/apps/webapp/app/assets/icons/AnimatedHourglassIcon.tsx +++ b/apps/webapp/app/assets/icons/AnimatedHourglassIcon.tsx @@ -12,7 +12,7 @@ export function AnimatedHourglassIcon({ const [scope, animate] = useAnimate(); useEffect(() => { - animate( + const controls = animate( [ [scope.current, { rotate: 0 }, { duration: 0.7 }], [scope.current, { rotate: 180 }, { duration: 0.3 }], @@ -21,7 +21,9 @@ export function AnimatedHourglassIcon({ ], { repeat: Infinity, delay } ); - }, []); + + return () => controls.stop(); + }, [animate, delay, scope]); return ; } diff --git a/apps/webapp/app/components/AskAI.tsx b/apps/webapp/app/components/AskAI.tsx index 96be9dd7075..37911b87334 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 4ce4a480cb8..907a520723c 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 b679b042da1..f6a9971cce3 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 88710370082..8433c74f39d 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 6274dda3585..6e8d4b79573 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 f9ceb3384ab..e06d0326837 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 f6564a805db..3d356d9b548 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 419fb5f22aa..a8e6c32643f 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 bda62a1b32f..d606b512579 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 78cb52ca591..20c2b589703 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 b98d9b551bf..bcde7edd1af 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 63d70de3e2e..f82967828eb 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 fea0f9d8900..88c9bc3f90a 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 8db6defb4ac..a00bfee9916 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 a68616fc1b1..88350831644 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 40015f01e5f..deac7a96e0c 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 e4f5af6520d..e1ce40f5d9b 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; - - setHours(newHours); - setMinutes(newMinutes); - setSeconds(newSeconds); - } + if (controlledValue === undefined) return; + + 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 00259d38f28..08e6a126df0 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 85cd716e15b..de453f3eb8e 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 c2eecd0758b..b4562c3df4a 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 { 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; - } - - setPosition({ x, y }); - }, - [ref.current] - ); + 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; + + if (x < 0 || x > 1 || y < 0 || y > 1) { + setPosition(undefined); + return; + } + + 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(() => { + onScrollRef.current = onScroll; + }, [onScroll]); + const hasOnScroll = onScroll !== undefined; 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]); + 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]); + + const treeNodeCount = tree.length; - //update the filter, if it's changed - const previousFilter = useRef(filter); + // 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; + if (treeNodeCount !== previousNodeCount.current) { + previousNodeCount.current = treeNodeCount; + dispatch({ type: "UPDATE_TREE", payload: { tree: latestTreeRef.current } }); + } + }, [treeNodeCount]); - previousFilter.current = filter; + const latestFilterRef = useRef(filter); + latestFilterRef.current = filter; + const serializedFilterValue = filter ? JSON.stringify(filter.value) : undefined; + const previousSerializedFilterValue = useRef(serializedFilterValue); - if (previousValue !== newValue) { - dispatch({ type: "UPDATE_FILTER", payload: { filter } }); - } - }, [filter?.value]); + // 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 ea41be33772..8c842c35f5c 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 d6a6b32340b..10b4986c2f0 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 3128c6003cc..496acbf8638 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 9e22052bbb6..65381312d88 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; - - if (environmentIdOverride) { - const environmentOverride = replayDataFetcher.data?.environments.find( - (env) => env.id === environmentIdOverride - ); - envSlug = environmentOverride?.slug ?? envSlug; - } + const envSlug = environmentOverrideSlug ?? params.envParam; - 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 27a7511e5fd..f08e09f44e1 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 }[] = []; - - 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, - }); - } - } - } + const items: { name: string; type: "custom" | "task"; value: string }[] = []; - if (fetcher.data === undefined) { - return matchSorter(items, searchValue); + 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, + }); + } } + } + 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 }[] = []; - - for (const version of selected ?? []) { - const versionItem = fetcher.data?.versions.find((v) => v.version === version); - if (!versionItem) { - items.push({ - version, - isCurrent: false, - }); - } - } + const items: { version: string; isCurrent: boolean }[] = []; - 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 ee922513738..70832a1d098 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 1b9830ec54f..db92ad601de 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 4f134a14b68..724b92d9e24 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 4eb31e617c3..3a7efa026f3 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]); - - const filtered = useMemo(() => { - let items: string[] = []; - if (searchValue === "") { - items = values("tags"); - } + }, [environment.slug, load, organization.slug, project.slug, searchValue]); - 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 8d144d5962a..96b1b094e10 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 f705568b26e..272cda08ad8 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 4205b03bcc0..4be50a34e12 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 650a6b37814..e2f1b6215a0 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; - previousItemId.current = item?.id; - }, [item]); + if (previousItemId.current !== itemId || shouldSendInitialUndefined) { + actionRef.current(itemRef.current); + } - //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 c12a02005c5..30a0bae3e44 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 0efff831114..0ba80f5cecc 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 887151ca397..73b45f704eb 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 822217d963b..6bd9d7e863f 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 00cdeb58a1c..1761f0e86ed 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 c3ff335e3bb..fe63e572692 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 f9bddbcca39..e866a4e8ae4 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 320a3e66d0c..163a25deac6 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 @@ -285,7 +285,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 d0813ea330a..9a7407e7baf 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 @@ -190,15 +190,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 244b65e204a..362e0dd9179 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 bce570c0a37..6a87b4b14e7 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 1946312aac3..85b88a5144b 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 1825aeb7fe1..8d8adcf2cfc 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 27ed07ec481..36ccdbff03e 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 be4b2b4a816..32ce8d71584 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 f045467adfd..3d848113c8e 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 1e377cd4558..d56396dfbad 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 9c1499ec41d..65f136de6d5 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 5697caccc85..7ead145d015 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 8aa406c59d7..c645b5f5908 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 4cb2f0cae76..c4d2dca6a33 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 6deaedce66e..3624d05e065 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 84f8dfc1da6..950eb25f2f7 100644 --- a/apps/webapp/app/routes/resources.incidents.tsx +++ b/apps/webapp/app/routes/resources.incidents.tsx @@ -41,26 +41,27 @@ const POLL_INTERVAL_MS = 60_000; export function useIncidentStatus() { const { isManagedCloud } = useFeatures(); const fetcher = useFetcher(); + const { load, state } = fetcher; 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"); } // Poll every 60 seconds const interval = setInterval(() => { - if (fetcher.state === "idle") { - fetcher.load("/resources/incidents"); + if (state === "idle") { + load("/resources/incidents"); } }, POLL_INTERVAL_MS); return () => clearInterval(interval); - }, [isManagedCloud]); + }, [isManagedCloud, load, state]); 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 af98a54b84d..bba26c03c2b 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,25 @@ 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({ + query: props.query, + from: props.from, + to: props.to, + period: props.period, + scope: props.scope, + taskIdentifiers: props.taskIdentifiers, + queues: props.queues, + responseModels: props.responseModels, + promptSlugs: props.promptSlugs, + promptVersions: props.promptVersions, + operations: props.operations, + providers: props.providers, + }); + // 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 4840a424d8c..fb12f772ffc 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 807e527793c..914d94f786a 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 5bef068b994..d275e8e7b9c 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 70e727916b6..b8df543c857 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 c202e2d897f..c02a4824685 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 ed62de3c1df..aa8ed9fc94f 100644 --- a/apps/webapp/app/routes/resources.platform-changelogs.tsx +++ b/apps/webapp/app/routes/resources.platform-changelogs.tsx @@ -42,6 +42,7 @@ const POLL_INTERVAL_MS = 60_000; export function useRecentChangelogs(organizationId?: string, projectId?: string) { const fetcher = useFetcher(); + const { load, state } = fetcher; const lastLoadedUrl = useRef(null); useEffect(() => { @@ -51,19 +52,19 @@ export function useRecentChangelogs(organizationId?: string, projectId?: string) 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); } const interval = setInterval(() => { - if (fetcher.state === "idle") { - fetcher.load(url); + if (state === "idle") { + load(url); } }, POLL_INTERVAL_MS); return () => clearInterval(interval); - }, [organizationId, projectId]); + }, [organizationId, projectId, load, state]); 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 bf3dfe41e44..56fc5bd75f6 100644 --- a/apps/webapp/app/routes/resources.platform-notifications.tsx +++ b/apps/webapp/app/routes/resources.platform-notifications.tsx @@ -41,24 +41,25 @@ const POLL_INTERVAL_MS = 60000; // 1 minute export function usePlatformNotifications(organizationId: string, projectId: string) { const fetcher = useFetcher(); + const { load, state } = fetcher; const lastLoadedUrl = useRef(null); 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); } const interval = setInterval(() => { - if (fetcher.state === "idle") { - fetcher.load(url); + if (state === "idle") { + load(url); } }, POLL_INTERVAL_MS); return () => clearInterval(interval); - }, [organizationId, projectId]); + }, [organizationId, projectId, load, state]); 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 f69a43c57b6..6220aec5495 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 ed5b65ed005..6658ae69581 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 c9ce1495c96..605abca5aca 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 (