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

rusackas 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 5776aff50ac fix(charts): handle async (202) chart-data responses in 
StatefulChart (#42157)
5776aff50ac is described below

commit 5776aff50ac6e4069eb8858dca39ea6330d744e7
Author: jenwitteng <[email protected]>
AuthorDate: Fri Jul 24 08:01:05 2026 +0700

    fix(charts): handle async (202) chart-data responses in StatefulChart 
(#42157)
    
    Co-authored-by: Claude Sonnet 4.5 <[email protected]>
    Co-authored-by: Evan Rusackas <[email protected]>
---
 .../src/chart/components/StatefulChart.test.tsx    | 491 +++++++++++++++++++--
 .../src/chart/components/StatefulChart.tsx         | 187 ++++++--
 .../src/chart/models/ChartProps.ts                 |  12 +
 .../src/components/Chart/ChartRenderer.tsx         |  14 +
 .../src/components/Chart/chartAction.ts            |  47 +-
 .../src/components/Chart/chartActions.test.ts      | 107 +++++
 .../src/middleware/asyncEvent.test.ts              |  61 ++-
 superset-frontend/src/middleware/asyncEvent.ts     |  51 ++-
 8 files changed, 901 insertions(+), 69 deletions(-)

diff --git 
a/superset-frontend/packages/superset-ui-core/src/chart/components/StatefulChart.test.tsx
 
b/superset-frontend/packages/superset-ui-core/src/chart/components/StatefulChart.test.tsx
index 3d73326b45e..e1109a33710 100644
--- 
a/superset-frontend/packages/superset-ui-core/src/chart/components/StatefulChart.test.tsx
+++ 
b/superset-frontend/packages/superset-ui-core/src/chart/components/StatefulChart.test.tsx
@@ -17,7 +17,7 @@
  * under the License.
  */
 
-import { render, waitFor, configure } from '@testing-library/react';
+import { render, waitFor, configure, act } from '@testing-library/react';
 import '@testing-library/jest-dom';
 import StatefulChart from './StatefulChart';
 import getChartControlPanelRegistry from 
'../registries/ChartControlPanelRegistrySingleton';
@@ -67,19 +67,19 @@ beforeEach(() => {
   jest.clearAllMocks();
 
   // Setup default registry mocks
-  (getChartMetadataRegistry as any).mockReturnValue({
+  jest.mocked(getChartMetadataRegistry).mockReturnValue({
     get: jest.fn().mockReturnValue({
       useLegacyApi: false,
     }),
-  });
+  } as unknown as ReturnType<typeof getChartMetadataRegistry>);
 
-  (getChartBuildQueryRegistry as any).mockReturnValue({
+  jest.mocked(getChartBuildQueryRegistry).mockReturnValue({
     get: jest.fn().mockResolvedValue(null),
-  });
+  } as unknown as ReturnType<typeof getChartBuildQueryRegistry>);
 
-  (getChartControlPanelRegistry as any).mockReturnValue({
+  jest.mocked(getChartControlPanelRegistry).mockReturnValue({
     get: jest.fn().mockReturnValue(null),
-  });
+  } as unknown as ReturnType<typeof getChartControlPanelRegistry>);
 
   // Mock ChartClient constructor
   // eslint-disable-next-line global-require, 
@typescript-eslint/no-var-requires
@@ -113,9 +113,9 @@ test('should refetch data when non-renderTrigger control 
changes', async () => {
     ],
   };
 
-  (getChartControlPanelRegistry as any).mockReturnValue({
+  jest.mocked(getChartControlPanelRegistry).mockReturnValue({
     get: jest.fn().mockReturnValue(controlPanelConfig),
-  });
+  } as unknown as ReturnType<typeof getChartControlPanelRegistry>);
 
   const { rerender } = render(
     <StatefulChart formData={mockFormData} chartType="test_chart" />,
@@ -165,9 +165,9 @@ test('should NOT refetch data when only renderTrigger 
controls change', async ()
     ],
   };
 
-  (getChartControlPanelRegistry as any).mockReturnValue({
+  jest.mocked(getChartControlPanelRegistry).mockReturnValue({
     get: jest.fn().mockReturnValue(controlPanelConfig),
-  });
+  } as unknown as ReturnType<typeof getChartControlPanelRegistry>);
 
   const { rerender, getByTestId } = render(
     <StatefulChart formData={mockFormData} chartType="test_chart" />,
@@ -201,9 +201,9 @@ test('should NOT refetch data when only renderTrigger 
controls change', async ()
 
 test('should refetch when control panel config is not available', async () => {
   // No control panel config available
-  (getChartControlPanelRegistry as any).mockReturnValue({
+  jest.mocked(getChartControlPanelRegistry).mockReturnValue({
     get: jest.fn().mockReturnValue(null),
-  });
+  } as unknown as ReturnType<typeof getChartControlPanelRegistry>);
 
   const { rerender } = render(
     <StatefulChart formData={mockFormData} chartType="test_chart" />,
@@ -245,9 +245,9 @@ test('should refetch when viz_type changes', async () => {
     ],
   };
 
-  (getChartControlPanelRegistry as any).mockReturnValue({
+  jest.mocked(getChartControlPanelRegistry).mockReturnValue({
     get: jest.fn().mockReturnValue(controlPanelConfig),
-  });
+  } as unknown as ReturnType<typeof getChartControlPanelRegistry>);
 
   const { rerender } = render(
     <StatefulChart formData={mockFormData} chartType="test_chart" />,
@@ -299,9 +299,9 @@ test('should handle mixed renderTrigger and 
non-renderTrigger changes', async ()
     ],
   };
 
-  (getChartControlPanelRegistry as any).mockReturnValue({
+  jest.mocked(getChartControlPanelRegistry).mockReturnValue({
     get: jest.fn().mockReturnValue(controlPanelConfig),
-  });
+  } as unknown as ReturnType<typeof getChartControlPanelRegistry>);
 
   const { rerender } = render(
     <StatefulChart formData={mockFormData} chartType="test_chart" />,
@@ -352,9 +352,9 @@ test('should handle controls with complex structure', async 
() => {
     ],
   };
 
-  (getChartControlPanelRegistry as any).mockReturnValue({
+  jest.mocked(getChartControlPanelRegistry).mockReturnValue({
     get: jest.fn().mockReturnValue(controlPanelConfig),
-  });
+  } as unknown as ReturnType<typeof getChartControlPanelRegistry>);
 
   const { rerender, getByTestId } = render(
     <StatefulChart formData={mockFormData} chartType="test_chart" />,
@@ -404,11 +404,11 @@ test('should not refetch when formData has not changed', 
async () => {
 
 test('should handle errors gracefully when accessing registry', async () => {
   // Mock registry to throw an error
-  (getChartControlPanelRegistry as any).mockReturnValue({
+  jest.mocked(getChartControlPanelRegistry).mockReturnValue({
     get: jest.fn().mockImplementation(() => {
       throw new Error('Registry error');
     }),
-  });
+  } as unknown as ReturnType<typeof getChartControlPanelRegistry>);
 
   const { rerender } = render(
     <StatefulChart formData={mockFormData} chartType="test_chart" />,
@@ -492,9 +492,9 @@ test('should NOT refetch data when string-based 
renderTrigger control (zoomable)
     ],
   };
 
-  (getChartControlPanelRegistry as any).mockReturnValue({
+  jest.mocked(getChartControlPanelRegistry).mockReturnValue({
     get: jest.fn().mockReturnValue(controlPanelConfig),
-  });
+  } as unknown as ReturnType<typeof getChartControlPanelRegistry>);
 
   const formDataWithZoom = {
     ...mockFormData,
@@ -542,9 +542,9 @@ test('should NOT refetch data when other string-based 
renderTrigger controls cha
     ],
   };
 
-  (getChartControlPanelRegistry as any).mockReturnValue({
+  jest.mocked(getChartControlPanelRegistry).mockReturnValue({
     get: jest.fn().mockReturnValue(controlPanelConfig),
-  });
+  } as unknown as ReturnType<typeof getChartControlPanelRegistry>);
 
   const { rerender, getByTestId } = render(
     <StatefulChart formData={mockFormData} chartType="test_chart" />,
@@ -585,9 +585,9 @@ test('should refetch when string control is NOT in 
RENDER_TRIGGER_SHARED_CONTROL
     ],
   };
 
-  (getChartControlPanelRegistry as any).mockReturnValue({
+  jest.mocked(getChartControlPanelRegistry).mockReturnValue({
     get: jest.fn().mockReturnValue(controlPanelConfig),
-  });
+  } as unknown as ReturnType<typeof getChartControlPanelRegistry>);
 
   const { rerender } = render(
     <StatefulChart formData={mockFormData} chartType="test_chart" />,
@@ -631,9 +631,9 @@ test('should handle mixed string and object controls 
correctly', async () => {
     ],
   };
 
-  (getChartControlPanelRegistry as any).mockReturnValue({
+  jest.mocked(getChartControlPanelRegistry).mockReturnValue({
     get: jest.fn().mockReturnValue(controlPanelConfig),
-  });
+  } as unknown as ReturnType<typeof getChartControlPanelRegistry>);
 
   const formDataWithControls = {
     ...mockFormData,
@@ -687,9 +687,9 @@ test('should refetch when mixing renderTrigger string 
control with non-renderTri
     ],
   };
 
-  (getChartControlPanelRegistry as any).mockReturnValue({
+  jest.mocked(getChartControlPanelRegistry).mockReturnValue({
     get: jest.fn().mockReturnValue(controlPanelConfig),
-  });
+  } as unknown as ReturnType<typeof getChartControlPanelRegistry>);
 
   const formDataWithZoom = {
     ...mockFormData,
@@ -720,6 +720,435 @@ test('should refetch when mixing renderTrigger string 
control with non-renderTri
   });
 });
 
+test('resolves async (202) responses via the injected handleAsyncChartData 
hook', async () => {
+  const asyncJob = {
+    channel_id: 'c1',
+    job_id: 'j1',
+    status: 'running',
+    result_url: '/api/v1/chart/data/abc',
+  };
+  mockChartClient.client.post.mockResolvedValue({
+    response: { status: 202 } as Response,
+    json: asyncJob,
+  });
+  const handleAsyncChartData = jest
+    .fn()
+    .mockResolvedValue([{ data: 'async result' }]);
+
+  const { getByTestId } = render(
+    <StatefulChart
+      formData={mockFormData}
+      chartType="test_chart"
+      hooks={{ handleAsyncChartData }}
+    />,
+  );
+
+  await waitFor(() => {
+    expect(handleAsyncChartData).toHaveBeenCalledTimes(1);
+  });
+  // Delegates the raw response + job metadata (and useLegacyApi + abort 
signal)
+  expect(handleAsyncChartData).toHaveBeenCalledWith(
+    { status: 202 },
+    asyncJob,
+    false,
+    expect.any(AbortSignal),
+  );
+  // Chart renders once the async data resolves
+  await waitFor(() => {
+    expect(getByTestId('super-chart')).toBeInTheDocument();
+  });
+});
+
+test('errors on async (202) response when no async handler is provided', async 
() => {
+  mockChartClient.client.post.mockResolvedValue({
+    response: { status: 202 } as Response,
+    json: { job_id: 'j1', channel_id: 'c1', status: 'running' },
+  });
+  const onError = jest.fn();
+
+  const { findByText } = render(
+    <StatefulChart
+      formData={mockFormData}
+      chartType="test_chart"
+      onError={onError}
+    />,
+  );
+
+  // Fails loudly instead of rendering the job metadata as empty data
+  expect(await findByText(/async handler/i)).toBeInTheDocument();
+  await waitFor(() => {
+    expect(onError).toHaveBeenCalledTimes(1);
+  });
+});
+
+test('renders synchronous (200) responses that include a response object', 
async () => {
+  mockChartClient.client.post.mockResolvedValue({
+    response: { status: 200 } as Response,
+    json: [{ result: [{ data: 'sync result' }] }],
+  });
+
+  const { getByTestId } = render(
+    <StatefulChart formData={mockFormData} chartType="test_chart" />,
+  );
+
+  await waitFor(() => {
+    expect(getByTestId('super-chart')).toBeInTheDocument();
+  });
+  // Synchronous path: no async handler needed, single request
+  expect(mockChartClient.client.post).toHaveBeenCalledTimes(1);
+});
+
+test('wraps the legacy async body as { result: [body] } for the async 
handler', async () => {
+  const legacyBody = { job_id: 'j1', channel_id: 'c1', status: 'running' };
+  mockChartClient.client.post.mockResolvedValue({
+    response: { status: 202 } as Response,
+    json: legacyBody,
+  });
+  // Force the legacy API path for this viz type
+  jest.mocked(getChartMetadataRegistry).mockReturnValue({
+    get: jest.fn().mockReturnValue({ useLegacyApi: true }),
+  } as unknown as ReturnType<typeof getChartMetadataRegistry>);
+  const handleAsyncChartData = jest
+    .fn()
+    .mockResolvedValue([{ data: 'legacy result' }]);
+
+  const { getByTestId } = render(
+    <StatefulChart
+      formData={mockFormData}
+      chartType="test_chart"
+      hooks={{ handleAsyncChartData }}
+    />,
+  );
+
+  await waitFor(() => {
+    expect(handleAsyncChartData).toHaveBeenCalledTimes(1);
+  });
+  // Legacy body must be wrapped to match the V1 response signature
+  expect(handleAsyncChartData).toHaveBeenCalledWith(
+    { status: 202 },
+    { result: [legacyBody] },
+    true,
+    expect.any(AbortSignal),
+  );
+  await waitFor(() => {
+    expect(getByTestId('super-chart')).toBeInTheDocument();
+  });
+});
+
+test('does not apply a superseded async response over a newer one', async () 
=> {
+  mockChartClient.client.post.mockResolvedValue({
+    response: { status: 202 } as Response,
+    json: { job_id: 'j', channel_id: 'c' },
+  });
+  let resolveFirst: (data: unknown) => void = () => {};
+  let resolveSecond: (data: unknown) => void = () => {};
+  const handleAsyncChartData = jest
+    .fn()
+    .mockImplementationOnce(
+      () =>
+        new Promise(resolve => {
+          resolveFirst = resolve;
+        }),
+    )
+    .mockImplementationOnce(
+      () =>
+        new Promise(resolve => {
+          resolveSecond = resolve;
+        }),
+    );
+  const onLoad = jest.fn();
+
+  const { rerender } = render(
+    <StatefulChart
+      formData={mockFormData}
+      chartType="test_chart"
+      hooks={{ handleAsyncChartData }}
+      onLoad={onLoad}
+    />,
+  );
+
+  await waitFor(() => {
+    expect(handleAsyncChartData).toHaveBeenCalledTimes(1);
+  });
+
+  // A newer request supersedes the first (viz_type change forces a refetch)
+  const newFormData = { ...mockFormData, viz_type: 'different_chart' };
+  rerender(
+    <StatefulChart
+      formData={newFormData}
+      chartType="different_chart"
+      hooks={{ handleAsyncChartData }}
+      onLoad={onLoad}
+    />,
+  );
+
+  await waitFor(() => {
+    expect(handleAsyncChartData).toHaveBeenCalledTimes(2);
+  });
+
+  // Resolve the newer request first, then the stale one
+  await act(async () => {
+    resolveSecond([{ data: 'B' }]);
+  });
+  await act(async () => {
+    resolveFirst([{ data: 'A' }]);
+  });
+
+  await waitFor(() => {
+    expect(onLoad).toHaveBeenCalledWith([{ data: 'B' }]);
+  });
+  // The stale (superseded) response must not overwrite the newer one
+  expect(onLoad).not.toHaveBeenCalledWith([{ data: 'A' }]);
+  expect(onLoad).toHaveBeenCalledTimes(1);
+});
+
+test('preserves the detailed message from an async (array) rejection', async 
() => {
+  mockChartClient.client.post.mockResolvedValue({
+    response: { status: 202 } as Response,
+    json: { job_id: 'j', channel_id: 'c' },
+  });
+  const handleAsyncChartData = jest
+    .fn()
+    .mockRejectedValue([{ error: 'Async query failed: table not found' }]);
+  const onError = jest.fn();
+
+  const { findByText } = render(
+    <StatefulChart
+      formData={mockFormData}
+      chartType="test_chart"
+      hooks={{ handleAsyncChartData }}
+      onError={onError}
+    />,
+  );
+
+  // The detailed message survives instead of collapsing to the generic one
+  expect(await findByText(/table not found/i)).toBeInTheDocument();
+  await waitFor(() => {
+    expect(onError).toHaveBeenCalledTimes(1);
+    expect(onError.mock.calls[0][0].message).toContain('table not found');
+  });
+});
+
+test('refetches with the latest formData rather than the initial props', async 
() => {
+  mockChartClient.client.post.mockResolvedValue({
+    response: { status: 200 } as Response,
+    json: [{ result: [{ data: 'x' }] }],
+  });
+
+  const { rerender } = render(
+    <StatefulChart
+      formData={{ ...mockFormData, metrics: ['metric_v1'] }}
+      chartType="test_chart"
+    />,
+  );
+  await waitFor(() => {
+    expect(mockChartClient.client.post).toHaveBeenCalledTimes(1);
+  });
+
+  // Change a data-affecting control -> triggers a refetch
+  rerender(
+    <StatefulChart
+      formData={{ ...mockFormData, metrics: ['metric_v2'] }}
+      chartType="test_chart"
+    />,
+  );
+  await waitFor(() => {
+    expect(mockChartClient.client.post).toHaveBeenCalledTimes(2);
+  });
+
+  // The second request must carry the updated formData, not the initial props
+  const secondRequestConfig = mockChartClient.client.post.mock.calls[1][0];
+  expect(JSON.stringify(secondRequestConfig)).toContain('metric_v2');
+  expect(JSON.stringify(secondRequestConfig)).not.toContain('metric_v1');
+});
+
+test('does not revert a render-only change when a slow async request 
resolves', async () => {
+  mockChartClient.client.post.mockResolvedValue({
+    response: { status: 202 } as Response,
+    json: { job_id: 'j', channel_id: 'c' },
+  });
+  // color_scheme is a renderTrigger control -> its change does not refetch
+  jest.mocked(getChartControlPanelRegistry).mockReturnValue({
+    get: jest.fn().mockReturnValue({
+      controlPanelSections: [
+        {
+          controlSetRows: [
+            [{ name: 'color_scheme', config: { renderTrigger: true } }],
+          ],
+        },
+      ],
+    }),
+  } as unknown as ReturnType<typeof getChartControlPanelRegistry>);
+  let resolveAsync: (data: unknown) => void = () => {};
+  const handleAsyncChartData = jest.fn(
+    () =>
+      new Promise(resolve => {
+        resolveAsync = resolve;
+      }),
+  );
+
+  const { rerender, getByTestId } = render(
+    <StatefulChart
+      formData={{ ...mockFormData, color_scheme: 'scheme_one' }}
+      chartType="test_chart"
+      hooks={{ handleAsyncChartData }}
+    />,
+  );
+  await waitFor(() => {
+    expect(handleAsyncChartData).toHaveBeenCalledTimes(1);
+  });
+
+  // Render-only change while the async request is still pending (no refetch)
+  rerender(
+    <StatefulChart
+      formData={{ ...mockFormData, color_scheme: 'scheme_two' }}
+      chartType="test_chart"
+      hooks={{ handleAsyncChartData }}
+    />,
+  );
+  expect(handleAsyncChartData).toHaveBeenCalledTimes(1);
+
+  // The stale request resolves; it must not revert color_scheme back
+  await act(async () => {
+    resolveAsync([{ data: 'd' }]);
+  });
+
+  await waitFor(() => {
+    expect(getByTestId('super-chart')).toHaveTextContent('scheme_two');
+  });
+  expect(getByTestId('super-chart')).not.toHaveTextContent('scheme_one');
+});
+
+test('passes an abort signal to the async handler and aborts it on unmount', 
async () => {
+  mockChartClient.client.post.mockResolvedValue({
+    response: { status: 202 } as Response,
+    json: { job_id: 'j', channel_id: 'c' },
+  });
+  // Typed with a rest param so mock.calls is indexable (the 4th arg is the 
signal)
+  const handleAsyncChartData = jest.fn(
+    (..._args: unknown[]) => new Promise<never>(() => {}), // never resolves
+  );
+
+  const { unmount } = render(
+    <StatefulChart
+      formData={mockFormData}
+      chartType="test_chart"
+      hooks={{ handleAsyncChartData }}
+    />,
+  );
+
+  await waitFor(() => {
+    expect(handleAsyncChartData).toHaveBeenCalledTimes(1);
+  });
+  const signal = handleAsyncChartData.mock.calls[0][3] as AbortSignal;
+  expect(signal).toBeInstanceOf(AbortSignal);
+  expect(signal.aborted).toBe(false);
+
+  // Unmounting aborts the signal so a signal-aware handler can stop polling
+  unmount();
+  expect(signal.aborted).toBe(true);
+});
+
+test('suppresses stale error state from a superseded request', async () => {
+  mockChartClient.client.post.mockResolvedValue({
+    response: { status: 202 } as Response,
+    json: { job_id: 'j', channel_id: 'c' },
+  });
+  let rejectFirst: (err: unknown) => void = () => {};
+  const handleAsyncChartData = jest
+    .fn()
+    .mockImplementationOnce(
+      () =>
+        new Promise((_resolve, reject) => {
+          rejectFirst = reject;
+        }),
+    )
+    .mockImplementationOnce(() => new Promise(() => {})); // newer request 
stays pending
+  const onError = jest.fn();
+
+  const { rerender } = render(
+    <StatefulChart
+      formData={mockFormData}
+      chartType="test_chart"
+      hooks={{ handleAsyncChartData }}
+      onError={onError}
+    />,
+  );
+  await waitFor(() => {
+    expect(handleAsyncChartData).toHaveBeenCalledTimes(1);
+  });
+
+  // Supersede the first request (aborts its controller)
+  rerender(
+    <StatefulChart
+      formData={{ ...mockFormData, viz_type: 'different_chart' }}
+      chartType="different_chart"
+      hooks={{ handleAsyncChartData }}
+      onError={onError}
+    />,
+  );
+  await waitFor(() => {
+    expect(handleAsyncChartData).toHaveBeenCalledTimes(2);
+  });
+
+  // The stale request now fails; its error must not surface
+  await act(async () => {
+    rejectFirst(new Error('stale failure'));
+  });
+  expect(onError).not.toHaveBeenCalled();
+});
+
+test('does not publish stale data when switching from chartId to formData 
mode', async () => {
+  mockChartClient.loadFormData.mockResolvedValue({ ...mockFormData });
+  mockChartClient.client.post.mockResolvedValue({
+    response: { status: 202 } as Response,
+    json: { job_id: 'j', channel_id: 'c' },
+  });
+  let resolveFirst: (data: unknown) => void = () => {};
+  const handleAsyncChartData = jest
+    .fn()
+    .mockImplementationOnce(
+      () =>
+        new Promise(resolve => {
+          resolveFirst = resolve;
+        }),
+    )
+    .mockImplementationOnce(() => new Promise(() => {}));
+  const onLoad = jest.fn();
+
+  // Start in chartId mode
+  const { rerender } = render(
+    <StatefulChart
+      chartId={1}
+      chartType="test_chart"
+      hooks={{ handleAsyncChartData }}
+      onLoad={onLoad}
+    />,
+  );
+  await waitFor(() => {
+    expect(handleAsyncChartData).toHaveBeenCalledTimes(1);
+  });
+
+  // Switch to direct-formData mode
+  rerender(
+    <StatefulChart
+      formData={{ ...mockFormData, metrics: ['m'] }}
+      chartType="test_chart"
+      hooks={{ handleAsyncChartData }}
+      onLoad={onLoad}
+    />,
+  );
+  await waitFor(() => {
+    expect(handleAsyncChartData).toHaveBeenCalledTimes(2);
+  });
+
+  // The stale chartId-mode request resolves; its data must not be published
+  await act(async () => {
+    resolveFirst([{ data: 'stale' }]);
+  });
+  expect(onLoad).not.toHaveBeenCalledWith([{ data: 'stale' }]);
+});
+
 test('should display error message when HTTP request fails with Response 
object', async () => {
   const errorBody = JSON.stringify({ message: 'Error: division by zero' });
   const mockResponse = new Response(errorBody, {
diff --git 
a/superset-frontend/packages/superset-ui-core/src/chart/components/StatefulChart.tsx
 
b/superset-frontend/packages/superset-ui-core/src/chart/components/StatefulChart.tsx
index baae1ae2ce4..05aa3517f3d 100644
--- 
a/superset-frontend/packages/superset-ui-core/src/chart/components/StatefulChart.tsx
+++ 
b/superset-frontend/packages/superset-ui-core/src/chart/components/StatefulChart.tsx
@@ -18,17 +18,19 @@
  */
 
 import { useState, useEffect, useRef, useCallback } from 'react';
+import { isEqual } from 'lodash';
 import { ParentSize } from '@visx/responsive';
 import { t } from '@apache-superset/core/translation';
 import {
   QueryFormData,
   QueryData,
+  JsonObject,
   SupersetClientInterface,
   buildQueryContext,
   RequestConfig,
   getClientErrorObject,
+  ensureIsArray,
 } from '../..';
-import type { HandlerFunction } from '../types/Base';
 import { Loading } from '../../components/Loading';
 import ChartClient from '../clients/ChartClient';
 import getChartBuildQueryRegistry from 
'../registries/ChartBuildQueryRegistrySingleton';
@@ -189,6 +191,12 @@ export default function StatefulChart(props: 
StatefulChartProps) {
   const chartClientRef = useRef<ChartClient>();
   const abortControllerRef = useRef<AbortController>();
 
+  // fetchData is memoized with an empty dep list, so it would otherwise close
+  // over the first render's props. Keep the latest props in a ref so refetches
+  // (triggered by updated filters/formData/overrides) use current values.
+  const propsRef = useRef(props);
+  propsRef.current = props;
+
   // Initialize chart client
   if (!chartClientRef.current) {
     chartClientRef.current = new ChartClient({ client: props.client });
@@ -199,20 +207,48 @@ export default function StatefulChart(props: 
StatefulChartProps) {
       chartId,
       formData: propsFormData,
       formDataOverrides,
-      onError,
-      onLoad,
       chartType,
       force,
       timeout,
-    } = props;
+      hooks,
+    } = propsRef.current;
 
     // Cancel any in-flight requests
     if (abortControllerRef.current) {
       abortControllerRef.current.abort();
     }
 
-    // Create new abort controller
-    abortControllerRef.current = new AbortController();
+    // Create new abort controller (kept in a local so we can detect when this
+    // request has been superseded by a newer one, even across async awaits).
+    const controller = new AbortController();
+    abortControllerRef.current = controller;
+
+    // A request is superseded if it was aborted, or if the props changed in a
+    // data-affecting way since it began - including switching between chartId
+    // and direct-formData modes. Props are captured during render but the 
abort
+    // happens in a passive effect, so the abort signal alone can let a stale
+    // success or error slip through in the render->effect gap. This mirrors 
the
+    // effect's own refetch decision; render-only changes are intentionally not
+    // treated as superseding.
+    const isSuperseded = () => {
+      if (controller.signal.aborted) {
+        return true;
+      }
+      const latest = propsRef.current;
+      const vizTypeForCompare = latest.formData?.viz_type || latest.chartType;
+      return (
+        latest.chartId !== chartId ||
+        // Deep compare overrides: callers commonly pass a fresh object with 
the
+        // same contents each render, which should not count as superseding.
+        !isEqual(latest.formDataOverrides, formDataOverrides) ||
+        latest.force !== force ||
+        Boolean(propsFormData) !== Boolean(latest.formData) ||
+        (!!propsFormData &&
+          !!latest.formData &&
+          latest.formData !== propsFormData &&
+          shouldRefetchData(propsFormData, latest.formData, vizTypeForCompare))
+      );
+    };
 
     setStatus('loading');
     setError(undefined);
@@ -224,7 +260,7 @@ export default function StatefulChart(props: 
StatefulChartProps) {
         // Load formData from chartId
         finalFormData = await chartClientRef.current!.loadFormData(
           { sliceId: chartId },
-          { signal: abortControllerRef.current.signal } as RequestConfig,
+          { signal: controller.signal } as RequestConfig,
         );
       } else if (propsFormData) {
         // Use provided formData
@@ -267,7 +303,7 @@ export default function StatefulChart(props: 
StatefulChartProps) {
 
       const requestConfig: RequestConfig = {
         endpoint,
-        signal: abortControllerRef.current.signal,
+        signal: controller.signal,
         ...(timeout && { timeout: timeout * 1000 }),
       };
 
@@ -285,39 +321,136 @@ export default function StatefulChart(props: 
StatefulChartProps) {
         };
       }
 
-      const response = await 
chartClientRef.current!.client.post(requestConfig);
-      let responseData = Array.isArray(response.json)
-        ? response.json
-        : [response.json];
+      const clientResponse =
+        await chartClientRef.current!.client.post(requestConfig);
+
+      // A newer request may have started while the POST was in flight; discard
+      // this stale response so it can't overwrite the newer chart data.
+      if (isSuperseded()) {
+        return;
+      }
 
-      // Handle the nested result structure from the new API
-      if (!useLegacyApi && responseData[0]?.result) {
-        responseData = responseData[0].result;
+      const rawResponse = clientResponse.response as Response | undefined;
+
+      let responseData: QueryData[];
+      if (rawResponse?.status === 202) {
+        // With GLOBAL_ASYNC_QUERIES the query is dispatched to a Celery worker
+        // and the 202 body is job metadata (channel_id, job_id, result_url),
+        // not chart data. Delegate to the injected handler, which polls the
+        // async event channel and resolves the cached results. Without a
+        // handler we fail loudly rather than rendering the job metadata as if
+        // it were an (empty) result set.
+        if (!hooks?.handleAsyncChartData) {
+          throw new Error(
+            'Received an async chart data response (HTTP 202) but no async ' +
+              'handler was provided, so results cannot be retrieved. Wire up ' 
+
+              'the async handler or disable GLOBAL_ASYNC_QUERIES for this 
chart.',
+          );
+        }
+        // The async handler (handleChartDataResponse) expects the V1 chart 
data
+        // response signature. The legacy endpoint returns a flat body, so wrap
+        // it as { result: [body] } exactly like legacyChartDataRequest does 
for
+        // the standard chart path; the V1 body is already correctly shaped.
+        const asyncPayload = useLegacyApi
+          ? ({ result: [clientResponse.json] } as JsonObject)
+          : (clientResponse.json as JsonObject);
+        responseData = ensureIsArray(
+          await hooks.handleAsyncChartData(
+            rawResponse,
+            asyncPayload,
+            useLegacyApi,
+            controller.signal,
+          ),
+        );
+
+        // Async results can resolve well after a newer request began polling.
+        if (isSuperseded()) {
+          return;
+        }
+      } else {
+        const rows = (
+          Array.isArray(clientResponse.json)
+            ? clientResponse.json
+            : [clientResponse.json]
+        ) as JsonObject[];
+
+        // Handle the nested result structure from the new API
+        responseData = (
+          !useLegacyApi && rows[0]?.result ? rows[0].result : rows
+        ) as QueryData[];
+      }
+
+      // Don't pair this request's data with newer props or fire a stale onLoad
+      // if it has been superseded (see isSuperseded).
+      if (isSuperseded()) {
+        return;
       }
 
+      const latestProps = propsRef.current;
       setStatus('loaded');
       setData(responseData);
-      setFormData(finalFormData);
+      // Render the resolved data with the latest formData so a render-only
+      // change made while the request was in flight isn't reverted.
+      setFormData(
+        latestProps.formData
+          ? {
+              ...latestProps.formData,
+              ...latestProps.formDataOverrides,
+              viz_type: finalFormData.viz_type,
+            }
+          : finalFormData,
+      );
 
-      if (onLoad) {
-        onLoad(responseData);
+      // Read onLoad from the latest props (like setFormData above) so a stale
+      // callback captured at request start isn't invoked.
+      if (latestProps.onLoad) {
+        latestProps.onLoad(responseData);
       }
     } catch (err) {
-      // Ignore abort errors
-      if ((err as Error).name === 'AbortError') {
+      // Ignore aborted requests, whether they threw AbortError or were
+      // superseded by a newer request (including the render->effect gap).
+      if ((err as Error)?.name === 'AbortError' || isSuperseded()) {
         return;
       }
 
-      const parsedError = await getClientErrorObject(
-        err as Parameters<typeof getClientErrorObject>[0],
-      );
-      const errorMessage =
-        parsedError.error || parsedError.message || 'An error occurred';
+      // waitForAsyncData rejects with an array of already-parsed client-error
+      // objects; unwrap the first element so its detailed message survives.
+      const rawError = Array.isArray(err) ? err[0] : err;
+
+      let errorMessage: string | undefined;
+      if (
+        rawError &&
+        typeof rawError === 'object' &&
+        !(rawError instanceof Error) &&
+        !(rawError instanceof Response) &&
+        typeof (rawError as { error?: unknown }).error === 'string'
+      ) {
+        // Already a parsed client-error object (e.g. from the async handler);
+        // getClientErrorObject would discard its `error` field, so read it 
here.
+        const parsed = rawError as { error?: string; message?: string };
+        errorMessage = parsed.error || parsed.message;
+      } else {
+        const parsedError = await getClientErrorObject(
+          rawError as Parameters<typeof getClientErrorObject>[0],
+        );
+        errorMessage = parsedError.error || parsedError.message;
+      }
+
+      const errorObj = new Error(errorMessage || 'An error occurred');
+
+      // The request may have been superseded while its error response was 
being
+      // parsed above (or in the render->effect gap before its abort ran); 
don't
+      // set stale error state or call onError in that case.
+      if (isSuperseded()) {
+        return;
+      }
 
-      const errorObj = new Error(errorMessage);
       setStatus('error');
       setError(errorObj);
 
+      // Read onError from the latest props so a stale callback captured at
+      // request start isn't invoked.
+      const { onError } = propsRef.current;
       if (onError) {
         onError(errorObj);
       }
@@ -481,7 +614,7 @@ export default function StatefulChart(props: 
StatefulChartProps) {
         enableNoResults={enableNoResults}
         noResults={NoDataComponent && <NoDataComponent />}
         onRenderSuccess={onRenderSuccess}
-        onRenderFailure={onRenderFailure as HandlerFunction | undefined}
+        onRenderFailure={onRenderFailure}
         hooks={hooks}
       />
     );
diff --git 
a/superset-frontend/packages/superset-ui-core/src/chart/models/ChartProps.ts 
b/superset-frontend/packages/superset-ui-core/src/chart/models/ChartProps.ts
index a66667c5b36..c372a0cc691 100644
--- a/superset-frontend/packages/superset-ui-core/src/chart/models/ChartProps.ts
+++ b/superset-frontend/packages/superset-ui-core/src/chart/models/ChartProps.ts
@@ -66,6 +66,18 @@ type Hooks = {
   setTooltip?: HandlerFunction;
   /* handle legend scroll changes */
   onLegendScroll?: HandlerFunction;
+  /**
+   * Resolve an async chart-data response (HTTP 202 from GLOBAL_ASYNC_QUERIES).
+   * Injected by the app so components in this package (e.g. Matrixify's
+   * StatefulChart) can await async results without importing app-level
+   * async-event middleware. Returns the resolved query results.
+   */
+  handleAsyncChartData?: (
+    response: Response,
+    json: JsonObject,
+    useLegacyApi?: boolean,
+    signal?: AbortSignal,
+  ) => Promise<QueryData[]> | QueryData[];
 } & PlainObject;
 
 /**
diff --git a/superset-frontend/src/components/Chart/ChartRenderer.tsx 
b/superset-frontend/src/components/Chart/ChartRenderer.tsx
index 65be71c5aff..b192c9cf1f2 100644
--- a/superset-frontend/src/components/Chart/ChartRenderer.tsx
+++ b/superset-frontend/src/components/Chart/ChartRenderer.tsx
@@ -56,6 +56,7 @@ import type { Dispatch } from 'redux';
 import ChartContextMenu, {
   ChartContextMenuRef,
 } from './ChartContextMenu/ChartContextMenu';
+import { handleChartDataResponse } from './chartAction';
 
 // Types for filter values
 type FilterValue = string | number | boolean | null | undefined;
@@ -162,6 +163,15 @@ interface ChartHooks {
   setDataMask: (dataMask: DataMask) => void;
   onLegendScroll: (legendIndex: number) => void;
   onChartStateChange?: (chartState: AgGridChartState) => void;
+  // Resolve async (HTTP 202 / GLOBAL_ASYNC_QUERIES) chart-data responses for
+  // self-contained chart components in superset-ui-core (e.g. StatefulChart),
+  // which cannot import app-level async-event middleware.
+  handleAsyncChartData?: (
+    response: Response,
+    json: JsonObject,
+    useLegacyApi?: boolean,
+    signal?: AbortSignal,
+  ) => Promise<QueryData[]> | QueryData[];
 }
 
 const BLANK = {};
@@ -385,6 +395,10 @@ function ChartRendererComponent({
       setDataMask: setDataMaskCallback,
       onLegendScroll: handleLegendScroll,
       onChartStateChange,
+      // Lets self-contained chart components in superset-ui-core (e.g.
+      // StatefulChart) resolve async (202) chart-data responses without
+      // depending on app-level async-event middleware.
+      handleAsyncChartData: handleChartDataResponse,
     }),
     [
       handleAddFilter,
diff --git a/superset-frontend/src/components/Chart/chartAction.ts 
b/superset-frontend/src/components/Chart/chartAction.ts
index 65b94f231b0..2e6854e0791 100644
--- a/superset-frontend/src/components/Chart/chartAction.ts
+++ b/superset-frontend/src/components/Chart/chartAction.ts
@@ -51,6 +51,7 @@ import { Logger, LOG_ACTIONS_LOAD_CHART } from 
'src/logger/LogUtils';
 import { allowCrossDomain as domainShardingEnabled } from 
'src/utils/hostNamesConfig';
 import { updateDataMask } from 'src/dataMask/actions';
 import { waitForAsyncData } from 'src/middleware/asyncEvent';
+import { ensureAppRoot } from 'src/utils/navigationUtils';
 import { safeStringify } from 'src/utils/safeStringify';
 import { extendedDayjs } from '@superset-ui/core/utils/dates';
 import type { Dispatch, Action, AnyAction } from 'redux';
@@ -699,6 +700,7 @@ export function handleChartDataResponse(
   response: Response,
   json: { result: QueryData[] },
   useLegacyApi?: boolean,
+  signal?: AbortSignal,
 ): Promise<QueryData[]> | QueryData[] {
   if (isFeatureEnabled(FeatureFlag.GlobalAsyncQueries)) {
     // deal with getChartDataRequest transforming the response data
@@ -711,13 +713,17 @@ export function handleChartDataResponse(
         // Query is running asynchronously and we must await the results.
         // When status is 202, result contains async event data (job_id, 
channel_id, etc.)
         // which differs from QueryData. We cast through unknown to handle 
this safely.
+        // The optional signal lets a caller (e.g. StatefulChart) cancel the 
wait
+        // when its chart is superseded or unmounted, avoiding leaked 
listeners.
         if (useLegacyApi) {
           return waitForAsyncData(
             result[0] as unknown as Parameters<typeof waitForAsyncData>[0],
+            signal,
           ) as Promise<QueryData[]>;
         }
         return waitForAsyncData(
           result as unknown as Parameters<typeof waitForAsyncData>[0],
+          signal,
         ) as Promise<QueryData[]>;
       default:
         throw new Error(
@@ -853,11 +859,40 @@ export function exploreJSON(
           }
 
           if (isFeatureEnabled(FeatureFlag.GlobalAsyncQueries)) {
-            // In async mode we just pass the raw error response through
-            return dispatch(
-              chartUpdateFailed(
-                [response as JsonObject],
-                key as string | number,
+            // `waitForAsyncData` rejects with an already-normalized 
async-event
+            // error object (JOB_STATUS.ERROR) or with an array of client error
+            // objects (cached-data fetch failure). Those carry a usable
+            // `error`/`errors` field and can be passed straight through.
+            // Synchronous HTTP failures — e.g. a QueryObjectValidationError
+            // surfaced by the pre-cache probe in `_run_async` — reject with a
+            // raw response that still needs parsing, otherwise the chart error
+            // banner renders a bare "Data error" with no description.
+            if (Array.isArray(response)) {
+              return dispatch(
+                chartUpdateFailed(
+                  response as JsonObject[],
+                  key as string | number,
+                ),
+              );
+            }
+            if (
+              response != null &&
+              typeof response === 'object' &&
+              !(response instanceof Response) &&
+              ('error' in response || 'errors' in response)
+            ) {
+              return dispatch(
+                chartUpdateFailed(
+                  [response as JsonObject],
+                  key as string | number,
+                ),
+              );
+            }
+            return getClientErrorObject(
+              response as unknown as Parameters<typeof 
getClientErrorObject>[0],
+            ).then((parsedResponse: JsonObject) =>
+              dispatch(
+                chartUpdateFailed([parsedResponse], key as string | number),
               ),
             );
           }
@@ -960,7 +995,7 @@ export function redirectSQLLab(
             requestedQuery: payload,
           });
         } else {
-          SupersetClient.postForm(redirectUrl, {
+          SupersetClient.postForm(ensureAppRoot(redirectUrl), {
             form_data: safeStringify(payload),
           });
         }
diff --git a/superset-frontend/src/components/Chart/chartActions.test.ts 
b/superset-frontend/src/components/Chart/chartActions.test.ts
index 30ba44359aa..7bfe8dcc580 100644
--- a/superset-frontend/src/components/Chart/chartActions.test.ts
+++ b/superset-frontend/src/components/Chart/chartActions.test.ts
@@ -463,6 +463,113 @@ describe('chart actions', () => {
       expect(addWarningToastSpy).not.toHaveBeenCalled();
       addWarningToastSpy.mockRestore();
     });
+
+    // eslint-disable-next-line no-restricted-globals -- TODO: Migrate from 
describe blocks
+    describe('GlobalAsyncQueries error handling', () => {
+      beforeEach(() => {
+        (
+          global as unknown as { featureFlags: Record<string, boolean> }
+        ).featureFlags = {
+          [FeatureFlag.GlobalAsyncQueries]: true,
+        };
+      });
+
+      beforeEach(() => {
+        // Simulate the server dispatching the query asynchronously so
+        // handleChartDataResponse delegates to waitForAsyncData.
+        fetchMock.removeRoute(MOCK_URL);
+        fetchMock.post(
+          `glob:*${MOCK_URL}*`,
+          { status: 202, body: { result: [{ job_id: 'job-1' }] } },
+          { name: MOCK_URL },
+        );
+      });
+
+      afterEach(() => {
+        fetchMock.removeRoute(MOCK_URL);
+        setupDefaultFetchMock();
+      });
+
+      test('dispatches CHART_UPDATE_FAILED with the array as-is when 
waitForAsyncData rejects with an array of client error objects', async () => {
+        const clientErrors = [{ error: 'cached-data fetch failed' }];
+        waitForAsyncDataStub.mockImplementation(() =>
+          Promise.reject(clientErrors),
+        );
+
+        const actionThunk = actions.postChartFormData(
+          { viz_type: 'my_viz' } as QueryFormData,
+          false,
+          undefined,
+          undefined,
+        );
+        await actionThunk(
+          dispatch as unknown as actions.ChartThunkDispatch,
+          mockGetState as unknown as () => actions.RootState,
+          undefined,
+        );
+
+        const updateFailedAction = dispatch.mock.calls.find(
+          ([action]) => action?.type === actions.CHART_UPDATE_FAILED,
+        )?.[0];
+        expect(updateFailedAction).toBeDefined();
+        expect(updateFailedAction.queriesResponse).toEqual(clientErrors);
+      });
+
+      test('dispatches CHART_UPDATE_FAILED wrapping the error object when 
waitForAsyncData rejects with a normalized async-event error', async () => {
+        const asyncEventError = { error: 'query failed', errors: [] };
+        waitForAsyncDataStub.mockImplementation(() =>
+          Promise.reject(asyncEventError),
+        );
+
+        const actionThunk = actions.postChartFormData(
+          { viz_type: 'my_viz' } as QueryFormData,
+          false,
+          undefined,
+          undefined,
+        );
+        await actionThunk(
+          dispatch as unknown as actions.ChartThunkDispatch,
+          mockGetState as unknown as () => actions.RootState,
+          undefined,
+        );
+
+        const updateFailedAction = dispatch.mock.calls.find(
+          ([action]) => action?.type === actions.CHART_UPDATE_FAILED,
+        )?.[0];
+        expect(updateFailedAction).toBeDefined();
+        expect(updateFailedAction.queriesResponse).toEqual([asyncEventError]);
+      });
+
+      test('dispatches CHART_UPDATE_FAILED with a parsed error when the 
pre-cache probe rejects with a raw Response', async () => {
+        const rawResponse = new Response(
+          JSON.stringify({ message: 'validation failed' }),
+          { status: 400, statusText: 'Bad Request' },
+        );
+        waitForAsyncDataStub.mockImplementation(() =>
+          Promise.reject(rawResponse),
+        );
+
+        const actionThunk = actions.postChartFormData(
+          { viz_type: 'my_viz' } as QueryFormData,
+          false,
+          undefined,
+          undefined,
+        );
+        await actionThunk(
+          dispatch as unknown as actions.ChartThunkDispatch,
+          mockGetState as unknown as () => actions.RootState,
+          undefined,
+        );
+
+        const updateFailedAction = dispatch.mock.calls.find(
+          ([action]) => action?.type === actions.CHART_UPDATE_FAILED,
+        )?.[0];
+        expect(updateFailedAction).toBeDefined();
+        expect(updateFailedAction.queriesResponse[0].error).toBe(
+          'validation failed',
+        );
+      });
+    });
   });
 
   // eslint-disable-next-line no-restricted-globals -- TODO: Migrate from 
describe blocks
diff --git a/superset-frontend/src/middleware/asyncEvent.test.ts 
b/superset-frontend/src/middleware/asyncEvent.test.ts
index e78e95769b7..909f1887076 100644
--- a/superset-frontend/src/middleware/asyncEvent.test.ts
+++ b/superset-frontend/src/middleware/asyncEvent.test.ts
@@ -18,7 +18,11 @@
  */
 import fetchMock from 'fetch-mock';
 import WS from 'jest-websocket-mock';
-import { parseErrorJson, isFeatureEnabled } from '@superset-ui/core';
+import {
+  parseErrorJson,
+  isFeatureEnabled,
+  SupersetClient,
+} from '@superset-ui/core';
 import * as asyncEvent from 'src/middleware/asyncEvent';
 
 jest.mock('@superset-ui/core', () => ({
@@ -524,5 +528,60 @@ describe('asyncEvent middleware', () => {
       
expect(fetchMock.callHistory.calls(CACHED_DATA_ENDPOINT)).toHaveLength(1);
       expect(fetchMock.callHistory.calls(EVENTS_ENDPOINT)).toHaveLength(0);
     });
+
+    test('rejects with AbortError and stops listening when the signal aborts', 
async () => {
+      await wsServer.connected;
+
+      const controller = new AbortController();
+      const promise = asyncEvent.waitForAsyncData(
+        asyncPendingEvent,
+        controller.signal,
+      );
+      const assertion = expect(promise).rejects.toMatchObject({
+        name: 'AbortError',
+      });
+      controller.abort();
+      await assertion;
+
+      // A late DONE event must not trigger a cached-data fetch: the listener
+      // was removed on abort, so no leak / stray request.
+      wsServer.send(JSON.stringify(asyncDoneEvent));
+      await new Promise(resolve => {
+        setTimeout(resolve, 0);
+      });
+      
expect(fetchMock.callHistory.calls(CACHED_DATA_ENDPOINT)).toHaveLength(0);
+    });
+
+    test('rejects immediately if the signal is already aborted', async () => {
+      await wsServer.connected;
+
+      const controller = new AbortController();
+      controller.abort();
+
+      await expect(
+        asyncEvent.waitForAsyncData(asyncPendingEvent, controller.signal),
+      ).rejects.toMatchObject({ name: 'AbortError' });
+    });
+
+    test('forwards the abort signal to the cached-data download', async () => {
+      await wsServer.connected;
+
+      const getSpy = jest.spyOn(SupersetClient, 'get');
+      const controller = new AbortController();
+
+      const promise = asyncEvent.waitForAsyncData(
+        asyncPendingEvent,
+        controller.signal,
+      );
+      wsServer.send(JSON.stringify(asyncDoneEvent));
+      await expect(promise).resolves.toEqual([chartData]);
+
+      // The cached-result download must receive the signal so it can be
+      // cancelled if the caller aborts mid-fetch.
+      expect(getSpy).toHaveBeenCalledWith(
+        expect.objectContaining({ signal: controller.signal }),
+      );
+      getSpy.mockRestore();
+    });
   });
 });
diff --git a/superset-frontend/src/middleware/asyncEvent.ts 
b/superset-frontend/src/middleware/asyncEvent.ts
index b22a34d3f1b..622926a49fa 100644
--- a/superset-frontend/src/middleware/asyncEvent.ts
+++ b/superset-frontend/src/middleware/asyncEvent.ts
@@ -86,12 +86,14 @@ const removeListener = (id: string) => {
 
 const fetchCachedData = async (
   asyncEvent: AsyncEvent,
+  signal?: AbortSignal,
 ): Promise<CachedDataResponse> => {
   let status = 'success';
   let data;
   try {
     const { json } = await SupersetClient.get({
       endpoint: String(asyncEvent.result_url),
+      signal,
     });
     data = 'result' in json ? json.result : json;
   } catch (response) {
@@ -102,32 +104,73 @@ const fetchCachedData = async (
   return { status, data };
 };
 
-export const waitForAsyncData = async (asyncResponse: AsyncEvent) =>
+export const waitForAsyncData = async (
+  asyncResponse: AsyncEvent,
+  signal?: AbortSignal,
+) =>
   new Promise((resolve, reject) => {
     const jobId = asyncResponse.job_id;
+
+    let onAbort: (() => void) | undefined;
+    const cleanup = () => {
+      removeListener(jobId);
+      if (onAbort && signal) {
+        signal.removeEventListener('abort', onAbort);
+      }
+    };
+
+    // Bail immediately if the caller has already aborted (e.g. the chart was
+    // unmounted before the job started), avoiding a leaked listener.
+    if (signal?.aborted) {
+      reject(new DOMException('Aborted', 'AbortError'));
+      return;
+    }
+
     const listener = async (asyncEvent: AsyncEvent) => {
       switch (asyncEvent.status) {
         case JOB_STATUS.DONE: {
-          let { data, status } = await fetchCachedData(asyncEvent); // 
eslint-disable-line prefer-const
+          // Forward the signal so the cached-result download is cancelled too 
if
+          // the caller aborts mid-fetch, rather than wasting 
network/processing.
+          let { data, status } = await fetchCachedData(asyncEvent, signal); // 
eslint-disable-line prefer-const
           data = ensureIsArray(data);
           if (status === 'success') {
             resolve(data);
           } else {
             reject(data);
           }
+          // Terminal status: the promise is settled, so fully clean up.
+          cleanup();
           break;
         }
         case JOB_STATUS.ERROR: {
           const err = parseErrorJson(asyncEvent);
           reject(err);
+          // Terminal status: the promise is settled, so fully clean up.
+          cleanup();
           break;
         }
         default: {
-          logging.warn('received event with status', asyncEvent.status);
+          // Non-terminal status (e.g., 'pending', 'running'): keep the 
listener
+          // registered so it can receive the eventual terminal event ('done', 
'error').
+          // Only cleanup happens on terminal states or abort.
+          logging.info(
+            'received non-terminal event with status',
+            asyncEvent.status,
+          );
         }
       }
-      removeListener(jobId);
     };
+
+    // When the caller aborts (chart superseded/unmounted), stop listening so 
the
+    // listener and its retained closure don't leak and keep the poller busy.
+    if (signal) {
+      onAbort = () => {
+        cleanup();
+        reject(new DOMException('Aborted', 'AbortError'));
+      };
+      signal.addEventListener('abort', onAbort, { once: true });
+    }
+
     addListener(jobId, listener);
   });
 

Reply via email to