11import type { OutputColumnMetadata } from "@internal/clickhouse" ;
2- import { useFetcher } from "@remix-run/react" ;
32import { type ActionFunctionArgs , json } from "@remix-run/server-runtime" ;
4- import { useCallback , useEffect } from "react" ;
3+ import { useCallback , useEffect , useRef , useState } from "react" ;
54import { z } from "zod" ;
65import { requireUserId } from "~/services/session.server" ;
76import { hasAccessToEnvironment } from "~/models/runtimeEnvironment.server" ;
@@ -164,24 +163,60 @@ export function MetricWidget({
164163 onDuplicate,
165164 ...props
166165} : MetricWidgetProps ) {
167- const fetcher = useFetcher < MetricWidgetActionResponse > ( ) ;
168- const isLoading = fetcher . state !== "idle" ;
166+ const [ response , setResponse ] = useState < MetricWidgetActionResponse | null > ( null ) ;
167+ const [ isLoading , setIsLoading ] = useState ( false ) ;
168+ const abortControllerRef = useRef < AbortController | null > ( null ) ;
169169
170- const submit = useCallback ( async ( ) => {
171- fetcher . submit ( props , {
170+ // Track the latest props so the submit callback always uses fresh values
171+ // without needing to be recreated (which would cause useInterval to re-register listeners).
172+ const propsRef = useRef ( props ) ;
173+ propsRef . current = props ;
174+
175+ const submit = useCallback ( ( ) => {
176+ // Abort any in-flight request for this widget
177+ abortControllerRef . current ?. abort ( ) ;
178+
179+ const controller = new AbortController ( ) ;
180+ abortControllerRef . current = controller ;
181+ setIsLoading ( true ) ;
182+
183+ fetch ( `/resources/metric` , {
172184 method : "POST" ,
173- action : `/resources/metric` ,
174- encType : "application/json" ,
175- } ) ;
176- } , [ JSON . stringify ( props ) ] ) ;
185+ headers : { "Content-Type" : "application/json" } ,
186+ body : JSON . stringify ( propsRef . current ) ,
187+ signal : controller . signal ,
188+ } )
189+ . then ( ( res ) => res . json ( ) as Promise < MetricWidgetActionResponse > )
190+ . then ( ( data ) => {
191+ if ( ! controller . signal . aborted ) {
192+ setResponse ( data ) ;
193+ setIsLoading ( false ) ;
194+ }
195+ } )
196+ . catch ( ( err ) => {
197+ // Ignore aborted requests
198+ if ( err instanceof DOMException && err . name === "AbortError" ) return ;
199+ if ( ! controller . signal . aborted ) {
200+ setIsLoading ( false ) ;
201+ }
202+ } ) ;
203+ } , [ ] ) ;
204+
205+ // Clean up on unmount
206+ useEffect ( ( ) => {
207+ return ( ) => {
208+ abortControllerRef . current ?. abort ( ) ;
209+ } ;
210+ } , [ ] ) ;
177211
178- // Reload periodically and on focus
179- useInterval ( { interval : refreshIntervalMs , callback : submit } ) ;
212+ // Reload periodically and on focus (onLoad: false — the useEffect below handles initial load)
213+ useInterval ( { interval : refreshIntervalMs , callback : submit , onLoad : false } ) ;
180214
181- // Reload when query, time period, or filters change
215+ // Reload on mount and when query, time period, or filters change
182216 useEffect ( ( ) => {
183217 submit ( ) ;
184218 } , [
219+ submit ,
185220 props . query ,
186221 props . from ,
187222 props . to ,
@@ -191,8 +226,8 @@ export function MetricWidget({
191226 JSON . stringify ( props . queues ) ,
192227 ] ) ;
193228
194- const data = fetcher . data ?. success
195- ? { rows : fetcher . data . data . rows , columns : fetcher . data . data . columns }
229+ const data = response ?. success
230+ ? { rows : response . data . rows , columns : response . data . columns }
196231 : { rows : [ ] , columns : [ ] } ;
197232
198233 return (
@@ -202,7 +237,7 @@ export function MetricWidget({
202237 config = { config }
203238 isLoading = { isLoading }
204239 data = { data }
205- error = { fetcher . data ?. success === false ? fetcher . data . error : undefined }
240+ error = { response ?. success === false ? response . error : undefined }
206241 isResizing = { isResizing }
207242 isDraggable = { isDraggable }
208243 onEdit = { onEdit }
0 commit comments