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);
});