From 8c9624ba5f5c54256b20b3cde8e33ab1aa59f6d6 Mon Sep 17 00:00:00 2001 From: Ezequiel Victorero Date: Mon, 20 Oct 2025 12:26:29 -0300 Subject: [PATCH] Library Panels: Cancel in flight previous search requests (#112606) --- .../LibraryPanelsSearch.test.tsx | 4 +++ .../LibraryPanelsView/LibraryPanelsView.tsx | 31 ++++++++++++++--- .../components/LibraryPanelsView/actions.ts | 33 +++++++++++++------ .../app/features/library-panels/state/api.ts | 13 ++++++-- 4 files changed, 64 insertions(+), 17 deletions(-) diff --git a/public/app/features/library-panels/components/LibraryPanelsSearch/LibraryPanelsSearch.test.tsx b/public/app/features/library-panels/components/LibraryPanelsSearch/LibraryPanelsSearch.test.tsx index 06d90268267..bb938595b72 100644 --- a/public/app/features/library-panels/components/LibraryPanelsSearch/LibraryPanelsSearch.test.tsx +++ b/public/app/features/library-panels/components/LibraryPanelsSearch/LibraryPanelsSearch.test.tsx @@ -120,6 +120,7 @@ describe('LibraryPanelsSearch', () => { page: 0, typeFilter: [], perPage: 40, + signal: expect.any(AbortSignal), }) ); }); @@ -148,6 +149,7 @@ describe('LibraryPanelsSearch', () => { page: 0, typeFilter: [], perPage: 40, + signal: expect.any(AbortSignal), }) ); }); @@ -176,6 +178,7 @@ describe('LibraryPanelsSearch', () => { page: 0, typeFilter: ['graph', 'timeseries'], perPage: 40, + signal: expect.any(AbortSignal), }) ); }); @@ -234,6 +237,7 @@ describe('LibraryPanelsSearch', () => { page: 0, typeFilter: [], perPage: 40, + signal: expect.any(AbortSignal), }); }); }); diff --git a/public/app/features/library-panels/components/LibraryPanelsView/LibraryPanelsView.tsx b/public/app/features/library-panels/components/LibraryPanelsView/LibraryPanelsView.tsx index 50fa85c3f72..62cb676ec9a 100644 --- a/public/app/features/library-panels/components/LibraryPanelsView/LibraryPanelsView.tsx +++ b/public/app/features/library-panels/components/LibraryPanelsView/LibraryPanelsView.tsx @@ -1,5 +1,5 @@ import { css } from '@emotion/css'; -import { useMemo, useReducer } from 'react'; +import { useEffect, useMemo, useReducer, useRef } from 'react'; import { useDebounce } from 'react-use'; import { GrafanaTheme2, LoadingState } from '@grafana/data'; @@ -43,8 +43,20 @@ export const LibraryPanelsView = ({ } ); const asyncDispatch = useMemo(() => asyncDispatcher(dispatch), [dispatch]); + const abortControllerRef = useRef(); + useDebounce( - () => + () => { + // Abort previous request if it exists + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + + // Create new AbortController for this request + const abortController = new AbortController(); + abortControllerRef.current = abortController; + + // Start search with abort controller asyncDispatch( searchForLibraryPanels({ searchString, @@ -54,12 +66,23 @@ export const LibraryPanelsView = ({ page, perPage, currentPanelId, - }) - ), + }), + abortController + ); + }, 300, [searchString, sortDirection, panelFilter, folderFilter, page, asyncDispatch] ); + // Cleanup: abort any pending request on unmount + useEffect(() => { + return () => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + }; + }, []); + const onDelete = ({ uid }: LibraryElementDTO) => asyncDispatch( deleteLibraryPanel(uid, { diff --git a/public/app/features/library-panels/components/LibraryPanelsView/actions.ts b/public/app/features/library-panels/components/LibraryPanelsView/actions.ts index c425bee8711..bb39d279a62 100644 --- a/public/app/features/library-panels/components/LibraryPanelsView/actions.ts +++ b/public/app/features/library-panels/components/LibraryPanelsView/actions.ts @@ -1,4 +1,4 @@ -import { AnyAction } from '@reduxjs/toolkit'; +import { Action } from '@reduxjs/toolkit'; import { Dispatch } from 'react'; import { from, merge, of, Subscription, timer } from 'rxjs'; import { catchError, finalize, mapTo, mergeMap, share, takeUntil } from 'rxjs/operators'; @@ -7,7 +7,8 @@ import { deleteLibraryPanel as apiDeleteLibraryPanel, getLibraryPanels } from '. import { initialLibraryPanelsViewState, initSearch, searchCompleted } from './reducer'; -type DispatchResult = (dispatch: Dispatch) => void; +type SearchDispatchResult = (dispatch: Dispatch, abortController?: AbortController) => void; + interface SearchArgs { perPage: number; page: number; @@ -18,10 +19,10 @@ interface SearchArgs { currentPanelId?: string; } -export function searchForLibraryPanels(args: SearchArgs): DispatchResult { +export function searchForLibraryPanels(args: SearchArgs): SearchDispatchResult { // Functions to support filtering out library panels per plugin type that have skipDataQuery set to true - return function (dispatch) { + return function (dispatch, abortController) { const subscription = new Subscription(); const dataObservable = from( getLibraryPanels({ @@ -32,6 +33,7 @@ export function searchForLibraryPanels(args: SearchArgs): DispatchResult { sortDirection: args.sortDirection, typeFilter: args.panelFilter, folderFilterUIDs: args.folderFilterUIDs, + signal: abortController?.signal, }) ).pipe( //filter out library panels per plugin type that have skipDataQuery set to true @@ -43,7 +45,18 @@ export function searchForLibraryPanels(args: SearchArgs): DispatchResult { of(searchCompleted({ libraryPanels, page, perPage, totalCount })) ), catchError((err) => { - console.error(err); + // Check if this is an aborted request - if so, silently ignore it + const isAbortError = + err.name === 'AbortError' || err.cancelled === true || err.statusText === 'Request was aborted'; + + if (isAbortError) { + return of(); // Silently ignore aborted requests + } + + // For real errors, log and show error to user + console.error('Error fetching library panels:', err); + + // Update state to show empty results return of(searchCompleted({ ...initialLibraryPanelsViewState, page: args.page, perPage: args.perPage })); }), finalize(() => subscription.unsubscribe()), // make sure we unsubscribe @@ -59,8 +72,8 @@ export function searchForLibraryPanels(args: SearchArgs): DispatchResult { }; } -export function deleteLibraryPanel(uid: string, args: SearchArgs): DispatchResult { - return async function (dispatch) { +export function deleteLibraryPanel(uid: string, args: SearchArgs) { + return async function (dispatch: Dispatch) { try { await apiDeleteLibraryPanel(uid); searchForLibraryPanels(args)(dispatch); @@ -70,10 +83,10 @@ export function deleteLibraryPanel(uid: string, args: SearchArgs): DispatchResul }; } -export function asyncDispatcher(dispatch: Dispatch) { - return function (action: AnyAction | DispatchResult) { +export function asyncDispatcher(dispatch: Dispatch) { + return function (action: Action | SearchDispatchResult | Function, abortController?: AbortController) { if (action instanceof Function) { - return action(dispatch); + return action(dispatch, abortController); } return dispatch(action); }; diff --git a/public/app/features/library-panels/state/api.ts b/public/app/features/library-panels/state/api.ts index 6ae546af666..00c68ccf1ab 100644 --- a/public/app/features/library-panels/state/api.ts +++ b/public/app/features/library-panels/state/api.ts @@ -25,6 +25,7 @@ export interface GetLibraryPanelsOptions { sortDirection?: string; typeFilter?: string[]; folderFilterUIDs?: string[]; + signal?: AbortSignal; } export async function getLibraryPanels({ @@ -35,6 +36,7 @@ export async function getLibraryPanels({ sortDirection = '', typeFilter = [], folderFilterUIDs = [], + signal, }: GetLibraryPanelsOptions = {}): Promise { const params = new URLSearchParams(); params.append('searchString', searchString); @@ -46,10 +48,15 @@ export async function getLibraryPanels({ params.append('page', page.toString(10)); params.append('kind', LibraryElementKind.Panel.toString(10)); - const { result } = await getBackendSrv().get<{ result: LibraryElementsSearchResult }>( - `/api/library-elements?${params.toString()}` + const response = await lastValueFrom( + getBackendSrv().fetch<{ result: LibraryElementsSearchResult }>({ + method: 'GET', + url: `/api/library-elements?${params.toString()}`, + abortSignal: signal, + showErrorAlert: false, + }) ); - return result; + return response.data.result; } export async function getLibraryPanel(uid: string, isHandled = false): Promise {