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