This is an automated email from the ASF dual-hosted git repository.

yousoph pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git


The following commit(s) were added to refs/heads/master by this push:
     new 9d2dcf21980 fix(oauth2): clear schema/catalog auth banner after OAuth2 
redirect refetch (#41913)
9d2dcf21980 is described below

commit 9d2dcf219804950f8085bc92ca3c6f5bc90ad32f
Author: yousoph <[email protected]>
AuthorDate: Wed Jul 29 10:20:15 2026 -0700

    fix(oauth2): clear schema/catalog auth banner after OAuth2 redirect refetch 
(#41913)
    
    Co-authored-by: Claude Opus 4.8 <[email protected]>
---
 .../TableExploreTree/TableExploreTree.test.tsx     |  71 ++++++-
 .../components/TableExploreTree/useTreeData.ts     |  99 ++++++++-
 .../DatabaseSelector/DatabaseSelector.test.tsx     |  52 +++++
 .../src/hooks/apiResources/catalogs.test.ts        | 231 +++++++++++++++++++++
 .../src/hooks/apiResources/catalogs.ts             |  66 +++---
 .../src/hooks/apiResources/schemas.test.ts         | 142 ++++++++++++-
 .../src/hooks/apiResources/schemas.ts              |  74 ++++---
 .../src/hooks/apiResources/tables.test.ts          |  61 ++++++
 superset-frontend/src/hooks/apiResources/tables.ts |  41 ++--
 9 files changed, 754 insertions(+), 83 deletions(-)

diff --git 
a/superset-frontend/src/SqlLab/components/TableExploreTree/TableExploreTree.test.tsx
 
b/superset-frontend/src/SqlLab/components/TableExploreTree/TableExploreTree.test.tsx
index 585fa09128b..6766c04384d 100644
--- 
a/superset-frontend/src/SqlLab/components/TableExploreTree/TableExploreTree.test.tsx
+++ 
b/superset-frontend/src/SqlLab/components/TableExploreTree/TableExploreTree.test.tsx
@@ -18,8 +18,16 @@
  */
 import type { ReactChild } from 'react';
 import fetchMock from 'fetch-mock';
-import { render, screen, waitFor } from 'spec/helpers/testing-library';
+import {
+  act,
+  createStore,
+  render,
+  screen,
+  waitFor,
+} from 'spec/helpers/testing-library';
+import reducerIndex from 'spec/helpers/reducerIndex';
 import userEvent from '@testing-library/user-event';
+import { api } from 'src/hooks/apiResources/queryApi';
 import { initialState, defaultQueryEditor } from 'src/SqlLab/fixtures';
 
 import { ViewLocations } from 'src/SqlLab/contributions';
@@ -346,3 +354,64 @@ test('closes a schema while searchTerm is active and keeps 
it closed', async ()
   // The schema node itself remains visible as a matching ancestor (just 
collapsed)
   expect(screen.getByText('public')).toBeInTheDocument();
 });
+
+test('clears the OAuth error banner after a Tables invalidateTags refetch', 
async () => {
+  // Regression test for the OAuth2 crud symptom (follow-up to PR #41101).
+  // Expanding a schema lazily fetches its tables; when that fails with an
+  // OAuth2 auth error the banner is held in local reducer state and, before
+  // this fix, only cleared when a table list loaded via manual re-expansion.
+  // After the OAuth2 redirect, OAuth2RedirectMessage dispatches
+  // invalidateTags(['Tables']); the errored node's subscribed tables query 
must
+  // now refetch automatically and clear the banner.
+  fetchMock.removeRoutes().clearHistory();
+  fetchMock.get('glob:*/api/v1/database/1/schemas/?*', {
+    count: mockSchemas.length,
+    result: mockSchemas,
+  });
+  let tablesShouldFail = true;
+  fetchMock.get('glob:*/api/v1/database/1/tables/*', () =>
+    tablesShouldFail
+      ? {
+          status: 500,
+          body: {
+            errors: [
+              {
+                error_type: 'GENERIC_DB_ENGINE_ERROR',
+                level: 'error',
+                message: 'Tables could not be loaded',
+                extra: {},
+              },
+            ],
+          },
+        }
+      : { count: mockTables.length, result: mockTables },
+  );
+
+  const store = createStore(getInitialState(), reducerIndex);
+  render(<TableExploreTree queryEditorId={mockedQueryEditorId} />, {
+    useRedux: true,
+    store,
+  });
+
+  await waitFor(() => {
+    expect(screen.getByText('public')).toBeInTheDocument();
+  });
+
+  // Expand the schema node → its table fetch rejects → the banner appears.
+  await userEvent.click(screen.getByText('public'));
+  expect(await screen.findByText('Unexpected error')).toBeInTheDocument();
+
+  // The OAuth2 redirect completes: the stored token makes the next fetch
+  // succeed, and the redirect handler invalidates the Tables cache.
+  tablesShouldFail = false;
+  act(() => {
+    store.dispatch(api.util.invalidateTags(['Tables']));
+  });
+
+  // The subscribed tables query refetches, the tables load, and the banner is
+  // cleared without the user manually re-expanding the node.
+  expect(await screen.findByText('users')).toBeInTheDocument();
+  await waitFor(() =>
+    expect(screen.queryByText('Unexpected error')).not.toBeInTheDocument(),
+  );
+});
diff --git 
a/superset-frontend/src/SqlLab/components/TableExploreTree/useTreeData.ts 
b/superset-frontend/src/SqlLab/components/TableExploreTree/useTreeData.ts
index 6dd01a5279c..c26830b3897 100644
--- a/superset-frontend/src/SqlLab/components/TableExploreTree/useTreeData.ts
+++ b/superset-frontend/src/SqlLab/components/TableExploreTree/useTreeData.ts
@@ -16,7 +16,7 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { useMemo, useReducer, useCallback } from 'react';
+import { useMemo, useReducer, useCallback, useEffect, useRef } from 'react';
 import { useAppDispatch } from 'src/SqlLab/hooks/useAppDispatch';
 import { t } from '@apache-superset/core/translation';
 import {
@@ -24,21 +24,33 @@ import {
   type TableMetaData,
   useSchemas,
   useLazyTablesQuery,
+  useTablesQuery,
   useLazyTableMetadataQuery,
   useLazyTableExtendedMetadataQuery,
 } from 'src/hooks/apiResources';
 import { addDangerToast } from 'src/SqlLab/actions/sqlLab';
 import type { TreeNodeData } from './types';
-import { SupersetError } from '@superset-ui/core';
+import { ClientErrorObject, SupersetError } from '@superset-ui/core';
 
 export const EMPTY_NODE_ID_PREFIX = 'empty:';
 
+// Identifies the schema node whose table list failed to load, so the tree can
+// automatically recover once the underlying Tables cache is refetched (e.g.
+// after the OAuth2 redirect dispatches invalidateTags(['Tables'])).
+interface ErroredNode {
+  schemaKey: string;
+  dbId: number;
+  catalog: string | null | undefined;
+  schema: string;
+}
+
 // Reducer state and actions
 interface TreeDataState {
   tableData: Record<string, { options: Table[] }>;
   tableSchemaData: Record<string, TableMetaData>;
   loadingNodes: Record<string, boolean>;
   errorPayload: SupersetError | null;
+  erroredNode: ErroredNode | null;
 }
 
 type TreeDataAction =
@@ -46,13 +58,18 @@ type TreeDataAction =
   | { type: 'SET_TABLE_SCHEMA_DATA'; key: string; data: TableMetaData }
   | { type: 'CLEAR_TABLE_SCHEMA_DATA'; key: string }
   | { type: 'SET_LOADING_NODE'; nodeId: string; loading: boolean }
-  | { type: 'SET_ERROR'; errorPayload: SupersetError | null };
+  | {
+      type: 'SET_ERROR';
+      errorPayload: SupersetError | null;
+      erroredNode: ErroredNode | null;
+    };
 
 const initialState: TreeDataState = {
   tableData: {},
   tableSchemaData: {},
   loadingNodes: {},
   errorPayload: null,
+  erroredNode: null,
 };
 
 function treeDataReducer(
@@ -64,6 +81,7 @@ function treeDataReducer(
       return {
         ...state,
         errorPayload: null,
+        erroredNode: null,
         tableData: { ...state.tableData, [action.key]: action.data },
       };
     case 'SET_TABLE_SCHEMA_DATA':
@@ -90,6 +108,7 @@ function treeDataReducer(
       return {
         ...state,
         errorPayload: action.errorPayload,
+        erroredNode: action.erroredNode,
       };
 
     default:
@@ -144,7 +163,67 @@ const useTreeData = ({
 
   // Combined state for table data, schema data, loading nodes, and data 
version
   const [state, dispatch] = useReducer(treeDataReducer, initialState);
-  const { tableData, tableSchemaData, loadingNodes, errorPayload } = state;
+  const {
+    tableData,
+    tableSchemaData,
+    loadingNodes,
+    errorPayload,
+    erroredNode,
+  } = state;
+
+  // Tables are loaded lazily on node toggle, so a schema whose table list 
fails
+  // (e.g. an OAuth2 auth error) has no active subscription that would recover 
on
+  // cache invalidation. Subscribe to the tables query for the single errored
+  // node so that when OAuth2RedirectMessage dispatches 
invalidateTags(['Tables'])
+  // after the redirect, this query refetches automatically. The subscribed
+  // entry shares the cache key (dbId + schema) that the lazy fetch already
+  // populated with the error, so this reflects that error and does not trigger
+  // an eager refetch of its own.
+  const erroredTablesResult = useTablesQuery(
+    {
+      dbId: erroredNode?.dbId,
+      catalog: erroredNode?.catalog,
+      schema: erroredNode?.schema,
+      forceRefresh: false,
+    },
+    { skip: !erroredNode },
+  );
+  const wasFetchingErroredRef = useRef(false);
+
+  useEffect(() => {
+    // Recover the errored schema node when its subscribed tables query 
finishes
+    // a fetch (driven by the Tables cache invalidation). Keying off the
+    // isFetching true->false transition avoids acting on the initial rejected
+    // state and on unrelated re-renders. On success, SET_TABLE_DATA 
repopulates
+    // the node and clears the banner; on renewed failure the banner is 
re-armed.
+    if (!erroredNode) {
+      wasFetchingErroredRef.current = erroredTablesResult.isFetching;
+      return;
+    }
+    const { isSuccess, isError, isFetching, currentData, error } =
+      erroredTablesResult;
+    const nodeId = `schema:${erroredNode.dbId}:${erroredNode.schema}`;
+    if (isFetching && !wasFetchingErroredRef.current) {
+      dispatch({ type: 'SET_LOADING_NODE', nodeId, loading: true });
+    }
+    if (!isFetching && wasFetchingErroredRef.current) {
+      if (isSuccess && currentData) {
+        dispatch({
+          type: 'SET_TABLE_DATA',
+          key: erroredNode.schemaKey,
+          data: currentData,
+        });
+      } else if (isError) {
+        dispatch({
+          type: 'SET_ERROR',
+          errorPayload: (error as ClientErrorObject)?.errors?.[0] ?? null,
+          erroredNode,
+        });
+      }
+      dispatch({ type: 'SET_LOADING_NODE', nodeId, loading: false });
+    }
+    wasFetchingErroredRef.current = isFetching;
+  }, [erroredTablesResult, erroredNode]);
 
   // Shared helper: fetch table metadata + extended metadata and store in 
state.
   // preferCacheValue=true on initial open (use cached data if available),
@@ -233,6 +312,12 @@ const useTreeData = ({
               dispatch({
                 type: 'SET_ERROR',
                 errorPayload: error?.errors?.[0] ?? null,
+                erroredNode: {
+                  schemaKey,
+                  dbId: parsedDbId,
+                  catalog,
+                  schema,
+                },
               });
             })
             .finally(() => {
@@ -296,6 +381,12 @@ const useTreeData = ({
           dispatch({
             type: 'SET_ERROR',
             errorPayload: error?.errors?.[0] ?? null,
+            erroredNode: {
+              schemaKey,
+              dbId: refreshDbId,
+              catalog: refreshCatalog,
+              schema,
+            },
           });
         })
         .finally(() => {
diff --git 
a/superset-frontend/src/components/DatabaseSelector/DatabaseSelector.test.tsx 
b/superset-frontend/src/components/DatabaseSelector/DatabaseSelector.test.tsx
index a1a4a56b35d..cd8382c323c 100644
--- 
a/superset-frontend/src/components/DatabaseSelector/DatabaseSelector.test.tsx
+++ 
b/superset-frontend/src/components/DatabaseSelector/DatabaseSelector.test.tsx
@@ -367,6 +367,58 @@ test('Sends the correct db when changing the database', 
async () => {
   );
 });
 
+test('clears the schema error banner after an invalidateTags-driven refetch', 
async () => {
+  // Regression test for the OAuth2 crud symptom (follow-up to PR #41101).
+  // The schema fetch fails and shows an error banner held in 
DatabaseSelector's
+  // local `errorPayload` state. After the OAuth2 redirect completes,
+  // OAuth2RedirectMessage dispatches invalidateTags, which refetches the
+  // subscribed schemas query. The banner must disappear once the refetch
+  // succeeds — previously it lingered because useSchemas only fired onSuccess
+  // through its lazy trigger, not on the subscribed refetch.
+  fetchMock.removeRoutes().clearHistory();
+  fetchMock.get(databaseApiRoute, fakeDatabaseApiResult, {
+    name: databaseApiRoute,
+  });
+  fetchMock.get(catalogApiRoute, fakeCatalogApiResult);
+  fetchMock.get(tablesApiRoute, fakeFunctionNamesApiResult);
+  let failSchemas = true;
+  fetchMock.get(schemaApiRoute, () =>
+    failSchemas
+      ? {
+          status: 500,
+          body: {
+            errors: [
+              {
+                error_type: 'GENERIC_DB_ENGINE_ERROR',
+                level: 'error',
+                message: 'Schemas could not be loaded',
+                extra: {},
+              },
+            ],
+          },
+        }
+      : fakeSchemaApiResult,
+  );
+
+  const props = createProps();
+  render(<DatabaseSelector {...props} />, { useRedux: true, store });
+
+  // The error banner appears once the initial schemas fetch fails.
+  expect(await screen.findByText('Unexpected error')).toBeInTheDocument();
+
+  // Simulate the OAuth2 redirect completing: the stored token makes the next
+  // fetch succeed, and the redirect handler invalidates the Schemas tag.
+  failSchemas = false;
+  act(() => {
+    store.dispatch(api.util.invalidateTags([{ type: 'Schemas', id: 'LIST' }]));
+  });
+
+  // The subscribed query refetches successfully and the banner is cleared.
+  await waitFor(() =>
+    expect(screen.queryByText('Unexpected error')).not.toBeInTheDocument(),
+  );
+});
+
 test('Sends the correct schema when changing the schema', async () => {
   const props = createProps();
   const { rerender } = render(<DatabaseSelector {...props} db={null} />, {
diff --git a/superset-frontend/src/hooks/apiResources/catalogs.test.ts 
b/superset-frontend/src/hooks/apiResources/catalogs.test.ts
new file mode 100644
index 00000000000..e7cbce0a4e8
--- /dev/null
+++ b/superset-frontend/src/hooks/apiResources/catalogs.test.ts
@@ -0,0 +1,231 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import rison from 'rison';
+import fetchMock from 'fetch-mock';
+import { act, renderHook, waitFor } from '@testing-library/react';
+import {
+  createWrapper,
+  defaultStore as store,
+} from 'spec/helpers/testing-library';
+import { api } from 'src/hooks/apiResources/queryApi';
+import { useCatalogs } from './catalogs';
+
+const fakeApiResult = {
+  result: ['test catalog 1', 'test catalog b'],
+};
+
+const expectedResult = fakeApiResult.result.map((value: string) => ({
+  value,
+  label: value,
+  title: value,
+}));
+
+// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from 
describe blocks
+describe('useCatalogs hook', () => {
+  beforeEach(() => {
+    fetchMock.clearHistory().removeRoutes();
+    store.dispatch(api.util.resetApiState());
+  });
+
+  test('returns api response mapping json result', async () => {
+    const expectDbId = 'db1';
+    const forceRefresh = false;
+    const catalogApiRoute = `glob:*/api/v1/database/${expectDbId}/catalogs/*`;
+    fetchMock.get(catalogApiRoute, fakeApiResult);
+    const onSuccess = jest.fn();
+    const { result } = renderHook(
+      () =>
+        useCatalogs({
+          dbId: expectDbId,
+          onSuccess,
+        }),
+      {
+        wrapper: createWrapper({
+          useRedux: true,
+          store,
+        }),
+      },
+    );
+    await waitFor(() =>
+      expect(fetchMock.callHistory.calls(catalogApiRoute).length).toBe(1),
+    );
+    expect(result.current.data).toEqual(expectedResult);
+    expect(
+      fetchMock.callHistory.calls(
+        `end:/api/v1/database/${expectDbId}/catalogs/?q=${rison.encode({
+          force: forceRefresh,
+        })}`,
+      ).length,
+    ).toBe(1);
+    expect(onSuccess).toHaveBeenCalledTimes(1);
+    act(() => {
+      result.current.refetch();
+    });
+    await waitFor(() =>
+      expect(fetchMock.callHistory.calls(catalogApiRoute).length).toBe(2),
+    );
+    expect(
+      fetchMock.callHistory.calls(
+        `end:/api/v1/database/${expectDbId}/catalogs/?q=${rison.encode({
+          force: true,
+        })}`,
+      ).length,
+    ).toBe(1);
+    expect(onSuccess).toHaveBeenCalledTimes(2);
+    expect(result.current.data).toEqual(expectedResult);
+  });
+
+  test('fires onSuccess when the subscribed query refetches after 
invalidateTags', async () => {
+    // Regression test for the OAuth2 retry-after-redirect path (PR #41101).
+    // The redirect handler dispatches invalidateTags, which refetches the
+    // SUBSCRIBED query (not the lazy trigger). onSuccess must still fire so
+    // consumers holding local state (e.g. an auth error banner) get cleared.
+    const expectDbId = 'db1';
+    const catalogApiRoute = `glob:*/api/v1/database/${expectDbId}/catalogs/*`;
+    fetchMock.get(catalogApiRoute, fakeApiResult);
+    const onSuccess = jest.fn();
+    const { result } = renderHook(
+      () =>
+        useCatalogs({
+          dbId: expectDbId,
+          onSuccess,
+        }),
+      {
+        wrapper: createWrapper({
+          useRedux: true,
+          store,
+        }),
+      },
+    );
+
+    await waitFor(() =>
+      expect(fetchMock.callHistory.calls(catalogApiRoute).length).toBe(1),
+    );
+    expect(result.current.currentData).toEqual(expectedResult);
+    expect(onSuccess).toHaveBeenCalledTimes(1);
+
+    act(() => {
+      store.dispatch(
+        api.util.invalidateTags([{ type: 'Catalogs', id: 'LIST' }]),
+      );
+    });
+
+    await waitFor(() =>
+      expect(fetchMock.callHistory.calls(catalogApiRoute).length).toBe(2),
+    );
+    await waitFor(() => expect(onSuccess).toHaveBeenCalledTimes(2));
+    // isRefetched must be false so the selectors don't emit a "List refreshed"
+    // toast for an automatic refetch the user did not request.
+    expect(onSuccess).toHaveBeenLastCalledWith(expectedResult, false);
+  });
+
+  test('recovers from an error when the subscribed query refetches (OAuth2 
retry)', async () => {
+    const expectDbId = 'db1';
+    const catalogApiRoute = `glob:*/api/v1/database/${expectDbId}/catalogs/*`;
+    let shouldFail = true;
+    fetchMock.get(catalogApiRoute, () =>
+      shouldFail ? { status: 500, body: {} } : fakeApiResult,
+    );
+    const onSuccess = jest.fn();
+    const onError = jest.fn();
+    renderHook(
+      () =>
+        useCatalogs({
+          dbId: expectDbId,
+          onSuccess,
+          onError,
+        }),
+      {
+        wrapper: createWrapper({
+          useRedux: true,
+          store,
+        }),
+      },
+    );
+
+    await waitFor(() => expect(onError).toHaveBeenCalledTimes(1));
+    expect(onSuccess).not.toHaveBeenCalled();
+
+    // The OAuth2 redirect completes and the token is stored: the next fetch
+    // succeeds, and onSuccess must fire to clear the error banner.
+    shouldFail = false;
+    act(() => {
+      store.dispatch(
+        api.util.invalidateTags([{ type: 'Catalogs', id: 'LIST' }]),
+      );
+    });
+
+    await waitFor(() => expect(onSuccess).toHaveBeenCalledTimes(1));
+    expect(onSuccess).toHaveBeenLastCalledWith(expectedResult, false);
+  });
+
+  test('fires callbacks on invalidation refetch even after a failed force 
refresh (OAuth2 refresh button)', async () => {
+    // Reviewer regression: serializeQueryArgs strips forceRefresh, so the
+    // subscribed query and the lazy force-refresh trigger share one cache 
entry.
+    // A failed refresh-button click (forceRefresh:true) leaves the entry's
+    // originalArgs.forceRefresh sticky-true. The old 
`!originalArgs.forceRefresh`
+    // guard then suppressed onSuccess/onError on the later invalidation 
refetch,
+    // so the banner stayed stuck. The ref-based flag must fire the callbacks.
+    const expectDbId = 'db1';
+    const catalogApiRoute = `glob:*/api/v1/database/${expectDbId}/catalogs/*`;
+    let mode: 'ok' | 'fail' = 'ok';
+    fetchMock.get(catalogApiRoute, () =>
+      mode === 'fail' ? { status: 500, body: {} } : fakeApiResult,
+    );
+    const onSuccess = jest.fn();
+    const onError = jest.fn();
+    const { result } = renderHook(
+      () =>
+        useCatalogs({
+          dbId: expectDbId,
+          onSuccess,
+          onError,
+        }),
+      {
+        wrapper: createWrapper({
+          useRedux: true,
+          store,
+        }),
+      },
+    );
+
+    // Initial subscribed load succeeds (not a manual refresh: 
isRefetched=false).
+    await waitFor(() => expect(onSuccess).toHaveBeenCalledTimes(1));
+    expect(onSuccess).toHaveBeenLastCalledWith(expectedResult, false);
+
+    // User clicks the refresh button (force refresh) and hits the OAuth2 wall.
+    // This makes the shared entry's originalArgs.forceRefresh sticky-true.
+    mode = 'fail';
+    act(() => {
+      result.current.refetch();
+    });
+    await waitFor(() => expect(onError).toHaveBeenCalledTimes(1));
+
+    // User authorizes: invalidateTags refetches the subscribed query, which 
now
+    // succeeds. onSuccess must fire (isRefetched=false: not a manual refresh).
+    mode = 'ok';
+    act(() => {
+      store.dispatch(
+        api.util.invalidateTags([{ type: 'Catalogs', id: 'LIST' }]),
+      );
+    });
+    await waitFor(() => expect(onSuccess).toHaveBeenCalledTimes(2));
+    expect(onSuccess).toHaveBeenLastCalledWith(expectedResult, false);
+  });
+});
diff --git a/superset-frontend/src/hooks/apiResources/catalogs.ts 
b/superset-frontend/src/hooks/apiResources/catalogs.ts
index 26f56b1dd63..1f118c883b9 100644
--- a/superset-frontend/src/hooks/apiResources/catalogs.ts
+++ b/superset-frontend/src/hooks/apiResources/catalogs.ts
@@ -16,7 +16,7 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { useCallback, useEffect } from 'react';
+import { useCallback, useEffect, useRef } from 'react';
 import { ClientErrorObject } from '@superset-ui/core';
 import useEffectEvent from 'src/hooks/useEffectEvent';
 import { api, JsonResponse } from './queryApi';
@@ -70,42 +70,60 @@ export const EMPTY_CATALOGS = [] as CatalogOption[];
 
 export function useCatalogs(options: Params) {
   const { dbId, onSuccess, onError } = options || {};
-  const [trigger] = useLazyCatalogsQuery();
+  const wasFetchingRef = useRef(false);
+  const isRefreshingRef = useRef(false);
   const result = useCatalogsQuery(
     { dbId, forceRefresh: false },
     {
       skip: !dbId,
     },
   );
+  const [trigger] = useLazyCatalogsQuery();
 
-  useEffect(() => {
-    if (result.isError) {
-      onError?.(result.error as ClientErrorObject);
-    }
-  }, [result.isError, result.error, onError]);
-
-  const fetchData = useEffectEvent(
-    (dbId: FetchCatalogsQueryParams['dbId'], forceRefresh = false) => {
-      if (dbId && (!result.currentData || forceRefresh)) {
-        trigger({ dbId, forceRefresh }).then(({ isSuccess, isError, data }) => 
{
-          if (isSuccess) {
-            onSuccess?.(data || EMPTY_CATALOGS, forceRefresh);
-          }
-          if (isError) {
-            onError?.(result.error as ClientErrorObject);
-          }
-        });
-      }
+  const handleOnSuccess = useEffectEvent(
+    (data: CatalogOption[], isRefetched: boolean) => {
+      onSuccess?.(data, isRefetched);
     },
   );
 
+  const handleOnError = useEffectEvent((error: ClientErrorObject) => {
+    onError?.(error);
+  });
+
   const refetch = useCallback(() => {
-    fetchData(dbId, true);
-  }, [dbId, fetchData]);
+    if (dbId) {
+      // Force a real server refresh. The success/error callbacks are fired by
+      // the subscribed effect below (the single source of truth), which
+      // observes the shared cache entry's isFetching transition. 
isRefreshingRef
+      // flags that completion as a user-requested refresh (isRefetched=true).
+      isRefreshingRef.current = true;
+      trigger({ dbId, forceRefresh: true });
+    }
+  }, [dbId, trigger]);
 
   useEffect(() => {
-    fetchData(dbId, false);
-  }, [dbId, fetchData]);
+    // Fire the success/error callbacks whenever the subscribed query finishes 
a
+    // fetch, not just when data is loaded through the lazy `trigger` path. 
This
+    // covers refetches driven by cache invalidation (e.g. after an OAuth2
+    // redirect) so consumers holding local state such as an auth error banner
+    // are notified. Keying off the isFetching true->false transition avoids
+    // re-firing on cache-hit re-renders, which would spuriously re-run
+    // auto-select logic in the selectors. isRefreshingRef (not the sticky
+    // originalArgs.forceRefresh, which the shared cache entry never resets)
+    // distinguishes a user-requested refresh from other fetches.
+    const { isSuccess, isError, isFetching, currentData, error } = result;
+    if (wasFetchingRef.current && !isFetching) {
+      const isRefetched = isRefreshingRef.current;
+      if (isSuccess && currentData) {
+        handleOnSuccess(currentData, isRefetched);
+      }
+      if (isError) {
+        handleOnError(error as ClientErrorObject);
+      }
+      isRefreshingRef.current = false;
+    }
+    wasFetchingRef.current = isFetching;
+  }, [result, handleOnSuccess, handleOnError]);
 
   return {
     ...result,
diff --git a/superset-frontend/src/hooks/apiResources/schemas.test.ts 
b/superset-frontend/src/hooks/apiResources/schemas.test.ts
index e09c74fa597..29ff80c2598 100644
--- a/superset-frontend/src/hooks/apiResources/schemas.test.ts
+++ b/superset-frontend/src/hooks/apiResources/schemas.test.ts
@@ -177,8 +177,10 @@ describe('useSchemas hook', () => {
       store.dispatch(api.util.invalidateTags(['Schemas']));
     });
 
+    // Only the currently subscribed query (expectDbId) is refetched on
+    // invalidation; the previously visited db2 entry is no longer subscribed.
     await waitFor(() =>
-      expect(fetchMock.callHistory.calls(schemaApiRoute).length).toBe(4),
+      expect(fetchMock.callHistory.calls(schemaApiRoute).length).toBe(3),
     );
     expect(fetchMock.callHistory.calls(schemaApiRoute)[2].url).toContain(
       expectDbId,
@@ -188,6 +190,144 @@ describe('useSchemas hook', () => {
     );
   });
 
+  test('fires onSuccess when the subscribed query refetches after 
invalidateTags', async () => {
+    // Regression test for the OAuth2 retry-after-redirect path (PR #41101).
+    // The redirect handler dispatches invalidateTags, which refetches the
+    // SUBSCRIBED query (not the lazy trigger). onSuccess must still fire so
+    // consumers holding local state (e.g. an auth error banner) get cleared.
+    const expectDbId = 'db1';
+    const schemaApiRoute = `glob:*/api/v1/database/${expectDbId}/schemas/*`;
+    fetchMock.get(schemaApiRoute, fakeApiResult);
+    const onSuccess = jest.fn();
+    const { result } = renderHook(
+      () =>
+        useSchemas({
+          dbId: expectDbId,
+          onSuccess,
+        }),
+      {
+        wrapper: createWrapper({
+          useRedux: true,
+          store,
+        }),
+      },
+    );
+
+    await waitFor(() =>
+      expect(fetchMock.callHistory.calls(schemaApiRoute).length).toBe(1),
+    );
+    expect(result.current.currentData).toEqual(expectedResult);
+    expect(onSuccess).toHaveBeenCalledTimes(1);
+
+    act(() => {
+      store.dispatch(
+        api.util.invalidateTags([{ type: 'Schemas', id: 'LIST' }]),
+      );
+    });
+
+    await waitFor(() =>
+      expect(fetchMock.callHistory.calls(schemaApiRoute).length).toBe(2),
+    );
+    await waitFor(() => expect(onSuccess).toHaveBeenCalledTimes(2));
+    // isRefetched must be false so the selectors don't emit a "List refreshed"
+    // toast for an automatic refetch the user did not request.
+    expect(onSuccess).toHaveBeenLastCalledWith(expectedResult, false);
+  });
+
+  test('recovers from an error when the subscribed query refetches (OAuth2 
retry)', async () => {
+    const expectDbId = 'db1';
+    const schemaApiRoute = `glob:*/api/v1/database/${expectDbId}/schemas/*`;
+    let shouldFail = true;
+    fetchMock.get(schemaApiRoute, () =>
+      shouldFail ? { status: 500, body: {} } : fakeApiResult,
+    );
+    const onSuccess = jest.fn();
+    const onError = jest.fn();
+    renderHook(
+      () =>
+        useSchemas({
+          dbId: expectDbId,
+          onSuccess,
+          onError,
+        }),
+      {
+        wrapper: createWrapper({
+          useRedux: true,
+          store,
+        }),
+      },
+    );
+
+    await waitFor(() => expect(onError).toHaveBeenCalledTimes(1));
+    expect(onSuccess).not.toHaveBeenCalled();
+
+    // The OAuth2 redirect completes and the token is stored: the next fetch
+    // succeeds, and onSuccess must fire to clear the error banner.
+    shouldFail = false;
+    act(() => {
+      store.dispatch(
+        api.util.invalidateTags([{ type: 'Schemas', id: 'LIST' }]),
+      );
+    });
+
+    await waitFor(() => expect(onSuccess).toHaveBeenCalledTimes(1));
+    expect(onSuccess).toHaveBeenLastCalledWith(expectedResult, false);
+  });
+
+  test('fires callbacks on invalidation refetch even after a failed force 
refresh (OAuth2 refresh button)', async () => {
+    // Reviewer regression: serializeQueryArgs strips forceRefresh, so the
+    // subscribed query and the lazy force-refresh trigger share one cache 
entry.
+    // A failed refresh-button click (forceRefresh:true) leaves the entry's
+    // originalArgs.forceRefresh sticky-true. The old 
`!originalArgs.forceRefresh`
+    // guard then suppressed onSuccess/onError on the later invalidation 
refetch,
+    // so the banner stayed stuck. The ref-based flag must fire the callbacks.
+    const expectDbId = 'db1';
+    const schemaApiRoute = `glob:*/api/v1/database/${expectDbId}/schemas/*`;
+    let mode: 'ok' | 'fail' = 'ok';
+    fetchMock.get(schemaApiRoute, () =>
+      mode === 'fail' ? { status: 500, body: {} } : fakeApiResult,
+    );
+    const onSuccess = jest.fn();
+    const onError = jest.fn();
+    const { result } = renderHook(
+      () =>
+        useSchemas({
+          dbId: expectDbId,
+          onSuccess,
+          onError,
+        }),
+      {
+        wrapper: createWrapper({
+          useRedux: true,
+          store,
+        }),
+      },
+    );
+
+    // Initial subscribed load succeeds (not a manual refresh: 
isRefetched=false).
+    await waitFor(() => expect(onSuccess).toHaveBeenCalledTimes(1));
+    expect(onSuccess).toHaveBeenLastCalledWith(expectedResult, false);
+
+    // User clicks the refresh button (force refresh) and hits the OAuth2 wall.
+    // This makes the shared entry's originalArgs.forceRefresh sticky-true.
+    mode = 'fail';
+    act(() => {
+      result.current.refetch();
+    });
+    await waitFor(() => expect(onError).toHaveBeenCalledTimes(1));
+
+    // User authorizes: invalidateTags refetches the subscribed query, which 
now
+    // succeeds. onSuccess must fire (isRefetched=false: not a manual refresh).
+    mode = 'ok';
+    act(() => {
+      store.dispatch(
+        api.util.invalidateTags([{ type: 'Schemas', id: 'LIST' }]),
+      );
+    });
+    await waitFor(() => expect(onSuccess).toHaveBeenCalledTimes(2));
+    expect(onSuccess).toHaveBeenLastCalledWith(expectedResult, false);
+  });
+
   test('returns correct schema list by a catalog', async () => {
     const dbId = '1';
     const expectCatalog = 'catalog3';
diff --git a/superset-frontend/src/hooks/apiResources/schemas.ts 
b/superset-frontend/src/hooks/apiResources/schemas.ts
index 4439b894cfa..912023dda69 100644
--- a/superset-frontend/src/hooks/apiResources/schemas.ts
+++ b/superset-frontend/src/hooks/apiResources/schemas.ts
@@ -16,7 +16,7 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { useCallback, useEffect } from 'react';
+import { useCallback, useEffect, useRef } from 'react';
 import { ClientErrorObject } from '@superset-ui/core';
 import useEffectEvent from 'src/hooks/useEffectEvent';
 import { api, JsonResponse } from './queryApi';
@@ -74,48 +74,60 @@ export const EMPTY_SCHEMAS = [] as SchemaOption[];
 
 export function useSchemas(options: Params) {
   const { dbId, catalog, onSuccess, onError } = options || {};
-  const [trigger] = useLazySchemasQuery();
+  const wasFetchingRef = useRef(false);
+  const isRefreshingRef = useRef(false);
   const result = useSchemasQuery(
     { dbId, catalog: catalog || undefined, forceRefresh: false },
     {
       skip: !dbId,
     },
   );
+  const [trigger] = useLazySchemasQuery();
 
-  useEffect(() => {
-    if (result.isError) {
-      onError?.(result.error as ClientErrorObject);
-    }
-  }, [result.isError, result.error, onError]);
-
-  const fetchData = useEffectEvent(
-    (
-      dbId: FetchSchemasQueryParams['dbId'],
-      catalog: FetchSchemasQueryParams['catalog'],
-      forceRefresh = false,
-    ) => {
-      if (dbId && (!result.currentData || forceRefresh)) {
-        trigger({ dbId, catalog, forceRefresh }).then(
-          ({ isSuccess, isError, data }) => {
-            if (isSuccess) {
-              onSuccess?.(data || EMPTY_SCHEMAS, forceRefresh);
-            }
-            if (isError) {
-              onError?.(result.error as ClientErrorObject);
-            }
-          },
-        );
-      }
+  const handleOnSuccess = useEffectEvent(
+    (data: SchemaOption[], isRefetched: boolean) => {
+      onSuccess?.(data, isRefetched);
     },
   );
 
-  useEffect(() => {
-    fetchData(dbId, catalog, false);
-  }, [dbId, catalog, fetchData]);
+  const handleOnError = useEffectEvent((error: ClientErrorObject) => {
+    onError?.(error);
+  });
 
   const refetch = useCallback(() => {
-    fetchData(dbId, catalog, true);
-  }, [dbId, catalog, fetchData]);
+    if (dbId) {
+      // Force a real server refresh. The success/error callbacks are fired by
+      // the subscribed effect below (the single source of truth), which
+      // observes the shared cache entry's isFetching transition. 
isRefreshingRef
+      // flags that completion as a user-requested refresh (isRefetched=true).
+      isRefreshingRef.current = true;
+      trigger({ dbId, catalog, forceRefresh: true });
+    }
+  }, [dbId, catalog, trigger]);
+
+  useEffect(() => {
+    // Fire the success/error callbacks whenever the subscribed query finishes 
a
+    // fetch, not just when data is loaded through the lazy `trigger` path. 
This
+    // covers refetches driven by cache invalidation (e.g. after an OAuth2
+    // redirect) so consumers holding local state such as an auth error banner
+    // are notified. Keying off the isFetching true->false transition avoids
+    // re-firing on cache-hit re-renders, which would spuriously re-run
+    // auto-select logic in the selectors. isRefreshingRef (not the sticky
+    // originalArgs.forceRefresh, which the shared cache entry never resets)
+    // distinguishes a user-requested refresh from other fetches.
+    const { isSuccess, isError, isFetching, currentData, error } = result;
+    if (wasFetchingRef.current && !isFetching) {
+      const isRefetched = isRefreshingRef.current;
+      if (isSuccess && currentData) {
+        handleOnSuccess(currentData, isRefetched);
+      }
+      if (isError) {
+        handleOnError(error as ClientErrorObject);
+      }
+      isRefreshingRef.current = false;
+    }
+    wasFetchingRef.current = isFetching;
+  }, [result, handleOnSuccess, handleOnError]);
 
   return {
     ...result,
diff --git a/superset-frontend/src/hooks/apiResources/tables.test.ts 
b/superset-frontend/src/hooks/apiResources/tables.test.ts
index 4e7abc4fd79..55fb45d3ccc 100644
--- a/superset-frontend/src/hooks/apiResources/tables.test.ts
+++ b/superset-frontend/src/hooks/apiResources/tables.test.ts
@@ -334,4 +334,65 @@ describe('useTables hook', () => {
     await waitFor(() => expect(result.current.data).toEqual(expectedData));
     expect(fetchMock.callHistory.calls(tableApiRoute).length).toBe(4);
   });
+
+  test('fires callbacks on invalidation refetch even after a failed force 
refresh (OAuth2 refresh button)', async () => {
+    // Reviewer regression: serializeQueryArgs strips forceRefresh, so the
+    // subscribed query and the lazy force-refresh trigger share one cache 
entry.
+    // A failed refresh-button click (forceRefresh:true) leaves the entry's
+    // originalArgs.forceRefresh sticky-true. The old 
`!originalArgs.forceRefresh`
+    // guard then suppressed onSuccess/onError on the later invalidation 
refetch,
+    // so TableSelector's banner stayed stuck. The ref-based flag must fire 
them.
+    const expectDbId = 'db1';
+    const expectedSchema = 'schema1';
+    const tableApiRoute = `glob:*/api/v1/database/${expectDbId}/tables/?q=*`;
+    let mode: 'ok' | 'fail' = 'ok';
+    fetchMock.get(tableApiRoute, () =>
+      mode === 'fail' ? { status: 500, body: {} } : fakeApiResult,
+    );
+    fetchMock.get(`glob:*/api/v1/database/${expectDbId}/catalogs/*`, {
+      count: 0,
+      result: [],
+    });
+    fetchMock.get(`glob:*/api/v1/database/${expectDbId}/schemas/*`, {
+      result: fakeSchemaApiResult,
+    });
+    const onSuccess = jest.fn();
+    const onError = jest.fn();
+    const { result } = renderHook(
+      () =>
+        useTables({
+          dbId: expectDbId,
+          schema: expectedSchema,
+          onSuccess,
+          onError,
+        }),
+      {
+        wrapper: createWrapper({
+          useRedux: true,
+          store,
+        }),
+      },
+    );
+
+    // Initial subscribed load succeeds (not a manual refresh: 
isRefetched=false).
+    await waitFor(() => expect(onSuccess).toHaveBeenCalledTimes(1));
+    expect(onSuccess).toHaveBeenLastCalledWith(expectedData, false);
+
+    // User clicks the refresh button (force refresh) and hits the OAuth2 wall.
+    // This makes the shared entry's originalArgs.forceRefresh sticky-true.
+    mode = 'fail';
+    act(() => {
+      result.current.refetch();
+    });
+    await waitFor(() => expect(onError).toHaveBeenCalledTimes(1));
+
+    // User authorizes: invalidateTags refetches the subscribed query, which 
now
+    // succeeds. onSuccess must fire (isRefetched=false: not a manual refresh).
+    mode = 'ok';
+    act(() => {
+      store.dispatch(api.util.invalidateTags(['Tables']));
+    });
+    await waitFor(() => expect(onSuccess).toHaveBeenCalledTimes(2));
+    expect(onSuccess).toHaveBeenLastCalledWith(expectedData, false);
+  });
 });
diff --git a/superset-frontend/src/hooks/apiResources/tables.ts 
b/superset-frontend/src/hooks/apiResources/tables.ts
index e8b59326604..c3c07db9218 100644
--- a/superset-frontend/src/hooks/apiResources/tables.ts
+++ b/superset-frontend/src/hooks/apiResources/tables.ts
@@ -177,6 +177,7 @@ export function useTables(options: Params) {
     onError,
   } = options || {};
   const isMountedRef = useRef(false);
+  const isRefreshingRef = useRef(false);
   const { currentData: schemaOptions, isFetching } = useSchemas({
     dbId,
     catalog: catalog || undefined,
@@ -208,37 +209,33 @@ export function useTables(options: Params) {
 
   const refetch = useCallback(() => {
     if (enabled) {
-      trigger({ dbId, catalog, schema, forceRefresh: true }).then(
-        ({ isSuccess, isError, data, error }) => {
-          if (isSuccess && data) {
-            handleOnSuccess(data, true);
-          }
-          if (isError) {
-            handleOnError(error as ClientErrorObject);
-          }
-        },
-      );
+      // Force a real server refresh. The success/error callbacks are fired by
+      // the subscribed effect below (the single source of truth), which 
observes
+      // the shared cache entry's completion. isRefreshingRef flags that
+      // completion as a user-requested refresh (isRefetched=true).
+      isRefreshingRef.current = true;
+      trigger({ dbId, catalog, schema, forceRefresh: true });
     }
-  }, [dbId, catalog, schema, enabled, handleOnSuccess, handleOnError, 
trigger]);
+  }, [dbId, catalog, schema, enabled, trigger]);
 
   useEffect(() => {
     if (isMountedRef.current) {
-      const {
-        requestId,
-        isSuccess,
-        isError,
-        isFetching,
-        currentData,
-        error,
-        originalArgs,
-      } = result;
-      if (!originalArgs?.forceRefresh && requestId && !isFetching) {
+      const { requestId, isSuccess, isError, isFetching, currentData, error } =
+        result;
+      // Fire once per completed fetch. isRefreshingRef (not the sticky
+      // originalArgs.forceRefresh, which the shared cache entry never resets)
+      // distinguishes a user-requested refresh from other fetches, so a failed
+      // force refresh no longer suppresses the callback on a later
+      // invalidation-driven refetch (e.g. after an OAuth2 redirect).
+      if (requestId && !isFetching) {
+        const isRefetched = isRefreshingRef.current;
         if (isSuccess && currentData) {
-          handleOnSuccess(currentData, false);
+          handleOnSuccess(currentData, isRefetched);
         }
         if (isError) {
           handleOnError(error as ClientErrorObject);
         }
+        isRefreshingRef.current = false;
       }
     } else {
       isMountedRef.current = true;

Reply via email to