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 22cfc4536b fix(export): URL prefix handling for subdirectory 
deployments (#36771)
22cfc4536b is described below

commit 22cfc4536b17120227778081d6ef9ba264c2215b
Author: Joe Li <[email protected]>
AuthorDate: Mon Jan 12 10:58:59 2026 -0800

    fix(export): URL prefix handling for subdirectory deployments (#36771)
    
    Co-authored-by: Claude Opus 4.5 <[email protected]>
---
 .../SqlLab/components/ResultSet/ResultSet.test.tsx | 141 +++
 .../src/SqlLab/components/ResultSet/index.tsx      |   2 +-
 .../useStreamingExport.test.ts                     | 959 +++++++++++++++++++++
 .../StreamingExportModal/useStreamingExport.ts     |  57 +-
 .../src/explore/exploreUtils/exportChart.test.ts   | 173 ++++
 superset-frontend/src/utils/export.test.ts         |  51 ++
 superset-frontend/src/utils/export.ts              |   5 +-
 7 files changed, 1381 insertions(+), 7 deletions(-)

diff --git 
a/superset-frontend/src/SqlLab/components/ResultSet/ResultSet.test.tsx 
b/superset-frontend/src/SqlLab/components/ResultSet/ResultSet.test.tsx
index 29e2fd162f..a45e38bf0c 100644
--- a/superset-frontend/src/SqlLab/components/ResultSet/ResultSet.test.tsx
+++ b/superset-frontend/src/SqlLab/components/ResultSet/ResultSet.test.tsx
@@ -47,6 +47,19 @@ jest.mock('src/components/ErrorMessage', () => ({
   ErrorMessageWithStackTrace: () => <div data-test="error-message">Error</div>,
 }));
 
+// Mock useStreamingExport to capture startExport calls
+const mockStartExport = jest.fn();
+const mockResetExport = jest.fn();
+const mockCancelExport = jest.fn();
+jest.mock('src/components/StreamingExportModal/useStreamingExport', () => ({
+  useStreamingExport: () => ({
+    startExport: mockStartExport,
+    resetExport: mockResetExport,
+    cancelExport: mockCancelExport,
+    progress: { status: 'streaming', rowsProcessed: 0 },
+  }),
+}));
+
 jest.mock(
   'react-virtualized-auto-sizer',
   () =>
@@ -160,6 +173,7 @@ describe('ResultSet', () => {
 
   beforeEach(() => {
     applicationRootMock.mockReturnValue('');
+    mockStartExport.mockClear();
   });
 
   // Add cleanup after each test
@@ -657,4 +671,131 @@ describe('ResultSet', () => {
     const resultsCalls = fetchMock.calls('glob:*/api/v1/sqllab/results/*');
     expect(resultsCalls).toHaveLength(1);
   });
+
+  test('should use non-streaming export (href) when rows below threshold', 
async () => {
+    // This test validates that when rows < CSV_STREAMING_ROW_THRESHOLD,
+    // the component uses the direct download href instead of streaming export.
+    const appRoot = '/superset';
+    applicationRootMock.mockReturnValue(appRoot);
+
+    // Create a query with rows BELOW the threshold
+    const smallQuery = {
+      ...queries[0],
+      rows: 500, // Below the 1000 threshold
+      limitingFactor: 'NOT_LIMITED',
+    };
+
+    const { getByTestId } = setup(
+      mockedProps,
+      mockStore({
+        ...initialState,
+        user: {
+          ...user,
+          roles: {
+            sql_lab: [['can_export_csv', 'SQLLab']],
+          },
+        },
+        sqlLab: {
+          ...initialState.sqlLab,
+          queries: {
+            [smallQuery.id]: smallQuery,
+          },
+        },
+        common: {
+          conf: {
+            CSV_STREAMING_ROW_THRESHOLD: 1000,
+          },
+        },
+      }),
+    );
+
+    await waitFor(() => {
+      expect(getByTestId('export-csv-button')).toBeInTheDocument();
+    });
+
+    const exportButton = getByTestId('export-csv-button');
+
+    // Non-streaming export should have href attribute with prefixed URL
+    expect(exportButton).toHaveAttribute(
+      'href',
+      expect.stringMatching(new RegExp(`^${appRoot}/api/v1/sqllab/export/`)),
+    );
+
+    // Click should NOT trigger startExport for non-streaming
+    fireEvent.click(exportButton);
+    expect(mockStartExport).not.toHaveBeenCalled();
+  });
+
+  test.each([
+    {
+      name: 'no prefix (default deployment)',
+      appRoot: '',
+      expectedUrl: '/api/v1/sqllab/export_streaming/',
+    },
+    {
+      name: 'with subdirectory prefix',
+      appRoot: '/superset',
+      expectedUrl: '/superset/api/v1/sqllab/export_streaming/',
+    },
+    {
+      name: 'with nested subdirectory prefix',
+      appRoot: '/my-app/superset',
+      expectedUrl: '/my-app/superset/api/v1/sqllab/export_streaming/',
+    },
+  ])(
+    'streaming export URL respects app root configuration: $name',
+    async ({ appRoot, expectedUrl }) => {
+      // This test validates that streaming export startExport receives the 
correct URL
+      // based on the applicationRoot configuration.
+      applicationRootMock.mockReturnValue(appRoot);
+
+      // Create a query with enough rows to trigger streaming export (>= 
threshold)
+      const largeQuery = {
+        ...queries[0],
+        rows: 5000, // Above the default 1000 threshold
+        limitingFactor: 'NOT_LIMITED',
+      };
+
+      const { getByTestId } = setup(
+        mockedProps,
+        mockStore({
+          ...initialState,
+          user: {
+            ...user,
+            roles: {
+              sql_lab: [['can_export_csv', 'SQLLab']],
+            },
+          },
+          sqlLab: {
+            ...initialState.sqlLab,
+            queries: {
+              [largeQuery.id]: largeQuery,
+            },
+          },
+          common: {
+            conf: {
+              CSV_STREAMING_ROW_THRESHOLD: 1000,
+            },
+          },
+        }),
+      );
+
+      await waitFor(() => {
+        expect(getByTestId('export-csv-button')).toBeInTheDocument();
+      });
+
+      const exportButton = getByTestId('export-csv-button');
+      fireEvent.click(exportButton);
+
+      // Verify startExport was called exactly once
+      expect(mockStartExport).toHaveBeenCalledTimes(1);
+
+      // The URL should match the expected prefixed URL
+      expect(mockStartExport).toHaveBeenCalledWith(
+        expect.objectContaining({
+          url: expectedUrl,
+        }),
+      );
+    },
+  );
 });
diff --git a/superset-frontend/src/SqlLab/components/ResultSet/index.tsx 
b/superset-frontend/src/SqlLab/components/ResultSet/index.tsx
index def05372e7..439bf37ca2 100644
--- a/superset-frontend/src/SqlLab/components/ResultSet/index.tsx
+++ b/superset-frontend/src/SqlLab/components/ResultSet/index.tsx
@@ -424,7 +424,7 @@ const ResultSet = ({
                     setShowStreamingModal(true);
 
                     startExport({
-                      url: '/api/v1/sqllab/export_streaming/',
+                      url: makeUrl('/api/v1/sqllab/export_streaming/'),
                       payload: { client_id: query.id },
                       exportType: 'csv',
                       expectedRows: rows,
diff --git 
a/superset-frontend/src/components/StreamingExportModal/useStreamingExport.test.ts
 
b/superset-frontend/src/components/StreamingExportModal/useStreamingExport.test.ts
index 03241082dd..8f114c6e46 100644
--- 
a/superset-frontend/src/components/StreamingExportModal/useStreamingExport.test.ts
+++ 
b/superset-frontend/src/components/StreamingExportModal/useStreamingExport.test.ts
@@ -16,10 +16,16 @@
  * specific language governing permissions and limitations
  * under the License.
  */
+import { TextEncoder, TextDecoder } from 'util';
 import { renderHook, act } from '@testing-library/react-hooks';
+import { waitFor } from '@testing-library/react';
 import { useStreamingExport } from './useStreamingExport';
 import { ExportStatus } from './StreamingExportModal';
 
+// Polyfill TextEncoder/TextDecoder for Jest environment
+global.TextEncoder = TextEncoder;
+global.TextDecoder = TextDecoder as typeof global.TextDecoder;
+
 // Mock SupersetClient
 jest.mock('@superset-ui/core', () => ({
   ...jest.requireActual('@superset-ui/core'),
@@ -28,6 +34,15 @@ jest.mock('@superset-ui/core', () => ({
   },
 }));
 
+// Mock pathUtils and getBootstrapData for URL prefix guard tests
+jest.mock('src/utils/pathUtils', () => ({
+  makeUrl: jest.fn((path: string) => path),
+}));
+
+jest.mock('src/utils/getBootstrapData', () => ({
+  applicationRoot: jest.fn(() => ''),
+}));
+
 global.URL.createObjectURL = jest.fn(() => 'blob:mock-url');
 global.URL.revokeObjectURL = jest.fn();
 
@@ -35,6 +50,7 @@ global.fetch = jest.fn();
 
 beforeEach(() => {
   jest.clearAllMocks();
+  global.fetch = jest.fn();
 });
 
 test('useStreamingExport initializes with default progress state', () => {
@@ -124,3 +140,946 @@ test('useStreamingExport cleans up on unmount', () => {
   // Cleanup should not throw errors
   expect(true).toBe(true);
 });
+
+test('retryExport reuses the same URL from the original startExport call', 
async () => {
+  // This test ensures that retryExport uses the exact same URL that was 
passed to startExport,
+  // which is important for subdirectory deployments where the URL is already 
prefixed.
+  const originalUrl = '/superset/api/v1/sqllab/export_streaming/';
+  const mockFetch = jest.fn().mockResolvedValue({
+    ok: true,
+    headers: new Headers({
+      'Content-Disposition': 'attachment; filename="export.csv"',
+    }),
+    body: {
+      getReader: () => ({
+        read: jest.fn().mockResolvedValue({ done: true, value: undefined }),
+      }),
+    },
+  });
+  global.fetch = mockFetch;
+
+  const { result } = renderHook(() => useStreamingExport());
+
+  // First call with startExport
+  act(() => {
+    result.current.startExport({
+      url: originalUrl,
+      payload: { client_id: 'test-id' },
+      exportType: 'csv',
+      expectedRows: 100,
+    });
+  });
+
+  await waitFor(() => {
+    expect(mockFetch).toHaveBeenCalledTimes(1);
+  });
+  expect(mockFetch).toHaveBeenCalledWith(originalUrl, expect.any(Object));
+
+  // Reset mock to track retry call
+  mockFetch.mockClear();
+
+  // Reset the export state so we can retry
+  act(() => {
+    result.current.resetExport();
+  });
+
+  // Call retryExport - should reuse the same URL
+  act(() => {
+    result.current.retryExport();
+  });
+
+  await waitFor(() => {
+    expect(mockFetch).toHaveBeenCalledTimes(1);
+  });
+  // Retry should use the exact same URL that was passed to startExport
+  expect(mockFetch).toHaveBeenCalledWith(originalUrl, expect.any(Object));
+});
+
+test('sets ERROR status and calls onError when fetch rejects', async () => {
+  const errorMessage = 'Network error';
+  const mockFetch = jest.fn().mockRejectedValue(new Error(errorMessage));
+  global.fetch = mockFetch;
+
+  const onError = jest.fn();
+  const { result } = renderHook(() => useStreamingExport({ onError }));
+
+  act(() => {
+    result.current.startExport({
+      url: '/api/v1/sqllab/export_streaming/',
+      payload: { client_id: 'test-id' },
+      exportType: 'csv',
+      expectedRows: 100,
+    });
+  });
+
+  // Wait for fetch to be called and error to be processed
+  await waitFor(() => {
+    expect(result.current.progress.status).toBe(ExportStatus.ERROR);
+  });
+
+  // Verify onError was called exactly once with the error message
+  expect(onError).toHaveBeenCalledTimes(1);
+  expect(onError).toHaveBeenCalledWith(errorMessage);
+});
+
+// URL prefix guard tests - prevent regression of missing app root prefix
+const { applicationRoot } = jest.requireMock('src/utils/getBootstrapData');
+const { makeUrl } = jest.requireMock('src/utils/pathUtils');
+
+const createPrefixTestMockFetch = () =>
+  jest.fn().mockResolvedValue({
+    ok: true,
+    headers: new Headers({
+      'Content-Disposition': 'attachment; filename="export.csv"',
+    }),
+    body: {
+      getReader: () => ({
+        read: jest.fn().mockResolvedValue({ done: true, value: undefined }),
+      }),
+    },
+  });
+
+test('URL prefix guard applies prefix to unprefixed relative URL when app root 
is configured', async () => {
+  const appRoot = '/superset';
+  applicationRoot.mockReturnValue(appRoot);
+  makeUrl.mockImplementation((path: string) => `${appRoot}${path}`);
+
+  const mockFetch = createPrefixTestMockFetch();
+  global.fetch = mockFetch;
+
+  const { result } = renderHook(() => useStreamingExport());
+
+  act(() => {
+    result.current.startExport({
+      url: '/api/v1/sqllab/export_streaming/',
+      payload: { client_id: 'test-id' },
+      exportType: 'csv',
+    });
+  });
+
+  await waitFor(() => {
+    expect(mockFetch).toHaveBeenCalledTimes(1);
+  });
+
+  expect(mockFetch).toHaveBeenCalledWith(
+    '/superset/api/v1/sqllab/export_streaming/',
+    expect.any(Object),
+  );
+});
+
+test('URL prefix guard does not double-prefix URL that already has app root', 
async () => {
+  const appRoot = '/superset';
+  applicationRoot.mockReturnValue(appRoot);
+  makeUrl.mockImplementation((path: string) => `${appRoot}${path}`);
+
+  const mockFetch = createPrefixTestMockFetch();
+  global.fetch = mockFetch;
+
+  const { result } = renderHook(() => useStreamingExport());
+
+  act(() => {
+    result.current.startExport({
+      url: '/superset/api/v1/sqllab/export_streaming/',
+      payload: { client_id: 'test-id' },
+      exportType: 'csv',
+    });
+  });
+
+  await waitFor(() => {
+    expect(mockFetch).toHaveBeenCalledTimes(1);
+  });
+
+  expect(mockFetch).toHaveBeenCalledWith(
+    '/superset/api/v1/sqllab/export_streaming/',
+    expect.any(Object),
+  );
+});
+
+test('URL prefix guard leaves URL unchanged when no app root is configured', 
async () => {
+  applicationRoot.mockReturnValue('');
+
+  const mockFetch = createPrefixTestMockFetch();
+  global.fetch = mockFetch;
+
+  const { result } = renderHook(() => useStreamingExport());
+
+  act(() => {
+    result.current.startExport({
+      url: '/api/v1/sqllab/export_streaming/',
+      payload: { client_id: 'test-id' },
+      exportType: 'csv',
+    });
+  });
+
+  await waitFor(() => {
+    expect(mockFetch).toHaveBeenCalledTimes(1);
+  });
+
+  expect(mockFetch).toHaveBeenCalledWith(
+    '/api/v1/sqllab/export_streaming/',
+    expect.any(Object),
+  );
+});
+
+test('URL prefix guard normalizes relative URL without leading slash and 
applies prefix', async () => {
+  const appRoot = '/superset';
+  applicationRoot.mockReturnValue(appRoot);
+  makeUrl.mockImplementation((path: string) => `${appRoot}${path}`);
+
+  const mockFetch = createPrefixTestMockFetch();
+  global.fetch = mockFetch;
+
+  const { result } = renderHook(() => useStreamingExport());
+
+  act(() => {
+    result.current.startExport({
+      url: 'api/v1/sqllab/export_streaming/',
+      payload: { client_id: 'test-id' },
+      exportType: 'csv',
+    });
+  });
+
+  await waitFor(() => {
+    expect(mockFetch).toHaveBeenCalledTimes(1);
+  });
+
+  // Should add leading slash and apply prefix
+  expect(mockFetch).toHaveBeenCalledWith(
+    '/superset/api/v1/sqllab/export_streaming/',
+    expect.any(Object),
+  );
+});
+
+test('URL prefix guard normalizes non-slash URL to leading slash when no app 
root configured', async () => {
+  applicationRoot.mockReturnValue('');
+  makeUrl.mockImplementation((path: string) => path);
+
+  const mockFetch = createPrefixTestMockFetch();
+  global.fetch = mockFetch;
+
+  const { result } = renderHook(() => useStreamingExport());
+
+  act(() => {
+    result.current.startExport({
+      url: 'api/v1/sqllab/export_streaming/',
+      payload: { client_id: 'test-id' },
+      exportType: 'csv',
+    });
+  });
+
+  await waitFor(() => {
+    expect(mockFetch).toHaveBeenCalledTimes(1);
+  });
+
+  // Should normalize to leading slash even without app root
+  expect(mockFetch).toHaveBeenCalledWith(
+    '/api/v1/sqllab/export_streaming/',
+    expect.any(Object),
+  );
+});
+
+test('URL prefix guard leaves absolute URLs (https) unchanged', async () => {
+  const appRoot = '/superset';
+  applicationRoot.mockReturnValue(appRoot);
+  makeUrl.mockImplementation((path: string) => `${appRoot}${path}`);
+
+  const mockFetch = createPrefixTestMockFetch();
+  global.fetch = mockFetch;
+
+  const { result } = renderHook(() => useStreamingExport());
+
+  act(() => {
+    result.current.startExport({
+      url: 'https://external.example.com/api/export/',
+      payload: { client_id: 'test-id' },
+      exportType: 'csv',
+    });
+  });
+
+  await waitFor(() => {
+    expect(mockFetch).toHaveBeenCalledTimes(1);
+  });
+
+  expect(mockFetch).toHaveBeenCalledWith(
+    'https://external.example.com/api/export/',
+    expect.any(Object),
+  );
+});
+
+test('URL prefix guard leaves protocol-relative URLs (//host) unchanged', 
async () => {
+  const appRoot = '/superset';
+  applicationRoot.mockReturnValue(appRoot);
+  makeUrl.mockImplementation((path: string) => `${appRoot}${path}`);
+
+  const mockFetch = createPrefixTestMockFetch();
+  global.fetch = mockFetch;
+
+  const { result } = renderHook(() => useStreamingExport());
+
+  act(() => {
+    result.current.startExport({
+      url: '//external.example.com/api/export/',
+      payload: { client_id: 'test-id' },
+      exportType: 'csv',
+    });
+  });
+
+  await waitFor(() => {
+    expect(mockFetch).toHaveBeenCalledTimes(1);
+  });
+
+  expect(mockFetch).toHaveBeenCalledWith(
+    '//external.example.com/api/export/',
+    expect.any(Object),
+  );
+});
+
+test('URL prefix guard correctly handles sibling paths (prefixes /app2 when 
appRoot is /app)', async () => {
+  const appRoot = '/app';
+  applicationRoot.mockReturnValue(appRoot);
+  makeUrl.mockImplementation((path: string) => `${appRoot}${path}`);
+
+  const mockFetch = createPrefixTestMockFetch();
+  global.fetch = mockFetch;
+
+  const { result } = renderHook(() => useStreamingExport());
+
+  act(() => {
+    result.current.startExport({
+      url: '/app2/api/v1/export/',
+      payload: { client_id: 'test-id' },
+      exportType: 'csv',
+    });
+  });
+
+  await waitFor(() => {
+    expect(mockFetch).toHaveBeenCalledTimes(1);
+  });
+
+  // /app2 should be prefixed because it's not under /app/ - it's a sibling 
path
+  expect(mockFetch).toHaveBeenCalledWith(
+    '/app/app2/api/v1/export/',
+    expect.any(Object),
+  );
+});
+
+test('URL prefix guard does not double-prefix URL with query string at app 
root', async () => {
+  const appRoot = '/superset';
+  applicationRoot.mockReturnValue(appRoot);
+  makeUrl.mockImplementation((path: string) => `${appRoot}${path}`);
+
+  const mockFetch = createPrefixTestMockFetch();
+  global.fetch = mockFetch;
+
+  const { result } = renderHook(() => useStreamingExport());
+
+  act(() => {
+    result.current.startExport({
+      url: '/superset?foo=1&bar=2',
+      payload: { client_id: 'test-id' },
+      exportType: 'csv',
+    });
+  });
+
+  await waitFor(() => {
+    expect(mockFetch).toHaveBeenCalledTimes(1);
+  });
+
+  // Should NOT double-prefix to /superset/superset?foo=1&bar=2
+  expect(mockFetch).toHaveBeenCalledWith(
+    '/superset?foo=1&bar=2',
+    expect.any(Object),
+  );
+});
+
+test('URL prefix guard does not double-prefix URL with hash at app root', 
async () => {
+  const appRoot = '/superset';
+  applicationRoot.mockReturnValue(appRoot);
+  makeUrl.mockImplementation((path: string) => `${appRoot}${path}`);
+
+  const mockFetch = createPrefixTestMockFetch();
+  global.fetch = mockFetch;
+
+  const { result } = renderHook(() => useStreamingExport());
+
+  act(() => {
+    result.current.startExport({
+      url: '/superset#section',
+      payload: { client_id: 'test-id' },
+      exportType: 'csv',
+    });
+  });
+
+  await waitFor(() => {
+    expect(mockFetch).toHaveBeenCalledTimes(1);
+  });
+
+  // Should NOT double-prefix to /superset/superset#section
+  expect(mockFetch).toHaveBeenCalledWith(
+    '/superset#section',
+    expect.any(Object),
+  );
+});
+
+// Streaming export behavior tests
+
+test('sets ERROR status and calls onError when stream contains 
__STREAM_ERROR__ marker', async () => {
+  const errorMessage = 'Database connection failed';
+  const errorChunk = new TextEncoder().encode(
+    `__STREAM_ERROR__:${errorMessage}`,
+  );
+
+  let readCount = 0;
+  const mockFetch = jest.fn().mockResolvedValue({
+    ok: true,
+    headers: new Headers({
+      'Content-Disposition': 'attachment; filename="export.csv"',
+    }),
+    body: {
+      getReader: () => ({
+        read: jest.fn().mockImplementation(() => {
+          readCount += 1;
+          if (readCount === 1) {
+            return Promise.resolve({ done: false, value: errorChunk });
+          }
+          return Promise.resolve({ done: true, value: undefined });
+        }),
+      }),
+    },
+  });
+  global.fetch = mockFetch;
+
+  const onError = jest.fn();
+  const { result } = renderHook(() => useStreamingExport({ onError }));
+
+  act(() => {
+    result.current.startExport({
+      url: '/api/v1/sqllab/export_streaming/',
+      payload: { client_id: 'test-id' },
+      exportType: 'csv',
+    });
+  });
+
+  await waitFor(() => {
+    expect(result.current.progress.status).toBe(ExportStatus.ERROR);
+  });
+
+  expect(result.current.progress.error).toBe(errorMessage);
+  expect(onError).toHaveBeenCalledTimes(1);
+  expect(onError).toHaveBeenCalledWith(errorMessage);
+});
+
+test('completes CSV export successfully with correct status and downloadUrl', 
async () => {
+  applicationRoot.mockReturnValue('');
+  const csvData = new TextEncoder().encode('id,name\n1,Alice\n2,Bob\n');
+
+  let readCount = 0;
+  const mockFetch = jest.fn().mockResolvedValue({
+    ok: true,
+    headers: new Headers({
+      'Content-Disposition': 'attachment; filename="results.csv"',
+    }),
+    body: {
+      getReader: () => ({
+        read: jest.fn().mockImplementation(() => {
+          readCount += 1;
+          if (readCount === 1) {
+            return Promise.resolve({ done: false, value: csvData });
+          }
+          return Promise.resolve({ done: true, value: undefined });
+        }),
+      }),
+    },
+  });
+  global.fetch = mockFetch;
+
+  const onComplete = jest.fn();
+  const { result } = renderHook(() => useStreamingExport({ onComplete }));
+
+  act(() => {
+    result.current.startExport({
+      url: '/api/v1/sqllab/export_streaming/',
+      payload: { client_id: 'test-id' },
+      exportType: 'csv',
+    });
+  });
+
+  await waitFor(() => {
+    expect(result.current.progress.status).toBe(ExportStatus.COMPLETED);
+  });
+
+  expect(result.current.progress.downloadUrl).toBe('blob:mock-url');
+  expect(result.current.progress.filename).toBe('results.csv');
+  expect(onComplete).toHaveBeenCalledTimes(1);
+  expect(onComplete).toHaveBeenCalledWith('blob:mock-url', 'results.csv');
+});
+
+test('completes XLSX export successfully with correct filename', async () => {
+  applicationRoot.mockReturnValue('');
+  const xlsxData = new Uint8Array([0x50, 0x4b, 0x03, 0x04]); // XLSX magic 
bytes
+
+  let readCount = 0;
+  const mockFetch = jest.fn().mockResolvedValue({
+    ok: true,
+    headers: new Headers({
+      'Content-Disposition': 'attachment; filename="report.xlsx"',
+    }),
+    body: {
+      getReader: () => ({
+        read: jest.fn().mockImplementation(() => {
+          readCount += 1;
+          if (readCount === 1) {
+            return Promise.resolve({ done: false, value: xlsxData });
+          }
+          return Promise.resolve({ done: true, value: undefined });
+        }),
+      }),
+    },
+  });
+  global.fetch = mockFetch;
+
+  const onComplete = jest.fn();
+  const { result } = renderHook(() => useStreamingExport({ onComplete }));
+
+  act(() => {
+    result.current.startExport({
+      url: '/api/v1/chart/data',
+      payload: { datasource: '1__table', viz_type: 'table' },
+      exportType: 'xlsx',
+    });
+  });
+
+  await waitFor(() => {
+    expect(result.current.progress.status).toBe(ExportStatus.COMPLETED);
+  });
+
+  expect(result.current.progress.filename).toBe('report.xlsx');
+  expect(onComplete).toHaveBeenCalledWith('blob:mock-url', 'report.xlsx');
+});
+
+test('sets ERROR status when response is not ok (4xx/5xx)', async () => {
+  applicationRoot.mockReturnValue('');
+  const mockFetch = jest.fn().mockResolvedValue({
+    ok: false,
+    status: 500,
+    statusText: 'Internal Server Error',
+    headers: new Headers({}),
+  });
+  global.fetch = mockFetch;
+
+  const onError = jest.fn();
+  const { result } = renderHook(() => useStreamingExport({ onError }));
+
+  act(() => {
+    result.current.startExport({
+      url: '/api/v1/sqllab/export_streaming/',
+      payload: { client_id: 'test-id' },
+      exportType: 'csv',
+    });
+  });
+
+  await waitFor(() => {
+    expect(result.current.progress.status).toBe(ExportStatus.ERROR);
+  });
+
+  expect(result.current.progress.error).toBe(
+    'Export failed: 500 Internal Server Error',
+  );
+  expect(onError).toHaveBeenCalledWith(
+    'Export failed: 500 Internal Server Error',
+  );
+});
+
+test('sets ERROR status when response body is missing', async () => {
+  applicationRoot.mockReturnValue('');
+  const mockFetch = jest.fn().mockResolvedValue({
+    ok: true,
+    headers: new Headers({}),
+    body: null,
+  });
+  global.fetch = mockFetch;
+
+  const onError = jest.fn();
+  const { result } = renderHook(() => useStreamingExport({ onError }));
+
+  act(() => {
+    result.current.startExport({
+      url: '/api/v1/sqllab/export_streaming/',
+      payload: { client_id: 'test-id' },
+      exportType: 'csv',
+    });
+  });
+
+  await waitFor(() => {
+    expect(result.current.progress.status).toBe(ExportStatus.ERROR);
+  });
+
+  expect(result.current.progress.error).toBe(
+    'Response body is not available for streaming',
+  );
+  expect(onError).toHaveBeenCalledWith(
+    'Response body is not available for streaming',
+  );
+});
+
+test('cancelExport sets CANCELLED status and aborts the request', async () => {
+  applicationRoot.mockReturnValue('');
+  let abortSignal: AbortSignal | undefined;
+
+  // Create a reader that will hang until aborted
+  const mockFetch = jest.fn().mockImplementation((_url, options) => {
+    abortSignal = options?.signal;
+    return Promise.resolve({
+      ok: true,
+      headers: new Headers({
+        'Content-Disposition': 'attachment; filename="export.csv"',
+      }),
+      body: {
+        getReader: () => ({
+          read: jest.fn().mockImplementation(
+            () =>
+              new Promise((resolve, reject) => {
+                // Simulate slow stream that can be aborted
+                const timeout = setTimeout(() => {
+                  resolve({
+                    done: false,
+                    value: new TextEncoder().encode('data'),
+                  });
+                }, 10000);
+                abortSignal?.addEventListener('abort', () => {
+                  clearTimeout(timeout);
+                  reject(new Error('Export cancelled by user'));
+                });
+              }),
+          ),
+        }),
+      },
+    });
+  });
+  global.fetch = mockFetch;
+
+  const { result } = renderHook(() => useStreamingExport());
+
+  act(() => {
+    result.current.startExport({
+      url: '/api/v1/sqllab/export_streaming/',
+      payload: { client_id: 'test-id' },
+      exportType: 'csv',
+    });
+  });
+
+  // Wait for fetch to be called
+  await waitFor(() => {
+    expect(mockFetch).toHaveBeenCalledTimes(1);
+  });
+
+  // Cancel the export
+  act(() => {
+    result.current.cancelExport();
+  });
+
+  await waitFor(() => {
+    expect(result.current.progress.status).toBe(ExportStatus.CANCELLED);
+  });
+});
+
+test('parses filename from Content-Disposition header with quotes', async () 
=> {
+  applicationRoot.mockReturnValue('');
+  const csvData = new TextEncoder().encode('data\n');
+
+  let readCount = 0;
+  const mockFetch = jest.fn().mockResolvedValue({
+    ok: true,
+    headers: new Headers({
+      'Content-Disposition': 'attachment; filename="my export file.csv"',
+    }),
+    body: {
+      getReader: () => ({
+        read: jest.fn().mockImplementation(() => {
+          readCount += 1;
+          if (readCount === 1) {
+            return Promise.resolve({ done: false, value: csvData });
+          }
+          return Promise.resolve({ done: true, value: undefined });
+        }),
+      }),
+    },
+  });
+  global.fetch = mockFetch;
+
+  const { result } = renderHook(() => useStreamingExport());
+
+  act(() => {
+    result.current.startExport({
+      url: '/api/v1/sqllab/export_streaming/',
+      payload: { client_id: 'test-id' },
+      exportType: 'csv',
+    });
+  });
+
+  await waitFor(() => {
+    expect(result.current.progress.status).toBe(ExportStatus.COMPLETED);
+  });
+
+  expect(result.current.progress.filename).toBe('my export file.csv');
+});
+
+test('uses default filename when Content-Disposition header is missing', async 
() => {
+  applicationRoot.mockReturnValue('');
+  const csvData = new TextEncoder().encode('data\n');
+
+  let readCount = 0;
+  const mockFetch = jest.fn().mockResolvedValue({
+    ok: true,
+    headers: new Headers({}),
+    body: {
+      getReader: () => ({
+        read: jest.fn().mockImplementation(() => {
+          readCount += 1;
+          if (readCount === 1) {
+            return Promise.resolve({ done: false, value: csvData });
+          }
+          return Promise.resolve({ done: true, value: undefined });
+        }),
+      }),
+    },
+  });
+  global.fetch = mockFetch;
+
+  const { result } = renderHook(() => useStreamingExport());
+
+  act(() => {
+    result.current.startExport({
+      url: '/api/v1/sqllab/export_streaming/',
+      payload: { client_id: 'test-id' },
+      exportType: 'csv',
+    });
+  });
+
+  await waitFor(() => {
+    expect(result.current.progress.status).toBe(ExportStatus.COMPLETED);
+  });
+
+  expect(result.current.progress.filename).toBe('export.csv');
+});
+
+test('updates progress with rowsProcessed and totalSize during streaming', 
async () => {
+  applicationRoot.mockReturnValue('');
+  const chunk1 = new TextEncoder().encode('id,name\n1,Alice\n');
+  const chunk2 = new TextEncoder().encode('2,Bob\n3,Charlie\n');
+
+  let readCount = 0;
+  const mockFetch = jest.fn().mockResolvedValue({
+    ok: true,
+    headers: new Headers({
+      'Content-Disposition': 'attachment; filename="export.csv"',
+    }),
+    body: {
+      getReader: () => ({
+        read: jest.fn().mockImplementation(() => {
+          readCount += 1;
+          if (readCount === 1) {
+            return Promise.resolve({ done: false, value: chunk1 });
+          }
+          if (readCount === 2) {
+            return Promise.resolve({ done: false, value: chunk2 });
+          }
+          return Promise.resolve({ done: true, value: undefined });
+        }),
+      }),
+    },
+  });
+  global.fetch = mockFetch;
+
+  const { result } = renderHook(() => useStreamingExport());
+
+  act(() => {
+    result.current.startExport({
+      url: '/api/v1/sqllab/export_streaming/',
+      payload: { client_id: 'test-id' },
+      exportType: 'csv',
+      expectedRows: 100,
+    });
+  });
+
+  await waitFor(() => {
+    expect(result.current.progress.status).toBe(ExportStatus.COMPLETED);
+  });
+
+  // Verify final progress reflects data received
+  expect(result.current.progress.totalSize).toBe(chunk1.length + 
chunk2.length);
+  // 4 newlines total (2 in chunk1, 2 in chunk2)
+  expect(result.current.progress.rowsProcessed).toBe(4);
+});
+
+test('prevents double startExport calls while export is in progress', async () 
=> {
+  applicationRoot.mockReturnValue('');
+
+  // Create a slow reader that takes time to complete
+  let readCount = 0;
+  const mockFetch = jest.fn().mockResolvedValue({
+    ok: true,
+    headers: new Headers({
+      'Content-Disposition': 'attachment; filename="export.csv"',
+    }),
+    body: {
+      getReader: () => ({
+        read: jest.fn().mockImplementation(
+          () =>
+            new Promise(resolve => {
+              readCount += 1;
+              if (readCount === 1) {
+                // Delay first chunk to simulate in-progress export
+                setTimeout(() => {
+                  resolve({
+                    done: false,
+                    value: new TextEncoder().encode('data\n'),
+                  });
+                }, 50);
+              } else {
+                resolve({ done: true, value: undefined });
+              }
+            }),
+        ),
+      }),
+    },
+  });
+  global.fetch = mockFetch;
+
+  const { result } = renderHook(() => useStreamingExport());
+
+  // Start first export
+  act(() => {
+    result.current.startExport({
+      url: '/api/v1/first/',
+      payload: { client_id: 'first' },
+      exportType: 'csv',
+    });
+  });
+
+  // Immediately try to start second export
+  act(() => {
+    result.current.startExport({
+      url: '/api/v1/second/',
+      payload: { client_id: 'second' },
+      exportType: 'csv',
+    });
+  });
+
+  await waitFor(() => {
+    expect(result.current.progress.status).toBe(ExportStatus.COMPLETED);
+  });
+
+  // Only one fetch call should have been made (first export)
+  expect(mockFetch).toHaveBeenCalledTimes(1);
+  expect(mockFetch).toHaveBeenCalledWith('/api/v1/first/', expect.any(Object));
+});
+
+test('retryExport does nothing when no prior export exists', async () => {
+  applicationRoot.mockReturnValue('');
+  const mockFetch = jest.fn();
+  global.fetch = mockFetch;
+
+  const { result } = renderHook(() => useStreamingExport());
+
+  // Call retry without ever calling startExport
+  act(() => {
+    result.current.retryExport();
+  });
+
+  // Give it time to potentially make a call
+  await new Promise(resolve => {
+    setTimeout(resolve, 50);
+  });
+
+  // No fetch should have been made
+  expect(mockFetch).not.toHaveBeenCalled();
+});
+
+test('state resets correctly after successful export and resetExport call', 
async () => {
+  applicationRoot.mockReturnValue('');
+  const csvData = new TextEncoder().encode('data\n');
+
+  let readCount = 0;
+  const mockFetch = jest.fn().mockResolvedValue({
+    ok: true,
+    headers: new Headers({
+      'Content-Disposition': 'attachment; filename="export.csv"',
+    }),
+    body: {
+      getReader: () => ({
+        read: jest.fn().mockImplementation(() => {
+          readCount += 1;
+          if (readCount === 1) {
+            return Promise.resolve({ done: false, value: csvData });
+          }
+          return Promise.resolve({ done: true, value: undefined });
+        }),
+      }),
+    },
+  });
+  global.fetch = mockFetch;
+
+  const { result } = renderHook(() => useStreamingExport());
+
+  act(() => {
+    result.current.startExport({
+      url: '/api/v1/sqllab/export_streaming/',
+      payload: { client_id: 'test-id' },
+      exportType: 'csv',
+    });
+  });
+
+  await waitFor(() => {
+    expect(result.current.progress.status).toBe(ExportStatus.COMPLETED);
+  });
+
+  // Verify completed state
+  expect(result.current.progress.downloadUrl).toBe('blob:mock-url');
+
+  // Reset the export
+  act(() => {
+    result.current.resetExport();
+  });
+
+  // Verify state is reset
+  expect(result.current.progress.status).toBe(ExportStatus.STREAMING);
+  expect(result.current.progress.rowsProcessed).toBe(0);
+  expect(result.current.progress.totalSize).toBe(0);
+  expect(result.current.progress.downloadUrl).toBeUndefined();
+  expect(result.current.progress.error).toBeUndefined();
+  expect(global.URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url');
+});
+
+test('state resets correctly after failed export and resetExport call', async 
() => {
+  applicationRoot.mockReturnValue('');
+  const mockFetch = jest.fn().mockRejectedValue(new Error('Network error'));
+  global.fetch = mockFetch;
+
+  const { result } = renderHook(() => useStreamingExport());
+
+  act(() => {
+    result.current.startExport({
+      url: '/api/v1/sqllab/export_streaming/',
+      payload: { client_id: 'test-id' },
+      exportType: 'csv',
+    });
+  });
+
+  await waitFor(() => {
+    expect(result.current.progress.status).toBe(ExportStatus.ERROR);
+  });
+
+  // Verify error state
+  expect(result.current.progress.error).toBe('Network error');
+
+  // Reset the export
+  act(() => {
+    result.current.resetExport();
+  });
+
+  // Verify state is reset
+  expect(result.current.progress.status).toBe(ExportStatus.STREAMING);
+  expect(result.current.progress.error).toBeUndefined();
+  expect(result.current.progress.rowsProcessed).toBe(0);
+});
diff --git 
a/superset-frontend/src/components/StreamingExportModal/useStreamingExport.ts 
b/superset-frontend/src/components/StreamingExportModal/useStreamingExport.ts
index fd0bf34cb2..05a7e5d5b7 100644
--- 
a/superset-frontend/src/components/StreamingExportModal/useStreamingExport.ts
+++ 
b/superset-frontend/src/components/StreamingExportModal/useStreamingExport.ts
@@ -19,6 +19,8 @@
 import { useState, useCallback, useRef, useEffect } from 'react';
 import { SupersetClient } from '@superset-ui/core';
 import { ExportStatus, StreamingProgress } from './StreamingExportModal';
+import { makeUrl } from 'src/utils/pathUtils';
+import { applicationRoot } from 'src/utils/getBootstrapData';
 
 interface UseStreamingExportOptions {
   onComplete?: (downloadUrl: string, filename: string) => void;
@@ -30,6 +32,16 @@ interface StreamingExportPayload {
 }
 
 interface StreamingExportParams {
+  /**
+   * The API endpoint URL for the export request.
+   *
+   * URLs should be prefixed with the application root at the call site using
+   * `makeUrl()` from 'src/utils/pathUtils'. This ensures proper handling for
+   * subdirectory deployments (e.g., /superset/api/v1/...).
+   *
+   * A defensive guard (`ensureUrlPrefix`) will apply the prefix if missing,
+   * but callers should not rely on this fallback behavior.
+   */
   url: string;
   payload: StreamingExportPayload;
   filename?: string;
@@ -39,6 +51,45 @@ interface StreamingExportParams {
 
 const NEWLINE_BYTE = 10; // '\n' character code
 
+/**
+ * Ensures URL has the application root prefix for subdirectory deployments.
+ * Applies makeUrl to relative paths that don't already include the app root.
+ * This guards against callers forgetting to prefix URLs when using native 
fetch.
+ */
+const ensureUrlPrefix = (url: string): string => {
+  const appRoot = applicationRoot();
+  // Protocol-relative URLs (//example.com/...) should pass through unchanged
+  if (url.startsWith('//')) {
+    return url;
+  }
+  // Absolute URLs (http:// or https://) should pass through unchanged
+  if (url.match(/^https?:\/\//)) {
+    return url;
+  }
+  // Relative URLs without leading slash (e.g., "api/v1/...") need 
normalization
+  // Add leading slash and apply prefix
+  if (!url.startsWith('/')) {
+    return makeUrl(`/${url}`);
+  }
+  // If no app root configured, return as-is
+  if (!appRoot) {
+    return url;
+  }
+  // If URL already has the app root prefix, return as-is
+  // Use strict check to avoid false positives with sibling paths (e.g., /app2 
when appRoot is /app)
+  // Also handle query strings and hashes (e.g., /superset?foo=1 or 
/superset#hash)
+  if (
+    url === appRoot ||
+    url.startsWith(`${appRoot}/`) ||
+    url.startsWith(`${appRoot}?`) ||
+    url.startsWith(`${appRoot}#`)
+  ) {
+    return url;
+  }
+  // Apply prefix via makeUrl
+  return makeUrl(url);
+};
+
 const createFetchRequest = async (
   _url: string,
   payload: StreamingExportPayload,
@@ -63,7 +114,7 @@ const createFetchRequest = async (
     formParams.filename = filename;
   }
 
-  if (expectedRows) {
+  if (expectedRows !== undefined) {
     formParams.expected_rows = expectedRows.toString();
   }
 
@@ -157,7 +208,9 @@ export const useStreamingExport = (options: 
UseStreamingExportOptions = {}) => {
           expectedRows,
           abortControllerRef.current.signal,
         );
-        const response = await fetch(url, fetchOptions);
+        // Guard: ensure URL has app root prefix for subdirectory deployments
+        const prefixedUrl = ensureUrlPrefix(url);
+        const response = await fetch(prefixedUrl, fetchOptions);
 
         if (!response.ok) {
           throw new Error(
diff --git a/superset-frontend/src/explore/exploreUtils/exportChart.test.ts 
b/superset-frontend/src/explore/exploreUtils/exportChart.test.ts
new file mode 100644
index 0000000000..661e1248b3
--- /dev/null
+++ b/superset-frontend/src/explore/exploreUtils/exportChart.test.ts
@@ -0,0 +1,173 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { exportChart } from '.';
+
+// Mock pathUtils to control app root prefix
+jest.mock('src/utils/pathUtils', () => ({
+  ensureAppRoot: jest.fn((path: string) => path),
+}));
+
+// Mock SupersetClient
+jest.mock('@superset-ui/core', () => ({
+  ...jest.requireActual('@superset-ui/core'),
+  SupersetClient: {
+    postForm: jest.fn(),
+    get: jest.fn().mockResolvedValue({ json: {} }),
+    post: jest.fn().mockResolvedValue({ json: {} }),
+  },
+  getChartBuildQueryRegistry: jest.fn().mockReturnValue({
+    get: jest.fn().mockReturnValue(() => () => ({})),
+  }),
+  getChartMetadataRegistry: jest.fn().mockReturnValue({
+    get: jest.fn().mockReturnValue({ parseMethod: 'json' }),
+  }),
+}));
+
+const { ensureAppRoot } = jest.requireMock('src/utils/pathUtils');
+const { getChartMetadataRegistry } = jest.requireMock('@superset-ui/core');
+
+// Minimal formData that won't trigger legacy API (useLegacyApi = false)
+const baseFormData = {
+  datasource: '1__table',
+  viz_type: 'table',
+};
+
+beforeEach(() => {
+  jest.clearAllMocks();
+  // Default: no prefix
+  ensureAppRoot.mockImplementation((path: string) => path);
+  // Default: v1 API (not legacy)
+  getChartMetadataRegistry.mockReturnValue({
+    get: jest.fn().mockReturnValue({ parseMethod: 'json' }),
+  });
+});
+
+// Tests for exportChart URL prefix handling in streaming export
+test('exportChart v1 API passes prefixed URL to onStartStreamingExport when 
app root is configured', async () => {
+  const appRoot = '/superset';
+  ensureAppRoot.mockImplementation((path: string) => `${appRoot}${path}`);
+
+  const onStartStreamingExport = jest.fn();
+
+  await exportChart({
+    formData: baseFormData,
+    resultFormat: 'csv',
+    onStartStreamingExport: onStartStreamingExport as unknown as null,
+  });
+
+  expect(onStartStreamingExport).toHaveBeenCalledTimes(1);
+  const callArgs = onStartStreamingExport.mock.calls[0][0];
+  expect(callArgs.url).toBe('/superset/api/v1/chart/data');
+  expect(callArgs.exportType).toBe('csv');
+});
+
+test('exportChart v1 API passes unprefixed URL when no app root is 
configured', async () => {
+  ensureAppRoot.mockImplementation((path: string) => path);
+
+  const onStartStreamingExport = jest.fn();
+
+  await exportChart({
+    formData: baseFormData,
+    resultFormat: 'csv',
+    onStartStreamingExport: onStartStreamingExport as unknown as null,
+  });
+
+  expect(onStartStreamingExport).toHaveBeenCalledTimes(1);
+  const callArgs = onStartStreamingExport.mock.calls[0][0];
+  expect(callArgs.url).toBe('/api/v1/chart/data');
+});
+
+test('exportChart v1 API passes nested prefix for deeply nested deployments', 
async () => {
+  const appRoot = '/my-company/analytics/superset';
+  ensureAppRoot.mockImplementation((path: string) => `${appRoot}${path}`);
+
+  const onStartStreamingExport = jest.fn();
+
+  await exportChart({
+    formData: baseFormData,
+    resultFormat: 'xlsx',
+    onStartStreamingExport: onStartStreamingExport as unknown as null,
+  });
+
+  expect(onStartStreamingExport).toHaveBeenCalledTimes(1);
+  const callArgs = onStartStreamingExport.mock.calls[0][0];
+  
expect(callArgs.url).toBe('/my-company/analytics/superset/api/v1/chart/data');
+  expect(callArgs.exportType).toBe('xlsx');
+});
+
+test('exportChart passes csv exportType for CSV exports', async () => {
+  const onStartStreamingExport = jest.fn();
+
+  await exportChart({
+    formData: baseFormData,
+    resultFormat: 'csv',
+    onStartStreamingExport: onStartStreamingExport as unknown as null,
+  });
+
+  expect(onStartStreamingExport).toHaveBeenCalledWith(
+    expect.objectContaining({
+      exportType: 'csv',
+    }),
+  );
+});
+
+test('exportChart passes xlsx exportType for Excel exports', async () => {
+  const onStartStreamingExport = jest.fn();
+
+  await exportChart({
+    formData: baseFormData,
+    resultFormat: 'xlsx',
+    onStartStreamingExport: onStartStreamingExport as unknown as null,
+  });
+
+  expect(onStartStreamingExport).toHaveBeenCalledWith(
+    expect.objectContaining({
+      exportType: 'xlsx',
+    }),
+  );
+});
+
+test('exportChart legacy API (useLegacyApi=true) passes prefixed URL with app 
root configured', async () => {
+  // Legacy API uses getExploreUrl() -> getURIDirectory() -> ensureAppRoot()
+  const appRoot = '/superset';
+  ensureAppRoot.mockImplementation((path: string) => `${appRoot}${path}`);
+
+  // Configure mock to return useLegacyApi: true
+  getChartMetadataRegistry.mockReturnValue({
+    get: jest.fn().mockReturnValue({ useLegacyApi: true, parseMethod: 'json' 
}),
+  });
+
+  const onStartStreamingExport = jest.fn();
+  const legacyFormData = {
+    datasource: '1__table',
+    viz_type: 'legacy_viz',
+  };
+
+  await exportChart({
+    formData: legacyFormData,
+    resultFormat: 'csv',
+    onStartStreamingExport: onStartStreamingExport as unknown as null,
+  });
+
+  expect(onStartStreamingExport).toHaveBeenCalledTimes(1);
+  const callArgs = onStartStreamingExport.mock.calls[0][0];
+  // Legacy path uses getURIDirectory which calls ensureAppRoot
+  expect(callArgs.url).toContain(appRoot);
+  expect(callArgs.exportType).toBe('csv');
+});
diff --git a/superset-frontend/src/utils/export.test.ts 
b/superset-frontend/src/utils/export.test.ts
index 9f590005bf..1a0f96be71 100644
--- a/superset-frontend/src/utils/export.test.ts
+++ b/superset-frontend/src/utils/export.test.ts
@@ -37,6 +37,7 @@ jest.mock('@apache-superset/core', () => ({
 
 jest.mock('content-disposition');
 
+// Default no-op mock for pathUtils; specific tests customize ensureAppRoot to 
simulate app root prefixing
 jest.mock('./pathUtils', () => ({
   ensureAppRoot: jest.fn((path: string) => path),
 }));
@@ -400,3 +401,53 @@ test('handles export with empty IDs array', async () => {
     }),
   );
 });
+
+const { ensureAppRoot } = jest.requireMock('./pathUtils');
+
+const doublePrefixTestCases = [
+  {
+    name: 'subdirectory prefix',
+    appRoot: '/superset',
+    resource: 'dashboard',
+    ids: [1],
+  },
+  {
+    name: 'subdirectory prefix (dataset)',
+    appRoot: '/superset',
+    resource: 'dataset',
+    ids: [1],
+  },
+  {
+    name: 'nested prefix',
+    appRoot: '/my-app/superset',
+    resource: 'dataset',
+    ids: [1, 2],
+  },
+];
+
+test.each(doublePrefixTestCases)(
+  'handleResourceExport endpoint should not include app prefix: $name',
+  async ({ appRoot, resource, ids }) => {
+    // Simulate real ensureAppRoot behavior: prepend the appRoot
+    (ensureAppRoot as jest.Mock).mockImplementation(
+      (path: string) => `${appRoot}${path}`,
+    );
+
+    const doneMock = jest.fn();
+    await handleResourceExport(resource, ids, doneMock);
+
+    // The endpoint passed to SupersetClient.get should NOT have the appRoot 
prefix
+    // because SupersetClient.getUrl() adds it when building the full URL.
+    const expectedEndpoint = 
`/api/v1/${resource}/export/?q=!(${ids.join(',')})`;
+
+    // Explicitly verify no prefix in endpoint - this will fail if 
ensureAppRoot is used
+    const callArgs = (SupersetClient.get as jest.Mock).mock.calls.slice(
+      -1,
+    )[0][0];
+    expect(callArgs.endpoint).not.toContain(appRoot);
+    expect(callArgs.endpoint).toBe(expectedEndpoint);
+
+    // Reset mock for next test
+    (ensureAppRoot as jest.Mock).mockImplementation((path: string) => path);
+  },
+);
diff --git a/superset-frontend/src/utils/export.ts 
b/superset-frontend/src/utils/export.ts
index e26ab29921..cabbc49372 100644
--- a/superset-frontend/src/utils/export.ts
+++ b/superset-frontend/src/utils/export.ts
@@ -20,7 +20,6 @@ import { SupersetClient } from '@superset-ui/core';
 import { logging } from '@apache-superset/core';
 import rison from 'rison';
 import contentDisposition from 'content-disposition';
-import { ensureAppRoot } from './pathUtils';
 
 // Maximum blob size for in-memory downloads (100MB)
 const MAX_BLOB_SIZE = 100 * 1024 * 1024;
@@ -50,9 +49,7 @@ export default async function handleResourceExport(
   ids: number[],
   done: () => void,
 ): Promise<void> {
-  const endpoint = ensureAppRoot(
-    `/api/v1/${resource}/export/?q=${rison.encode(ids)}`,
-  );
+  const endpoint = `/api/v1/${resource}/export/?q=${rison.encode(ids)}`;
 
   try {
     // Use fetch with blob response instead of iframe to avoid CSP frame-src 
violations

Reply via email to