You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ru...@apache.org on 2026/07/13 23:03:59 UTC

(superset) branch master updated: fix(i18n): reset embedded start guard and align menu error handling (#41491)

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 836dce9b058 fix(i18n): reset embedded start guard and align menu error handling (#41491)
836dce9b058 is described below

commit 836dce9b058c7de819ce2d1d2492dd2bfd82dd40
Author: Evan Rusackas <ev...@preset.io>
AuthorDate: Mon Jul 13 16:03:53 2026 -0700

    fix(i18n): reset embedded start guard and align menu error handling (#41491)
    
    Co-authored-by: Claude Opus 4.8 <no...@anthropic.com>
---
 superset-frontend/src/embedded/index.test.tsx | 155 ++++++++++++++++++++++++--
 superset-frontend/src/embedded/index.tsx      | 121 ++++++++++++--------
 superset-frontend/src/views/menu.test.tsx     | 109 ++++++++++++++++++
 superset-frontend/src/views/menu.tsx          |  15 ++-
 4 files changed, 341 insertions(+), 59 deletions(-)

diff --git a/superset-frontend/src/embedded/index.test.tsx b/superset-frontend/src/embedded/index.test.tsx
index c2d4ce63b51..031f9c4f3b6 100644
--- a/superset-frontend/src/embedded/index.test.tsx
+++ b/superset-frontend/src/embedded/index.test.tsx
@@ -16,26 +16,104 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { setupAGGridModules } from '@superset-ui/core/components/ThemedAgGridReact';
+// Mark this file as a module so its top-level declarations stay file-scoped
+// (the file has no imports; modules are loaded via require() inside tests).
+export {};
+
+// Stable mock references so they survive jest.resetModules() between tests
+// (a factory-created jest.fn() would otherwise be replaced on each reset,
+// leaving these imported handles pointing at a stale instance).
+const mockSetupAGGridModules = jest.fn();
+const mockLogging = { debug: jest.fn(), warn: jest.fn(), error: jest.fn() };
 
 jest.mock('src/public-path', () => ({}));
 
 jest.mock('query-string', () => ({}));
 
 jest.mock('@superset-ui/core/components/ThemedAgGridReact', () => ({
-  setupAGGridModules: jest.fn(),
+  setupAGGridModules: mockSetupAGGridModules,
 }));
 
-jest.mock('src/preamble', () => jest.fn().mockResolvedValue(true));
+jest.mock('@apache-superset/core/utils', () => ({
+  logging: mockLogging,
+}));
 
-jest.mock('src/setup/setupPlugins', () => jest.fn(), { virtual: true });
+// setupPlugins is driven per-test so the retry path can force a rejection.
+const mockSetupPlugins = jest.fn();
+jest.mock('src/setup/setupPlugins', () => mockSetupPlugins, { virtual: true });
 
 jest.mock('src/setup/setupCodeOverrides', () => jest.fn(), { virtual: true });
 
+jest.mock('src/preamble', () => jest.fn().mockResolvedValue(true));
+
+// makeApi returns a callable that resolves with the current user roles.
+const mockGetMeWithRole = jest.fn();
+jest.mock('@superset-ui/core', () => ({
+  ...jest.requireActual('@superset-ui/core'),
+  makeApi: () => mockGetMeWithRole,
+}));
+
+jest.mock('src/components/UiConfigContext', () => ({
+  useUiConfig: () => ({}),
+}));
+
+// Capture the guestToken handler that start() is wired to, so tests can
+// re-trigger the handshake and assert the retry behavior.
+const mockSwitchboard = {
+  handler: undefined as ((arg: { guestToken: string }) => void) | undefined,
+};
+jest.mock('@superset-ui/switchboard', () => ({
+  __esModule: true,
+  default: {
+    init: jest.fn(),
+    start: jest.fn(),
+    defineMethod: (name: string, fn: (arg: { guestToken: string }) => void) => {
+      if (name === 'guestToken') {
+        mockSwitchboard.handler = fn;
+      }
+    },
+    emit: jest.fn(),
+  },
+}));
+
+jest.mock('src/setup/setupClient', () => jest.fn(), { virtual: true });
+
+jest.mock('src/views/store', () => ({
+  store: {
+    dispatch: jest.fn(),
+    getState: () => ({ dataMask: {} }),
+    subscribe: jest.fn(),
+  },
+  USER_LOADED: 'USER_LOADED',
+}));
+
+jest.mock('src/components/MessageToasts/actions', () => ({
+  addDangerToast: jest.fn(() => ({ type: 'ADD_TOAST' })),
+}));
+
+jest.mock('src/components', () => ({ ErrorBoundary: () => null }));
+
+jest.mock('src/components/MessageToasts/ToastContainer', () => () => null);
+
+jest.mock('./EmbeddedContextProviders', () => ({
+  EmbeddedContextProviders: () => null,
+  getThemeController: () => null,
+}));
+
+jest.mock('./api', () => ({ embeddedApi: {} }));
+
+jest.mock('./originValidation', () => ({ validateMessageEvent: () => true }));
+
+jest.mock('./utils', () => ({ getDataMaskChangeTrigger: () => ({}) }));
+
+jest.mock('react-dom/client', () => ({
+  createRoot: () => ({ render: jest.fn(), unmount: jest.fn() }),
+}));
+
 jest.mock('src/utils/getBootstrapData', () => ({
   __esModule: true,
   default: () => ({
-    embedded: { dashboard_id: '123' },
+    embedded: { dashboard_id: '123', allowed_domains: [] },
     common: {
       application_root: '/',
       static_assets_prefix: '/',
@@ -49,18 +127,75 @@ jest.mock('src/utils/getBootstrapData', () => ({
   staticAssetsPrefix: () => '/',
 }));
 
+const flush = async () => {
+  // Several microtask hops: initPreamble -> dynamic import() -> setup calls.
+  for (let i = 0; i < 20; i += 1) {
+    // eslint-disable-next-line no-await-in-loop
+    await new Promise(resolve => setTimeout(resolve, 0));
+  }
+};
+
+// Drive the postMessage handshake so index.tsx registers its Switchboard methods.
+// jsdom lacks MessageChannel, so a minimal fake port is enough here.
+function sendHandshake() {
+  const port = {} as MessagePort;
+  window.dispatchEvent(
+    new MessageEvent('message', {
+      data: { handshake: 'port transfer' },
+      ports: [port],
+    }),
+  );
+}
+
 describe('embedded/index.tsx', () => {
-  beforeAll(() => {
+  beforeEach(() => {
+    jest.resetModules();
+    mockSwitchboard.handler = undefined;
+    mockSetupPlugins.mockReset();
+    mockSetupAGGridModules.mockReset();
+    mockLogging.error.mockClear();
+    mockGetMeWithRole.mockReset();
+    mockGetMeWithRole.mockResolvedValue({ result: { roles: {} } });
     document.body.innerHTML = '<div id="app"></div>';
   });
 
   test('initializes AG Grid modules on bootstrap', async () => {
+    mockSetupPlugins.mockImplementation(() => undefined);
     require('./index');
+    await flush();
 
-    // index.tsx uses initPreamble().then(...) to initialize plugins and AG grid
-    // Wait for the promise chain to resolve
-    await new Promise(resolve => setTimeout(resolve, 0));
+    expect(mockSetupAGGridModules).toHaveBeenCalled();
+  });
+
+  test('retries plugin setup after setupPlugins rejects, then bootstraps the user', async () => {
+    // First plugin setup throws; the second attempt (after a re-handshake) succeeds.
+    mockSetupPlugins
+      .mockImplementationOnce(() => {
+        throw new Error('setupPlugins failed');
+      })
+      .mockImplementation(() => undefined);
+
+    require('./index');
+    await flush();
+
+    sendHandshake();
+    expect(mockSwitchboard.handler).toBeDefined();
+
+    // First guest token: plugin setup rejects, start() resets the guard and
+    // recreates pluginsReady so a retry can re-run setup.
+    mockSwitchboard.handler!({ guestToken: 'token-1' });
+    await flush();
+    expect(mockLogging.error).toHaveBeenCalled();
+    expect(mockGetMeWithRole).not.toHaveBeenCalled();
+    // The user gets a visible failure message rather than a blank #app.
+    expect(document.getElementById('app')!.innerHTML).toContain(
+      'Something went wrong loading the dashboard',
+    );
 
-    expect(setupAGGridModules).toHaveBeenCalled();
+    // Second guest token retries: plugin setup now succeeds and the user loads.
+    mockSwitchboard.handler!({ guestToken: 'token-2' });
+    await flush();
+    expect(mockSetupPlugins).toHaveBeenCalledTimes(2);
+    expect(mockGetMeWithRole).toHaveBeenCalled();
   });
 });
diff --git a/superset-frontend/src/embedded/index.tsx b/superset-frontend/src/embedded/index.tsx
index d46a43d1019..cbd2c281066 100644
--- a/superset-frontend/src/embedded/index.tsx
+++ b/superset-frontend/src/embedded/index.tsx
@@ -55,23 +55,39 @@ import { validateMessageEvent } from './originValidation';
 // Dynamic imports (webpackMode: "eager") keep modules in the same bundle chunk but defer
 // their evaluation until after initPreamble() resolves, so module-level t() calls in plugin
 // control panels and setup code run only after translations are available.
-const pluginsReady = initPreamble()
-  .catch(err => {
-    logging.warn(
-      'Preamble initialization failed, loading plugins without translations.',
-      err,
-    );
-  })
-  .then(async () => {
-    const [{ default: setupPlugins }, { default: setupCodeOverrides }] =
-      await Promise.all([
-        import(/* webpackMode: "eager" */ 'src/setup/setupPlugins'),
-        import(/* webpackMode: "eager" */ 'src/setup/setupCodeOverrides'),
-      ]);
-    setupPlugins();
-    setupCodeOverrides({ embedded: true });
-    setupAGGridModules();
-  });
+function loadPlugins() {
+  return initPreamble()
+    .catch(err => {
+      logging.warn(
+        'Preamble initialization failed, loading plugins without translations.',
+        err,
+      );
+    })
+    .then(async () => {
+      const [{ default: setupPlugins }, { default: setupCodeOverrides }] =
+        await Promise.all([
+          import(/* webpackMode: "eager" */ 'src/setup/setupPlugins'),
+          import(/* webpackMode: "eager" */ 'src/setup/setupCodeOverrides'),
+        ]);
+      setupPlugins();
+      setupCodeOverrides({ embedded: true });
+      setupAGGridModules();
+    });
+}
+
+// Kick off plugin setup and attach a no-op rejection handler. If the promise
+// settles before start() attaches its own handler, this keeps it from surfacing
+// as an unhandled-rejection warning; start() still handles the rejection itself.
+function schedulePlugins() {
+  const promise = loadPlugins();
+  promise.catch(() => {});
+  return promise;
+}
+
+// Kick off plugin setup eagerly at module load so it overlaps the handshake.
+// If it rejects, start() recreates the promise so a retry can re-run setup
+// instead of chaining off a permanently rejected promise.
+let pluginsReady = schedulePlugins();
 
 const debugMode = process.env.WEBPACK_MODE === 'development';
 const bootstrapData = getBootstrapData();
@@ -192,34 +208,49 @@ function start() {
     method: 'GET',
     endpoint: '/api/v1/me/roles/',
   });
-  return pluginsReady.then(() =>
-    getMeWithRole().then(
-      ({ result }) => {
-        // fill in some missing bootstrap data
-        // (because at pageload, we don't have any auth yet)
-        // this allows the frontend's permissions checks to work.
-        bootstrapData.user = result;
-        store.dispatch({
-          type: USER_LOADED,
-          user: result,
-        });
-        if (!root) {
-          root = createRoot(appMountPoint);
-        }
-        root.render(<EmbeddedApp />);
-      },
-      err => {
-        // something is most likely wrong with the guest token; reset the guard
-        // so a rehandshake with a valid token can retry.
-        logging.error(err);
-        showFailureMessage(
-          t(
-            'Something went wrong with embedded authentication. Check the dev console for details.',
-          ),
-        );
-        started = false;
-      },
-    ),
+  return pluginsReady.then(
+    () =>
+      getMeWithRole().then(
+        ({ result }) => {
+          // fill in some missing bootstrap data
+          // (because at pageload, we don't have any auth yet)
+          // this allows the frontend's permissions checks to work.
+          bootstrapData.user = result;
+          store.dispatch({
+            type: USER_LOADED,
+            user: result,
+          });
+          if (!root) {
+            root = createRoot(appMountPoint);
+          }
+          root.render(<EmbeddedApp />);
+        },
+        err => {
+          // something is most likely wrong with the guest token; reset the guard
+          // so a rehandshake with a valid token can retry.
+          logging.error(err);
+          showFailureMessage(
+            t(
+              'Something went wrong with embedded authentication. Check the dev console for details.',
+            ),
+          );
+          started = false;
+        },
+      ),
+    err => {
+      // setupPlugins() or setupCodeOverrides() threw while preparing plugins;
+      // reset the guard and recreate pluginsReady so a retry actually re-runs
+      // plugin setup instead of chaining off this rejected promise and leaving
+      // the dashboard stuck in a failed state.
+      logging.error(err);
+      showFailureMessage(
+        t(
+          'Something went wrong loading the dashboard. Check the dev console for details.',
+        ),
+      );
+      pluginsReady = schedulePlugins();
+      started = false;
+    },
   );
 }
 
diff --git a/superset-frontend/src/views/menu.test.tsx b/superset-frontend/src/views/menu.test.tsx
new file mode 100644
index 00000000000..29fca95ed62
--- /dev/null
+++ b/superset-frontend/src/views/menu.test.tsx
@@ -0,0 +1,109 @@
+/**
+ * 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.
+ */
+// Mark this file as a module so its top-level declarations stay file-scoped
+// (the file has no imports; modules are loaded via require() inside tests).
+export {};
+
+// Stable mock references so they survive jest.resetModules() between tests.
+const mockLogging = { debug: jest.fn(), warn: jest.fn(), error: jest.fn() };
+const mockInitPreamble = jest.fn();
+const mockRender = jest.fn();
+
+jest.mock('src/public-path', () => ({}));
+
+jest.mock('query-string', () => ({ parse: jest.fn(), stringify: jest.fn() }));
+
+jest.mock('@apache-superset/core/utils', () => ({
+  logging: mockLogging,
+}));
+
+jest.mock('src/preamble', () => mockInitPreamble);
+
+jest.mock('react-dom/client', () => ({
+  createRoot: () => ({ render: mockRender, unmount: jest.fn() }),
+}));
+
+// Menu is imported dynamically inside menu.tsx; a simple stub is enough.
+jest.mock(
+  'src/features/home/Menu',
+  () => ({ __esModule: true, default: () => null }),
+  {
+    virtual: true,
+  },
+);
+
+jest.mock('./store', () => ({ setupStore: () => ({}) }));
+
+jest.mock('src/utils/getBootstrapData', () => ({
+  __esModule: true,
+  default: () => ({
+    common: { menu_data: {}, application_root: '/' },
+  }),
+  applicationRoot: () => '/',
+}));
+
+const flush = async () => {
+  // Several microtask hops: initPreamble -> dynamic import() -> render.
+  for (let i = 0; i < 20; i += 1) {
+    // eslint-disable-next-line no-await-in-loop
+    await new Promise(resolve => setTimeout(resolve, 0));
+  }
+};
+
+describe('views/menu.tsx', () => {
+  beforeEach(() => {
+    jest.resetModules();
+    mockRender.mockReset();
+    mockInitPreamble.mockReset();
+    mockLogging.error.mockClear();
+    document.body.innerHTML = '<div id="app-menu"></div>';
+  });
+
+  test('renders the menu even when initPreamble rejects', async () => {
+    // The language pack failed to load, but the menu should still render
+    // (falling back to English) because the import/render runs in `finally`.
+    // The preamble rejection still propagates to the outer handler and is
+    // logged, but rendering is not blocked by it.
+    mockInitPreamble.mockRejectedValue(new Error('preamble failed'));
+
+    require('./menu');
+    await flush();
+
+    expect(mockRender).toHaveBeenCalled();
+    expect(mockLogging.error).toHaveBeenCalledWith(
+      'Unhandled error during menu initialization',
+      expect.any(Error),
+    );
+  });
+
+  test('logs an error when menu render fails', async () => {
+    mockInitPreamble.mockResolvedValue(true);
+    mockRender.mockImplementation(() => {
+      throw new Error('render blew up');
+    });
+
+    require('./menu');
+    await flush();
+
+    expect(mockLogging.error).toHaveBeenCalledWith(
+      'Unhandled error during menu initialization',
+      expect.any(Error),
+    );
+  });
+});
diff --git a/superset-frontend/src/views/menu.tsx b/superset-frontend/src/views/menu.tsx
index 9ff7b22af13..f2fd024d678 100644
--- a/superset-frontend/src/views/menu.tsx
+++ b/superset-frontend/src/views/menu.tsx
@@ -29,6 +29,7 @@ import { ReactRouter5Adapter } from 'use-query-params/adapters/react-router-5';
 import createCache from '@emotion/cache';
 import { ThemeProvider } from '@apache-superset/core/theme';
 import { theme } from '@apache-superset/core/theme';
+import { logging } from '@apache-superset/core/utils';
 import getBootstrapData from 'src/utils/getBootstrapData';
 import initPreamble from 'src/preamble';
 import { setupStore } from './store';
@@ -46,13 +47,16 @@ const emotionCache = createCache({
 
 const menuMountPoint = document.getElementById('app-menu');
 if (menuMountPoint) {
-  initPreamble()
-    .catch(() => {}) // preamble logs failures internally; always render the menu
-    .then(async () => {
+  (async () => {
+    try {
+      await initPreamble();
+    } finally {
       // Defer Menu import until after initPreamble() resolves so that module-level
       // t() calls in Menu's dependency tree (e.g. commonMenuData.ts) run only after
       // translations are loaded. webpackMode: "eager" keeps the module in this bundle
       // chunk but defers evaluation until the import() expression is awaited.
+      // The import/render runs in finally so the menu always renders, falling back
+      // to English if the language pack failed to load.
       const { default: Menu } = await import(
         /* webpackMode: "eager" */ 'src/features/home/Menu'
       );
@@ -76,5 +80,8 @@ if (menuMountPoint) {
           </ThemeProvider>
         </CacheProvider>,
       );
-    });
+    }
+  })().catch(err => {
+    logging.error('Unhandled error during menu initialization', err);
+  });
 }