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

villebro 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 599026a0e5a feat(standalone): granular Explore standalone modes and 
iframe-safe dashboard fullscreen (#44165)
599026a0e5a is described below

commit 599026a0e5a82f9728a333aea0e07b6a7bf8f771
Author: anamitraadhikari <[email protected]>
AuthorDate: Thu Sep 24 16:29:56 2026 -0700

    feat(standalone): granular Explore standalone modes and iframe-safe 
dashboard fullscreen (#44165)
    
    Co-authored-by: aadhikari <[email protected]>
---
 .../src/components/Echart.test.tsx                 |  3 +-
 .../plugin-chart-echarts/src/components/Echart.tsx |  5 +-
 .../dashboard/components/Header/Header.test.tsx    | 41 ++++++++++--
 .../Header/useHeaderActionsDropdownMenu.tsx        | 10 ++-
 .../ExploreChartPanel/ExploreChartPanel.test.tsx   | 11 ++--
 .../explore/components/ExploreChartPanel/index.tsx |  5 +-
 .../ExploreViewContainer.test.tsx                  | 77 +++++++++++++++++++---
 .../components/ExploreViewContainer/index.tsx      | 28 ++++++--
 superset-frontend/src/explore/constants.ts         |  6 ++
 .../src/explore/exploreUtils/exploreUtils.test.tsx | 25 +++++++
 .../src/explore/exploreUtils/index.ts              |  8 ++-
 .../src/explore/reducers/exploreReducer.ts         |  2 +-
 superset-frontend/src/explore/types.ts             |  5 +-
 superset-frontend/src/utils/urlUtils.test.ts       | 23 +++++++
 superset/utils/screenshots.py                      |  9 ++-
 superset/utils/webdriver.py                        |  8 ++-
 tests/unit_tests/utils/screenshot_test.py          | 57 ++++++++++++++++
 17 files changed, 284 insertions(+), 39 deletions(-)

diff --git 
a/superset-frontend/plugins/plugin-chart-echarts/src/components/Echart.test.tsx 
b/superset-frontend/plugins/plugin-chart-echarts/src/components/Echart.test.tsx
index 3025164dd64..cdfc7987278 100644
--- 
a/superset-frontend/plugins/plugin-chart-echarts/src/components/Echart.test.tsx
+++ 
b/superset-frontend/plugins/plugin-chart-echarts/src/components/Echart.test.tsx
@@ -241,7 +241,8 @@ test('replaces stale query event handlers without clearing 
regular event handler
 });
 
 test.each([
-  // Report/thumbnail screenshots render in standalone "true" (charts) or 3 
(reports)
+  // Report/thumbnail captures use standalone=3; "true" is the legacy capture
+  // value and stays supported for links created before numeric modes existed
   ['true', true],
   ['3', true],
   // Live embeds use 1/2 and must keep animation
diff --git 
a/superset-frontend/plugins/plugin-chart-echarts/src/components/Echart.tsx 
b/superset-frontend/plugins/plugin-chart-echarts/src/components/Echart.tsx
index 3306f0a4e84..2ef66956bf7 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/components/Echart.tsx
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/components/Echart.tsx
@@ -125,8 +125,9 @@ use([
   LegacyGridContainLabel,
 ]);
 
-// Report/thumbnail screenshots use standalone="true" (charts) or 3 (reports);
-// live embeds use 1/2 and keep animation. See superset/utils/screenshots.py.
+// Report/thumbnail screenshots use standalone=3. Legacy chart capture URLs may
+// still carry standalone="true"; live embeds use 1/2 and keep animation.
+// See superset/utils/screenshots.py.
 export function isReportScreenshotMode(): boolean {
   try {
     const standalone = new URLSearchParams(window.location.search).get(
diff --git a/superset-frontend/src/dashboard/components/Header/Header.test.tsx 
b/superset-frontend/src/dashboard/components/Header/Header.test.tsx
index 7b7312aefeb..ce68b3054c2 100644
--- a/superset-frontend/src/dashboard/components/Header/Header.test.tsx
+++ b/superset-frontend/src/dashboard/components/Header/Header.test.tsx
@@ -36,6 +36,11 @@ import { UPDATE_COMPONENTS } from 
'../../actions/dashboardLayout';
 import { AutoRefreshStatus } from '../../types/autoRefresh';
 
 const mockHistoryReplace = jest.fn();
+// Dashboards render top-level (not iframed) unless a test says otherwise.
+const mockIsInIframe = jest.fn(() => false);
+jest.mock('src/dashboard/util/isEmbedded', () => ({
+  isEmbedded: () => mockIsInIframe(),
+}));
 jest.mock('react-router-dom', () => ({
   ...jest.requireActual('react-router-dom'),
   useHistory: () => ({
@@ -244,6 +249,7 @@ beforeAll(() => {
 
 beforeEach(() => {
   jest.clearAllMocks();
+  mockIsInIframe.mockReturnValue(false);
   const { useLocation } = jest.requireMock('react-router-dom');
   useLocation.mockReturnValue({
     pathname: '/dashboard',
@@ -863,18 +869,41 @@ test('should hide edit button and navbar, and show Exit 
fullscreen when in fulls
   expect(screen.queryByTestId('main-navigation')).not.toBeInTheDocument();
 });
 
-test('should show Exit fullscreen when in fullscreen mode', async () => {
+test('should show Exit fullscreen when in standalone mode at top level', async 
() => {
+  // Default setup URL carries standalone=1. A user who clicked "Enter 
fullscreen"
+  // must be able to get back out, so this must not be hidden outside an 
iframe.
   setup();
+  await openActionsDropdown();
+  expect(screen.getByText('Exit fullscreen')).toBeInTheDocument();
+  expect(screen.queryByText('Enter fullscreen')).not.toBeInTheDocument();
+});
 
-  await userEvent.click(screen.getByTestId('actions-trigger'));
-
-  expect(await screen.findByText('Exit fullscreen')).toBeInTheDocument();
+test('should show Enter fullscreen when not in standalone mode', async () => {
+  // Keep the router location and window.location in agreement. The shared mock
+  // reports `?standalone=1`; leaving it would let this test pass on a stale
+  // premise if the component ever read `location.search` instead of
+  // `window.location.search`.
+  const { useLocation } = jest.requireMock('react-router-dom');
+  useLocation.mockReturnValue({
+    pathname: '/dashboard',
+    search: '',
+    hash: '',
+    state: undefined,
+  });
+  window.history.pushState({}, 'Test page', '/dashboard');
+  setup();
+  await openActionsDropdown();
+  expect(screen.getByText('Enter fullscreen')).toBeInTheDocument();
+  expect(screen.queryByText('Exit fullscreen')).not.toBeInTheDocument();
 });
 
-test('should have fullscreen option in dropdown', async () => {
+test('should hide the fullscreen toggle entirely inside an iframe', async () 
=> {
+  // Exiting inside an iframe reloads without the standalone param and 
restores the
+  // full Superset nav, breaking the embed — so neither direction is offered.
+  mockIsInIframe.mockReturnValue(true);
   setup();
   await openActionsDropdown();
-  expect(screen.getByText('Exit fullscreen')).toBeInTheDocument();
+  expect(screen.queryByText('Exit fullscreen')).not.toBeInTheDocument();
   expect(screen.queryByText('Enter fullscreen')).not.toBeInTheDocument();
 });
 
diff --git 
a/superset-frontend/src/dashboard/components/Header/useHeaderActionsDropdownMenu.tsx
 
b/superset-frontend/src/dashboard/components/Header/useHeaderActionsDropdownMenu.tsx
index d1585312ead..a2812f6d056 100644
--- 
a/superset-frontend/src/dashboard/components/Header/useHeaderActionsDropdownMenu.tsx
+++ 
b/superset-frontend/src/dashboard/components/Header/useHeaderActionsDropdownMenu.tsx
@@ -34,6 +34,7 @@ import { SAVE_TYPE_NEWDASHBOARD } from 
'src/dashboard/util/constants';
 import FilterScopeModal from 
'src/dashboard/components/filterscope/FilterScopeModal';
 import getDashboardUrl from 'src/dashboard/util/getDashboardUrl';
 import { getActiveFilters } from 'src/dashboard/util/activeDashboardFilters';
+import { isEmbedded as isInIframe } from 'src/dashboard/util/isEmbedded';
 import { getUrlParam } from 'src/utils/urlUtils';
 import { MenuKeys, RootState } from 'src/dashboard/types';
 import { HeaderDropdownProps } from 'src/dashboard/components/Header/types';
@@ -290,8 +291,13 @@ export const useHeaderActionsMenu = ({
       });
     }
 
-    // Toggle fullscreen (hide on mobile)
-    if (!editMode && !isEmbedded && !isMobile) {
+    // Toggle fullscreen (hide on mobile). Hidden entirely inside an iframe: 
there,
+    // "Exit fullscreen" reloads without the standalone param and brings back 
the full
+    // Superset nav, breaking the embed. Note `isEmbedded` above is
+    // `!dashboardInfo.userId` (anonymous guest via the embedded SDK), which 
does not
+    // cover an authenticated user whose dashboard is iframed, hence the 
separate check.
+    // Top-level users keep both directions so fullscreen is not a one-way 
door.
+    if (!editMode && !isEmbedded && !isMobile && !isInIframe()) {
       menuItems.push({
         key: MenuKeys.ToggleFullscreen,
         label: getUrlParam(URL_PARAMS.standalone)
diff --git 
a/superset-frontend/src/explore/components/ExploreChartPanel/ExploreChartPanel.test.tsx
 
b/superset-frontend/src/explore/components/ExploreChartPanel/ExploreChartPanel.test.tsx
index 82d83b23e43..edc848f0a68 100644
--- 
a/superset-frontend/src/explore/components/ExploreChartPanel/ExploreChartPanel.test.tsx
+++ 
b/superset-frontend/src/explore/components/ExploreChartPanel/ExploreChartPanel.test.tsx
@@ -29,6 +29,7 @@ import {
   VizType,
 } from '@superset-ui/core';
 import ChartContainerComponent from 'src/explore/components/ExploreChartPanel';
+import { ExploreStandaloneMode } from 'src/explore/constants';
 import { setItem, LocalStorageKeys } from 'src/utils/localStorageHelpers';
 
 jest.mock('./StandaloneDownloadControl', () => ({
@@ -203,7 +204,7 @@ describe('ChartContainer', () => {
 
   test('does not render standalone download control when show_download is 
absent', () => {
     const props = createProps({
-      standalone: true,
+      standalone: ExploreStandaloneMode.HideNav,
       can_download: true,
     });
 
@@ -218,7 +219,7 @@ describe('ChartContainer', () => {
     window.history.replaceState({}, '', '?show_download=0');
 
     const props = createProps({
-      standalone: true,
+      standalone: ExploreStandaloneMode.HideNav,
       can_download: true,
     });
 
@@ -233,7 +234,7 @@ describe('ChartContainer', () => {
     window.history.replaceState({}, '', '?show_download=1');
 
     const props = createProps({
-      standalone: true,
+      standalone: ExploreStandaloneMode.HideNav,
       can_download: true,
     });
 
@@ -248,7 +249,7 @@ describe('ChartContainer', () => {
     window.history.replaceState({}, '', '?show_download=1');
 
     const props = createProps({
-      standalone: true,
+      standalone: ExploreStandaloneMode.HideNav,
       can_download: false,
     });
 
@@ -263,7 +264,7 @@ describe('ChartContainer', () => {
     window.history.replaceState({}, '', '?show_download=1');
 
     const props = createProps({
-      standalone: false,
+      standalone: ExploreStandaloneMode.None,
       can_download: true,
     });
 
diff --git 
a/superset-frontend/src/explore/components/ExploreChartPanel/index.tsx 
b/superset-frontend/src/explore/components/ExploreChartPanel/index.tsx
index 4df280bc09f..ef1ea33b2c8 100644
--- a/superset-frontend/src/explore/components/ExploreChartPanel/index.tsx
+++ b/superset-frontend/src/explore/components/ExploreChartPanel/index.tsx
@@ -32,6 +32,7 @@ import {
 import { logging } from '@apache-superset/core/utils';
 import { URL_PARAMS } from 'src/constants';
 import { getUrlParam } from 'src/utils/urlUtils';
+import { ExploreStandaloneMode } from 'src/explore/constants';
 import { css, styled, useTheme } from '@apache-superset/core/theme';
 import ChartContainer from 'src/components/Chart/ChartContainer';
 import { updateExploreChartState } from 'src/explore/actions/exploreActions';
@@ -89,7 +90,7 @@ export interface ExploreChartPanelProps {
   vizType: string;
   form_data: QueryFormData;
   ownState?: JsonObject;
-  standalone?: boolean;
+  standalone?: number;
   force?: boolean;
   timeout?: number;
   chartIsStale?: boolean;
@@ -513,7 +514,7 @@ const ExploreChartPanel = ({
     [gutterMargin],
   );
 
-  if (standalone) {
+  if (standalone === ExploreStandaloneMode.HideNav) {
     // dom manipulation hack to get rid of the bootstrap theme's body 
background
     const standaloneClass = 'background-transparent';
     const bodyClasses = document.body.className.split(' ');
diff --git 
a/superset-frontend/src/explore/components/ExploreViewContainer/ExploreViewContainer.test.tsx
 
b/superset-frontend/src/explore/components/ExploreViewContainer/ExploreViewContainer.test.tsx
index f07ccd78fec..3a1c93709f3 100644
--- 
a/superset-frontend/src/explore/components/ExploreViewContainer/ExploreViewContainer.test.tsx
+++ 
b/superset-frontend/src/explore/components/ExploreViewContainer/ExploreViewContainer.test.tsx
@@ -27,6 +27,8 @@ import {
   VizType,
 } from '@superset-ui/core';
 import { QUERY_MODE_REQUISITES } from 'src/explore/constants';
+import { URL_PARAMS } from 'src/constants';
+import { getUrlParam } from 'src/utils/urlUtils';
 import { MemoryRouter, Route, Router } from 'react-router-dom';
 import type { RouteComponentProps } from 'react-router-dom';
 import { createMemoryHistory } from 'history';
@@ -117,10 +119,13 @@ jest.mock('../ExploreChartPanel', () => ({
     standalone,
     onQuery,
   }: {
-    standalone?: boolean;
+    standalone?: number;
     onQuery?: () => void;
   }) => {
     const { useEffect, useRef } = jest.requireActual('react');
+    const { ExploreStandaloneMode } = jest.requireActual(
+      'src/explore/constants',
+    );
     const hasQueried = useRef(false);
 
     useEffect(() => {
@@ -130,9 +135,17 @@ jest.mock('../ExploreChartPanel', () => ({
       }
     }, [onQuery]);
 
-    return (
-      <div data-test={standalone ? 'standalone-app' : 'explore-chart-panel'} />
-    );
+    // Distinct markers per mode so a test can tell mode 2 from mode 0: both
+    // render the editor, and asserting only the absence of `standalone-app`
+    // would pass for either.
+    const marker =
+      standalone === ExploreStandaloneMode.HideNav
+        ? 'standalone-app'
+        : standalone === ExploreStandaloneMode.HideNavShowControls
+          ? 'explore-chart-panel-hide-nav'
+          : 'explore-chart-panel';
+
+    return <div data-test={marker} />;
   },
 }));
 
@@ -285,16 +298,60 @@ test('generates a new form_data param when none is 
available', async () => {
   replaceSpy.mockRestore();
 });
 
-test('renders chart in standalone mode', () => {
-  const { queryByTestId } = renderWithRouter({
-    initialState: {
-      ...reduxState,
-      explore: { ...reduxState.explore, standalone: true },
+// Mirrors production: hydrateExplore seeds `explore.standalone` from
+// getUrlParam(URL_PARAMS.standalone), so it holds the coerced numeric mode 
('true'
+// arrives as 1) or null. Deriving it from the same search string here keeps 
the
+// fixture honest instead of hardcoding a boolean the app never stores.
+const standaloneState = (search: string) => ({
+  search,
+  initialState: {
+    ...reduxState,
+    explore: {
+      ...reduxState.explore,
+      standalone: getUrlParam(URL_PARAMS.standalone, search),
     },
-  });
+  },
+});
+
+test('renders chart in standalone mode', () => {
+  const { queryByTestId } = renderWithRouter(standaloneState('?standalone=1'));
+  expect(queryByTestId('standalone-app')).toBeInTheDocument();
+});
+
+test('preserves legacy standalone=true as chart-only mode', () => {
+  // Backwards compatibility for links created before numeric modes existed.
+  // `standalone` is declared a number param, so getUrlParam maps 'true' to 1;
+  // the backend also still treats 'true' as standalone. Old bookmarks, embeds
+  // and report URLs must keep rendering chart-only rather than the full 
editor.
+  const { queryByTestId } = renderWithRouter(
+    standaloneState('?standalone=true'),
+  );
+  expect(queryByTestId('standalone-app')).toBeInTheDocument();
+});
+
+test('renders chart-only for report captures (standalone=3)', () => {
+  // ChartStandaloneMode.REPORT. Report and thumbnail captures must stay
+  // chart-only; 3 is also what the backend's truthiness check accepts, so this
+  // doubles as coverage for any value it allows that is not the mode 2 opt-in.
+  const { queryByTestId } = renderWithRouter(standaloneState('?standalone=3'));
   expect(queryByTestId('standalone-app')).toBeInTheDocument();
 });
 
+test('renders full editor in standalone=2 mode (hide nav, show controls)', () 
=> {
+  const { queryByTestId } = renderWithRouter(standaloneState('?standalone=2'));
+  expect(queryByTestId('standalone-app')).not.toBeInTheDocument();
+  // Positive assertion on the mode-2 marker: without it this test would also
+  // pass for mode 0, since both render the editor.
+  expect(queryByTestId('explore-chart-panel-hide-nav')).toBeInTheDocument();
+});
+
+test('renders the normal editor when standalone is absent', () => {
+  const { queryByTestId } = renderWithRouter({ search: '' });
+  expect(queryByTestId('explore-chart-panel')).toBeInTheDocument();
+  expect(queryByTestId('standalone-app')).not.toBeInTheDocument();
+  
expect(queryByTestId('explore-chart-panel-hide-nav')).not.toBeInTheDocument();
+});
+
 test('generates a form_data param with datasource_id when mounting with 
existing key', async () => {
   const history = createMemoryHistory({
     initialEntries: [`${defaultPath}${SEARCH}`],
diff --git 
a/superset-frontend/src/explore/components/ExploreViewContainer/index.tsx 
b/superset-frontend/src/explore/components/ExploreViewContainer/index.tsx
index c54a6b5c51e..d67dc1a380b 100644
--- a/superset-frontend/src/explore/components/ExploreViewContainer/index.tsx
+++ b/superset-frontend/src/explore/components/ExploreViewContainer/index.tsx
@@ -64,7 +64,10 @@ import {
   LocalStorageKeys,
 } from 'src/utils/localStorageHelpers';
 import { RESERVED_CHART_URL_PARAMS, URL_PARAMS } from 'src/constants';
-import { QUERY_MODE_REQUISITES } from 'src/explore/constants';
+import {
+  QUERY_MODE_REQUISITES,
+  ExploreStandaloneMode,
+} from 'src/explore/constants';
 import { areObjectsEqual } from 'src/reduxUtils';
 import * as logActions from 'src/logger/actions';
 import {
@@ -245,6 +248,8 @@ const updateHistory = debounce(
           standalone ? URL_PARAMS.standalone.name : 'base',
           {
             [URL_PARAMS.formDataKey.name]: key ?? '',
+            // Carry the active mode through, so mode 2 is not rewritten to 1.
+            ...(standalone ? { [URL_PARAMS.standalone.name]: standalone } : 
{}),
             ...additionalParam,
           },
           force,
@@ -333,7 +338,9 @@ interface ExploreRootState {
     can_overwrite: boolean;
     sliceName?: string;
     triggerRender: boolean;
-    standalone: boolean;
+    // Seeded by hydrateExplore from getUrlParam(URL_PARAMS.standalone), so 
this is
+    // the coerced numeric mode, or null when the param is absent or 
unparseable.
+    standalone: number | null;
     force: boolean;
     form_data?: QueryFormData;
     saveAction?: SaveActionType | null;
@@ -380,7 +387,7 @@ interface StateProps {
   form_data: QueryFormData;
   table_name?: string;
   vizType?: string;
-  standalone: boolean;
+  standalone: number;
   force: boolean;
   chart: ChartState;
   timeout: number;
@@ -1027,7 +1034,7 @@ function ExploreViewContainer(props: 
ExploreViewContainerProps) {
     );
   }
 
-  if (props.standalone) {
+  if (props.standalone === ExploreStandaloneMode.HideNav) {
     return renderChartContainer();
   }
 
@@ -1391,7 +1398,18 @@ function mapStateToProps(state: ExploreRootState) {
     form_data: patchedFormData,
     table_name: datasource.table_name,
     vizType: form_data.viz_type,
-    standalone: !!explore.standalone,
+    // `explore.standalone` is the URL param coerced by getUrlParam 
(hydrateExplore
+    // sets it), so 'true' arrives as 1 and 'false' as 0. Mode 2 is the only 
value
+    // that keeps the editor; every other truthy mode renders chart-only, 
which is
+    // what any truthy `standalone` did before granular modes existed. Values
+    // getUrlParam cannot parse arrive as null and render normally, matching 
the
+    // pre-existing behaviour for unparseable params.
+    standalone:
+      explore.standalone === ExploreStandaloneMode.HideNavShowControls
+        ? ExploreStandaloneMode.HideNavShowControls
+        : explore.standalone
+          ? ExploreStandaloneMode.HideNav
+          : ExploreStandaloneMode.None,
     force: !!explore.force,
     chart,
     timeout: common.conf.SUPERSET_WEBSERVER_TIMEOUT,
diff --git a/superset-frontend/src/explore/constants.ts 
b/superset-frontend/src/explore/constants.ts
index 93f56ceab08..73d11c15246 100644
--- a/superset-frontend/src/explore/constants.ts
+++ b/superset-frontend/src/explore/constants.ts
@@ -223,6 +223,12 @@ export const POPOVER_INITIAL_WIDTH = 320;
 export const CONTROL_SECTIONS_ID = 'controlSections';
 export const UNSAVED_CHART_ID = 0;
 
+export enum ExploreStandaloneMode {
+  None = 0,
+  HideNav = 1,
+  HideNavShowControls = 2,
+}
+
 export const QUERY_MODE_REQUISITES = new Set([
   'all_columns',
   'groupby',
diff --git a/superset-frontend/src/explore/exploreUtils/exploreUtils.test.tsx 
b/superset-frontend/src/explore/exploreUtils/exploreUtils.test.tsx
index 478eba7ace4..ade6815594c 100644
--- a/superset-frontend/src/explore/exploreUtils/exploreUtils.test.tsx
+++ b/superset-frontend/src/explore/exploreUtils/exploreUtils.test.tsx
@@ -23,6 +23,7 @@ import {
   getExploreUrl,
   getSimpleSQLExpression,
   getQuerySettings,
+  mountExploreUrl,
 } from 'src/explore/exploreUtils';
 import { DashboardStandaloneMode } from 'src/dashboard/util/constants';
 import * as hostNamesConfig from 'src/utils/hostNamesConfig';
@@ -272,4 +273,28 @@ describe('exploreUtils', () => {
       expect(postFormSpy).toHaveBeenCalledTimes(1);
     });
   });
+
+  // eslint-disable-next-line no-restricted-globals -- TODO: Migrate from 
describe blocks
+  describe('.mountExploreUrl() standalone mode', () => {
+    // Explore calls history.replace with this URL after interactions. Writing
+    // HideNav unconditionally downgraded mode 2 to mode 1 on the first click,
+    // collapsing the editor to a bare chart.
+    test('preserves a caller-supplied mode 2', () => {
+      expect(mountExploreUrl('standalone', { standalone: 2 })).toContain(
+        'standalone=2',
+      );
+    });
+
+    test('keeps mode 1 as mode 1', () => {
+      expect(mountExploreUrl('standalone', { standalone: 1 })).toContain(
+        'standalone=1',
+      );
+    });
+
+    test('falls back to HideNav when the caller supplies nothing', () => {
+      expect(mountExploreUrl('standalone')).toContain(
+        `standalone=${DashboardStandaloneMode.HideNav}`,
+      );
+    });
+  });
 });
diff --git a/superset-frontend/src/explore/exploreUtils/index.ts 
b/superset-frontend/src/explore/exploreUtils/index.ts
index 08cbc27a848..1008aadc1e8 100644
--- a/superset-frontend/src/explore/exploreUtils/index.ts
+++ b/superset-frontend/src/explore/exploreUtils/index.ts
@@ -178,7 +178,13 @@ export function mountExploreUrl(
     if (force) {
       search.force = '1';
     }
-    search.standalone = DashboardStandaloneMode.HideNav;
+    // Callers pass the active mode through extraSearch (already merged above).
+    // Only default when absent — unconditionally writing HideNav downgrades
+    // mode 2 (hide nav, keep editor controls) to mode 1 (chart only) on every
+    // history.replace an Explore interaction triggers.
+    if (search.standalone === undefined) {
+      search.standalone = DashboardStandaloneMode.HideNav;
+    }
   }
   return uri.directory(directory).search(search).toString();
 }
diff --git a/superset-frontend/src/explore/reducers/exploreReducer.ts 
b/superset-frontend/src/explore/reducers/exploreReducer.ts
index 2cbdc69c47c..8e08b5cdd2e 100644
--- a/superset-frontend/src/explore/reducers/exploreReducer.ts
+++ b/superset-frontend/src/explore/reducers/exploreReducer.ts
@@ -60,7 +60,7 @@ export interface ExploreState {
   slice?: Slice | null;
   sliceName?: string;
   controlsTransferred?: string[];
-  standalone?: boolean;
+  standalone?: number | null;
   force?: boolean;
   common?: {
     conf: {
diff --git a/superset-frontend/src/explore/types.ts 
b/superset-frontend/src/explore/types.ts
index 7d3344004db..1ec9973b37a 100644
--- a/superset-frontend/src/explore/types.ts
+++ b/superset-frontend/src/explore/types.ts
@@ -179,7 +179,10 @@ export interface ExplorePageState {
     hiddenFormData?: Partial<QueryFormData>;
     slice: Slice;
     controlsTransferred: string[];
-    standalone: boolean;
+    // Set by hydrateExplore from getUrlParam(URL_PARAMS.standalone), so it is 
the
+    // coerced numeric mode (or null when absent/unparseable), not the 
backend's
+    // boolean `is_standalone_mode()`. See ExploreViewContainer's 
mapStateToProps.
+    standalone: number | null;
     force: boolean;
     common: JsonObject;
     compatibility?: CompatibilityResult;
diff --git a/superset-frontend/src/utils/urlUtils.test.ts 
b/superset-frontend/src/utils/urlUtils.test.ts
index a3a5d468ed4..15a58bf693e 100644
--- a/superset-frontend/src/utils/urlUtils.test.ts
+++ b/superset-frontend/src/utils/urlUtils.test.ts
@@ -137,6 +137,29 @@ test('getDashboardUrlParams should exclude multiple 
parameters when provided', (
   locationSpy.mockRestore();
 });
 
+test.each([
+  // Legacy form. Links, bookmarks and report URLs created before numeric
+  // standalone modes existed carry `standalone=true`, and must keep resolving 
to
+  // HideNav (1) rather than null, which callers would read as "not 
standalone".
+  ['?standalone=true', 1],
+  ['?standalone=TRUE', 1],
+  ['?standalone=false', 0],
+  // Canonical numeric modes.
+  ['?standalone=0', 0],
+  ['?standalone=1', 1],
+  ['?standalone=2', 2],
+  // Report/thumbnail captures (ChartStandaloneMode.REPORT).
+  ['?standalone=3', 3],
+  // Absent or unparseable yields null so callers can apply their own default.
+  ['', null],
+  ['?standalone=banana', null],
+])(
+  'getUrlParam coerces %s to %p for the numeric standalone param',
+  (search, expected) => {
+    expect(getUrlParam(URL_PARAMS.standalone, search)).toBe(expected);
+  },
+);
+
 test('getUrlParam reads from window.location.search by default', () => {
   const locationSpy = jest.spyOn(window, 'location', 'get').mockReturnValue({
     ...window.location,
diff --git a/superset/utils/screenshots.py b/superset/utils/screenshots.py
index cd331a8b3e6..4793bea6d6b 100644
--- a/superset/utils/screenshots.py
+++ b/superset/utils/screenshots.py
@@ -678,10 +678,15 @@ class ChartScreenshot(BaseScreenshot):
         window_size: WindowSize | None = None,
         thumb_size: WindowSize | None = None,
     ):
-        # Chart reports are in standalone="true" mode
+        # Chart reports render chart-only, with no nav and no editor controls.
+        # REPORT rather than HIDE_NAV so the frontend can distinguish an 
automated
+        # capture from a live standalone embed: `isReportScreenshotMode()` in
+        # plugin-chart-echarts suppresses animation for captures, which 
prevents a
+        # screenshot catching a chart mid-draw. HIDE_NAV (1) is a live embed 
there
+        # and deliberately keeps its animation.
         url = modify_url_query(
             url,
-            standalone=ChartStandaloneMode.HIDE_NAV.value,
+            standalone=ChartStandaloneMode.REPORT.value,
         )
         super().__init__(url, digest)
         self.window_size = window_size or DEFAULT_CHART_WINDOW_SIZE
diff --git a/superset/utils/webdriver.py b/superset/utils/webdriver.py
index d7ae9618d05..e32066a5617 100644
--- a/superset/utils/webdriver.py
+++ b/superset/utils/webdriver.py
@@ -164,8 +164,14 @@ class DashboardStandaloneMode(Enum):
 
 
 class ChartStandaloneMode(Enum):
-    HIDE_NAV = "true"
     SHOW_NAV = 0
+    HIDE_NAV = 1
+    HIDE_NAV_SHOW_CONTROLS = 2
+    # Report/thumbnail captures, mirroring DashboardStandaloneMode.REPORT. Kept
+    # distinct from HIDE_NAV so the frontend can tell an automated capture 
from a
+    # live chart-only embed: ECharts suppresses animation for captures only, 
and
+    # treating a live embed as a capture would disable its animation too.
+    REPORT = 3
 
 
 # pylint: disable=too-few-public-methods
diff --git a/tests/unit_tests/utils/screenshot_test.py 
b/tests/unit_tests/utils/screenshot_test.py
index 12ba5f64701..99c9ff60e7f 100644
--- a/tests/unit_tests/utils/screenshot_test.py
+++ b/tests/unit_tests/utils/screenshot_test.py
@@ -730,3 +730,60 @@ class TestScreenshotSubclassesDriverBehavior:
 
         assert driver._window == custom_window_size
         assert chart_screenshot.thumb_size == custom_thumb_size
+
+
+class TestScreenshotStandaloneMode:
+    """Pin the standalone mode each screenshot type requests.
+
+    The frontend distinguishes automated captures from live standalone embeds 
by
+    this value: `isReportScreenshotMode()` in plugin-chart-echarts suppresses
+    animation for captures only, so a capture must not be indistinguishable 
from
+    a live embed or screenshots can catch a chart mid-draw.
+    """
+
+    def test_chart_screenshot_requests_report_mode(self):
+        """Chart captures request standalone=3, not the live-embed value 1."""
+        from superset.utils.webdriver import ChartStandaloneMode
+
+        chart_screenshot = ChartScreenshot("http://example.com/chart";, 
"digest")
+
+        assert f"standalone={ChartStandaloneMode.REPORT.value}" in 
chart_screenshot.url
+        assert "standalone=3" in chart_screenshot.url
+        assert "standalone=1" not in chart_screenshot.url
+
+    def test_dashboard_screenshot_requests_report_mode(self):
+        """Dashboard captures already used report mode; keep them aligned."""
+        from superset.utils.screenshots import DashboardScreenshot
+        from superset.utils.webdriver import DashboardStandaloneMode
+
+        dashboard_screenshot = DashboardScreenshot(
+            "http://example.com/dashboard/1/";, "digest"
+        )
+
+        assert (
+            f"standalone={DashboardStandaloneMode.REPORT.value}"
+            in dashboard_screenshot.url
+        )
+
+    def test_standalone_modes_are_all_integers(self):
+        """Both enums stay integer-valued so URL modes read consistently.
+
+        `ChartStandaloneMode.HIDE_NAV` was previously the string "true"; the
+        numeric form is what `getUrlParam` normalises legacy "true" links onto.
+        """
+        from superset.utils.webdriver import (
+            ChartStandaloneMode,
+            DashboardStandaloneMode,
+        )
+
+        for mode in (*ChartStandaloneMode, *DashboardStandaloneMode):
+            assert isinstance(mode.value, int), f"{mode!r} is not an int"
+
+    def test_chart_and_dashboard_report_values_match(self):
+        """A single report sentinel keeps the frontend check simple."""
+        from superset.utils.webdriver import (
+            ChartStandaloneMode,
+            DashboardStandaloneMode,
+        )
+
+        assert ChartStandaloneMode.REPORT.value == 
DashboardStandaloneMode.REPORT.value

Reply via email to